Implement TODO backlog: LATE phase fix, protester, CALL_SOMEONE, door lock, flashlight, persistence, navmesh, JSON localization, and release tooling
CI / headless-tests (push) Successful in 55s
CI / headless-tests (push) Successful in 55s
- Fix LATE shift phase (start 700s) so the violent-threat event is reachable - Add PROTESTER archetype (rules, profile, colors, events in public venues) - Add CALL_SOMEONE reaction (FSM -> ARGUING, influencer/trespasser/protester weights) - Door lock: DoorState core class, L binding, red/green visual indicator, locked doors thwart FORCE_ENTRY - Flashlight: G binding, SpotLight3D, perception radius bonus at evening/late - Persistence: save shift results via Save autoload, history + best score on review screen, round-trip test - Navigation: flat NavigationRegion3D navmesh, NavigationAgent3D path following with direct-movement fallback; steps made visual-only so NPCs reach entrance - Localization: strings moved to content/strings.en.json (loader keeps lookup/fmt signatures; coverage test over all keys) - Distribution: export_presets.cfg (Linux/Windows/macOS), tools/build_releases.sh, docs/RELEASING.md, prominent README Downloads section - Remove stale signal_bus.gd (invalid static-signal experiment) - Tests: 53 passing across 9 suites
This commit is contained in:
1 parent
c808165302
commit
1e972a7490
27 files changed
+1044
-340
No files matched your search
@@ -84,10 +84,18 @@ static func shared_profiles() -> Array[NpcProfile]:
|
||||
Rules.Reaction.ARGUE: 10,
|
||||
},
|
||||
Rules.Level.FORMAL_WARNING: {
|
||||
Rules.Reaction.RECORD: 45,
|
||||
Rules.Reaction.ARGUE: 25,
|
||||
Rules.Reaction.RECORD: 35,
|
||||
Rules.Reaction.ARGUE: 20,
|
||||
Rules.Reaction.CALL_SOMEONE: 15,
|
||||
Rules.Reaction.COMPLY: 30,
|
||||
},
|
||||
Rules.Level.BLOCKING: {
|
||||
Rules.Reaction.RECORD: 30,
|
||||
Rules.Reaction.ARGUE: 20,
|
||||
Rules.Reaction.CALL_SOMEONE: 25,
|
||||
Rules.Reaction.LEAVE: 15,
|
||||
Rules.Reaction.COMPLY: 10,
|
||||
},
|
||||
}
|
||||
influencer.dialogue_lines = {
|
||||
Rules.Tone.SUSPICIOUS: [
|
||||
@@ -138,9 +146,10 @@ static func shared_profiles() -> Array[NpcProfile]:
|
||||
Rules.Reaction.COMPLY: 40,
|
||||
},
|
||||
Rules.Level.BLOCKING: {
|
||||
Rules.Reaction.LEAVE: 40,
|
||||
Rules.Reaction.ARGUE: 30,
|
||||
Rules.Reaction.RETURN_LATER: 20,
|
||||
Rules.Reaction.LEAVE: 35,
|
||||
Rules.Reaction.ARGUE: 25,
|
||||
Rules.Reaction.RETURN_LATER: 15,
|
||||
Rules.Reaction.CALL_SOMEONE: 15,
|
||||
Rules.Reaction.FORCE_ENTRY: 10,
|
||||
},
|
||||
Rules.Level.RADIO_ASSIST: {
|
||||
@@ -235,6 +244,48 @@ static func shared_profiles() -> Array[NpcProfile]:
|
||||
}
|
||||
profiles.append(violent_threat)
|
||||
|
||||
var protester := NpcProfile.new()
|
||||
protester.id = "protester"
|
||||
protester.display_name = "Protester"
|
||||
protester.npc_type = Rules.NpcType.PROTESTER
|
||||
protester.temperament = 0.5
|
||||
protester.base_compliance = 0.4
|
||||
protester.return_probability = 0.25
|
||||
protester.reactions_by_level = {
|
||||
Rules.Level.SIMPLE_DETERRENCE: {
|
||||
Rules.Reaction.ARGUE: 35,
|
||||
Rules.Reaction.IGNORE: 25,
|
||||
Rules.Reaction.RECORD: 15,
|
||||
Rules.Reaction.COMPLY: 25,
|
||||
},
|
||||
Rules.Level.FORMAL_WARNING: {
|
||||
Rules.Reaction.ARGUE: 40,
|
||||
Rules.Reaction.RECORD: 25,
|
||||
Rules.Reaction.IGNORE: 15,
|
||||
Rules.Reaction.CALL_SOMEONE: 5,
|
||||
Rules.Reaction.COMPLY: 15,
|
||||
},
|
||||
Rules.Level.BLOCKING: {
|
||||
Rules.Reaction.ARGUE: 25,
|
||||
Rules.Reaction.RECORD: 25,
|
||||
Rules.Reaction.CALL_SOMEONE: 15,
|
||||
Rules.Reaction.LEAVE: 25,
|
||||
Rules.Reaction.COMPLY: 10,
|
||||
},
|
||||
Rules.Level.RADIO_ASSIST: {
|
||||
Rules.Reaction.LEAVE: 60,
|
||||
Rules.Reaction.ARGUE: 20,
|
||||
Rules.Reaction.FORCE_ENTRY: 20,
|
||||
},
|
||||
}
|
||||
protester.dialogue_lines = {
|
||||
Rules.Tone.SUSPICIOUS: [
|
||||
"I have every right to be on public sidewalk.",
|
||||
"You can't touch me, this is a lawful protest.",
|
||||
],
|
||||
}
|
||||
profiles.append(protester)
|
||||
|
||||
# --- Authorized crowd ---------------------------------------------------------
|
||||
var staff := NpcProfile.new()
|
||||
staff.id = "authorized_staff"
|
||||
@@ -276,7 +327,7 @@ static func hollywood_building() -> VenueScenario:
|
||||
scenario.equipment = ["wristband_policy", "flashlight", "radio"]
|
||||
scenario.locations = {
|
||||
"guard_post": Vector3(0, 0, -3),
|
||||
"entrance": Vector3(0, 0, 0),
|
||||
"entrance": Vector3(0, 0, 1.0),
|
||||
"steps_end": Vector3(0, 0, 3),
|
||||
"sidewalk": Vector3(0, 0, 9),
|
||||
"queue_area": Vector3(0, 0, 6),
|
||||
@@ -286,15 +337,18 @@ static func hollywood_building() -> VenueScenario:
|
||||
Rules.Phase.EARLY: {"start_s": 0.0},
|
||||
Rules.Phase.MIDDAY: {"start_s": 300.0},
|
||||
Rules.Phase.EVENING: {"start_s": 600.0},
|
||||
Rules.Phase.LATE: {"start_s": 700.0},
|
||||
}
|
||||
|
||||
scenario.npc_profiles = _filter_profiles([
|
||||
"tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest",
|
||||
"street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat",
|
||||
"protester",
|
||||
])
|
||||
|
||||
scenario.event_pool = [
|
||||
_building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
|
||||
_building_routine("protest_gather", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
|
||||
_building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.20),
|
||||
_building_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
|
||||
_building_routine("delivery_drop", Rules.NpcType.DELIVERY, "deliver", Vector2(0, 600), 0.30),
|
||||
@@ -349,7 +403,7 @@ static func joke_shop() -> VenueScenario:
|
||||
scenario.equipment = ["id_check", "guest_list", "wand", "radio"]
|
||||
scenario.locations = {
|
||||
"guard_post": Vector3(0, 0, -3),
|
||||
"entrance": Vector3(0, 0, 0),
|
||||
"entrance": Vector3(0, 0, 1.0),
|
||||
"door_area": Vector3(0, 0, 3),
|
||||
"queue_area": Vector3(0, 0, 9),
|
||||
}
|
||||
@@ -380,6 +434,7 @@ static func joke_shop() -> VenueScenario:
|
||||
Rules.Phase.EARLY: {"start_s": 0.0},
|
||||
Rules.Phase.MIDDAY: {"start_s": 300.0},
|
||||
Rules.Phase.EVENING: {"start_s": 600.0},
|
||||
Rules.Phase.LATE: {"start_s": 700.0},
|
||||
}
|
||||
|
||||
scenario.npc_profiles = _filter_profiles([
|
||||
@@ -430,7 +485,7 @@ static func tv_studio() -> VenueScenario:
|
||||
scenario.equipment = ["wristband_policy", "guest_list", "radio", "flashlight"]
|
||||
scenario.locations = {
|
||||
"guard_post": Vector3(0, 0, -3),
|
||||
"entrance": Vector3(0, 0, 0),
|
||||
"entrance": Vector3(0, 0, 1.0),
|
||||
"stage_door": Vector3(6, 0, -2),
|
||||
"queue_area": Vector3(0, 0, 9),
|
||||
}
|
||||
@@ -439,15 +494,18 @@ static func tv_studio() -> VenueScenario:
|
||||
Rules.Phase.EARLY: {"start_s": 0.0},
|
||||
Rules.Phase.MIDDAY: {"start_s": 300.0},
|
||||
Rules.Phase.EVENING: {"start_s": 600.0},
|
||||
Rules.Phase.LATE: {"start_s": 700.0},
|
||||
}
|
||||
|
||||
scenario.npc_profiles = _filter_profiles([
|
||||
"tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest",
|
||||
"street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat",
|
||||
"protester",
|
||||
])
|
||||
|
||||
scenario.event_pool = [
|
||||
_studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35),
|
||||
_studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
|
||||
_studio_routine("celebrity_arrival", Rules.NpcType.AUTHORIZED_GUEST, "arrive", Vector2(300, 700), 0.20),
|
||||
_studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30),
|
||||
_studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class_name DoorState
|
||||
extends RefCounted
|
||||
## Door lock/secure mechanic (PLAN.md: Level 3 — lock or secure the door).
|
||||
## Pure logic: GameManager and WorldBuilder only mirror state visually.
|
||||
|
||||
var locked := false
|
||||
|
||||
func _init(p_start_locked: bool = false) -> void:
|
||||
locked = p_start_locked
|
||||
|
||||
func toggle() -> bool:
|
||||
locked = not locked
|
||||
return locked
|
||||
|
||||
func set_locked(value: bool) -> void:
|
||||
locked = value
|
||||
|
||||
## A force-entry attempt. Returns { succeeded: bool, outcome: String }.
|
||||
## Locked doors thwart attempts; unlocked doors admit the forced entry.
|
||||
func attempt_force() -> Dictionary:
|
||||
if locked:
|
||||
return {"succeeded": false, "outcome": "thwarted"}
|
||||
return {"succeeded": true, "outcome": "entered"}
|
||||
@@ -114,6 +114,9 @@ func _update_state_for_reaction(r: int) -> void:
|
||||
fsm.transition_to("LOITERING_DISTANT")
|
||||
will_return = true
|
||||
return_delay_s = _roll_return_delay()
|
||||
Rules.Reaction.CALL_SOMEONE:
|
||||
# The NPC stops to call someone for help (PLAN.md: Level 2 reactions).
|
||||
fsm.transition_to("ARGUING")
|
||||
_:
|
||||
pass
|
||||
|
||||
@@ -153,12 +156,13 @@ func decide_leave() -> bool:
|
||||
## Perception pass: given the guard's distance (meters), return a perception
|
||||
## result describing how the NPC reacts to the guard's presence. Pure and
|
||||
## headless-testable; the presentation layer feeds it the real distance.
|
||||
## bonus_range widens awareness when the guard uses a flashlight at night.
|
||||
## Returns { noticed: bool, band: String, reaction: int }.
|
||||
func perceive_guard(distance: float) -> Dictionary:
|
||||
func perceive_guard(distance: float, bonus_range: float = 0.0) -> Dictionary:
|
||||
var band := "unaware"
|
||||
if distance <= NEAR_RADIUS:
|
||||
band = "near"
|
||||
elif distance <= AWARE_RADIUS:
|
||||
elif distance <= AWARE_RADIUS + bonus_range:
|
||||
band = "aware"
|
||||
var noticed := band != "unaware"
|
||||
if noticed and not guard_noticed:
|
||||
|
||||
@@ -37,6 +37,7 @@ enum NpcType {
|
||||
AUTHORIZED_GUEST,
|
||||
DELIVERY,
|
||||
RESPONDER,
|
||||
PROTESTER,
|
||||
}
|
||||
|
||||
const NPC_TYPE_NAMES := {
|
||||
@@ -52,6 +53,7 @@ const NPC_TYPE_NAMES := {
|
||||
NpcType.AUTHORIZED_GUEST: "Authorized Guest",
|
||||
NpcType.DELIVERY: "Delivery",
|
||||
NpcType.RESPONDER: "Responder",
|
||||
NpcType.PROTESTER: "Protester",
|
||||
}
|
||||
|
||||
## Access artifact kinds used by AccessVerifier.
|
||||
@@ -103,6 +105,7 @@ enum Reaction {
|
||||
THREATEN = 5,
|
||||
RETURN_LATER = 6,
|
||||
FORCE_ENTRY = 7,
|
||||
CALL_SOMEONE = 8,
|
||||
}
|
||||
|
||||
## Player dialogue tones.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
class_name SignalBus
|
||||
extends RefCounted
|
||||
## Static signal bus (ADR-0005 spirit: decoupled, deterministic).
|
||||
## Unlike an autoload, `SignalBus` resolves at parse time through preloads,
|
||||
## so it works identically under `--script`, in headless CI, and in the game.
|
||||
|
||||
static signal incident_recorded(incident: Dictionary)
|
||||
static signal player_action(action: Dictionary)
|
||||
static signal escalation_changed(payload: Dictionary)
|
||||
static signal radio_call(call: Dictionary)
|
||||
static signal responder_arrived(responder: Dictionary)
|
||||
static signal npc_state_changed(payload: Dictionary)
|
||||
static signal shift_state_changed(payload: Dictionary)
|
||||
static signal reputation_changed(payload: Dictionary)
|
||||
static signal crowd_changed(payload: Dictionary)
|
||||
static signal access_decided(payload: Dictionary)
|
||||
static signal shift_review_ready(review: Dictionary)
|
||||
static signal anything_happened(value: String)
|
||||
|
||||
func _init() -> void:
|
||||
push_error("SignalBus is a static utility class; use SignalBus.<signal> directly.")
|
||||
+33
-52
@@ -1,64 +1,45 @@
|
||||
class_name Strings
|
||||
## Centralized UI and dialogue text table (PLAN.md: Polish and Accessibility —
|
||||
## localization). All user-facing strings live here so they can be swapped for
|
||||
## a localized table later. Keys are stable identifiers; values are English.
|
||||
## localization). English strings live in an editable JSON file so they can be
|
||||
## swapped or translated without touching code. Keys are stable identifiers.
|
||||
|
||||
const TABLE := {
|
||||
# HUD / controls
|
||||
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · T tone · F debug",
|
||||
"hud.tone_prefix": "Tone: %s",
|
||||
"hud.escalation_prefix": "Escalation: %s",
|
||||
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
||||
"hud.aim_first": "Aim at someone first.",
|
||||
"hud.no_one_nearby": "No one nearby.",
|
||||
"hud.targeting": "Targeting %s (Tab to cycle).",
|
||||
"hud.no_wand": "No wand at this venue.",
|
||||
"hud.screen_first": "Screen this guest with the wand (W) before admitting them.",
|
||||
const DEFAULT_LOCALE := "en"
|
||||
const TABLE_PATH := "res://content/strings.%s.json"
|
||||
|
||||
# Access decisions
|
||||
"access.granted": "Entry granted: %s",
|
||||
"access.denied": "Entry denied: %s",
|
||||
"access.policy_satisfied": "policy satisfied",
|
||||
"access.wrong_grant": "You granted entry despite verification failure!",
|
||||
"access.wrong_deny": "You denied entry despite valid verification!",
|
||||
## Populated lazily from the JSON table. Kept public so tests and tooling can
|
||||
## enumerate keys (e.g. coverage checks).
|
||||
static var TABLE: Dictionary = {}
|
||||
static var _load_attempted := false
|
||||
|
||||
# Wand screening
|
||||
"wand.admitted_prohibited": "You admitted %s with a prohibited item!",
|
||||
"wand.denied_false_alarm": "You denied %s over a harmless item.",
|
||||
"wand.cleared": "%s cleared the wand check.",
|
||||
"wand.refused": "%s refused entry after the wand check.",
|
||||
|
||||
# Radio
|
||||
"radio.address_first": "Address someone first before calling.",
|
||||
"radio.blocked": "Radio blocked: %s",
|
||||
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).",
|
||||
"radio.received": "Dispatch received your report. Assistance en route.",
|
||||
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
||||
|
||||
# Responder
|
||||
"responder.arrived": "Responder arrives and %ss the situation.",
|
||||
|
||||
# Reactions
|
||||
"react.comply": "%s complies and steps back.",
|
||||
"react.leave": "%s turns and leaves.",
|
||||
"react.argue": "%s argues with you.",
|
||||
"react.record": "%s starts filming you.",
|
||||
"react.ignore": "%s ignores you.",
|
||||
"react.threaten": "%s is threatening you. Emergency radio (R) is justified now.",
|
||||
"react.force_entry": "%s is forcing the door!",
|
||||
"react.return_later": "%s leaves but may come back.",
|
||||
"react.none": "No reaction (yet).",
|
||||
|
||||
# Perception
|
||||
"perception.near": "%s notices you standing close.",
|
||||
"perception.aware": "%s notices you watching.",
|
||||
}
|
||||
static func _ensure_loaded() -> void:
|
||||
if _load_attempted:
|
||||
return
|
||||
_load_attempted = true
|
||||
var path := TABLE_PATH % DEFAULT_LOCALE
|
||||
if not FileAccess.file_exists(path):
|
||||
push_error("Strings table missing: %s" % path)
|
||||
return
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
var parsed = JSON.parse_string(text)
|
||||
if parsed is Dictionary:
|
||||
TABLE = parsed
|
||||
else:
|
||||
push_error("Strings table malformed: %s" % path)
|
||||
|
||||
## Look up a string by key. Returns the key itself when missing (fail-safe).
|
||||
## Absolute keys (locale.table.key) bypass the default table lookup.
|
||||
static func lookup(key: String) -> String:
|
||||
if key.begins_with("locale."):
|
||||
return key
|
||||
_ensure_loaded()
|
||||
return TABLE.get(key, key)
|
||||
|
||||
## Format a string by key with positional arguments.
|
||||
static func fmt(key: String, args: Array) -> String:
|
||||
var template: String = TABLE.get(key, key)
|
||||
return template % args
|
||||
var template := lookup(key)
|
||||
if template == key:
|
||||
return template
|
||||
return template % args
|
||||
|
||||
func _init() -> void:
|
||||
push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.")
|
||||
@@ -7,6 +7,7 @@ const Rules := preload("res://src/core/rules.gd")
|
||||
const Strings := preload("res://src/core/strings.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||
const DoorState := preload("res://src/core/door_state.gd")
|
||||
const WandScreener := preload("res://src/core/wand_screener.gd")
|
||||
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||
@@ -21,6 +22,9 @@ const Hud := preload("res://src/game/ui/hud.gd")
|
||||
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
|
||||
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
||||
|
||||
var world_builder: WorldBuilder = null
|
||||
var door := DoorState.new()
|
||||
var flashlight_on := false
|
||||
var coordinator: ShiftCoordinator = null
|
||||
var guard: GuardController = null
|
||||
var hud: Hud = null
|
||||
@@ -55,7 +59,8 @@ func _ready() -> void:
|
||||
scene_root.name = "SceneRoot"
|
||||
add_child(scene_root)
|
||||
|
||||
WorldBuilder.new(scene_root).build()
|
||||
world_builder = WorldBuilder.new(scene_root)
|
||||
world_builder.build()
|
||||
_ensure_lighting()
|
||||
|
||||
guard = _build_guard()
|
||||
@@ -86,6 +91,15 @@ func _build_guard() -> GuardController:
|
||||
camera.name = "Camera3D"
|
||||
camera.current = true
|
||||
head.add_child(camera)
|
||||
var flashlight := SpotLight3D.new()
|
||||
flashlight.name = "Flashlight"
|
||||
flashlight.visible = false
|
||||
flashlight.position = Vector3(0, -0.15, -0.4)
|
||||
flashlight.rotation_degrees = Vector3(-10, 0, 0)
|
||||
flashlight.spot_range = 18.0
|
||||
flashlight.spot_angle = 28.0
|
||||
flashlight.light_energy = 2.5
|
||||
head.add_child(flashlight)
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
shape.radius = 0.4
|
||||
@@ -202,7 +216,10 @@ func _responder_profile() -> NpcProfile:
|
||||
return p
|
||||
|
||||
func _on_shift_ended(review: Dictionary) -> void:
|
||||
shift_review_panel.show_review(review, selected_scenario)
|
||||
# Persist the finished shift locally (Save autoload, user:// JSON).
|
||||
if Save:
|
||||
Save.save_shift_result(review)
|
||||
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
|
||||
|
||||
func _on_guard_target(target: Node3D) -> void:
|
||||
targeted_npc = target
|
||||
@@ -259,6 +276,10 @@ func _handle_key(keycode: Key) -> void:
|
||||
_cycle_nearby_target()
|
||||
KEY_F:
|
||||
hud.toggle_debug()
|
||||
KEY_G:
|
||||
_toggle_flashlight()
|
||||
KEY_L:
|
||||
_toggle_door_lock()
|
||||
_:
|
||||
_handle_action_key(keycode)
|
||||
|
||||
@@ -291,6 +312,25 @@ func _handle_action_key(keycode: Key) -> void:
|
||||
_:
|
||||
pass
|
||||
|
||||
## Toggle the guard's flashlight. In evening/late phases it widens NPC
|
||||
## perception radius (PLAN.md: Level 3 — flashlight at night).
|
||||
func _toggle_flashlight() -> void:
|
||||
flashlight_on = not flashlight_on
|
||||
var lamp: SpotLight3D = guard.get_node_or_null("Head/Flashlight") as SpotLight3D
|
||||
if lamp:
|
||||
lamp.visible = flashlight_on
|
||||
hud.set_flashlight(flashlight_on)
|
||||
hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off"))
|
||||
|
||||
## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart
|
||||
## FORCE_ENTRY attempts during the reaction flow.
|
||||
func _toggle_door_lock() -> void:
|
||||
door.toggle()
|
||||
if world_builder:
|
||||
world_builder.set_door_locked(door.locked)
|
||||
hud.set_door(door.locked)
|
||||
hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked"))
|
||||
|
||||
## Cycle the dialogue tone (Professional → Friendly → Firm → Suspicious).
|
||||
func _cycle_tone() -> void:
|
||||
var idx := TONE_ORDER.find(selected_tone)
|
||||
@@ -368,11 +408,20 @@ func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
|
||||
hud.log(Strings.fmt("react.threaten", [name]))
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.FORCE_ENTRY:
|
||||
hud.log(Strings.fmt("react.force_entry", [name]))
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
|
||||
var force_result: Dictionary = door.attempt_force()
|
||||
if force_result.get("succeeded", false):
|
||||
hud.log(Strings.fmt("react.force_entry", [name]))
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
|
||||
else:
|
||||
hud.log(Strings.fmt("door.thwarted", [name]))
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
coordinator.reputation.apply("prop", -2.0, "forcing attempt (locked door)")
|
||||
Rules.Reaction.RETURN_LATER:
|
||||
hud.log(Strings.fmt("react.return_later", [name]))
|
||||
Rules.Reaction.CALL_SOMEONE:
|
||||
hud.log(Strings.fmt("react.call_someone", [name]))
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
_:
|
||||
hud.log(Strings.lookup("react.none"))
|
||||
|
||||
@@ -576,6 +625,9 @@ func _perception_pass() -> void:
|
||||
if guard == null:
|
||||
return
|
||||
var guard_pos: Vector3 = guard.global_position
|
||||
var lit_bonus := 0.0
|
||||
if flashlight_on and coordinator.current_phase >= Rules.Phase.EVENING:
|
||||
lit_bonus = 4.0
|
||||
for avatar in npcs:
|
||||
if not is_instance_valid(avatar):
|
||||
continue
|
||||
@@ -584,7 +636,7 @@ func _perception_pass() -> void:
|
||||
continue
|
||||
var distance: float = (avatar.global_position - guard_pos).length()
|
||||
var already_noticed: bool = controller.guard_noticed
|
||||
var perception: Dictionary = controller.perceive_guard(distance)
|
||||
var perception: Dictionary = controller.perceive_guard(distance, lit_bonus)
|
||||
if perception.get("noticed", false) and not already_noticed:
|
||||
# First-time notice: log a subtle reaction.
|
||||
var name: String = (controller.profile.display_name if controller.profile else "Person")
|
||||
|
||||
@@ -12,6 +12,10 @@ var target_position: Vector3 = Vector3.ZERO
|
||||
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
|
||||
var awaiting_interaction := false
|
||||
var arrived := false
|
||||
var agent: NavigationAgent3D = null
|
||||
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
||||
var _nav_usable := false
|
||||
var _nav_try_frames := 0
|
||||
|
||||
const TYPE_COLORS := {
|
||||
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55),
|
||||
@@ -26,6 +30,7 @@ const TYPE_COLORS := {
|
||||
Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65),
|
||||
Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40),
|
||||
Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85),
|
||||
Rules.NpcType.PROTESTER: Color(0.90, 0.40, 0.20),
|
||||
}
|
||||
|
||||
func _init() -> void:
|
||||
@@ -42,6 +47,15 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
||||
position = p_spawn
|
||||
npc.fsm.state_changed.connect(_on_state_changed)
|
||||
_build_body(profile.npc_type)
|
||||
_nav_usable = false
|
||||
_nav_try_frames = 0
|
||||
agent = NavigationAgent3D.new()
|
||||
agent.name = "NavAgent"
|
||||
agent.path_desired_distance = 0.4
|
||||
agent.target_desired_distance = 4.0
|
||||
agent.radius = 0.35
|
||||
agent.height = 1.2
|
||||
add_child(agent)
|
||||
|
||||
# Normalize intent targets: approach the entrance, stop near the door.
|
||||
var collider := CollisionShape3D.new()
|
||||
@@ -96,6 +110,40 @@ func _advance_fsm(delta: float) -> void:
|
||||
awaiting_interaction = true
|
||||
|
||||
func _move_toward_target(delta: float) -> void:
|
||||
if agent == null:
|
||||
_move_direct(delta)
|
||||
return
|
||||
agent.target_position = target_position
|
||||
if agent.is_navigation_finished():
|
||||
if (global_position - target_position).length() < 3.0:
|
||||
_arrive()
|
||||
else:
|
||||
# Path finished but we are far away: navigation is unusable, fall back.
|
||||
_move_direct(delta)
|
||||
return
|
||||
# Learn whether the navmesh actually serves this spot.
|
||||
if not _nav_usable:
|
||||
_nav_try_frames += 1
|
||||
if agent.is_target_reachable():
|
||||
_nav_usable = true
|
||||
elif _nav_try_frames > 10:
|
||||
_move_direct(delta)
|
||||
return
|
||||
var next := agent.get_next_path_position()
|
||||
var dir := (next - global_position)
|
||||
dir.y = 0.0
|
||||
if dir.length() < 0.2:
|
||||
velocity = Vector3.ZERO
|
||||
return
|
||||
dir = dir.normalized()
|
||||
velocity.x = dir.x * 1.3
|
||||
velocity.z = dir.z * 1.3
|
||||
velocity.y -= 9.8 * delta
|
||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||
move_and_slide()
|
||||
|
||||
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
|
||||
func _move_direct(delta: float) -> void:
|
||||
var to_target := target_position - global_position
|
||||
to_target.y = 0.0
|
||||
var dist := to_target.length()
|
||||
|
||||
@@ -16,6 +16,7 @@ var inspect_panel: PanelContainer
|
||||
var inspect_text: Label
|
||||
var inspect_meta: Label
|
||||
var radio_cooldown_label: Label
|
||||
var mode_label: Label
|
||||
var wand_panel: PanelContainer
|
||||
var wand_text: Label
|
||||
var wand_meta: Label
|
||||
@@ -91,6 +92,13 @@ func _init() -> void:
|
||||
radio_cooldown_label.modulate = Color(1, 0.55, 0.45)
|
||||
vbox.add_child(radio_cooldown_label)
|
||||
|
||||
# --- door / flashlight state ---
|
||||
mode_label = Label.new()
|
||||
mode_label.text = "Door: open · Light: off"
|
||||
mode_label.add_theme_font_size_override("font_size", 13)
|
||||
mode_label.modulate = Color(0.85, 0.85, 0.9)
|
||||
vbox.add_child(mode_label)
|
||||
|
||||
# --- dialogue tone indicator ---
|
||||
tone_label = Label.new()
|
||||
tone_label.text = "Tone: Professional"
|
||||
@@ -175,6 +183,20 @@ func _update_radio_cooldown(coordinator: Object) -> void:
|
||||
func set_target_label(text: String) -> void:
|
||||
target_label.text = text
|
||||
|
||||
var _flashlight := false
|
||||
var _door_locked := false
|
||||
|
||||
func set_door(locked: bool) -> void:
|
||||
_door_locked = locked
|
||||
_refresh_mode_label()
|
||||
|
||||
func set_flashlight(on: bool) -> void:
|
||||
_flashlight = on
|
||||
_refresh_mode_label()
|
||||
|
||||
func _refresh_mode_label() -> void:
|
||||
mode_label.text = "Door: %s · Light: %s" % ["LOCKED" if _door_locked else "open", "on" if _flashlight else "off"]
|
||||
|
||||
func set_action_hint(text: String) -> void:
|
||||
hint_label.text = text
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func _init() -> void:
|
||||
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
center.add_child(quit_btn)
|
||||
|
||||
func show_review(review: Dictionary, scenario: Resource) -> void:
|
||||
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
|
||||
var reputation: Dictionary = review.get("reputation", {})
|
||||
var incidents: Dictionary = review.get("incidents", {})
|
||||
var crowd: Dictionary = review.get("crowd", {})
|
||||
@@ -55,9 +55,25 @@ func show_review(review: Dictionary, scenario: Resource) -> void:
|
||||
if inc.get("outcome", "") in ["peaceful", "left"]:
|
||||
peaceful += 1
|
||||
|
||||
# Persisted-shift history (Save autoload): record count + best rating.
|
||||
var history_line := ""
|
||||
if history.size() > 0:
|
||||
var best := -1.0
|
||||
var past_grades: Array[String] = []
|
||||
for entry in history:
|
||||
var rep: Dictionary = entry.get("reputation", {})
|
||||
var rating_v: float = float(rep.get("rating", 0.0))
|
||||
best = maxf(best, rating_v)
|
||||
var g: String = rep.get("grade", "?")
|
||||
if not past_grades.has(g):
|
||||
past_grades.append(g)
|
||||
history_line = "Shifts on record: %d · best score: %.0f · grades seen: %s" % [
|
||||
history.size(), best, ", ".join(past_grades)]
|
||||
|
||||
var lines: Array[String] = [
|
||||
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
|
||||
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
|
||||
history_line,
|
||||
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
|
||||
"NPCs encountered: %d" % review.get("spawned_count", 0),
|
||||
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
|
||||
|
||||
@@ -4,6 +4,9 @@ class_name WorldBuilder
|
||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||
|
||||
var root: Node3D = null
|
||||
## Door lock indicator mesh near the handle; toggled by GameManager.
|
||||
var door_indicator: MeshInstance3D = null
|
||||
var _door_indicator_mat: StandardMaterial3D = null
|
||||
|
||||
func _init(root_node: Node3D) -> void:
|
||||
root = root_node
|
||||
@@ -15,6 +18,7 @@ func build() -> void:
|
||||
_build_sidewalk()
|
||||
_build_street()
|
||||
_build_guard_spawn()
|
||||
_build_navmesh()
|
||||
|
||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
|
||||
var mesh := BoxMesh.new()
|
||||
@@ -50,6 +54,9 @@ func _build_building() -> void:
|
||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13))
|
||||
# Door window
|
||||
_add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.5, 0.7, 0.9, 1.0))
|
||||
# Door lock indicator (green = open, red = locked), placed beside the handle
|
||||
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3))
|
||||
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
||||
# Awning sign bar
|
||||
_add_box(building, Vector3(8, 0.5, 0.6), Vector3(0, 7.0, -3.6), Color(0.45, 0.44, 0.44))
|
||||
var col := BoxShape3D.new()
|
||||
@@ -60,7 +67,10 @@ func _build_building() -> void:
|
||||
root.add_child(building)
|
||||
|
||||
func _build_steps() -> void:
|
||||
var steps := StaticBody3D.new()
|
||||
# Steps are visual only (no collider) so NPCs and the guard can walk up the
|
||||
# entrance on the flat navigation mesh; the ground and building provide the
|
||||
# actual collision boundaries.
|
||||
var steps := Node3D.new()
|
||||
steps.name = "Steps"
|
||||
var step_w := 8.0
|
||||
var tread := 0.6
|
||||
@@ -71,11 +81,6 @@ func _build_steps() -> void:
|
||||
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
||||
# Landing at the top
|
||||
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(step_w, 0.65, tread * 4 + 3)
|
||||
var so := steps.create_shape_owner(steps)
|
||||
steps.shape_owner_add_shape(so, col)
|
||||
steps.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0.32, 0.8)))
|
||||
root.add_child(steps)
|
||||
|
||||
func _build_sidewalk() -> void:
|
||||
@@ -103,8 +108,44 @@ func _build_street() -> void:
|
||||
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22)))
|
||||
root.add_child(street)
|
||||
|
||||
func _build_navmesh() -> void:
|
||||
## Flat navigation mesh over the walkable floor: landing strip, sidewalk,
|
||||
## and street front. Hand-authored polygons; no runtime bake needed.
|
||||
var region := NavigationRegion3D.new()
|
||||
region.name = "NavRegion"
|
||||
var mesh := NavigationMesh.new()
|
||||
mesh.agent_radius = 0.4
|
||||
mesh.agent_max_climb = 0.8
|
||||
mesh.agent_max_slope = 45.0
|
||||
mesh.cell_size = 0.25
|
||||
var verts := PackedVector3Array([
|
||||
Vector3(-18, 0.0, -0.6), Vector3(18, 0.0, -0.6),
|
||||
Vector3(18, 0.0, 20.0), Vector3(-18, 0.0, 20.0),
|
||||
])
|
||||
mesh.set_vertices(verts)
|
||||
mesh.add_polygon(PackedInt32Array([0, 1, 2, 3]))
|
||||
region.navigation_mesh = mesh
|
||||
root.add_child(region)
|
||||
|
||||
func _build_guard_spawn() -> void:
|
||||
var marker := Marker3D.new()
|
||||
marker.name = "GuardSpawn"
|
||||
marker.position = Vector3(0, 1.6, -2.5)
|
||||
root.add_child(marker)
|
||||
root.add_child(marker)
|
||||
|
||||
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.emission_enabled = true
|
||||
mat.emission = color
|
||||
mat.emission_energy_multiplier = 1.5
|
||||
return mat
|
||||
|
||||
## Mirror the door lock state visually (green open, red locked).
|
||||
func set_door_locked(locked: bool) -> void:
|
||||
if door_indicator == null:
|
||||
return
|
||||
var color := Color(0.9, 0.15, 0.15) if locked else Color(0.1, 0.9, 0.3)
|
||||
if door_indicator.material_override is StandardMaterial3D:
|
||||
(door_indicator.material_override as StandardMaterial3D).albedo_color = color
|
||||
(door_indicator.material_override as StandardMaterial3D).emission = color
|
||||
@@ -0,0 +1,46 @@
|
||||
extends RefCounted
|
||||
## Headless tests for the door lock mechanic and flashlight perception bonus.
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const DoorState := preload("res://src/core/door_state.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const NPCController := preload("res://src/core/npc_controller.gd")
|
||||
|
||||
func test_door_toggle_transitions() -> bool:
|
||||
var door := DoorState.new()
|
||||
var a := TestUtils.check_eq(door.locked, false, "starts unlocked")
|
||||
var b := TestUtils.check_eq(door.toggle(), true, "toggle locks")
|
||||
var c := TestUtils.check_eq(door.toggle(), false, "toggle unlocks")
|
||||
return a and b and c
|
||||
|
||||
func test_locked_door_thwarts_force() -> bool:
|
||||
var door := DoorState.new()
|
||||
door.set_locked(true)
|
||||
var result := door.attempt_force()
|
||||
var a := TestUtils.check_eq(result["succeeded"], false, "locked door blocks forced entry")
|
||||
var b := TestUtils.check_eq(result["outcome"], "thwarted", "thwarted outcome")
|
||||
return a and b
|
||||
|
||||
func test_unlocked_door_admits_force() -> bool:
|
||||
var door := DoorState.new()
|
||||
var result := door.attempt_force()
|
||||
var a := TestUtils.check_eq(result["succeeded"], true, "open door lets forced entry through")
|
||||
var b := TestUtils.check_eq(result["outcome"], "entered", "entered outcome")
|
||||
return a and b
|
||||
|
||||
func test_flashlight_widens_perception() -> bool:
|
||||
## Without the flashlight, an NPC at AWARE_RADIUS + 3 m is unaware.
|
||||
## With the flashlight bonus (4 m), the same distance registers as aware.
|
||||
var p := NpcProfile.new()
|
||||
p.id = "nightwalker"
|
||||
p.npc_type = Rules.NpcType.TOURIST
|
||||
var dim := NPCController.new()
|
||||
dim.setup("d1", p, "wander", 1)
|
||||
var unlit: Dictionary = dim.perceive_guard(NPCController.AWARE_RADIUS + 3.0, 0.0)
|
||||
var a := TestUtils.check_eq(unlit["band"], "unaware", "unlit: too far to notice")
|
||||
|
||||
var lit := NPCController.new()
|
||||
lit.setup("l1", p, "wander", 2)
|
||||
var lit_result: Dictionary = lit.perceive_guard(NPCController.AWARE_RADIUS + 3.0, 4.0)
|
||||
var b := TestUtils.check_eq(lit_result["band"], "aware", "flashlight bonus makes NPC aware")
|
||||
return a and b
|
||||
@@ -73,6 +73,20 @@ func test_violent_threat_scales_to_threaten() -> bool:
|
||||
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, Rules.Tone.PROFESSIONAL)
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
extends RefCounted
|
||||
## Headless tests for shift-result persistence (Save autoload, user:// JSON).
|
||||
|
||||
func _save_instance() -> Object:
|
||||
var save_script: GDScript = load("res://src/autoload/save.gd")
|
||||
return save_script.new()
|
||||
|
||||
func test_save_round_trip() -> bool:
|
||||
var save := _save_instance()
|
||||
var review := {
|
||||
"scenario_id": "hollywood_building",
|
||||
"reputation": {"rating": 82.5, "grade": "B"},
|
||||
"spawned_count": 7,
|
||||
"completed_at_ms": Time.get_ticks_msec(),
|
||||
}
|
||||
var err: int = save.save_shift_result(review)
|
||||
var a := TestUtils.check_eq(err, OK, "save succeeds")
|
||||
var results: Array = save.load_shift_results()
|
||||
var b := TestUtils.check(results.size() > 0, "history non-empty after save")
|
||||
var last: Dictionary = results[results.size() - 1]
|
||||
var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id")
|
||||
var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating")
|
||||
return a and b and c and d
|
||||
|
||||
func test_load_returns_array_when_empty_or_missing() -> bool:
|
||||
# The tester home may already contain results; this only asserts shape safety.
|
||||
var save := _save_instance()
|
||||
var results: Array = save.load_shift_results()
|
||||
return TestUtils.check(results is Array, "load_shift_results always returns Array")
|
||||
@@ -41,6 +41,42 @@ var _spawn_count := 0
|
||||
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void:
|
||||
_spawn_count += 1
|
||||
|
||||
func test_late_phase_reaches_violent_event() -> bool:
|
||||
## Bug fix: the LATE phase never fired because no shift_phases entry existed.
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var coordinator := ShiftCoordinator.new()
|
||||
coordinator.setup(scenario, 900.0, 1)
|
||||
coordinator.start()
|
||||
for i in 760:
|
||||
coordinator.tick(1.0)
|
||||
var a := TestUtils.check_eq(coordinator.current_phase, Rules.Phase.LATE, "LATE phase reached by 760s")
|
||||
var eligible := scenario.events_in_phase(Rules.Phase.LATE)
|
||||
var found := false
|
||||
for e in eligible:
|
||||
if e.get("id", "") == "violent_emergency_event":
|
||||
found = true
|
||||
var b := TestUtils.check(found, "violent event is eligible in LATE phase")
|
||||
# All venues declare the LATE phase honestly.
|
||||
var c := true
|
||||
for venue in [ScenarioFactory.hollywood_building(), ScenarioFactory.joke_shop(), ScenarioFactory.tv_studio()]:
|
||||
if not venue.shift_phases.has(Rules.Phase.LATE):
|
||||
c = false
|
||||
return a and b and TestUtils.check(c, "all venues declare a LATE phase")
|
||||
|
||||
func test_protester_archetype_present() -> bool:
|
||||
var profiles := ScenarioFactory.shared_profiles()
|
||||
var protester_found := false
|
||||
for p in profiles:
|
||||
if p.id == "protester":
|
||||
protester_found = true
|
||||
var a := TestUtils.check(protester_found, "protester profile defined")
|
||||
var in_pool := false
|
||||
for venue in [ScenarioFactory.hollywood_building(), ScenarioFactory.tv_studio()]:
|
||||
for e in venue.event_pool:
|
||||
if e.get("npc_type", -1) == Rules.NpcType.PROTESTER:
|
||||
in_pool = true
|
||||
return a and TestUtils.check(in_pool, "protester event referenced in public venues")
|
||||
|
||||
func test_shift_coordinator_runs_a_shift() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var coordinator := ShiftCoordinator.new()
|
||||
|
||||
@@ -14,6 +14,15 @@ func test_strings_format() -> bool:
|
||||
var b := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats")
|
||||
return a and b
|
||||
|
||||
func test_strings_json_backed_all_resolve() -> bool:
|
||||
## Every key in the JSON-loaded table must resolve to a value other than
|
||||
## the key itself (fail-safe would otherwise mask broken entries).
|
||||
for key in Strings.TABLE.keys():
|
||||
var value: String = Strings.lookup(key)
|
||||
if value == key:
|
||||
return TestUtils.fail("key %s resolves to itself — table entry missing" % key)
|
||||
return true
|
||||
|
||||
func test_strings_cover_ui_keys() -> bool:
|
||||
## Every key referenced by the HUD and game manager must exist.
|
||||
var required := [
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
extends RefCounted
|
||||
## Headless tests for the procedural world and navigation mesh.
|
||||
|
||||
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
||||
|
||||
func test_navmesh_built() -> bool:
|
||||
var root := Node3D.new()
|
||||
WorldBuilder.new(root).build()
|
||||
var region: NavigationRegion3D = root.find_child("NavRegion", true, false) as NavigationRegion3D
|
||||
if region == null:
|
||||
return TestUtils.fail("no NavRegion in world")
|
||||
var mesh: NavigationMesh = region.navigation_mesh
|
||||
if mesh == null:
|
||||
return TestUtils.fail("NavRegion has no navigation mesh")
|
||||
var a := TestUtils.check(mesh.get_polygon_count() > 0, "navmesh has polygons")
|
||||
var b := TestUtils.check(mesh.get_vertices().size() >= 4, "navmesh has vertices")
|
||||
root.free()
|
||||
return a and b
|
||||
|
||||
func test_door_indicator_built() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
builder.build()
|
||||
var a := TestUtils.check(builder.door_indicator != null, "door indicator mesh exists")
|
||||
if builder.door_indicator != null:
|
||||
builder.set_door_locked(true)
|
||||
var mat: StandardMaterial3D = builder.door_indicator.material_override as StandardMaterial3D
|
||||
var b := TestUtils.check(mat != null and mat.albedo_color.r > 0.5, "indicator turns red when locked")
|
||||
builder.set_door_locked(false)
|
||||
var c := TestUtils.check(mat != null and mat.albedo_color.g > 0.5, "indicator turns green when open")
|
||||
root.free()
|
||||
return a and b and c
|
||||
root.free()
|
||||
return a
|
||||
Reference in new issue
Block a user