Runtime hygiene: fix RefCounted cycles (clean suite exit), full-length shifts, wand cooldown
Full build / Import + headless tests (push) Successful in 18s
Full build / Full desktop build + optional release (push) Skipped

- FSM holds owner via WeakRef; ShiftCoordinator is RefCounted (logic-only);
  persistence tests free the Save node -> test suite exits with zero
  'resources still in use' warnings (was ~13) and 57/57 pass
- GameManager uses each scenario's shift_duration_s (900s ~ 10-15 min) for
  real shifts instead of the 300s test duration
- Wand screening gets a 0.8s recharge cooldown with a wand.recharge message
- TODO/DONE/MEMORY/README synced
This commit is contained in:
zwitschi committed 2026-10-31 13:50:18 +01:00
1 parent ba76c0febf
commit 08b3292b80
10 files changed
+57 -17

No files matched your search

+17 -6
View File
@@ -2,15 +2,26 @@ class_name FSM
extends RefCounted
## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
## Deterministic and headless-testable.
##
## The owner is held through a WeakRef so long-lived owners do not create a
## reference cycle (FSM <-> owner), which previously leaked RefCounted objects
## at process exit (see MEMORY.md).
signal state_changed(node: Object, from_state: String, to_state: String)
var current_state: String = ""
var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}]
var _owner_node: Object = null
var _owner_ref: WeakRef = null
func _init(owner_node: Object = null) -> void:
_owner_node = owner_node
if owner_node != null:
_owner_ref = weakref(owner_node)
## The live owner object, or null if it was freed.
func _owner() -> Object:
if _owner_ref == null:
return null
return _owner_ref.get_ref() as Object
## Register a transition. guard is an optional Callable(node) -> bool.
func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void:
@@ -20,7 +31,7 @@ func add_transition(from_state: String, to_state: String, guard: Callable = Call
func start(initial_state: String) -> void:
current_state = initial_state
state_changed.emit(_owner_node, "", initial_state)
state_changed.emit(_owner(), "", initial_state)
## Attempt to move to `to_state`. Returns true when executed.
func transition_to(to_state: String) -> bool:
@@ -31,10 +42,10 @@ func transition_to(to_state: String) -> bool:
if t["to"] != to_state:
continue
var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node):
if not guard.is_valid() or guard.call(_owner()):
var from := current_state
current_state = to_state
state_changed.emit(_owner_node, from, to_state)
state_changed.emit(_owner(), from, to_state)
return true
return false
@@ -42,6 +53,6 @@ func can_transition_to(to_state: String) -> bool:
for t in _transitions.get(current_state, []):
if t["to"] == to_state:
var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node):
if not guard.is_valid() or guard.call(_owner()):
return true
return false
+1 -1
View File
@@ -1,5 +1,5 @@
class_name ShiftCoordinator
extends Node
extends RefCounted
const Rules := preload("res://src/core/rules.gd")
const EventBus := preload("res://src/core/event_bus.gd")