Implement NPC perception, returning trespassers, responder dispatch, wand screening, and accessibility polish
CI / headless-tests (push) Successful in 2m11s
CI / headless-tests (push) Successful in 2m11s
This commit is contained in:
1 parent
439e0a4281
commit
81b5d5ce5a
22 files changed
+879
-73
No files matched your search
@@ -50,3 +50,23 @@ func test_dead_battery_blocks_calls() -> bool:
|
||||
radio.battery = 0.0
|
||||
var call: Dictionary = radio.request_assistance({"danger_level": 1})
|
||||
return TestUtils.check(not call["ok"], "no battery means no call") and TestUtils.check(call["reason"].contains("battery"), "battery reason")
|
||||
|
||||
func test_responder_arrives_after_delay() -> bool:
|
||||
var radio := RadioSystem.new()
|
||||
var arrived_calls: Array = []
|
||||
radio.responder_arrived.connect(func(call: Dictionary): arrived_calls.append(call))
|
||||
radio.request_assistance({"danger_level": 1, "weapons_seen": false})
|
||||
var a := TestUtils.check_eq(radio.pending_calls.size(), 1, "one pending call")
|
||||
# Advance past the 8s assist arrival delay.
|
||||
radio.tick(9.0)
|
||||
var b := TestUtils.check_eq(radio.pending_calls.size(), 0, "pending call cleared")
|
||||
var c := TestUtils.check_eq(arrived_calls.size(), 1, "responder_arrived emitted")
|
||||
return a and b and c
|
||||
|
||||
func test_responder_outcome_mapping() -> bool:
|
||||
var a := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "assist", "report": {"danger_level": 1, "weapons_seen": false}}), "command", "low danger -> command")
|
||||
var b := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "assist", "report": {"danger_level": 2, "weapons_seen": false}}), "restraint", "elevated -> restraint")
|
||||
var c := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "emergency", "report": {"danger_level": 1, "weapons_seen": false}}), "evacuation", "emergency -> evacuation")
|
||||
var d := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "assist", "report": {"danger_level": 3, "weapons_seen": false}}), "arrest", "immediate threat -> arrest")
|
||||
var e := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "assist", "report": {"danger_level": 1, "weapons_seen": true}}), "arrest", "weapon seen -> arrest")
|
||||
return a and b and c and d and e
|
||||
@@ -81,3 +81,59 @@ func test_fsm_transitions() -> bool:
|
||||
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
|
||||
var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves")
|
||||
return started and left
|
||||
|
||||
func test_returning_trespasser_circles_back() -> bool:
|
||||
## A trespasser with return_probability 1.0 will always circle back after
|
||||
## being directed away. The return timer fires and the FSM re-approaches.
|
||||
var p := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.35)
|
||||
p.return_probability = 1.0
|
||||
var npc := NPCController.new()
|
||||
npc.setup("rt1", p, "trespass", 4242, "AT_ENTRANCE")
|
||||
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL)
|
||||
var left := TestUtils.check_eq(npc.fsm.current_state, "LOITERING_DISTANT", "directed away loiters before returning")
|
||||
var scheduled := TestUtils.check(npc.will_return, "return scheduled")
|
||||
# Advance past the return delay (max 60s).
|
||||
var returned := false
|
||||
for i in 120:
|
||||
if npc.tick_return(1.0):
|
||||
returned = true
|
||||
break
|
||||
var came_back := TestUtils.check(returned, "NPC circles back within delay window")
|
||||
var reapproaching := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "FSM returns to APPROACH")
|
||||
var not_leaving := TestUtils.check_eq(npc.decide_leave(), false, "leave flag cleared on return")
|
||||
return left and scheduled and came_back and reapproaching and not_leaving
|
||||
|
||||
func test_tourist_never_returns() -> bool:
|
||||
## A tourist (return_probability 0) never schedules a return.
|
||||
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
|
||||
var npc := NPCController.new()
|
||||
npc.setup("t2", p, "photo", 7, "AT_ENTRANCE")
|
||||
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
|
||||
return TestUtils.check_eq(npc.will_return, false, "tourist does not schedule a return")
|
||||
|
||||
func test_perception_pass_bands() -> bool:
|
||||
## NPCs notice the guard within the aware radius and react to close
|
||||
## proximity based on temperament.
|
||||
var calm := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
|
||||
calm.temperament = 0.1
|
||||
var npc := NPCController.new()
|
||||
npc.setup("p1", calm, "photo", 11, "AT_ENTRANCE")
|
||||
var far := npc.perceive_guard(20.0)
|
||||
var a := TestUtils.check_eq(far["band"], "unaware", "far guard is unnoticed")
|
||||
var b := TestUtils.check_eq(far["noticed"], false, "far guard not noticed")
|
||||
var aware := npc.perceive_guard(4.0)
|
||||
var c := TestUtils.check_eq(aware["band"], "aware", "within aware radius")
|
||||
var d := TestUtils.check_eq(aware["noticed"], true, "guard noticed")
|
||||
var near := npc.perceive_guard(1.0)
|
||||
var e := TestUtils.check_eq(near["band"], "near", "within near radius")
|
||||
var f := TestUtils.check_eq(near["reaction"], Rules.Reaction.COMPLY, "calm NPC complies when guard is near")
|
||||
return a and b and c and d and e and f
|
||||
|
||||
func test_perception_hotheaded_reacts_to_proximity() -> bool:
|
||||
## A hot-headed NPC argues when the guard stands close.
|
||||
var hot := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.35)
|
||||
hot.temperament = 0.8
|
||||
var npc := NPCController.new()
|
||||
npc.setup("p2", hot, "trespass", 22, "AT_ENTRANCE")
|
||||
var near := npc.perceive_guard(1.5)
|
||||
return TestUtils.check_eq(near["reaction"], Rules.Reaction.ARGUE, "hot-headed NPC argues at close range")
|
||||
@@ -65,3 +65,15 @@ func test_emergency_event_requires_emergency() -> bool:
|
||||
if event.get("requires_emergency", false):
|
||||
found = true
|
||||
return TestUtils.check(found, "scenario contains a scripted emergency event")
|
||||
|
||||
func test_waypoints_map_intent_to_location() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var entrance: Vector3 = scenario.locations["entrance"]
|
||||
var sidewalk: Vector3 = scenario.locations["sidewalk"]
|
||||
var steps_end: Vector3 = scenario.locations["steps_end"]
|
||||
var a := TestUtils.check_eq(scenario.waypoint_for_intent("enter"), entrance, "enter -> entrance")
|
||||
var b := TestUtils.check_eq(scenario.waypoint_for_intent("trespass"), entrance, "trespass -> entrance")
|
||||
var c := TestUtils.check_eq(scenario.waypoint_for_intent("photo"), sidewalk, "photo -> sidewalk")
|
||||
var d := TestUtils.check_eq(scenario.waypoint_for_intent("sit"), steps_end, "sit -> steps_end")
|
||||
var e := TestUtils.check_eq(scenario.waypoint_for_intent("unknown_intent"), sidewalk, "unknown -> sidewalk fallback")
|
||||
return a and b and c and d and e
|
||||
@@ -0,0 +1,33 @@
|
||||
extends RefCounted
|
||||
## Headless tests for the centralized localization string table.
|
||||
|
||||
const Strings := preload("res://src/core/strings.gd")
|
||||
|
||||
|
||||
func test_strings_lookup() -> bool:
|
||||
var a := TestUtils.check_eq(Strings.lookup("hud.aim_first"), "Aim at someone first.", "known key returns value")
|
||||
var b := TestUtils.check_eq(Strings.lookup("missing.key"), "missing.key", "missing key returns key itself")
|
||||
return a and b
|
||||
|
||||
func test_strings_format() -> bool:
|
||||
var a := TestUtils.check_eq(Strings.fmt("hud.tone_prefix", ["Firm"]), "Tone: Firm", "tone prefix formats")
|
||||
var b := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats")
|
||||
return a and b
|
||||
|
||||
func test_strings_cover_ui_keys() -> bool:
|
||||
## Every key referenced by the HUD and game manager must exist.
|
||||
var required := [
|
||||
"hud.controls", "hud.tone_prefix", "hud.escalation_prefix", "hud.cooldown",
|
||||
"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",
|
||||
"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",
|
||||
"react.none", "perception.near", "perception.aware",
|
||||
]
|
||||
for key in required:
|
||||
if not Strings.TABLE.has(key):
|
||||
return TestUtils.fail("missing string key: %s" % key)
|
||||
return true
|
||||
@@ -0,0 +1,51 @@
|
||||
extends RefCounted
|
||||
## Headless tests for the handheld wand screening logic (PLAN.md: The Joke Shop).
|
||||
|
||||
const WandScreener := preload("res://src/core/wand_screener.gd")
|
||||
|
||||
|
||||
func test_wand_detects_prohibited_item() -> bool:
|
||||
var screener := WandScreener.new()
|
||||
var result: Dictionary = screener.screen(["keys", "weapon"])
|
||||
var a := TestUtils.check(result["alert"], "alert raised")
|
||||
var b := TestUtils.check(result["prohibited"], "prohibited flagged")
|
||||
var c := TestUtils.check_eq(result["item"], "weapon", "weapon identified")
|
||||
return a and b and c
|
||||
|
||||
func test_wand_false_alarm() -> bool:
|
||||
var screener := WandScreener.new()
|
||||
var result: Dictionary = screener.screen(["keys", "phone"])
|
||||
var a := TestUtils.check(result["alert"], "alert raised")
|
||||
var b := TestUtils.check(result["false_alarm"], "false alarm flagged")
|
||||
var c := TestUtils.check(not result["prohibited"], "not prohibited")
|
||||
return a and b and c
|
||||
|
||||
func test_wand_clear() -> bool:
|
||||
var screener := WandScreener.new()
|
||||
var result: Dictionary = screener.screen([])
|
||||
var a := TestUtils.check(not result["alert"], "no alert")
|
||||
var b := TestUtils.check(not result["prohibited"], "not prohibited")
|
||||
var c := TestUtils.check(not result["false_alarm"], "not false alarm")
|
||||
return a and b and c
|
||||
|
||||
func test_wand_prohibited_takes_priority() -> bool:
|
||||
var screener := WandScreener.new()
|
||||
# A weapon plus a harmless item: prohibited wins.
|
||||
var result: Dictionary = screener.screen(["keys", "knife"])
|
||||
return TestUtils.check(result["prohibited"], "prohibited wins over false alarm")
|
||||
|
||||
func test_wand_decision_correctness() -> bool:
|
||||
var screener := WandScreener.new()
|
||||
var prohibited := screener.screen(["weapon"])
|
||||
var a := TestUtils.check(not screener.decide(prohibited, true)["correct"], "admitting prohibited item is wrong")
|
||||
var b := TestUtils.check(screener.decide(prohibited, false)["correct"], "refusing prohibited item is correct")
|
||||
var clean := screener.screen([])
|
||||
var c := TestUtils.check(screener.decide(clean, true)["correct"], "admitting clean guest is correct")
|
||||
var d := TestUtils.check(not screener.decide(clean, false)["correct"], "refusing clean guest is wrong")
|
||||
return a and b and c and d
|
||||
|
||||
func test_classify_item() -> bool:
|
||||
var a := TestUtils.check_eq(WandScreener.classify_item("gun"), "prohibited", "gun prohibited")
|
||||
var b := TestUtils.check_eq(WandScreener.classify_item("belt"), "false_alarm", "belt false alarm")
|
||||
var c := TestUtils.check_eq(WandScreener.classify_item("wallet"), "clear", "wallet clear")
|
||||
return a and b and c
|
||||
Reference in new issue
Block a user