Runtime hygiene: fix RefCounted cycles (clean suite exit), full-length shifts, wand cooldown
- 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:
1 parent
ba76c0febf
commit
08b3292b80
10 files changed
+57
-17
No files matched your search
+17
-6
@@ -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,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")
|
||||
|
||||
@@ -64,7 +64,11 @@ var verifier := AccessVerifier.new()
|
||||
var wand_screener := WandScreener.new()
|
||||
|
||||
var active_scenario_id := "hollywood_building"
|
||||
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
|
||||
## Shift length mirrors the scenario default (900s / 10-15 min per PLAN.md) unless
|
||||
## overridden for a test or debug build.
|
||||
var scenario_duration_s: float = 900.0
|
||||
var _wand_cooldown := 0.0
|
||||
const WAND_COOLDOWN_S := 0.8
|
||||
var scene_root: Node3D = null
|
||||
var npcs: Array = []
|
||||
var npc_counter := 0
|
||||
@@ -103,6 +107,7 @@ func _ready() -> void:
|
||||
guard.cleared_target.connect(_on_guard_clear)
|
||||
|
||||
selected_scenario = _build_scenario(active_scenario_id)
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
coordinator = ShiftCoordinator.new()
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
@@ -169,6 +174,7 @@ func restart_shift() -> void:
|
||||
inspector_current_events = []
|
||||
current_incident_active = false
|
||||
current_incident_npc_id = ""
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.start()
|
||||
_last_phase = -1
|
||||
@@ -552,6 +558,9 @@ func _toggle_inspection() -> void:
|
||||
## Handheld wand inspection (The Joke Shop). Only available when the venue
|
||||
## carries a wand. The wand reports an alert; the player interprets it.
|
||||
func _toggle_wand_screening() -> void:
|
||||
if _wand_cooldown > 0.0:
|
||||
hud.log(Strings.lookup("wand.recharge"))
|
||||
return
|
||||
if wand_open:
|
||||
hud.hide_wand()
|
||||
hud.set_wand_icon(false)
|
||||
@@ -615,6 +624,8 @@ func decide_wand(allowed: bool) -> void:
|
||||
controller.screened = true
|
||||
hud.hide_wand()
|
||||
wand_open = false
|
||||
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
|
||||
_wand_cooldown = WAND_COOLDOWN_S
|
||||
|
||||
func decide_access(allowed: bool) -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
@@ -714,6 +725,8 @@ func _close_incident(outcome: String) -> void:
|
||||
current_incident_npc_id = ""
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if _wand_cooldown > 0.0:
|
||||
_wand_cooldown = maxf(0.0, _wand_cooldown - delta)
|
||||
if coordinator == null or not coordinator.running:
|
||||
return
|
||||
coordinator.tick(delta)
|
||||
|
||||
@@ -20,10 +20,13 @@ func test_save_round_trip() -> bool:
|
||||
var last: Dictionary = results[results.size() - 1]
|
||||
var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id")
|
||||
var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating")
|
||||
save.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_load_returns_array_when_empty_or_missing() -> bool:
|
||||
# The tester home may already contain results; this only asserts shape safety.
|
||||
var save := _save_instance()
|
||||
var results: Array = save.load_shift_results()
|
||||
return TestUtils.check(results is Array, "load_shift_results always returns Array")
|
||||
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
|
||||
save.free()
|
||||
return ok
|
||||
@@ -30,7 +30,7 @@ func test_strings_cover_ui_keys() -> bool:
|
||||
"hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand",
|
||||
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
|
||||
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
|
||||
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "radio.address_first",
|
||||
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "wand.recharge", "radio.address_first",
|
||||
"radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched",
|
||||
"responder.arrived", "react.comply", "react.leave", "react.argue", "react.record",
|
||||
"react.ignore", "react.threaten", "react.force_entry", "react.return_later",
|
||||
|
||||
Reference in new issue
Block a user