Add emergency aftermath: evacuation, investigation, review copy
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 4m20s

- Responder outcomes now reach the shift review
  (ShiftCoordinator.responder_outcomes), and the review screen names them.
- "evacuation" (emergency call, low danger, no weapon) actually clears the entrance:
  the door is locked, every NPC flees and is marked as leaving, and a
  security_failure reputation hit applies.
- An emergency call the situation did not justify (no weapon, danger <= 1) appends
  an investigation note, records investigation_opened, and applies the existing
  false_report hit. The review lists the investigations.
- Fix dispatch dying after the first shift: coordinator.setup() builds a new
  RadioSystem, so the responder_arrived connection made in _ready was lost on every
  venue switch/restart. _connect_radio() re-wires it beside _connect_crowd().

Tests/docs
- Headless suite 105 -> 113 (test_emergency_aftermath.gd), new smoke_emergency.gd
  covering the investigation, evacuation aftermath, and review copy.
- README controls/status/smoke checklist, TODO section moved to DONE, MEMORY notes.
- Version 0.2.5.
This commit is contained in:
zwitschi committed 2026-10-31 20:59:06 +01:00
1 parent 71b0b53705
commit 921bab796e
25 files changed
+1019 -23

No files matched your search

+8
View File
@@ -32,6 +32,10 @@ var crowd := CrowdSystem.new()
var scheduler := EventScheduler.new()
var spawned_count := 0
## Emergency aftermath (PLAN.md: Level 5). Responder resolutions and any
## investigation notes for calls that were not justified.
var responder_outcomes: Array[String] = []
var investigations: Array[String] = []
var spawn_seed := 0
var _shift_finished := false
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
@@ -45,6 +49,8 @@ func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int =
spawn_seed = p_seed
scheduler.setup(scenario, p_seed)
event_bus = EventBus.new()
responder_outcomes.clear()
investigations.clear()
radio = RadioSystem.new()
reputation = ReputationEvaluator.new()
crowd = CrowdSystem.new()
@@ -140,6 +146,8 @@ func build_review() -> Dictionary:
"battery": radio.battery,
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
},
"responder_outcomes": responder_outcomes.duplicate(),
"investigations": investigations.duplicate(),
"completed_at_ms": Time.get_ticks_msec(),
}
+62 -1
View File
@@ -134,7 +134,7 @@ func _ready() -> void:
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.npc_spawn_requested.connect(_spawn_npc)
coordinator.shift_ended.connect(_on_shift_ended)
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
_connect_radio()
_connect_crowd()
_build_ui()
@@ -222,6 +222,7 @@ func _reset_shift_state() -> void:
hud.set_body_camera(false)
scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
_connect_radio()
_connect_crowd()
_last_phase = -1
_apply_phase_lighting()
@@ -480,12 +481,51 @@ func _on_responder_arrived(call: Dictionary) -> void:
scene_root.add_child(avatar)
npcs.append(avatar)
hud.log(Strings.fmt("responder.arrived", [outcome]))
coordinator.responder_outcomes.append(outcome)
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
"type": "responder_action",
"outcome": outcome,
"call_type": call.get("type", "assist"),
})
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
if outcome == "evacuation":
_evacuate_entrance()
## Responder outcome "evacuation": the entrance is cleared and the door stays
## locked while the block is secured (PLAN.md: Level 5 Emergency Response).
func _evacuate_entrance() -> void:
if door and not door.locked:
door.set_locked(true)
if world_builder:
world_builder.set_door_locked(true)
if hud:
hud.set_door(true)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
for npc in npcs:
if not is_instance_valid(npc):
continue
if npc.has_method("start_flee"):
npc.start_flee(door_pos)
var controller: Object = npc.get_npc_controller() if npc.has_method("get_npc_controller") else null
if controller != null:
coordinator.recorder.add_event(controller.npc_id, {
"type": "evacuation",
"outcome": "evacuated",
})
if controller.leave_requested == false:
controller.leave_requested = true
coordinator.reputation.apply("security_failure", -3.0, "entrance evacuated by responder")
if hud:
hud.log(Strings.lookup("responder.evacuated"))
## Responder dispatch. `coordinator.setup()` builds a new RadioSystem for each
## shift, so the arrival signal must be re-connected after every setup or radio
## calls are silently resolved by nobody.
func _connect_radio() -> void:
if coordinator == null or coordinator.radio == null:
return
if not coordinator.radio.responder_arrived.is_connected(_on_responder_arrived):
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
## Crowd presentation (PLAN: Public Crowd Reaction). The crowd system is a
## mood/attention float; this turns its events into avatar behavior.
@@ -1073,12 +1113,33 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
if emergency:
coordinator.crowd.apply_event("emergency_responder")
hud.log(Strings.lookup("radio.emergency_dispatched"))
# A call that the situation did not justify is reviewed afterwards
# (PLAN.md: Level 5 consequences).
if _is_unjustified_emergency():
var who: String = "the person"
var controller: Object = _targeted_controller()
if controller and controller.profile:
who = str(controller.profile.display_name)
coordinator.investigations.append(Strings.fmt("investigation.unjustified", [who]))
coordinator.recorder.add_event(current_incident_npc_id, {
"type": "investigation_opened",
"reason": "unjustified emergency call",
})
coordinator.reputation.apply("false_report", -6.0, "unjustified emergency call")
hud.log(Strings.lookup("investigation.started"))
else:
coordinator.crowd.apply_event("calm_professionalism")
if audio:
audio.play_radio_response()
_maybe_radio_easter_egg()
## True when an emergency call is not supported by the situation: no weapon
## seen and low danger (PLAN.md: unjustified call consequences).
func _is_unjustified_emergency() -> bool:
var weapons := bool(current_npc_truth.get("weapons_seen", false))
var danger := int(current_npc_truth.get("danger_level", 0))
return not weapons and danger <= 1
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
## over the radio. Fires on a small random chance after a successful call.
func _maybe_radio_easter_egg() -> void:
+6
View File
@@ -2,6 +2,8 @@ class_name ShiftReview
extends CanvasLayer
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
const Strings := preload("res://src/core/strings.gd")
signal restart_requested()
var report_label: Label
@@ -49,6 +51,8 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
var incidents: Dictionary = review.get("incidents", {})
var crowd: Dictionary = review.get("crowd", {})
var radio: Dictionary = review.get("radio", {})
var outcomes: Array = review.get("responder_outcomes", [])
var investigations: Array = review.get("investigations", [])
var incident_list: Array = incidents.get("incidents", [])
var peaceful := 0
for inc in incident_list:
@@ -78,6 +82,8 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
"NPCs encountered: %d" % review.get("spawned_count", 0),
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
Strings.fmt("review.responder", [", ".join(outcomes) if outcomes.size() > 0 else Strings.lookup("review.none")]),
Strings.fmt("review.investigations", [" | ".join(investigations) if investigations.size() > 0 else Strings.lookup("review.none")]),
"",
"Highlights:",
]
+94
View File
@@ -0,0 +1,94 @@
extends SceneTree
## Emergency-aftermath smoke: places an unjustified emergency call, checks the
## investigation note and reputation hit, forces the responder to arrive, and
## verifies the evacuation outcome locks the door and clears the entrance.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_emergency.gd
## Exit code 0 = pass, 1 = failure.
const MAX_FRAMES := 900
var frame := 0
var gm: Node = null
var started := false
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout before the emergency aftermath ran")
return true
if frame < 200:
return false
if step == 0:
if gm.npcs.is_empty():
return false
gm.call("_on_guard_target", gm.npcs[0])
# A tourist: no weapon, low danger.
gm.current_npc_truth = {"weapons_seen": false, "danger_level": 1}
var rating_before: float = gm.coordinator.reputation.rating
gm.call("_on_radio_submitted", {
"npc_id": gm.current_incident_npc_id,
"location": "entrance",
"description": "tourist",
"behavior": "standing",
"weapons_seen": false,
"danger_level": 1,
}, true)
_check(gm.coordinator.investigations.size() == 1, "unjustified emergency opens one investigation")
_check(gm.coordinator.reputation.rating < rating_before, "unjustified emergency loses reputation")
_check(gm.coordinator.radio.pending_calls.size() == 1, "the emergency call is pending")
_check(not gm.door.locked, "the door is open before the responder arrives")
step = 1
if step == 1:
# Fast-forward the arrival delay.
gm.coordinator.radio.tick(30.0)
_check(gm.coordinator.radio.pending_calls.is_empty(), "the responder arrived")
_check(gm.coordinator.responder_outcomes.has("evacuation"), "low-danger emergency resolves as evacuation")
_check(gm.door.locked, "evacuation locks the door")
var clearing := 0
for npc in gm.npcs:
if is_instance_valid(npc) and (npc.reaction_mode == "flee" or npc.npc.leave_requested):
clearing += 1
_check(clearing > 0, "evacuation sends people away from the entrance (%d clearing)" % clearing)
var review: Dictionary = gm.coordinator.build_review()
_check((review.get("review", review).get("responder_outcomes", []) as Array).has("evacuation"),
"the review carries the responder outcome")
_check((review.get("investigations", []) as Array).size() == 1, "the review carries the investigation note")
step = 2
if step == 2:
_finish()
return true
return false
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: emergency aftermath (investigation, evacuation, review)")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+103
View File
@@ -0,0 +1,103 @@
extends RefCounted
## Emergency aftermath: responder outcomes reach the review, evacuation clears the
## entrance and locks the door, and an unjustified call opens an investigation.
const Rules := preload("res://src/core/rules.gd")
const RadioSystem := preload("res://src/core/radio_system.gd")
const Coordinator := preload("res://src/core/shift_coordinator.gd")
const Factory := preload("res://src/content/scenario_factory.gd")
const Strings := preload("res://src/core/strings.gd")
func _coordinator() -> Object:
var c := Coordinator.new()
c.setup(Factory.hollywood_building(), 60.0, 4242)
return c
func test_responder_outcomes_reach_the_review() -> bool:
var c := _coordinator()
c.responder_outcomes.append("arrest")
c.responder_outcomes.append("command")
var review: Dictionary = c.build_review()
var outcomes: Array = review.get("responder_outcomes", [])
var a := TestUtils.check_eq(outcomes, ["arrest", "command"], "responder outcomes are in the review")
var b := TestUtils.check(review.has("investigations"), "review carries an investigations list")
c = null
return a and b
func test_outcomes_reset_with_a_new_shift() -> bool:
var c := _coordinator()
c.responder_outcomes.append("evacuation")
c.investigations.append("note")
c.setup(Factory.hollywood_building(), 60.0, 1)
var a := TestUtils.check_eq(c.responder_outcomes.size(), 0, "outcomes reset on setup")
var b := TestUtils.check_eq(c.investigations.size(), 0, "investigations reset on setup")
c = null
return a and b
func test_evacuation_is_the_emergency_default() -> bool:
var call := {"type": "emergency", "report": {"danger_level": 2, "weapons_seen": false}}
var a := TestUtils.check_eq(RadioSystem.responder_outcome(call), "restraint", "danger 2 restrains")
var call2 := {"type": "emergency", "report": {"danger_level": 1, "weapons_seen": false}}
var b := TestUtils.check_eq(RadioSystem.responder_outcome(call2), "evacuation", "low-danger emergency evacuates")
var call3 := {"type": "assist", "report": {"danger_level": 1, "weapons_seen": false}}
var c := TestUtils.check_eq(RadioSystem.responder_outcome(call3), "command", "assistance just commands")
var call4 := {"type": "emergency", "report": {"danger_level": 3, "weapons_seen": true}}
var d := TestUtils.check_eq(RadioSystem.responder_outcome(call4), "arrest", "weapons arrest")
return a and b and c and d
func test_evacuation_wiring_locks_the_door_and_clears_npcs() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _evacuate_entrance"), "evacuation has a handler")
var b := TestUtils.check(src.contains('if outcome == "evacuation":'), "arrival routes evacuation")
var c := TestUtils.check(src.contains("door.set_locked(true)"), "evacuation locks the door")
var d := TestUtils.check(src.contains("npc.start_flee(door_pos)"), "evacuation sends NPCs away")
var e := TestUtils.check(src.contains("controller.leave_requested = true"), "evacuated NPCs are marked as leaving")
return a and b and c and d and e
func test_unjustified_emergency_is_detected() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _is_unjustified_emergency"), "unjustified check exists")
var b := TestUtils.check(src.contains("return not weapons and danger <= 1"), "no weapon and low danger is unjustified")
var c := TestUtils.check(src.contains("coordinator.investigations.append"), "an investigation note is recorded")
var d := TestUtils.check(src.contains('"type": "investigation_opened"'), "the incident log records the investigation")
var e := TestUtils.check(src.contains('coordinator.reputation.apply("false_report"'), "the false-report hit applies")
return a and b and c and d and e
func test_radio_arrival_reconnects_after_setup() -> bool:
## coordinator.setup() builds a new RadioSystem, so the arrival handler must be
## re-connected each shift or dispatched responders never resolve anything.
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _connect_radio"), "radio wiring has a helper")
var b := TestUtils.check(src.contains("coordinator.radio.responder_arrived.connect(_on_responder_arrived)"),
"the helper connects the arrival handler")
var c := TestUtils.check(src.contains("_connect_radio()"), "shifts call the helper")
return a and b and c
func test_review_strings_exist() -> bool:
var a := TestUtils.check(Strings.lookup("review.responder") != "review.responder", "responder line exists")
var b := TestUtils.check(Strings.lookup("review.investigations") != "review.investigations", "investigation line exists")
var c := TestUtils.check(Strings.lookup("review.none") != "review.none", "empty placeholder exists")
var d := TestUtils.check(Strings.lookup("responder.evacuated") != "responder.evacuated", "evacuation log line exists")
var e := TestUtils.check(Strings.lookup("investigation.started") != "investigation.started", "investigation log line exists")
return a and b and c and d and e
func test_review_renders_outcomes_and_investigations() -> bool:
var Review := load("res://src/game/ui/shift_review.gd")
var panel: Node = Review.new()
var review := {
"scenario_id": "hollywood_building",
"reputation": {"rating": 61.0, "grade": "C", "modifiers": []},
"incidents": {"incidents": []},
"crowd": {"mood": 0.4, "panic": false},
"radio": {"battery": 70.0, "emergency_cooldown_s": 0.0},
"spawned_count": 3,
"responder_outcomes": ["evacuation"],
"investigations": ["Emergency call reviewed"],
}
panel.show_review(review, Factory.hollywood_building(), [])
var text: String = panel.report_label.text
var a := TestUtils.check(text.contains("evacuation"), "review names the responder outcome")
var b := TestUtils.check(text.contains("Emergency call reviewed"), "review shows the investigation note")
var c := TestUtils.check(text.contains("Investigations:"), "investigations line is labelled")
panel.free()
return a and b and c