Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
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:
zwitschi committed 2026-10-31 11:37:54 +01:00
1 parent e23f284ad4
commit 439e0a4281
45 files changed
+4368 -7

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
extends SceneTree
## Dev diagnostic: preload every project script to surface parse errors one by one.
const FILES := [
"res://src/core/event_bus.gd",
"res://src/core/rules.gd",
"res://src/core/access_artifact.gd",
"res://src/core/access_policy.gd",
"res://src/core/access_verifier.gd",
"res://src/core/npc_profile.gd",
"res://src/core/npc_controller.gd",
"res://src/core/fsm.gd",
"res://src/core/escalation_manager.gd",
"res://src/core/incident_recorder.gd",
"res://src/core/reputation_evaluator.gd",
"res://src/core/radio_system.gd",
"res://src/core/crowd_system.gd",
"res://src/core/event_scheduler.gd",
"res://src/core/venue_scenario.gd",
"res://src/core/shift_coordinator.gd",
"res://src/content/scenario_factory.gd",
"res://src/game/main.gd",
"res://src/test/test_utils.gd",
]
func _initialize() -> void:
var failures := 0
for f in FILES:
var res: Variant = load(f)
if res == null or not (res is GDScript):
push_error("FAILED to load: %s" % f)
failures += 1
elif not (res as GDScript).can_instantiate():
push_error("PARSE/COMPILE FAILURE (cannot instantiate): %s" % f)
failures += 1
else:
print("OK: %s" % f)
print("load failures: %d" % failures)
quit(1 if failures > 0 else 0)
+25
View File
@@ -0,0 +1,25 @@
extends SceneTree
## Dev helper: run the game windowed for N frames, save a screenshot, quit.
## Usage: godot --path . -s res://src/test/take_screenshot.gd (windowed)
var frames := 0
const TARGET_FRAMES := 300
func _initialize() -> void:
# Launch the main scene.
var packed: PackedScene = load("res://scenes/main.tscn")
var inst: Node = packed.instantiate()
root.add_child(inst)
func _process(_delta: float) -> bool:
frames += 1
if frames == 60:
var img: Image = root.get_texture().get_image()
var err := img.save_png("user://screenshot.png")
print("screenshot saved err=", err)
quit(0)
return true
if frames > TARGET_FRAMES:
quit(0)
return true
return false
+73
View File
@@ -0,0 +1,73 @@
extends SceneTree
## Minimal headless test runner for CI (used in place of an editor-based addon
## until a Godot editor workflow is introduced).
##
## Usage:
## godot --headless --path . --script res://src/test/test_runner.gd
## Exit code 0 = all passed, 1 = failures.
const TestUtils := preload("res://src/test/test_utils.gd")
const TEST_DIR := "res://src/test/tests"
func _initialize() -> void:
var test_files := _list_gd_files(TEST_DIR)
var total_passed := 0
var total_failed := 0
var failures: Array[String] = []
for path in test_files:
var script: GDScript = load(path)
if script == null:
failures.append("Could not load %s" % path)
total_failed += 1
continue
var instance: Object = script.new()
TestUtils.reset()
var suite_passed := 0
var suite_failed := 0
for method_info in instance.get_method_list():
var method: StringName = method_info["name"]
if not String(method).begins_with("test_"):
continue
if method_info["flags"] & METHOD_FLAG_STATIC:
continue
TestUtils.reset()
var result: Variant = instance.call(method)
var errored: bool = TestUtils._errors.size() > 0
if result == true and not errored:
suite_passed += 1
else:
suite_failed += 1
var detail := ""
if errored:
detail = " :: " + TestUtils.report()
failures.append("%s.%s%s" % [path.get_file(), method, detail])
total_passed += suite_passed
total_failed += suite_failed
print("Suite %-42s pass=%d fail=%d" % [path.get_file(), suite_passed, suite_failed])
print("")
print("==================== TEST SUMMARY ====================")
print("Passed: %d Failed: %d" % [total_passed, total_failed])
if failures.size() > 0:
print("--------------------------------------------------")
for f in failures:
print("FAIL: %s" % f)
print("======================================================")
quit(0 if total_failed == 0 else 1)
func _list_gd_files(dir_path: String) -> Array[String]:
var out: Array[String] = []
var dir := DirAccess.open(dir_path)
if dir == null:
push_error("Cannot open test dir %s" % dir_path)
return out
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if not dir.current_is_dir() and file_name.ends_with(".gd"):
out.append(dir_path.path_join(file_name))
file_name = dir.get_next()
dir.list_dir_end()
out.sort()
return out
+32
View File
@@ -0,0 +1,32 @@
class_name TestUtils
## Tiny assertion helpers shared by headless test suites.
static var _errors: Array[String] = []
static func reset() -> void:
_errors.clear()
## Returns true and records the failure message when condition is false.
static func check(condition: bool, message: String) -> bool:
if not condition:
_errors.append(message)
return condition
static func check_eq(actual: Variant, expected: Variant, message: String) -> bool:
if actual == expected:
return true
_errors.append("%s (expected %s, got %s)" % [message, str(expected), str(actual)])
return false
static func check_dict_has(data: Dictionary, key: String, message: String) -> bool:
if data.has(key):
return true
_errors.append("%s (missing key %s)" % [message, key])
return false
static func fail(message: String) -> bool:
_errors.append(message)
return false
static func report() -> String:
return "\n".join(_errors)
+120
View File
@@ -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")
+83
View File
@@ -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
+67
View File
@@ -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")