Files
security-game/src/test/tests/test_npc_reactions.gd
T
zwitschi a3768c2ce4
Full build / Import + headless tests (push) Successful in 2m25s
Full build / Full desktop build + optional release (push) Skipped
Remove dialogue tone-of-voice system, add Gitea Actions full build, add venue cycling
- Delete Rules.Tone/TONE_NAMES, NpcProfile.tone_modifiers, tone params on
  NPCController.roll_reaction/apply_player_action; dialogue_lines flattened
  to Array[String]; HUD tone label, T binding, and hud.tone_prefix removed
- Docs updated everywhere (PLAN, README, DONE, MEMORY); PLAN section heading
  'Tone and Design Direction' -> 'Design Direction'
- Add .gitea/workflows/build.yml: checks job (import+tests) on push/PR; full
  build job (export templates, Linux/Windows/macOS exports, artifact upload,
  and tag-based Gitea release publish via GITEA_TOKEN) on tags/dispatch
- Remove old .github/workflows/ci.yml to avoid double runs
- Add venue cycling (V) across all three scenarios with hud.venue_switched
- Refresh README screenshots to match the new HUD
- 53/53 tests pass, boot smoke clean
2026-10-31 13:08:20 +01:00

154 lines
7.0 KiB
GDScript

extends RefCounted
## Headless tests for NPC reaction rolls (PLAN.md: NPC Behavior Types).
const Rules := preload("res://src/core/rules.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const NPCController := preload("res://src/core/npc_controller.gd")
func _profile_with_defaults(id: String, npc_type: int, compliance: float) -> NpcProfile:
var p := NpcProfile.new()
p.id = id
p.npc_type = npc_type
p.base_compliance = compliance
return p
func test_tourist_complies_at_level_1() -> bool:
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
var npc := NPCController.new()
npc.setup("t1", p, "photo", 1234, "AT_ENTRANCE")
var outcome := npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE)
var ok := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.COMPLY, "base_compliance 0.9 tourist should comply at level 1")
return ok and TestUtils.check_eq(npc.decide_leave(), true, "compliant NPC leaves")
func test_trespasser_frequent_non_compliance() -> bool:
var p := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.2)
var npc := NPCController.new()
npc.setup("t1", p, "trespass", 99, "AT_ENTRANCE")
## Several rolls at formal warning; default table for this test profile is
## from NpcProfile.default_reactions (mostly COMPLY there).
var outcomes: Array = []
for i in 5:
outcomes.append(npc.roll_reaction(Rules.Level.SIMPLE_DETERRENCE))
## With compliance 0.9/0.2 variation we simply assert we get SOME reaction.
var ok := TestUtils.check(npc.highest_escalation_received >= Rules.Level.SIMPLE_DETERRENCE, "highest escalation tracked")
return ok
func test_deterministic_with_seed() -> bool:
## Same seed and inputs => same reaction sequence.
var p1 := _profile_with_defaults("guest_a", Rules.NpcType.TRESPASSER, 0.4)
var n1 := NPCController.new()
n1.setup("a", p1, "trespass", 4242, "AT_ENTRANCE")
var p2 := _profile_with_defaults("guest_b", Rules.NpcType.TRESPASSER, 0.4)
var n2 := NPCController.new()
n2.setup("b", p2, "trespass", 4242, "AT_ENTRANCE")
var seq_a: Array = []
var seq_b: Array = []
for i in 4:
seq_a.append(n1.roll_reaction(Rules.Level.FORMAL_WARNING))
seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING))
return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence")
func test_violent_threat_scales_to_threaten() -> bool:
## Violent archetype with default reactions: from formal warning upward there
## is a meaningful probability of THREATEN / FORCE_ENTRY. We sample enough
## rolls to see at least one THREATEN with the violent profile rng.
var p := NpcProfile.new()
p.id = "violent"
p.npc_type = Rules.NpcType.VIOLENT_THREAT
p.base_compliance = 0.05
p.reactions_by_level = {
Rules.Level.EMERGENCY: {
Rules.Reaction.THREATEN: 40,
Rules.Reaction.FORCE_ENTRY: 30,
Rules.Reaction.LEAVE: 30,
},
}
var npc := NPCController.new()
npc.setup("v1", p, "threat", 777, "AT_ENTRANCE")
var saw_threat := false
for i in 20:
if npc.roll_reaction(Rules.Level.EMERGENCY) == Rules.Reaction.THREATEN:
saw_threat = true
return TestUtils.check(saw_threat, "violent profile can threaten at emergency level")
func test_call_someone_reaction() -> bool:
var p := NpcProfile.new()
p.id = "callsome"
p.npc_type = Rules.NpcType.INFLUENCER
p.reactions_by_level = {
Rules.Level.FORMAL_WARNING: { Rules.Reaction.CALL_SOMEONE: 100 },
}
var npc := NPCController.new()
npc.setup("c1", p, "film", 5, "AT_ENTRANCE")
var outcome := npc.apply_player_action(Rules.Level.FORMAL_WARNING)
var a := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.CALL_SOMEONE, "rolls CALL_SOMEONE")
var b := TestUtils.check_eq(npc.fsm.current_state, "ARGUING", "transitions to ARGUING")
return a and b
func test_fsm_transitions() -> bool:
var p := _profile_with_defaults("guest", Rules.NpcType.TOURIST, 0.9)
var npc := NPCController.new()
npc.setup("g1", p, "enter", 5, "APPROACH")
var started := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "starts approaching")
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE)
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)
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)
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")