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

+1 -1
View File
@@ -2,7 +2,7 @@
.godot/ .godot/
*.import *.import
.android/ .android/
export_presets.cfg # export_presets.cfg is committed so builds are reproducible
# OS # OS
.DS_Store .DS_Store
+63
View File
@@ -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] 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`). - [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 ## Core Simulation — NPC Perception
- [x] Implement a perception pass so NPCs react to the guard's gestures and proximity. - [x] Implement a perception pass so NPCs react to the guard's gestures and proximity.
+11 -13
View File
@@ -15,36 +15,34 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Current State ## 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`. - 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. - 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. - 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`. - 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). - 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`. - 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` - Run tests: `godot --headless --path . --script res://src/test/test_runner.gd`
- Boot check: `godot --headless --path . --quit-after 120` - Boot check: `godot --headless --path . --quit-after 120`
- Screenshot tool: `godot --path . --script res://src/test/take_screenshot.gd` (writes to `user://screenshot.png`). - 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 ## 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()`). - 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. - 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 ## Open Questions
- Whether the responder avatar should be playable/controllable or purely scripted. - 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. 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`.
- **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.
+29 -160
View File
@@ -6,6 +6,23 @@ The central design goal is simple:
> The player wins by choosing the lowest level of escalation that keeps everyone safe. > 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:** <https://git.allucanget.biz/zwitschi/security-game/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. 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 ## Game Overview
@@ -88,7 +105,7 @@ Venue Scenario / Rules
Shift Review 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 ## 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 | | A / D | Allow / deny entry during inspection or wand check |
| T | Cycle dialogue tone (Professional / Friendly / Firm / Suspicious) | | T | Cycle dialogue tone (Professional / Friendly / Firm / Suspicious) |
| Tab | Cycle target among nearby NPCs (aim-free alternative) | | 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 | | F | Toggle debug overlay |
## Architecture ## Architecture
This section follows the structure of the arc42 architecture documentation template. 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.
### 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.
## Project Documentation ## Project Documentation
@@ -312,6 +177,7 @@ Architecture decisions are recorded as ADRs:
- [`TODO.md`](TODO.md) — Active checkbox task list. - [`TODO.md`](TODO.md) — Active checkbox task list.
- [`DONE.md`](DONE.md) — Completed task sections. - [`DONE.md`](DONE.md) — Completed task sections.
- [`MEMORY.md`](MEMORY.md) — Persistent session notes. - [`MEMORY.md`](MEMORY.md) — Persistent session notes.
- [`docs/architecture.md`](docs/architecture.md) — arc42-style architecture documentation.
- [`docs/adr/`](docs/adr/) — Architecture Decision Records. - [`docs/adr/`](docs/adr/) — Architecture Decision Records.
## Development Status ## Development Status
@@ -321,8 +187,9 @@ Implemented so far:
- Godot 4.3 project scaffold, input map, procedural world builder - 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` - 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 - 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) - 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)
- 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 - 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`). 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. 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. 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. 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. 9. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
10. When the shift timer reaches 0, the shift review screen shows grade and incident summary. 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 ## Development Workflow
+9 -69
View File
@@ -1,76 +1,16 @@
# TODO # 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) ## Future Systems (design-level backlog)
- [ ] Body-camera replay timeline in the shift review. - [ ] Body-camera replay timeline in the shift review.
- [ ] Long (10-15 minute) shift lengths and phase-based lighting. - [ ] Long (10-15 minute) shift lengths and phase-based lighting (darkened evening / late phases).
- [ ] Venue selection screen; export presets for desktop targets. - [ ] 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. - [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
- [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`. - [ ] 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.
+43
View File
@@ -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."
}
+100
View File
@@ -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-<version>.x86_64`
- `build/step-line-windows-<version>.exe`
- `build/step-line-macos-<version>.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<version>`.
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.
+157
View File
@@ -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.
+105
View File
@@ -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
+66 -8
View File
@@ -84,10 +84,18 @@ static func shared_profiles() -> Array[NpcProfile]:
Rules.Reaction.ARGUE: 10, Rules.Reaction.ARGUE: 10,
}, },
Rules.Level.FORMAL_WARNING: { Rules.Level.FORMAL_WARNING: {
Rules.Reaction.RECORD: 45, Rules.Reaction.RECORD: 35,
Rules.Reaction.ARGUE: 25, Rules.Reaction.ARGUE: 20,
Rules.Reaction.CALL_SOMEONE: 15,
Rules.Reaction.COMPLY: 30, 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 = { influencer.dialogue_lines = {
Rules.Tone.SUSPICIOUS: [ Rules.Tone.SUSPICIOUS: [
@@ -138,9 +146,10 @@ static func shared_profiles() -> Array[NpcProfile]:
Rules.Reaction.COMPLY: 40, Rules.Reaction.COMPLY: 40,
}, },
Rules.Level.BLOCKING: { Rules.Level.BLOCKING: {
Rules.Reaction.LEAVE: 40, Rules.Reaction.LEAVE: 35,
Rules.Reaction.ARGUE: 30, Rules.Reaction.ARGUE: 25,
Rules.Reaction.RETURN_LATER: 20, Rules.Reaction.RETURN_LATER: 15,
Rules.Reaction.CALL_SOMEONE: 15,
Rules.Reaction.FORCE_ENTRY: 10, Rules.Reaction.FORCE_ENTRY: 10,
}, },
Rules.Level.RADIO_ASSIST: { Rules.Level.RADIO_ASSIST: {
@@ -235,6 +244,48 @@ static func shared_profiles() -> Array[NpcProfile]:
} }
profiles.append(violent_threat) 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 --------------------------------------------------------- # --- Authorized crowd ---------------------------------------------------------
var staff := NpcProfile.new() var staff := NpcProfile.new()
staff.id = "authorized_staff" staff.id = "authorized_staff"
@@ -276,7 +327,7 @@ static func hollywood_building() -> VenueScenario:
scenario.equipment = ["wristband_policy", "flashlight", "radio"] scenario.equipment = ["wristband_policy", "flashlight", "radio"]
scenario.locations = { scenario.locations = {
"guard_post": Vector3(0, 0, -3), "guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 0), "entrance": Vector3(0, 0, 1.0),
"steps_end": Vector3(0, 0, 3), "steps_end": Vector3(0, 0, 3),
"sidewalk": Vector3(0, 0, 9), "sidewalk": Vector3(0, 0, 9),
"queue_area": Vector3(0, 0, 6), "queue_area": Vector3(0, 0, 6),
@@ -286,15 +337,18 @@ static func hollywood_building() -> VenueScenario:
Rules.Phase.EARLY: {"start_s": 0.0}, Rules.Phase.EARLY: {"start_s": 0.0},
Rules.Phase.MIDDAY: {"start_s": 300.0}, Rules.Phase.MIDDAY: {"start_s": 300.0},
Rules.Phase.EVENING: {"start_s": 600.0}, Rules.Phase.EVENING: {"start_s": 600.0},
Rules.Phase.LATE: {"start_s": 700.0},
} }
scenario.npc_profiles = _filter_profiles([ scenario.npc_profiles = _filter_profiles([
"tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest", "tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest",
"street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat", "street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat",
"protester",
]) ])
scenario.event_pool = [ scenario.event_pool = [
_building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35), _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("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("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), _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.equipment = ["id_check", "guest_list", "wand", "radio"]
scenario.locations = { scenario.locations = {
"guard_post": Vector3(0, 0, -3), "guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 0), "entrance": Vector3(0, 0, 1.0),
"door_area": Vector3(0, 0, 3), "door_area": Vector3(0, 0, 3),
"queue_area": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 9),
} }
@@ -380,6 +434,7 @@ static func joke_shop() -> VenueScenario:
Rules.Phase.EARLY: {"start_s": 0.0}, Rules.Phase.EARLY: {"start_s": 0.0},
Rules.Phase.MIDDAY: {"start_s": 300.0}, Rules.Phase.MIDDAY: {"start_s": 300.0},
Rules.Phase.EVENING: {"start_s": 600.0}, Rules.Phase.EVENING: {"start_s": 600.0},
Rules.Phase.LATE: {"start_s": 700.0},
} }
scenario.npc_profiles = _filter_profiles([ scenario.npc_profiles = _filter_profiles([
@@ -430,7 +485,7 @@ static func tv_studio() -> VenueScenario:
scenario.equipment = ["wristband_policy", "guest_list", "radio", "flashlight"] scenario.equipment = ["wristband_policy", "guest_list", "radio", "flashlight"]
scenario.locations = { scenario.locations = {
"guard_post": Vector3(0, 0, -3), "guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 0), "entrance": Vector3(0, 0, 1.0),
"stage_door": Vector3(6, 0, -2), "stage_door": Vector3(6, 0, -2),
"queue_area": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 9),
} }
@@ -439,15 +494,18 @@ static func tv_studio() -> VenueScenario:
Rules.Phase.EARLY: {"start_s": 0.0}, Rules.Phase.EARLY: {"start_s": 0.0},
Rules.Phase.MIDDAY: {"start_s": 300.0}, Rules.Phase.MIDDAY: {"start_s": 300.0},
Rules.Phase.EVENING: {"start_s": 600.0}, Rules.Phase.EVENING: {"start_s": 600.0},
Rules.Phase.LATE: {"start_s": 700.0},
} }
scenario.npc_profiles = _filter_profiles([ scenario.npc_profiles = _filter_profiles([
"tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest", "tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest",
"street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat", "street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat",
"protester",
]) ])
scenario.event_pool = [ scenario.event_pool = [
_studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35), _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("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("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), _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") fsm.transition_to("LOITERING_DISTANT")
will_return = true will_return = true
return_delay_s = _roll_return_delay() 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 pass
@@ -153,12 +156,13 @@ func decide_leave() -> bool:
## Perception pass: given the guard's distance (meters), return a perception ## Perception pass: given the guard's distance (meters), return a perception
## result describing how the NPC reacts to the guard's presence. Pure and ## result describing how the NPC reacts to the guard's presence. Pure and
## headless-testable; the presentation layer feeds it the real distance. ## 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 }. ## 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" var band := "unaware"
if distance <= NEAR_RADIUS: if distance <= NEAR_RADIUS:
band = "near" band = "near"
elif distance <= AWARE_RADIUS: elif distance <= AWARE_RADIUS + bonus_range:
band = "aware" band = "aware"
var noticed := band != "unaware" var noticed := band != "unaware"
if noticed and not guard_noticed: if noticed and not guard_noticed:
+3
View File
@@ -37,6 +37,7 @@ enum NpcType {
AUTHORIZED_GUEST, AUTHORIZED_GUEST,
DELIVERY, DELIVERY,
RESPONDER, RESPONDER,
PROTESTER,
} }
const NPC_TYPE_NAMES := { const NPC_TYPE_NAMES := {
@@ -52,6 +53,7 @@ const NPC_TYPE_NAMES := {
NpcType.AUTHORIZED_GUEST: "Authorized Guest", NpcType.AUTHORIZED_GUEST: "Authorized Guest",
NpcType.DELIVERY: "Delivery", NpcType.DELIVERY: "Delivery",
NpcType.RESPONDER: "Responder", NpcType.RESPONDER: "Responder",
NpcType.PROTESTER: "Protester",
} }
## Access artifact kinds used by AccessVerifier. ## Access artifact kinds used by AccessVerifier.
@@ -103,6 +105,7 @@ enum Reaction {
THREATEN = 5, THREATEN = 5,
RETURN_LATER = 6, RETURN_LATER = 6,
FORCE_ENTRY = 7, FORCE_ENTRY = 7,
CALL_SOMEONE = 8,
} }
## Player dialogue tones. ## 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 class_name Strings
## Centralized UI and dialogue text table (PLAN.md: Polish and Accessibility — ## Centralized UI and dialogue text table (PLAN.md: Polish and Accessibility —
## localization). All user-facing strings live here so they can be swapped for ## localization). English strings live in an editable JSON file so they can be
## a localized table later. Keys are stable identifiers; values are English. ## swapped or translated without touching code. Keys are stable identifiers.
const TABLE := { const DEFAULT_LOCALE := "en"
# HUD / controls const TABLE_PATH := "res://content/strings.%s.json"
"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.",
# Access decisions ## Populated lazily from the JSON table. Kept public so tests and tooling can
"access.granted": "Entry granted: %s", ## enumerate keys (e.g. coverage checks).
"access.denied": "Entry denied: %s", static var TABLE: Dictionary = {}
"access.policy_satisfied": "policy satisfied", static var _load_attempted := false
"access.wrong_grant": "You granted entry despite verification failure!",
"access.wrong_deny": "You denied entry despite valid verification!",
# Wand screening static func _ensure_loaded() -> void:
"wand.admitted_prohibited": "You admitted %s with a prohibited item!", if _load_attempted:
"wand.denied_false_alarm": "You denied %s over a harmless item.", return
"wand.cleared": "%s cleared the wand check.", _load_attempted = true
"wand.refused": "%s refused entry after the wand check.", var path := TABLE_PATH % DEFAULT_LOCALE
if not FileAccess.file_exists(path):
# Radio push_error("Strings table missing: %s" % path)
"radio.address_first": "Address someone first before calling.", return
"radio.blocked": "Radio blocked: %s", var text := FileAccess.get_file_as_string(path)
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).", var parsed = JSON.parse_string(text)
"radio.received": "Dispatch received your report. Assistance en route.", if parsed is Dictionary:
"radio.emergency_dispatched": "Emergency responder dispatched.", TABLE = parsed
else:
# Responder push_error("Strings table malformed: %s" % path)
"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.",
}
## Look up a string by key. Returns the key itself when missing (fail-safe). ## 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: static func lookup(key: String) -> String:
if key.begins_with("locale."):
return key
_ensure_loaded()
return TABLE.get(key, key) return TABLE.get(key, key)
## Format a string by key with positional arguments. ## Format a string by key with positional arguments.
static func fmt(key: String, args: Array) -> String: static func fmt(key: String, args: Array) -> String:
var template: String = TABLE.get(key, key) var template := lookup(key)
return template % args 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 Strings := preload("res://src/core/strings.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd") const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessVerifier := preload("res://src/core/access_verifier.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 WandScreener := preload("res://src/core/wand_screener.gd")
const RadioSystem := preload("res://src/core/radio_system.gd") const RadioSystem := preload("res://src/core/radio_system.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.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 RadioPanel := preload("res://src/game/ui/radio_panel.gd")
const ShiftReview := preload("res://src/game/ui/shift_review.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 coordinator: ShiftCoordinator = null
var guard: GuardController = null var guard: GuardController = null
var hud: Hud = null var hud: Hud = null
@@ -55,7 +59,8 @@ func _ready() -> void:
scene_root.name = "SceneRoot" scene_root.name = "SceneRoot"
add_child(scene_root) add_child(scene_root)
WorldBuilder.new(scene_root).build() world_builder = WorldBuilder.new(scene_root)
world_builder.build()
_ensure_lighting() _ensure_lighting()
guard = _build_guard() guard = _build_guard()
@@ -86,6 +91,15 @@ func _build_guard() -> GuardController:
camera.name = "Camera3D" camera.name = "Camera3D"
camera.current = true camera.current = true
head.add_child(camera) 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 collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new() var shape := CapsuleShape3D.new()
shape.radius = 0.4 shape.radius = 0.4
@@ -202,7 +216,10 @@ func _responder_profile() -> NpcProfile:
return p return p
func _on_shift_ended(review: Dictionary) -> void: 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: func _on_guard_target(target: Node3D) -> void:
targeted_npc = target targeted_npc = target
@@ -259,6 +276,10 @@ func _handle_key(keycode: Key) -> void:
_cycle_nearby_target() _cycle_nearby_target()
KEY_F: KEY_F:
hud.toggle_debug() hud.toggle_debug()
KEY_G:
_toggle_flashlight()
KEY_L:
_toggle_door_lock()
_: _:
_handle_action_key(keycode) _handle_action_key(keycode)
@@ -291,6 +312,25 @@ func _handle_action_key(keycode: Key) -> void:
_: _:
pass 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). ## Cycle the dialogue tone (Professional → Friendly → Firm → Suspicious).
func _cycle_tone() -> void: func _cycle_tone() -> void:
var idx := TONE_ORDER.find(selected_tone) 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])) hud.log(Strings.fmt("react.threaten", [name]))
coordinator.crowd.apply_event("aggressive_behavior") coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.FORCE_ENTRY: Rules.Reaction.FORCE_ENTRY:
hud.log(Strings.fmt("react.force_entry", [name])) var force_result: Dictionary = door.attempt_force()
coordinator.crowd.apply_event("emergency_responder") if force_result.get("succeeded", false):
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt") 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: Rules.Reaction.RETURN_LATER:
hud.log(Strings.fmt("react.return_later", [name])) 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")) hud.log(Strings.lookup("react.none"))
@@ -576,6 +625,9 @@ func _perception_pass() -> void:
if guard == null: if guard == null:
return return
var guard_pos: Vector3 = guard.global_position 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: for avatar in npcs:
if not is_instance_valid(avatar): if not is_instance_valid(avatar):
continue continue
@@ -584,7 +636,7 @@ func _perception_pass() -> void:
continue continue
var distance: float = (avatar.global_position - guard_pos).length() var distance: float = (avatar.global_position - guard_pos).length()
var already_noticed: bool = controller.guard_noticed 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: if perception.get("noticed", false) and not already_noticed:
# First-time notice: log a subtle reaction. # First-time notice: log a subtle reaction.
var name: String = (controller.profile.display_name if controller.profile else "Person") 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 door_position: Vector3 = Vector3(0, 0.5, 1.2)
var awaiting_interaction := false var awaiting_interaction := false
var arrived := 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 := { const TYPE_COLORS := {
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55), 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.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65),
Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40), Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40),
Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85), Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85),
Rules.NpcType.PROTESTER: Color(0.90, 0.40, 0.20),
} }
func _init() -> void: func _init() -> void:
@@ -42,6 +47,15 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
position = p_spawn position = p_spawn
npc.fsm.state_changed.connect(_on_state_changed) npc.fsm.state_changed.connect(_on_state_changed)
_build_body(profile.npc_type) _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. # Normalize intent targets: approach the entrance, stop near the door.
var collider := CollisionShape3D.new() var collider := CollisionShape3D.new()
@@ -96,6 +110,40 @@ func _advance_fsm(delta: float) -> void:
awaiting_interaction = true awaiting_interaction = true
func _move_toward_target(delta: float) -> void: 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 var to_target := target_position - global_position
to_target.y = 0.0 to_target.y = 0.0
var dist := to_target.length() var dist := to_target.length()
+22
View File
@@ -16,6 +16,7 @@ var inspect_panel: PanelContainer
var inspect_text: Label var inspect_text: Label
var inspect_meta: Label var inspect_meta: Label
var radio_cooldown_label: Label var radio_cooldown_label: Label
var mode_label: Label
var wand_panel: PanelContainer var wand_panel: PanelContainer
var wand_text: Label var wand_text: Label
var wand_meta: Label var wand_meta: Label
@@ -91,6 +92,13 @@ func _init() -> void:
radio_cooldown_label.modulate = Color(1, 0.55, 0.45) radio_cooldown_label.modulate = Color(1, 0.55, 0.45)
vbox.add_child(radio_cooldown_label) 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 --- # --- dialogue tone indicator ---
tone_label = Label.new() tone_label = Label.new()
tone_label.text = "Tone: Professional" tone_label.text = "Tone: Professional"
@@ -175,6 +183,20 @@ func _update_radio_cooldown(coordinator: Object) -> void:
func set_target_label(text: String) -> void: func set_target_label(text: String) -> void:
target_label.text = text 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: func set_action_hint(text: String) -> void:
hint_label.text = text hint_label.text = text
+17 -1
View File
@@ -44,7 +44,7 @@ func _init() -> void:
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(quit_btn) 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 reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {}) var incidents: Dictionary = review.get("incidents", {})
var crowd: Dictionary = review.get("crowd", {}) 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"]: if inc.get("outcome", "") in ["peaceful", "left"]:
peaceful += 1 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] = [ var lines: Array[String] = [
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")), "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)], "Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
history_line,
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful], "Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
"NPCs encountered: %d" % review.get("spawned_count", 0), "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)], "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. ## No art assets required; all geometry is BoxMesh with flat materials.
var root: Node3D = null 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: func _init(root_node: Node3D) -> void:
root = root_node root = root_node
@@ -15,6 +18,7 @@ func build() -> void:
_build_sidewalk() _build_sidewalk()
_build_street() _build_street()
_build_guard_spawn() _build_guard_spawn()
_build_navmesh()
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D: func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
var mesh := BoxMesh.new() 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)) _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 # 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)) _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 # 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)) _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() var col := BoxShape3D.new()
@@ -60,7 +67,10 @@ func _build_building() -> void:
root.add_child(building) root.add_child(building)
func _build_steps() -> void: 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" steps.name = "Steps"
var step_w := 8.0 var step_w := 8.0
var tread := 0.6 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)) _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 # 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)) _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) root.add_child(steps)
func _build_sidewalk() -> void: func _build_sidewalk() -> void:
@@ -103,8 +108,44 @@ func _build_street() -> void:
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22))) street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22)))
root.add_child(street) 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: func _build_guard_spawn() -> void:
var marker := Marker3D.new() var marker := Marker3D.new()
marker.name = "GuardSpawn" marker.name = "GuardSpawn"
marker.position = Vector3(0, 1.6, -2.5) 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 saw_threat = true
return TestUtils.check(saw_threat, "violent profile can threaten at emergency level") 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: func test_fsm_transitions() -> bool:
var p := _profile_with_defaults("guest", Rules.NpcType.TOURIST, 0.9) var p := _profile_with_defaults("guest", Rules.NpcType.TOURIST, 0.9)
var npc := NPCController.new() 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: func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void:
_spawn_count += 1 _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: func test_shift_coordinator_runs_a_shift() -> bool:
var scenario: VenueScenario = ScenarioFactory.hollywood_building() var scenario: VenueScenario = ScenarioFactory.hollywood_building()
var coordinator := ShiftCoordinator.new() 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") var b := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats")
return a and b 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: func test_strings_cover_ui_keys() -> bool:
## Every key referenced by the HUD and game manager must exist. ## Every key referenced by the HUD and game manager must exist.
var required := [ 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
+34
View File
@@ -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-<version>.x86_64
# build/step-line-windows-<version>.exe
# build/step-line-macos-<version>.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."