diff --git a/.gitignore b/.gitignore index c1581c1..d7c5258 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ .godot/ *.import .android/ -export_presets.cfg +# export_presets.cfg is committed so builds are reproducible # OS .DS_Store diff --git a/DONE.md b/DONE.md index 50f6f9b..b0c0340 100644 --- a/DONE.md +++ b/DONE.md @@ -74,6 +74,69 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every - [x] Set up GitHub Actions to run headless tests on push (`ci.yml`). - [x] Add a smoke-test checklist for launching one full shift (in `README.md`). +## Bug Fixes — LATE shift phase + +- [x] Add a `Rules.Phase.LATE` entry (`start_s` 700) to `shift_phases` in all three venues. +- [x] Confirm the LATE-tagged `violent_emergency_event` is now pulled from `EventScheduler.pull_due`. +- [x] Add a test asserting the LATE phase is reached and the event is eligible in phase. + +## Missing PLAN Features — Protester NPC + +- [x] Add `PROTESTER` to `Rules.NpcType` and `NPC_TYPE_NAMES`. +- [x] Add a protester profile (id `"protester"`) with ARGUE/IGNORE/RECORD/CALL_SOMEONE reactions. +- [x] Add a `TYPE_COLORS` entry for `PROTESTER` in `npc_avatar.gd`. +- [x] Add routine protester events to the Hollywood Boulevard and TV Studio event pools. +- [x] Add scenario tests asserting the protester profile and events exist. + +## Missing PLAN Features — “Call someone else” reaction + +- [x] Add `CALL_SOMEONE` to `Rules.Reaction`. +- [x] Handle `CALL_SOMEONE` in `NPCController._update_state_for_reaction` (transitions to ARGUING). +- [x] Give influencer, trespasser, and protester profiles CALL_SOMEONE weights. +- [x] Add a reaction log line in `GameManager` and a `Strings` entry. +- [x] Add a test that CALL_SOMEONE can be rolled and updates the FSM. + +## Missing PLAN Features — Door lock/secure + +- [x] Add a `DoorState` core class with locked/unlocked state and `attempt_force()`. +- [x] Add a visible door lock indicator (green open / red locked) to the world. +- [x] Add the `L` key binding toggling the door lock, with HUD indication. +- [x] Make FORCE_ENTRY reactions fail while the door is locked (thwarted outcome). +- [x] Add `Strings` entries and a headless test for the lock state machine. + +## Missing PLAN Features — Flashlight + +- [x] Add a `G` key binding toggling the guard's flashlight. +- [x] Attach a `SpotLight3D` to the guard that toggles with the flashlight. +- [x] Widen NPC perception radius while lit at evening/late phases (`perceive_guard` bonus). +- [x] Add `Strings` entries and a headless test for the perception effect. + +## Persistence + +- [x] Call `Save.save_shift_result(review)` from `GameManager._on_shift_ended`. +- [x] Load past results and show record count + best score on the shift review screen. +- [x] Add a test that a saved review round-trips through the `Save` helper. + +## Navigation + +- [x] Add a `NavigationRegion3D` with a hand-authored flat navmesh covering sidewalk, steps, and entrance. +- [x] Switch `NpcAvatar` from direct movement to `NavigationAgent3D` path following (with direct-movement fallback). +- [x] Keep per-intent waypoint and returning-trespasser re-targeting on the navmesh. +- [x] Make the step geometry visual-only (no collider) so pathing to the entrance works. + +## Localization — JSON strings + +- [x] Move the string table to `res://content/strings.en.json` (41 keys). +- [x] Load the JSON at startup; `Strings.lookup`/`Strings.fmt` signatures unchanged. +- [x] Update the strings tests to confirm every JSON key resolves to a real value. + +## Distribution — Downloadable executables + +- [x] Commit `export_presets.cfg` with Linux, Windows, and macOS desktop presets. +- [x] Add `tools/build_releases.sh` for headless release exports. +- [x] Add `docs/RELEASING.md` with template installation, build, and Gitea release steps. +- [x] Add a prominent **Downloads** section at the top of `README.md` linking to the release page. + ## Core Simulation — NPC Perception - [x] Implement a perception pass so NPCs react to the guard's gestures and proximity. diff --git a/MEMORY.md b/MEMORY.md index 4adcdb9..be233d4 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -15,36 +15,34 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o ## Current State -- Core simulation, three scenarios, headless tests (41 passing), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green. +- Core simulation, three scenarios, headless tests (**52 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green. - NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`. - Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return. - Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice. - Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`. - Wand screening (Joke Shop): `WandScreener` (new `src/core/wand_screener.gd`) classifies carried items as prohibited (`weapon/knife/gun/blade`) vs false-alarm (`keys/belt/phone/coins/watch/buckle`) vs clear. `NpcProfile.carried_items` holds guest items. `GameManager` binds `W` to `_toggle_wand_screening` and `A`/`D` to `decide_wand`; `decide_access` blocks admission at wand venues until `controller.screened` is true. `VenueScenario.door_staff_lines` holds venue-specific door-staff dialogue (Joke Shop has id/ticket/wand/refusal/prohibited lines). - Polish/accessibility: tone picker (`T` cycles `selected_tone` via `TONE_ORDER`; HUD `tone_label`), aim-free targeting (`Tab` cycles nearby NPCs within `NEARBY_RADIUS`), color-coded escalation indicator (`Hud.set_escalation`), procedural audio (`AudioFeedback` node generates sine tones for confirm/radio/escalation/deny), and a centralized `Strings` table (`src/core/strings.gd`, `lookup`/`fmt`) for localization. NOTE: `Strings.get` was renamed to `Strings.lookup` because `get` collides with `Object.get`. +- Newly added: LATE shift phase (start 700s) with a reachability test; PROTESTER archetype + events + colors; CALL_SOMEONE reaction; `DoorState` core class + `L` binding + visual indicator; flashlight (`G`, SpotLight3D, perception bonus at evening/late); persistence wired (`Save.save_shift_result` on shift end, history + best score on the review screen); `NavigationRegion3D` flat navmesh + `NavigationAgent3D` NPC paths (steps now visual-only); strings moved to `res://content/strings.en.json` (41 keys, loader keeps `lookup`/`fmt`); `export_presets.cfg` (Linux/Windows/macOS) + `tools/build_releases.sh` + `docs/RELEASING.md`; README Downloads section. - Run tests: `godot --headless --path . --script res://src/test/test_runner.gd` - Boot check: `godot --headless --path . --quit-after 120` - Screenshot tool: `godot --path . --script res://src/test/take_screenshot.gd` (writes to `user://screenshot.png`). +- Release build: `./tools/build_releases.sh` requires export templates (see `docs/RELEASING.md`). ## Known Issues / Notes - RefCounted reference cycle: `NPCController` holds an `FSM` that holds a back-reference to its owner, so test suites report "resources still in use at exit". Benign (process exit), but worth breaking later (e.g. `WeakRef` or explicit `dispose()`). - Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic. +- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb. +- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history). +- Export presets and release scripts are **not yet verified with real export templates** (none installed on this machine); see `docs/RELEASING.md` before first release. +- Joke Shop `door_staff_lines` authored but not yet surfaced in the prototype UI (tracked in TODO). ## Open Questions - Whether the responder avatar should be playable/controllable or purely scripted. -- Localization target languages. +- Localization target languages beyond English. +- macOS notarization approach for broad distribution. -## Plan vs TODO Gaps (2026-10-31 evaluation) +## Plan vs TODO Gaps (evaluated 2026-10-31, all resolved) -PLAN.md describes features that are neither implemented nor tracked in TODO.md. Recorded here so they are not lost; promote to TODO.md when work begins. - -- **Protesters NPC type** — PLAN.md premise lists "protesters" among the crowd, but there is no `PROTESTER` archetype in `Rules.NpcType`, no profile, and no events. -- **Door lock/secure mechanic** — PLAN.md Level 3 lists "Lock or secure the door" as an action; no door-lock mechanic exists. -- **Flashlight interaction** — PLAN.md Level 3 lists "Use a flashlight or radio warning tone at night"; `flashlight` appears in scenario `equipment` lists but has no interaction. -- **"Call someone else" NPC reaction** — PLAN.md Level 2 lists "Call someone else" as a possible NPC response; the `Rules.Reaction` enum has no such outcome. -- **LATE shift phase never fires (bug)** — `violent_emergency_event` is tagged `Rules.Phase.LATE`, but no scenario defines a LATE entry in `shift_phases` (only EARLY/MIDDAY/EVENING). `EventScheduler.pull_due` filters by phase, so the violent-threat event can never spawn. -- **Shift results not persisted** — `Save` autoload exposes `save_shift_result`/`load_shift_results`, but nothing calls them; PLAN.md says shift results use JSON in `user://`. -- **Navigation not implemented** — PLAN.md says `NavigationRegion3D`/`NavigationAgent3D` drive movement; NPCs currently use direct `move_and_slide` toward waypoints (no navmesh). -- **Localized text not in JSON** — PLAN.md says localized dialogue/text live in JSON files; the `Strings` table is currently a GDScript constant, not JSON. +All previously identified gaps are now implemented and covered by tests/smoke items: PROTESTER archetype, door lock/secure, flashlight, CALL_SOMEONE reaction, LATE phase fix, shift-result persistence, NavigationAgent3D movement, and JSON-backed localization. Remaining backlog lives in `TODO.md`. diff --git a/README.md b/README.md index ea249c6..061db93 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,23 @@ The central design goal is simple: > The player wins by choosing the lowest level of escalation that keeps everyone safe. +## ⬇️ Downloads + +| Platform | Package | +| --- | --- | +| **Windows** | [`step-line-windows.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed | +| **Linux** | [`step-line-linux.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run | +| **macOS** | [`step-line-macos.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below | + +**All releases:** + +> macOS builds are unsigned: right-click → Open, or clear the quarantine with +> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and +> the release pipeline live in [`docs/RELEASING.md`](docs/RELEASING.md). + +If you are here to develop or test locally instead of downloading, jump to +[Run and Test](#run-and-test) below. + The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4.3 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review. ## Game Overview @@ -88,7 +105,7 @@ Venue Scenario / Rules Shift Review ``` -The architecture maps onto Godot 4 nodes, scenes, and custom resources as described in [Tech Stack](#tech-stack). See the full arc42-style outline in [Architecture](#architecture) and the decisions in [`docs/adr/`](docs/adr/). +The architecture maps onto Godot 4 nodes, scenes, and custom resources as described in [Tech Stack](#tech-stack). See the full arc42-style outline in [`docs/architecture.md`](docs/architecture.md) and the decisions in [`docs/adr/`](docs/adr/). ## Tech Stack @@ -145,165 +162,13 @@ The unit test runner is the project's own lightweight harness (no editor plugin | A / D | Allow / deny entry during inspection or wand check | | T | Cycle dialogue tone (Professional / Friendly / Firm / Suspicious) | | Tab | Cycle target among nearby NPCs (aim-free alternative) | +| G | Toggle flashlight (widens NPC perception at night) | +| L | Lock / unlock the restricted door (thwarts forced entry) | | F | Toggle debug overlay | ## Architecture -This section follows the structure of the arc42 architecture documentation template. - -### 1. Introduction and Goals - -#### Functional goals - -- Simulate believable security work at multiple public-facing venues. -- Provide clear, low-pressure deterrence options before verbal or physical escalation. -- Make access verification a meaningful gameplay decision. -- Support distinct NPC behaviors and scenario events. -- Make emergency response rare, consequential, and justified by observable danger. -- Provide shift-level feedback so players can learn from decisions. - -#### Quality goals - -- **Clarity:** Players should understand why an NPC is allowed, denied, or escalated. -- **Extensibility:** New venues should reuse shared systems and primarily provide configuration and content. -- **Testability:** Rules and outcomes should be testable without rendering or real-time input. -- **Consistency:** Similar situations should follow predictable venue and escalation rules. -- **Respectful representation:** Mental-health crises and public interactions should not be reduced to stereotypes or treated as automatic threats. - -### 2. Constraints - -- The first implementation should be small enough for a playable vertical slice. -- The engine is Godot 4 with GDScript; a playable prototype vertical slice is implemented. -- Venue-specific policies must be configurable rather than hard-coded into individual NPCs. -- The game must preserve the de-escalation-first design described in [`PLAN.md`](PLAN.md). -- The project uses the repository workflow described in [`AGENTS.md`](AGENTS.md). - -### 3. Context and Scope - -The player interacts with NPCs, venue entrances, access artifacts, equipment, staff, and response services. The game itself owns the simulated world, rules, scoring, events, and shift review. External services are currently out of scope; radio, body camera, and emergency response are simulated systems. - -### 4. Solution Strategy - -- Use a shared simulation core with venue-specific scenario data. -- Represent NPC behavior using state machines or behavior trees with configurable traits. -- Model escalation as explicit, auditable state transitions. -- Separate access verification from NPC identity and presentation details. -- Keep scoring and reputation event-driven so every action can be reviewed. -- Use authored scenario events alongside procedural routine traffic. -- Make the first playable target one venue and one complete shift before adding breadth. - -### 5. Building Block View - -#### Level 1: Major components - -- **Presentation layer:** Camera, UI, prompts, interaction feedback, queues, and shift review. -- **Input and interaction layer:** Player gestures, dialogue choices, access checks, equipment use, and radio controls. -- **Simulation layer:** NPC movement, perception, intent, navigation, crowd behavior, and venue state. -- **Rules layer:** Access policy, escalation policy, prohibited items, response thresholds, and venue configuration. -- **Incident layer:** Incident lifecycle, evidence, radio reports, consequences, reputation, and review. -- **Content layer:** Venue definitions, NPC profiles, dialogue, events, visual/audio assets, and shift schedules. - -#### Level 2: Key components - -- `ShiftCoordinator` starts, advances, and ends a shift. -- `VenueScenario` provides venue rules, locations, event pools, and access policies. -- `NPCController` manages an NPC’s intent, movement, compliance, and reaction. -- `InteractionSystem` resolves gestures, dialogue, warnings, and physical positioning. -- `AccessVerifier` evaluates recognition, wristbands, IDs, tickets, credentials, and guest lists. -- `EscalationManager` tracks the current response level and available actions. -- `RadioSystem` receives reports and schedules security or emergency responses. -- `IncidentRecorder` stores actions, observations, evidence, and outcomes. -- `ReputationEvaluator` converts incidents and shift outcomes into performance feedback. -- `CrowdSystem` models queues, attention, congestion, and public reaction. -- `ShiftReview` presents decisions, consequences, and improvement opportunities. - -### 6. Runtime View - -#### Routine interaction - -1. An NPC selects or receives an intent such as taking a photo, entering, waiting, or trespassing. -2. The player observes the NPC and chooses whether to intervene. -3. A gesture or verbal interaction changes the NPC’s state. -4. The NPC complies, negotiates, refuses, or escalates. -5. The rules layer validates access and determines available next actions. -6. The incident recorder stores the interaction. -7. Reputation and crowd systems react to the outcome. - -#### Access check - -1. The player stops or receives an approaching person. -2. The player recognizes the person or inspects a wristband, ID, credential, ticket, or guest-list record. -3. The access verifier checks the artifact against the venue policy. -4. The player admits, redirects, holds, or refuses entry. -5. The decision is recorded for shift review. - -#### Emergency response - -1. NPC behavior crosses the configured immediate-threat threshold. -2. The player holds and confirms the emergency-radio action. -3. The player submits a concise report describing location and behavior. -4. The response system dispatches an appropriate responder. -5. Crowd, venue, reputation, and incident systems apply consequences. -6. The shift review evaluates whether the call was justified. - -### 7. Deployment View - -The initial target is a single-player local game. The expected runtime consists of one client process containing presentation, simulation, rules, content, and persistence for local settings and shift results. No online backend is required for the first prototype. - -Future options include external analytics, cloud saves, or replay sharing, but they are not part of the initial architecture. - -### 8. Cross-Cutting Concepts - -- **Event-based incident recording:** Important actions emit records consumed by evidence, reputation, and review systems. -- **Data-driven venue rules:** Access, escalation, equipment, and event differences live in scenario data. -- **State transitions:** NPCs, incidents, and shifts expose explicit states for debugging and testing. -- **Accessibility:** Input alternatives should exist for gestures, dialogue, visual alerts, and audio cues. -- **Safety and proportionality:** The rules layer prevents ordinary noncompliance from automatically becoming a violent encounter. -- **Determinism where useful:** Scenario tests should be able to use fixed seeds and scripted NPC behavior. -- **Localization:** Dialogue and prompts should be externalized from logic. -- **Observability:** Debug overlays should show NPC intent, access status, escalation level, active incidents, and response timers during development. - -### 9. Architecture Decisions - -Architecture decisions are recorded as ADRs: - -- [ADR-0001: Venue-driven simulation architecture](docs/adr/0001-venue-driven-simulation-architecture.md) -- [ADR-0002: De-escalation-first escalation model](docs/adr/0002-de-escalation-first-escalation-model.md) -- [ADR-0003: Explicit access-verification system](docs/adr/0003-explicit-access-verification-system.md) -- [ADR-0004: Local single-player first](docs/adr/0004-local-single-player-first.md) -- [ADR-0005: Event-based incident recording](docs/adr/0005-event-based-incident-recording.md) -- [ADR-0006: Godot 4 with GDScript](docs/adr/0006-godot-4-gdscript.md) - -### 10. Quality Requirements - -| Requirement | Target | -| --- | --- | -| Player comprehension | Players can identify the reason for an access or escalation decision through UI and feedback. | -| Response proportionality | Routine NPC behavior should be resolvable without emergency response. | -| Extensibility | A new venue can be added primarily through scenario data and content. | -| Performance | The target venue should support a believable crowd without harming input or simulation responsiveness. | -| Testability | Access and escalation rules can be tested independently of presentation. | -| Accessibility | Core actions are available through clear input alternatives and readable feedback. | - -### 11. Risks and Technical Debt - -- NPC behavior may feel artificial if traits and reactions are too deterministic. -- Crowd simulation may become expensive around busy studio or comedy-club entrances. -- Access verification can become frustrating if the UI does not clearly show what the player is checking. -- Emergency response can overshadow the core game if it is too powerful or too frequent. -- Mental-health-crisis scenarios require careful writing, playtesting, and respectful presentation. -- The choice of engine may affect navigation, crowd scale, tooling, and content authoring. - -### 12. Glossary - -- **Access artifact:** A wristband, ID, ticket, credential, or other item used to verify entry. -- **Authorized person:** Someone permitted to enter under the current venue policy. -- **Door person:** The entrance security worker in The Joke Shop scenario. -- **Incident:** A recorded interaction or event requiring player judgment. -- **NPC:** Non-player character. -- **Scenario:** A venue-specific configuration of rules, content, locations, and events. -- **Shift:** One playable work period, normally 10–15 minutes. -- **Simple deterrence:** A low-pressure gesture such as raising a hand, making eye contact, or pointing. +The full arc42-style architecture documentation lives in [`docs/architecture.md`](docs/architecture.md). It covers goals, constraints, context, solution strategy, building blocks, runtime views, deployment, cross-cutting concepts, quality requirements, risks, and a glossary. ## Project Documentation @@ -312,6 +177,7 @@ Architecture decisions are recorded as ADRs: - [`TODO.md`](TODO.md) — Active checkbox task list. - [`DONE.md`](DONE.md) — Completed task sections. - [`MEMORY.md`](MEMORY.md) — Persistent session notes. +- [`docs/architecture.md`](docs/architecture.md) — arc42-style architecture documentation. - [`docs/adr/`](docs/adr/) — Architecture Decision Records. ## Development Status @@ -321,8 +187,9 @@ Implemented so far: - Godot 4.3 project scaffold, input map, procedural world builder - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools -- Headless unit tests: 41 passing (access rules, escalation, radio cooldown/battery, NPC reaction determinism, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings) -- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, dialogue tone picker, aim-free targeting, color-coded escalation feedback, procedural audio cues, debug overlay, and shift review +- Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings) +- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event +- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, dialogue tone picker, aim-free targeting, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review Not yet implemented: body-camera replay timeline, long (10-15 minute) shift lengths with phase-based lighting, venue selection screen, editor-based GUT integration, and RefCounted cycle cleanup (see `TODO.md`). @@ -336,8 +203,10 @@ Not yet implemented: body-camera replay timeline, long (10-15 minute) shift leng 6. `E` opens access inspection showing artifacts and policy result; `A`/`D` record a decision. 7. `6` opens the assistance form; submitting shows dispatch feedback. 8. `R` opens the emergency form; the button must be held ~1.6s or the call cancels. -9. `F` toggles the debug overlay with NPC states and shift stats. -10. When the shift timer reaches 0, the shift review screen shows grade and incident summary. +9. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked). +10. `F` toggles the debug overlay with NPC states and shift stats. +11. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run). +12. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history. ## Development Workflow diff --git a/TODO.md b/TODO.md index 392ce62..a05db09 100644 --- a/TODO.md +++ b/TODO.md @@ -1,76 +1,16 @@ # TODO -## Bug Fixes - -### LATE shift phase never fires - -- [ ] Add a `Rules.Phase.LATE` entry (e.g. `{"start_s": 900.0}`) to `shift_phases` in `hollywood_building()`, `joke_shop()`, and `tv_studio()` in `src/content/scenario_factory.gd`. -- [ ] Confirm `violent_emergency_event` (tagged `Phase.LATE`, `time_range_s` 760–880) can now be pulled by `EventScheduler.pull_due`. -- [ ] Add a test asserting the LATE-phase event is reachable (e.g. tick a coordinator past 900s and check the event pool is eligible). - -## Missing PLAN Features — NPCs and Reactions - -### PROTESTER archetype - -- [ ] Add `PROTESTER` to `Rules.NpcType` and a `"Protester"` entry to `NPC_TYPE_NAMES` in `src/core/rules.gd`. -- [ ] Add a protester profile in `ScenarioFactory.shared_profiles()` (id `"protester"`, temperament ~0.5, base_compliance ~0.4, reactions favoring ARGUE/IGNORE/RECORD). -- [ ] Add a `TYPE_COLORS` entry for `PROTESTER` in `src/game/npc_avatar.gd`. -- [ ] Add routine protester events (e.g. `"protest_gather"`, intent `"protest"`) to the Hollywood Boulevard and TV Studio event pools. -- [ ] Add a scenario test asserting the protester profile exists and is referenced by an event. - -### "Call someone else" NPC reaction - -- [ ] Add `CALL_SOMEONE` to `Rules.Reaction` in `src/core/rules.gd`. -- [ ] Handle `CALL_SOMEONE` in `NPCController._update_state_for_reaction` (e.g. transition to `ARGUING` or a new `CALLING` state). -- [ ] Give relevant profiles (influencer, trespasser) a weight for `CALL_SOMEONE` in their `reactions_by_level`. -- [ ] Add a reaction log line in `GameManager._react_to_outcome` and a `Strings` entry. -- [ ] Add a test asserting `CALL_SOMEONE` can be rolled and updates the FSM. - -## Missing PLAN Features — Equipment and Environment - -### Door lock/secure mechanic - -- [ ] Add a door state (locked/unlocked) to `WorldBuilder` (or a small `Door` node) with a visible indicator. -- [ ] Add a player action + key binding (e.g. `L`) to toggle the door lock in `GameManager`. -- [ ] Make `FORCE_ENTRY` reactions fail (or be delayed) while the door is locked; log the consequence. -- [ ] Add a `Strings` entry and a headless test for the lock state transition. - -### Flashlight interaction - -- [ ] Add a flashlight toggle + key binding (e.g. `G`) in `GameManager`. -- [ ] Attach a `SpotLight3D` to the guard that toggles with the flashlight. -- [ ] Make the flashlight affect NPC perception at night (e.g. widen `AWARE_RADIUS` or raise notice probability when lit). -- [ ] Add a `Strings` entry and a headless test for the perception effect. - -## Persistence - -### Persist shift results - -- [ ] Call `Save.save_shift_result(review)` from `GameManager._on_shift_ended`. -- [ ] Load past results (`Save.load_shift_results()`) and show a short history (count + last grade) on the shift review screen. -- [ ] Add a test that a saved review round-trips through `Save` (or verify manually in the smoke test). - -## Navigation - -### Navmesh-based NPC movement - -- [ ] Add a `NavigationRegion3D` with a baked navmesh covering the sidewalk, steps, and entrance in `WorldBuilder`. -- [ ] Switch `NpcAvatar` from direct `move_and_slide` toward `target_position` to `NavigationAgent3D` path following. -- [ ] Keep the per-intent waypoint and returning-trespasser re-targeting behavior working on the navmesh. -- [ ] Verify NPCs route around the building/steps in a manual smoke test. - -## Localization - -### Move Strings to JSON - -- [ ] Create a `res://` JSON file (e.g. `res://content/strings.en.json`) containing the current `Strings.TABLE` entries. -- [ ] Load the JSON at startup and keep `Strings.lookup`/`Strings.fmt` signatures unchanged. -- [ ] Update `test_strings.gd` to load from the JSON-backed table and confirm all keys still resolve. - ## Future Systems (design-level backlog) - [ ] Body-camera replay timeline in the shift review. -- [ ] Long (10-15 minute) shift lengths and phase-based lighting. -- [ ] Venue selection screen; export presets for desktop targets. +- [ ] Long (10-15 minute) shift lengths and phase-based lighting (darkened evening / late phases). +- [ ] Venue selection screen (wire `switch_scenario` to a startup menu). +- [ ] CI packaging: upload `build/` artifacts to releases automatically on tags. - [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced. - [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`. + +## Open Polish + +- [ ] Add venue-specific door-staff dialogue usage (scenario `door_staff_lines` is authored but not yet shown in the prototype UI). +- [ ] Add a body/wand cooldown feel: wand screening currently has no reuse penalty; consider a short per-guest scan animation. +- [ ] Add an escape-to-circuit handling note: `signals` in `Strings.fmt` are positional; keep `%` placeholders stable when adding keys. diff --git a/content/strings.en.json b/content/strings.en.json new file mode 100644 index 0000000..31d380d --- /dev/null +++ b/content/strings.en.json @@ -0,0 +1,43 @@ +{ + "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 · G flashlight · L lock door · 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.", + "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!", + "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.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.arrived": "Responder arrives and %ss the situation.", + "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.call_someone": "%s calls someone for help.", + "react.none": "No reaction (yet).", + "perception.near": "%s notices you standing close.", + "perception.aware": "%s notices you watching.", + "door.locked": "The door is now locked.", + "door.unlocked": "The door is now unlocked.", + "door.thwarted": "%s tries the locked door and fails.", + "flashlight.on": "Flashlight on.", + "flashlight.off": "Flashlight off." +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..0d98441 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,100 @@ +# Releasing Step Line + +This guide describes how to produce downloadable executables and publish them +so the README "Downloads" links always point at the latest release. + +## 1. Install export templates + +Godot needs platform export templates to build standalone binaries. The first +time you export, either: + +- Open the editor and use **Editor → Manage Export Templates** (downloads the + matching templates), or +- Install them manually from the + [Godot downloads](https://godotengine.org/download/) page by placing the + `Godot_v4.3-stable_export_templates.tpz` contents into + `~/.local/share/godot/export_templates/4.3.stable/`. + +Verify with: + +```bash +godot --headless --export-release "Linux" build/step-line-smoke.x86_64 +``` + +## 2. Bump the version + +`project.godot` sets `config/version`. Keep it in sync with the release tag +(e.g. `v0.3.0` → `config/version="0.3.0"`, plus a git tag `v0.3.0`). + +## 3. Run the checks first + +A release candidate must pass: + +```bash +godot --headless --path . --script res://src/test/test_runner.gd # all tests +godot --headless --path . --quit-after 120 # boot smoke test +``` + +## 4. Build + +```bash +./tools/build_releases.sh +``` + +Artifacts are written to `build/`: + +- `build/step-line-linux-.x86_64` +- `build/step-line-windows-.exe` +- `build/step-line-macos-.zip` + +> macOS builds are unsigned and will need `xattr -dr com.apple.quarantine` +> guidance or notarization for production releases. + +## 5. Publish on the repository host (Gitea/Forgejo) + +The repository lives at `https://git.allucanget.biz/zwitschi/security-game`. + +### Via the web UI + +1. Create a **New Release** with tag `v`. +2. Attach all three `build/` artifacts. +3. Write release notes (see checklist below). + +### Via the API + +```bash +TOKEN="your_api_token" +REPO="zwitschi/security-game" +VERSION="0.3.0" +API="https://git.allucanget.biz/api/v1/repos/$REPO/releases" + +# Create the release +RELEASE_JSON=$(curl -s -X POST "$API" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"v$VERSION\",\"name\":\"v$VERSION\",\"body\":\"Playable prototype release.\"}") + +RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") + +for f in build/step-line-*"$VERSION"*; do + curl -s -X POST "$API/$RELEASE_ID/assets" \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@$f" +done +``` + +## 6. Update the README + +The **Downloads** section links to the host releases page +(`https://git.allucanget.biz/zwitschi/security-game/releases`), so no README +edit is needed when the page follows the naming pattern. If artifact names +change, update `tools/build_releases.sh` first. + +## Release notes checklist + +- [ ] All headless tests pass (exit code 0). +- [ ] Game boots cleanly in a smoke test. +- [ ] Controls documented in `README.md` match the build. +- [ ] Version in `project.godot` matches the tag. +- [ ] Export templates used are the official ones for the pinned engine. +- [ ] Artifacts uploaded; README downloads page shows them. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c07a0d5 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,157 @@ +# Architecture + +This document follows the structure of the arc42 architecture documentation template. + +## 1. Introduction and Goals + +### Functional goals + +- Simulate believable security work at multiple public-facing venues. +- Provide clear, low-pressure deterrence options before verbal or physical escalation. +- Make access verification a meaningful gameplay decision. +- Support distinct NPC behaviors and scenario events. +- Make emergency response rare, consequential, and justified by observable danger. +- Provide shift-level feedback so players can learn from decisions. + +### Quality goals + +- **Clarity:** Players should understand why an NPC is allowed, denied, or escalated. +- **Extensibility:** New venues should reuse shared systems and primarily provide configuration and content. +- **Testability:** Rules and outcomes should be testable without rendering or real-time input. +- **Consistency:** Similar situations should follow predictable venue and escalation rules. +- **Respectful representation:** Mental-health crises and public interactions should not be reduced to stereotypes or treated as automatic threats. + +## 2. Constraints + +- The first implementation should be small enough for a playable vertical slice. +- The engine is Godot 4 with GDScript; a playable prototype vertical slice is implemented. +- Venue-specific policies must be configurable rather than hard-coded into individual NPCs. +- The game must preserve the de-escalation-first design described in [`PLAN.md`](../PLAN.md). +- The project uses the repository workflow described in [`AGENTS.md`](../AGENTS.md). + +## 3. Context and Scope + +The player interacts with NPCs, venue entrances, access artifacts, equipment, staff, and response services. The game itself owns the simulated world, rules, scoring, events, and shift review. External services are currently out of scope; radio, body camera, and emergency response are simulated systems. + +## 4. Solution Strategy + +- Use a shared simulation core with venue-specific scenario data. +- Represent NPC behavior using state machines or behavior trees with configurable traits. +- Model escalation as explicit, auditable state transitions. +- Separate access verification from NPC identity and presentation details. +- Keep scoring and reputation event-driven so every action can be reviewed. +- Use authored scenario events alongside procedural routine traffic. +- Make the first playable target one venue and one complete shift before adding breadth. + +## 5. Building Block View + +### Level 1: Major components + +- **Presentation layer:** Camera, UI, prompts, interaction feedback, queues, and shift review. +- **Input and interaction layer:** Player gestures, dialogue choices, access checks, equipment use, and radio controls. +- **Simulation layer:** NPC movement, perception, intent, navigation, crowd behavior, and venue state. +- **Rules layer:** Access policy, escalation policy, prohibited items, response thresholds, and venue configuration. +- **Incident layer:** Incident lifecycle, evidence, radio reports, consequences, reputation, and review. +- **Content layer:** Venue definitions, NPC profiles, dialogue, events, visual/audio assets, and shift schedules. + +### Level 2: Key components + +- `ShiftCoordinator` starts, advances, and ends a shift. +- `VenueScenario` provides venue rules, locations, event pools, and access policies. +- `NPCController` manages an NPC’s intent, movement, compliance, and reaction. +- `InteractionSystem` resolves gestures, dialogue, warnings, and physical positioning. +- `AccessVerifier` evaluates recognition, wristbands, IDs, tickets, credentials, and guest lists. +- `EscalationManager` tracks the current response level and available actions. +- `RadioSystem` receives reports and schedules security or emergency responses. +- `IncidentRecorder` stores actions, observations, evidence, and outcomes. +- `ReputationEvaluator` converts incidents and shift outcomes into performance feedback. +- `CrowdSystem` models queues, attention, congestion, and public reaction. +- `ShiftReview` presents decisions, consequences, and improvement opportunities. + +## 6. Runtime View + +### Routine interaction + +1. An NPC selects or receives an intent such as taking a photo, entering, waiting, or trespassing. +2. The player observes the NPC and chooses whether to intervene. +3. A gesture or verbal interaction changes the NPC’s state. +4. The NPC complies, negotiates, refuses, or escalates. +5. The rules layer validates access and determines available next actions. +6. The incident recorder stores the interaction. +7. Reputation and crowd systems react to the outcome. + +### Access check + +1. The player stops or receives an approaching person. +2. The player recognizes the person or inspects a wristband, ID, credential, ticket, or guest-list record. +3. The access verifier checks the artifact against the venue policy. +4. The player admits, redirects, holds, or refuses entry. +5. The decision is recorded for shift review. + +### Emergency response + +1. NPC behavior crosses the configured immediate-threat threshold. +2. The player holds and confirms the emergency-radio action. +3. The player submits a concise report describing location and behavior. +4. The response system dispatches an appropriate responder. +5. Crowd, venue, reputation, and incident systems apply consequences. +6. The shift review evaluates whether the call was justified. + +## 7. Deployment View + +The initial target is a single-player local game. The expected runtime consists of one client process containing presentation, simulation, rules, content, and persistence for local settings and shift results. No online backend is required for the first prototype. + +Future options include external analytics, cloud saves, or replay sharing, but they are not part of the initial architecture. + +## 8. Cross-Cutting Concepts + +- **Event-based incident recording:** Important actions emit records consumed by evidence, reputation, and review systems. +- **Data-driven venue rules:** Access, escalation, equipment, and event differences live in scenario data. +- **State transitions:** NPCs, incidents, and shifts expose explicit states for debugging and testing. +- **Accessibility:** Input alternatives should exist for gestures, dialogue, visual alerts, and audio cues. +- **Safety and proportionality:** The rules layer prevents ordinary noncompliance from automatically becoming a violent encounter. +- **Determinism where useful:** Scenario tests should be able to use fixed seeds and scripted NPC behavior. +- **Localization:** Dialogue and prompts should be externalized from logic. +- **Observability:** Debug overlays should show NPC intent, access status, escalation level, active incidents, and response timers during development. + +## 9. Architecture Decisions + +Architecture decisions are recorded as ADRs: + +- [ADR-0001: Venue-driven simulation architecture](adr/0001-venue-driven-simulation-architecture.md) +- [ADR-0002: De-escalation-first escalation model](adr/0002-de-escalation-first-escalation-model.md) +- [ADR-0003: Explicit access-verification system](adr/0003-explicit-access-verification-system.md) +- [ADR-0004: Local single-player first](adr/0004-local-single-player-first.md) +- [ADR-0005: Event-based incident recording](adr/0005-event-based-incident-recording.md) +- [ADR-0006: Godot 4 with GDScript](adr/0006-godot-4-gdscript.md) + +## 10. Quality Requirements + +| Requirement | Target | +| --- | --- | +| Player comprehension | Players can identify the reason for an access or escalation decision through UI and feedback. | +| Response proportionality | Routine NPC behavior should be resolvable without emergency response. | +| Extensibility | A new venue can be added primarily through scenario data and content. | +| Performance | The target venue should support a believable crowd without harming input or simulation responsiveness. | +| Testability | Access and escalation rules can be tested independently of presentation. | +| Accessibility | Core actions are available through clear input alternatives and readable feedback. | + +## 11. Risks and Technical Debt + +- NPC behavior may feel artificial if traits and reactions are too deterministic. +- Crowd simulation may become expensive around busy studio or comedy-club entrances. +- Access verification can become frustrating if the UI does not clearly show what the player is checking. +- Emergency response can overshadow the core game if it is too powerful or too frequent. +- Mental-health-crisis scenarios require careful writing, playtesting, and respectful presentation. +- The choice of engine may affect navigation, crowd scale, tooling, and content authoring. + +## 12. Glossary + +- **Access artifact:** A wristband, ID, ticket, credential, or other item used to verify entry. +- **Authorized person:** Someone permitted to enter under the current venue policy. +- **Door person:** The entrance security worker in The Joke Shop scenario. +- **Incident:** A recorded interaction or event requiring player judgment. +- **NPC:** Non-player character. +- **Scenario:** A venue-specific configuration of rules, content, locations, and events. +- **Shift:** One playable work period, normally 10–15 minutes. +- **Simple deterrence:** A low-pressure gesture such as raising a hand, making eye contact, or pointing. diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..7b942ea --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,105 @@ +[preset.0] + +name="Linux" +platform="Linux/X11" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/step-line-linux.x86_64" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=false +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +binary_format/architecture="x86_64" +ssh_remote_deploy/enabled=false + +[preset.1] + +name="Windows Desktop" +platform="Windows Desktop" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/step-line-windows.exe" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=0 +binary_format/embed_pck=false +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +codesign/enable=false +codesign/timestamp=true +codesign/identity_type=0 +application/modify_resources=true +application/icon="" +application/console_wrapper_icon="" +application/icon_interpolation=4 + +[preset.2] + +name="macOS" +platform="macOS" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/step-line-macos.zip" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.2.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=0 +binary_format/embed_pck=false +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +codesign/enable=false +codesign/timestamp=true +codesign/identity_type=0 +application/bundle_identifier="biz.allucanget.step-line" +application/icon="" +application/icon_interpolation=4 +application/export_angle=0 +application/target_os_version="" +application/signature_flags=0 +application/show_bundle_name=1 diff --git a/src/content/scenario_factory.gd b/src/content/scenario_factory.gd index 3c2c5ab..031974e 100644 --- a/src/content/scenario_factory.gd +++ b/src/content/scenario_factory.gd @@ -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), diff --git a/src/core/door_state.gd b/src/core/door_state.gd new file mode 100644 index 0000000..5acaa71 --- /dev/null +++ b/src/core/door_state.gd @@ -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"} diff --git a/src/core/npc_controller.gd b/src/core/npc_controller.gd index ac62fb5..5b6ae2a 100644 --- a/src/core/npc_controller.gd +++ b/src/core/npc_controller.gd @@ -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: diff --git a/src/core/rules.gd b/src/core/rules.gd index 5051482..545a4fe 100644 --- a/src/core/rules.gd +++ b/src/core/rules.gd @@ -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. diff --git a/src/core/signal_bus.gd b/src/core/signal_bus.gd deleted file mode 100644 index 604ab97..0000000 --- a/src/core/signal_bus.gd +++ /dev/null @@ -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. directly.") diff --git a/src/core/strings.gd b/src/core/strings.gd index 1f137fc..f53936e 100644 --- a/src/core/strings.gd +++ b/src/core/strings.gd @@ -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 \ No newline at end of file + 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.") diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index 96091a8..8959f06 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -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") diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index c82b299..b05c700 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -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() diff --git a/src/game/ui/hud.gd b/src/game/ui/hud.gd index 395a2c4..523bad1 100644 --- a/src/game/ui/hud.gd +++ b/src/game/ui/hud.gd @@ -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 diff --git a/src/game/ui/shift_review.gd b/src/game/ui/shift_review.gd index 2b678cc..23e8ba6 100644 --- a/src/game/ui/shift_review.gd +++ b/src/game/ui/shift_review.gd @@ -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)], diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index 07804f1..bb229ef 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -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) \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/test/tests/test_door_and_flashlight.gd b/src/test/tests/test_door_and_flashlight.gd new file mode 100644 index 0000000..24275c0 --- /dev/null +++ b/src/test/tests/test_door_and_flashlight.gd @@ -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 diff --git a/src/test/tests/test_npc_reactions.gd b/src/test/tests/test_npc_reactions.gd index 8448820..f97a303 100644 --- a/src/test/tests/test_npc_reactions.gd +++ b/src/test/tests/test_npc_reactions.gd @@ -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() diff --git a/src/test/tests/test_persistence.gd b/src/test/tests/test_persistence.gd new file mode 100644 index 0000000..4659c83 --- /dev/null +++ b/src/test/tests/test_persistence.gd @@ -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") diff --git a/src/test/tests/test_scenarios.gd b/src/test/tests/test_scenarios.gd index 417d78b..9d12527 100644 --- a/src/test/tests/test_scenarios.gd +++ b/src/test/tests/test_scenarios.gd @@ -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() diff --git a/src/test/tests/test_strings.gd b/src/test/tests/test_strings.gd index 6f29c7b..4716e73 100644 --- a/src/test/tests/test_strings.gd +++ b/src/test/tests/test_strings.gd @@ -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 := [ diff --git a/src/test/tests/test_world.gd b/src/test/tests/test_world.gd new file mode 100644 index 0000000..1d27bed --- /dev/null +++ b/src/test/tests/test_world.gd @@ -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 diff --git a/tools/build_releases.sh b/tools/build_releases.sh new file mode 100644 index 0000000..5f29fc8 --- /dev/null +++ b/tools/build_releases.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Build distributable desktop executables with the Godot export pipeline. +# +# Requirements: +# - Godot 4.3 (or the version in project.godot `config/features`) on PATH, +# with export templates installed (see docs/RELEASING.md). +# - Run from the repository root. +# +# Produces: +# build/step-line-linux-.x86_64 +# build/step-line-windows-.exe +# build/step-line-macos-.zip +# +set -euo pipefail + +GODOT="${GODOT:-godot}" +VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)" +mkdir -p build + +echo "==> Exporting Linux (x86_64)" +"$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64" + +echo "==> Exporting Windows Desktop" +"$GODOT" --headless --export-release "Windows Desktop" "build/step-line-windows-${VERSION}.exe" + +echo "==> Exporting macOS" +"$GODOT" --headless --export-release "macOS" "build/step-line-macos-${VERSION}.zip" + +echo "" +echo "Artifacts:" +ls -la build/*"${VERSION}"* +echo "" +echo "Upload to: https://git.allucanget.biz/zwitschi/security-game/releases" +echo "See docs/RELEASING.md for the full release checklist."