Add emergency aftermath: evacuation, investigation, review copy
- 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:
1 parent
71b0b53705
commit
921bab796e
25 files changed
+1019
-23
No files matched your search
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user