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

- 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:
zwitschi committed 2026-10-31 12:51:13 +01:00
1 parent c808165302
commit 1e972a7490
27 files changed
+1044 -340

No files matched your search

+66 -8
View File
@@ -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),
+23
View File
@@ -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"}
+6 -2
View File
@@ -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:
+3
View File
@@ -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.
-21
View File
@@ -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
View File
@@ -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.")
+58 -6
View File
@@ -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")
+48
View File
@@ -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()
+22
View File
@@ -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
+17 -1
View File
@@ -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)],
+48 -7
View File
@@ -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
+14
View File
@@ -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()
+29
View File
@@ -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")
+36
View File
@@ -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()
+9
View File
@@ -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 := [
+34
View File
@@ -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