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,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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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