Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
CI / headless-tests (push) Canceled after 49s
CI / headless-tests (push) Canceled after 49s
- Godot 4.3 project scaffold with first-person guard, procedural world, interaction HUD, access inspection, radio forms, debug overlay, shift review - Simulation core: ShiftCoordinator, VenueScenario, EventScheduler, NPCController/FSM, AccessVerifier, EscalationManager, RadioSystem, IncidentRecorder, ReputationEvaluator, CrowdSystem, EventBus - Three authored scenarios (Hollywood Blvd building, The Joke Shop, TV studio) - Custom headless test runner (25 tests) replacing editor-dependent GUT, plus GitHub Actions CI - Documentation workflow live: README, PLAN (tech stack), TODO/DONE, MEMORY
This commit is contained in:
1 parent
e23f284ad4
commit
439e0a4281
45 files changed
+4368
-7
No files matched your search
@@ -0,0 +1,120 @@
|
||||
extends RefCounted
|
||||
## Headless tests for AccessVerifier (ADR-0003, Access Policy).
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||
const AccessPolicy := preload("res://src/core/access_policy.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
|
||||
var verifier := AccessVerifier.new()
|
||||
|
||||
func test_wristband_valid_grants() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, {"color": "red", "date": "2026-02-13", "event": "gala"}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], true, "valid wristband should grant access")
|
||||
|
||||
func test_wristband_expired_denies() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.EXPIRED, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "expired wristband must deny")
|
||||
|
||||
func test_wristband_counterfeit_denies() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "counterfeit wristband must deny")
|
||||
|
||||
func test_missing_wristband_denies() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var result := verifier.verify([], policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "missing wristband must deny")
|
||||
|
||||
func test_recognition_grants_without_wristband() -> bool:
|
||||
## PLAN.md: "The guard already knows and recognizes them."
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], true, "recognition should grant access")
|
||||
|
||||
func test_recognition_expired_does_not_grant() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.MISSING, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "missing recognition should not grant under band policy")
|
||||
|
||||
func test_joke_shop_id_and_ticket() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.ID: true, Rules.AccessKind.TICKET: true }
|
||||
policy.minimum_age = 21
|
||||
var ok: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 24}),
|
||||
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var denied_underage: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 19}),
|
||||
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var denied_no_ticket: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 24}),
|
||||
]
|
||||
var ok_result := verifier.verify(ok, policy)
|
||||
var denied_underage_result := verifier.verify(denied_underage, policy)
|
||||
var denied_no_ticket_result := verifier.verify(denied_no_ticket, policy)
|
||||
var a := TestUtils.check_eq(ok_result["granted"], true, "valid ID + ticket grants")
|
||||
var b := TestUtils.check_eq(denied_underage_result["granted"], false, "underage denied")
|
||||
var c := TestUtils.check_eq(denied_no_ticket_result["granted"], false, "no ticket denied")
|
||||
return a and b and c
|
||||
|
||||
func test_joke_shop_requires_wand_screen() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.ID: true, Rules.AccessKind.TICKET: true }
|
||||
policy.wand_screening_required = true
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 25}),
|
||||
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "unscreened guest denied when screening required")
|
||||
|
||||
func test_tv_studio_any_valid_grants() -> bool:
|
||||
## OR-admission: any single valid artifact among several kinds grants entry.
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = {
|
||||
Rules.AccessKind.CREDENTIAL: true,
|
||||
Rules.AccessKind.WRISTBAND: true,
|
||||
Rules.AccessKind.TICKET: true,
|
||||
Rules.AccessKind.GUEST_LIST: true,
|
||||
}
|
||||
policy.grant_if_any_required = true
|
||||
var with_wristband: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var with_guest_list: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.GUEST_LIST, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var all_fake: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {}),
|
||||
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.EXPIRED, {}),
|
||||
]
|
||||
var a := TestUtils.check_eq(verifier.verify(with_wristband, policy)["granted"], true, "wristband grants")
|
||||
var b := TestUtils.check_eq(verifier.verify(with_guest_list, policy)["granted"], true, "guest list grants")
|
||||
var c := TestUtils.check_eq(verifier.verify(all_fake, policy)["granted"], false, "all invalid denied")
|
||||
return a and b and c
|
||||
@@ -0,0 +1,52 @@
|
||||
extends RefCounted
|
||||
## Headless tests for EscalationManager and RadioSystem.
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const EscalationManager := preload("res://src/core/escalation_manager.gd")
|
||||
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||
|
||||
|
||||
func test_level_advance_and_gate() -> bool:
|
||||
var mgr := EscalationManager.new(Rules.Level.EMERGENCY)
|
||||
var a := TestUtils.check_eq(mgr.current_level, Rules.Level.OBSERVATION, "starts at observation")
|
||||
var b := TestUtils.check(mgr.can_act(Rules.Level.FORMAL_WARNING), "warning is allowed")
|
||||
var c := TestUtils.check(mgr.advance_to(Rules.Level.FORMAL_WARNING, "refused_direction"), "advanced to warning")
|
||||
return a and b and c
|
||||
|
||||
func test_cannot_exceed_max_level() -> bool:
|
||||
var mgr := EscalationManager.new(Rules.Level.BLOCKING)
|
||||
var blocked := TestUtils.check(not mgr.advance_to(Rules.Level.EMERGENCY, "unavailable"), "emergency blocked by venue rules")
|
||||
var stayed := TestUtils.check_eq(mgr.current_level, Rules.Level.OBSERVATION, "stayed at observation")
|
||||
return blocked and stayed
|
||||
|
||||
func test_radio_assist_consumes_battery() -> bool:
|
||||
var radio := RadioSystem.new()
|
||||
var call: Dictionary = radio.request_assistance({
|
||||
"location": "front steps", "description": "person refusing to leave",
|
||||
"behavior": "refusing", "weapons_seen": false, "danger_level": 1,
|
||||
})
|
||||
var a := TestUtils.check(call["ok"], "assist call accepted")
|
||||
var b := TestUtils.check(radio.battery < radio.max_battery, "battery consumed")
|
||||
return a and b
|
||||
|
||||
func test_emergency_cooldown_blocks_spam() -> bool:
|
||||
var radio := RadioSystem.new()
|
||||
var first: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true})
|
||||
var a := TestUtils.check(first["ok"], "first emergency accepted")
|
||||
var second: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true})
|
||||
var b := TestUtils.check(not second["ok"], "second immediately rejected")
|
||||
var c := TestUtils.check(second["reason"].contains("Cooldown"), "cooldown reason given")
|
||||
return a and b and c
|
||||
|
||||
func test_emergency_after_cooldown_expires() -> bool:
|
||||
var radio := RadioSystem.new()
|
||||
radio.request_emergency({"danger_level": 3, "weapons_seen": true})
|
||||
radio.tick(radio.emergency_cooldown_seconds + 1.0)
|
||||
var again: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true})
|
||||
return TestUtils.check(again["ok"], "emergency available after cooldown")
|
||||
|
||||
func test_dead_battery_blocks_calls() -> bool:
|
||||
var radio := RadioSystem.new()
|
||||
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")
|
||||
@@ -0,0 +1,83 @@
|
||||
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, Rules.Tone.FRIENDLY)
|
||||
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, Rules.Tone.PROFESSIONAL))
|
||||
## 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, Rules.Tone.PROFESSIONAL))
|
||||
seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL))
|
||||
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.Tone.PROFESSIONAL) == Rules.Reaction.THREATEN:
|
||||
saw_threat = true
|
||||
return TestUtils.check(saw_threat, "violent profile can threaten at emergency level")
|
||||
|
||||
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, Rules.Tone.FRIENDLY)
|
||||
var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves")
|
||||
return started and left
|
||||
@@ -0,0 +1,67 @@
|
||||
extends RefCounted
|
||||
## Headless tests for venue scenario data (Plan: Venues and Scenarios).
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
||||
|
||||
|
||||
func test_hollywood_building_loads() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var a := TestUtils.check_eq(scenario.id, "hollywood_building", "scenario id")
|
||||
var b := TestUtils.check(scenario.npc_profiles.size() > 0, "has profiles")
|
||||
var c := TestUtils.check(scenario.access_policy.requires(Rules.AccessKind.WRISTBAND), "requires wristband")
|
||||
return a and b and c
|
||||
|
||||
func test_joke_shop_rules() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.joke_shop()
|
||||
var a := TestUtils.check_eq(scenario.access_policy.minimum_age, 21, "21+ club")
|
||||
var b := TestUtils.check(scenario.access_policy.require_id, "ID required")
|
||||
var c := TestUtils.check(scenario.access_policy.wand_screening_required, "wand screening required")
|
||||
var d := TestUtils.check(scenario.equipment.has("wand"), "wand in equipment")
|
||||
return a and b and c and d
|
||||
|
||||
func test_tv_studio_has_all_npc_types() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.tv_studio()
|
||||
var all_types := [Rules.NpcType.TOURIST, Rules.NpcType.PERFORMER, Rules.NpcType.INFLUENCER,
|
||||
Rules.NpcType.CONFUSED_VISITOR, Rules.NpcType.INTOXICATED, Rules.NpcType.TRESPASSER,
|
||||
Rules.NpcType.MENTAL_CRISIS, Rules.NpcType.VIOLENT_THREAT,
|
||||
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY]
|
||||
var profile_types: Array = []
|
||||
for p in scenario.npc_profiles:
|
||||
profile_types.append(p.npc_type)
|
||||
for npc_type in all_types:
|
||||
if not profile_types.has(npc_type):
|
||||
return TestUtils.fail("TV studio missing npc type %s" % Rules.NPC_TYPE_NAMES[npc_type])
|
||||
return true
|
||||
|
||||
var _spawn_count := 0
|
||||
|
||||
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void:
|
||||
_spawn_count += 1
|
||||
|
||||
func test_shift_coordinator_runs_a_shift() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var coordinator := ShiftCoordinator.new()
|
||||
coordinator.setup(scenario, 60.0, 1234)
|
||||
_spawn_count = 0
|
||||
coordinator.npc_spawn_requested.connect(_on_spawn)
|
||||
coordinator.start()
|
||||
for i in 60:
|
||||
coordinator.tick(1.0)
|
||||
if not coordinator.running:
|
||||
break
|
||||
var a := TestUtils.check(coordinator.elapsed_s > 0.0, "time advanced")
|
||||
var b := TestUtils.check(_spawn_count > 0, "NPCs spawned during shift")
|
||||
var review := coordinator.build_review()
|
||||
var c := TestUtils.check_dict_has(review, "reputation", "review has reputation")
|
||||
return a and b and c
|
||||
|
||||
func test_emergency_event_requires_emergency() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var found := false
|
||||
for event in scenario.event_pool:
|
||||
if event.get("requires_emergency", false):
|
||||
found = true
|
||||
return TestUtils.check(found, "scenario contains a scripted emergency event")
|
||||
Reference in new issue
Block a user