Compare commits

..
16 Commits
Author SHA1 Message Date
zwitschi a2a0ded527 Author the missing scenario content from the PLAN lists
Full build / Full desktop build + optional release (push) Successful in 3m26s
Full build / Import + headless tests (push) Successful in 35s
Joke Shop
- Reservation list without ID (guest list valid, ID missing) — denied.
- Borrowed/mismatched ID (ID valid but mismatched) — denied.
- Guest who refuses the wand screen: new `refuses_screening` event flag, copied
  onto NPCController, handled in _toggle_wand_screening (logs wand.refused, records
  a wand_refused event, small prop hit, panel stays closed).
- Late arrival during the performance (ID + ticket, "arrive" intent).

Studio
- Filming-boundary event and a later repeat, both using the new `film_boundary`
  intent which maps to `steps_end` so the influencer stands past the posted line.
- Celebrity arrival now pulls in a small crowd via a new `extra_spawns` event field
  (two tourists + an influencer), spawned through the normal path.

Building
- Filming-boundary event whose hint points at the body camera and request-reason
  verbs, plus an unclear-intent event that hints at asking for a reason.

Engine support
- Events may carry `hint` (string key logged on spawn), `extra_spawns`, and
  `refuses_screening`; 9 new localized hint strings.

Tests/docs
- Headless suite 139 -> 149 (test_scenario_content.gd) and a new
  smoke_scenario_content.gd that spawns the refusal and celebrity events through the
  real signal path.
- README, TODO section moved to DONE, MEMORY. Version 0.2.13.
2026-10-31 21:55:24 +01:00
zwitschi 922a990c7a Record verified CI publishing and host maintenance
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped
- v0.2.12 was tagged and CI produced the release with all five artifacts by itself,
  confirming the checkout-credential fallback end to end.
- Host maintenance through root@192.168.88.30: apt cache cleaned and journald capped
  at 300M (journal ~133M now), ~7.6G free of 16G.
- TODO release-pipeline section is fully closed; MEMORY records the host state.
2026-10-31 21:51:41 +01:00
zwitschi f0cec192a7 Restore pruned releases and verify CI publishing with v0.2.12
Full build / Full desktop build + optional release (push) Successful in 3m29s
Full build / Import + headless tests (push) Successful in 32s
- Host root filesystem was expanded from 7.8G to 16G (~7.8G free), so the disk
  blocker is gone.
- Re-published v0.2.0 (4 artifacts) and v0.2.9 (5 artifacts) from the local build/
  outputs; both releases verify.
- Root SSH from the workstation is not available (publickey denied, password auth
  closes the banner); the host is administered via the zwitschi login.
- Version 0.2.12 exists to verify the tag pipeline end to end: the workflow should
  create the release via the checkout-credential fallback and upload all five
  artifacts.
2026-10-31 21:46:09 +01:00
zwitschi 2dba562c52 Publish v0.2.11 and document the host disk limit
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Skipped
- Pruned the superseded v0.2.0 and v0.2.9 releases through the API (~565 MB of
  assets) after the host root filesystem hit 100%, which blocked release uploads
  (HTTP 500 "no space left on device") and even git push ("unpacker error").
- Published v0.2.11 locally with the fixed script: all five artifacts verified on
  the release (Linux/Windows/macOS ports, NSIS installer, AppImage).
- TODO records the remaining ~150 MB headroom and the exact host cleanup commands;
  MEMORY and RELEASING describe the disk failure mode and the pruning.
2026-10-31 21:39:15 +01:00
zwitschi afd086ba11 Fail loudly when release uploads do not land
Full build / Import + headless tests (push) Successful in 35s
Full build / Full desktop build + optional release (push) Skipped
Follow-up to the publishing fix: CI created the v0.2.11 release and logged each
upload, but the assets never arrived, so the job looked successful while the
release was empty. The host root filesystem is full (7.8G, 100%), and Gitea
answered HTTP 500 "Attachments.Save: no space left on device".

- publish_release.sh now checks the HTTP status of every asset upload and prints the
  response body on failure, then verifies the release reports as many assets as were
  sent. Empty releases can no longer pass silently.
- Deleted the empty v0.2.11 release so the "latest" release (v0.2.10) still has all
  five artifacts; the tag itself remains.
- TODO/MEMORY record the host disk blocker with concrete cleanup commands and note
  that v0.2.11 artifacts are built locally in build/ for upload once space exists.
2026-10-31 21:38:12 +01:00
zwitschi b4d189c325 Publish releases with the runner's checkout credentials
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Successful in 3m37s
The v0.2.10 tag build still failed to publish: its env dump showed no token at all,
because this Gitea/act runner does not provide `github.token`, and the repo has no
GITEA_TOKEN secret.

publish_release.sh now resolves credentials in order: GITEA_TOKEN,
GITEA_AUTH_HEADER, ~/.git-credentials, then the local git extraheader that
actions/checkout writes for the job. That header carries the runner's basic-auth
credentials for the same server, so tag builds publish with no repo secret.

Verified by adding the extraheader locally and publishing v0.2.10 (all five
artifacts: Linux/Windows/macOS ports, NSIS installer, AppImage), then removing it.

Docs: RELEASING describes the four credential sources and warns that github.token is
not available here; MEMORY records both failed attempts and the final fix; TODO keeps
the "verify on the next tag" follow-up. Version 0.2.11.
2026-10-31 21:30:24 +01:00
zwitschi 6684d36739 Fix release publishing and add a CI token fallback
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 3m58s
Tag builds since v0.2.2 built all artifacts but never published a release:
- tools/publish_release.sh only assembled AUTH in the GITEA_TOKEN branch; the
  ~/.git-credentials branch set CRED and then used an unbound ${AUTH[@]} under
  `set -u`, so every API call failed and the job ended "could not create or find
  release". It now sets AUTH=(-u "$CRED").
- The repo has no GITEA_TOKEN secret and the runner has no git credentials, so the
  workflow now falls back to the built-in workflow token:
  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}.

v0.2.9 was then built and published locally with all five artifacts (portable
Linux/Windows/macOS, NSIS installer, AppImage) and the release page verified via
the API. v0.2.4-v0.2.8 remain unpublished; they are superseded by the latest.

Docs
- docs/RELEASING.md records the incident and the local-publish fallback.
- TODO gains a release-pipeline follow-up (verify CI on the next tag).
- MEMORY notes the root cause, the missing secret, and which releases exist.
- Version 0.2.10.
2026-10-31 21:23:09 +01:00
zwitschi 3f4ac7e9aa Add accessibility pass: escalation meter and working HUD font scale
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Failing after 3m31s
- Escalation cue now includes a shape meter and numeric level
  ("Escalation: Blocking [###--] L3/5") in addition to the colour and name, so it
  reads in grayscale. Hud.escalation_meter(level) is pure and tested.
- HUD font scale from settings actually works: set_font_scale() resizes fonts via
  theme overrides instead of Control.scale, which pushed the right-anchored clock
  label off-screen. It now covers the top, log, target, hint, time, mode,
  escalation, cooldown, and debug labels.
- Settings load before the HUD exists, so _ready() re-applies player settings after
  _build_ui(); previously a saved font scale was ignored until the next apply.

Tests/docs
- Headless suite 134 -> 139 (test_accessibility.gd).
- Verified visually at 1.4x scale: no overflow, meter readable.
- README, TODO section moved to DONE, MEMORY. Version 0.2.9.
2026-10-31 21:17:42 +01:00
zwitschi 294ac17333 Add procedural crowd ambience and radio crackle
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Failing after 3m53s
- AudioFeedback.start_crowd_bed() builds a 4 s looping AudioStreamWAV at runtime
  (one-pole filtered noise plus a 68/103 Hz drone, seeded) and set_crowd_level()
  maps crowd mood 0..1 to -34..-12 dB, so the street gets louder as it gets
  agitated or panics. The bed starts with the shift and stops at shift end.
- play_radio_open()/play_radio_close() push short filtered-noise bursts; wired to
  opening the radio panel, submitting a report, cancelling, and closing the panel.
- Still asset-free and headless-safe: with a dummy audio server the calls no-op.
  _free_player() returns null before _ready and callers handle it.

Tests/docs
- Headless suite 127 -> 134 (test_audio_feedback.gd).
- New smoke_audio.gd: bed playing, level follows mood events, radio crackles once
  per open/close, bed stops at shift end. Verified windowed (real audio server).
- README, TODO section moved to DONE, MEMORY. Version 0.2.8.
2026-10-31 21:08:36 +01:00
zwitschi e2c2a0bf4c Add NPC theater poses: sit, photo, film, perform
Full build / Import + headless tests (push) Successful in 29s
Full build / Full desktop build + optional release (push) Failing after 3m44s
Once an NPC reaches its intent waypoint it now does something instead of standing
in an idle T-pose:

- sit: the rig settles 0.34 m into a seat and the legs swing forward on the steps
- photo/film: influencers and tourists raise their camera prop briefly (a sine
  pulse, so it reads as taking a shot rather than a permanently raised arm)
- perform: performers sway the sign board they carry

Pose selection lives in NpcAvatar.theater_pose() (intent + arrived); the math is in
pure helpers (crouch_for_pose, prop_lift_for_pose, sway_for_pose) so it is
headless-testable. Prop/leg nodes are captured at build time and animated from
their rest offsets. Performers also get a hand-held board and post.

Tests/docs
- Headless suite 120 -> 127 (test_npc_theater.gd).
- Verified visually with a staged sit/photo/perform lineup in front of the entrance.
- README, TODO section moved to DONE, MEMORY. Version 0.2.7.
2026-10-31 21:05:59 +01:00
zwitschi 1b976bf351 Add body-camera timeline to the shift review
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m38s
- New src/game/ui/event_timeline.gd flattens every recorded incident event
  (ADR-0005) into one time-ordered list and shows time, person, and a one-line
  summary. Step with Left/Right or the buttons; Esc returns to the review. No live
  3D replay in this first version.
- Shift review gains a "Body camera timeline" button that hands the review to the
  timeline; the review dim is hidden while it is open (stacked overlays made the
  screen nearly black) and restored on close.
- ShiftCoordinator records started_at_ms so the review can stamp events as mm:ss.
- 8 new string keys for the timeline UI and the review button.

Tests/docs
- Headless suite 113 -> 120 (test_event_timeline.gd), new smoke_timeline.gd that
  opens the timeline from the review, steps through every event, and closes back.
- README, TODO section moved to DONE, MEMORY notes. Version 0.2.6.
2026-10-31 21:02:25 +01:00
zwitschi 921bab796e Add emergency aftermath: evacuation, investigation, review copy
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 4m20s
- Responder outcomes now reach the shift review
  (ShiftCoordinator.responder_outcomes), and the review screen names them.
- "evacuation" (emergency call, low danger, no weapon) actually clears the entrance:
  the door is locked, every NPC flees and is marked as leaving, and a
  security_failure reputation hit applies.
- An emergency call the situation did not justify (no weapon, danger <= 1) appends
  an investigation note, records investigation_opened, and applies the existing
  false_report hit. The review lists the investigations.
- Fix dispatch dying after the first shift: coordinator.setup() builds a new
  RadioSystem, so the responder_arrived connection made in _ready was lost on every
  venue switch/restart. _connect_radio() re-wires it beside _connect_crowd().

Tests/docs
- Headless suite 105 -> 113 (test_emergency_aftermath.gd), new smoke_emergency.gd
  covering the investigation, evacuation aftermath, and review copy.
- README controls/status/smoke checklist, TODO section moved to DONE, MEMORY notes.
- Version 0.2.5.
2026-10-31 20:59:06 +01:00
zwitschi 71b0b53705 Add crowd presentation, fix NPC navigation, refresh screenshots
Full build / Import + headless tests (push) Successful in 27s
Full build / Full desktop build + optional release (push) Failing after 3m37s
NPC navigation (previously dead in the live game)
- Only assign NavigationAgent3D.target_position when it changes: re-assigning every
  frame invalidated the path, so get_next_path_position() kept returning the current
  point and avatars never moved.
- Follow the first path point that is closer to the destination than the avatars are.
  Nav points sit on the navmesh plane (y=0) while bodies stand at y~0.5, so the old
  3D distance test always looked "already there"; path[0] is also the old start point,
  so a naive "far enough away" pick steered backwards and oscillated.
- Fix fsm.state -> fsm.current_state in the idle-lifetime check.

Crowd presentation
- Aggressive behavior, visible weapons, and responder arrivals make idle bystanders
  stop and face the incident for 12 s; someone waiting at the entrance keeps focus.
- Panic (mood >= 0.75) walks people away from the door at 2.4 m/s, then they despawn.
- Wire reactions to EventBus.crowd_changed: CrowdSystem.apply_event() emits on the bus,
  not on its own `changed` signal (the first wiring received nothing).
- Measured a 16-NPC entrance at ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling.

World and HUD
- Skyline is now a ring of four alpha-scissored planes at 200 m instead of a single
  plane; PlaneMesh defaults to horizontal, which is why the backdrop rendered as a band
  overhead. Larger ground plane, mipmapped texture, scissor threshold 0.5 (0.3 left
  mip dashes in the sky), lifted albedo so the city is not pure black.
- Flashlight aims straight ahead and lights the person/door instead of the ground.
- Shorter HUD controls line so it stops overlapping itself.

Tests, tooling, docs
- Headless suite 98 -> 105 (new test_crowd_presentation.gd, 7 tests).
- New smoke_crowd.gd: rubbernecking, panic, and flee-target sanity.
- capture_screenshots.gd starts a shift from the venue menu and stages the night set
  properly; all six README screenshots regenerated.
- README, TODO/DONE (crowd section moved), MEMORY updated. Version 0.2.4.
2026-10-31 20:55:10 +01:00
zwitschi f5ad1642b5 Add PLAN verbs and fix dead incident recording
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 3m31s
Verbs (PLAN levels 1-3)
- Eye contact: own Level 1 radial slice, logged as "eye contact".
- Recognition is a player decision: "Recognize" sets NPCController.player_recognized,
  writes access evidence, and GameManager only then appends a RECOGNITION artifact.
  It is no longer implied by NPC type.
- Body camera: real flag (off by default) driving the REC overlay; B toggles it and
  a formal warning arms it automatically, writing a body_camera incident event.
- Blocking stations the guard 0.9 m from the person toward the entrance and turns
  to face them, instead of only emitting the escalation level.
- Request reason: Level 3 ask answered from the NPC's seeded dialogue_lines.
- Radial grows to 12 slices (174 px outer radius, centered labels).

Incident recording fix
- IncidentRecorder.start_incident() was never called, so add_event /
  add_access_evidence / add_escalation silently no-opped and the shift review
  always reported zero incidents. Incidents now open on focus (guarded by
  has_open_incident), verbs call _ensure_incident, and ShiftCoordinator closes
  leftovers as "unresolved" before building the review.

Copy and docs
- 9 new string keys (eye contact, body camera, recognition, reason, station).
- README controls, 26-step smoke checklist, test count, status lists.
- TODO section moved to DONE; MEMORY records the verb design and the recorder bug.

Verification: headless 98/98, smoke_plan_verbs.gd + smoke_session_shell.gd +
windowed smoke_focus_radial.gd all pass. Version bumped to 0.2.3.
2026-10-31 20:29:50 +01:00
zwitschi 028f608298 Bump version to 0.2.2 for the session-shell release
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 3m42s
config/version, README latest-release note, TODO/MEMORY status refreshed for the
v0.2.2 tag. Headless suite 88/88.
2026-10-31 20:21:54 +01:00
zwitschi be429cab29 Add session shell and close world presentation follow-ups
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped
Session shell
- Startup venue menu (src/game/ui/start_menu.gd): keyboard + mouse, replaces
  the live-shift boot and V as the player-facing venue path (V stays a debug bind).
- Pause overlay on Esc (src/game/ui/pause_overlay.gd): resume, restart shift,
  settings, quit to menu.
- Settings panel (src/game/ui/settings_panel.gd): mouse look sensitivity and
  HUD font scale, persisted via Save into user://settings.json and loaded on boot.
- Shift review "Run another shift" now returns to the venue menu.
- New smoke: src/test/smoke_session_shell.gd (menu -> shift -> pause -> settings
  -> menu), passes headless and windowed.

World and presentation
- Skyline backdrop restored with a transparent asset and an alpha-scissor
  unshaded material; fixes the opaque black slab from the earlier attempt and
  the unimported texture path.
- Steps and landing are a StaticBody3D with per-tread colliders, so the guard
  and NPCs walk up instead of clipping through.
- Marquee Label3D scaled up (72 -> 160) so STEP LINE reads from the sidewalk.
- Focus torus and name tag enlarged for 2-3 m readability.
- Routine traffic despawns after an idle lifetime (NpcAvatar.ephemeral), so the
  16-NPC cap no longer fills with standers; authored events stay for the shift.

Docs and tests
- PLAN.md test/CI section corrected to the custom headless runner and Gitea Actions.
- README controls table, smoke checklist, test count, and status lists refreshed.
- TODO/DONE/MEMORY updated; completed sections moved to DONE.md.
- Headless suite 77 -> 88 passing (settings round-trip, step colliders, skyline
  alpha, session-shell UI behavior, traffic lifetime).
2026-10-31 20:19:22 +01:00
75 changed files with 4710 additions and 264 deletions

No files matched your search

+3
View File
@@ -113,6 +113,9 @@ jobs:
- name: Publish Gitea release (tags only) - name: Publish Gitea release (tags only)
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
env: env:
# An explicit repo secret wins. Without one, publish_release.sh reuses
# the local git extraheader that actions/checkout configures for the job
# (the runner's own credentials), so tag builds still publish.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
VERSION: ${{ github.ref_name }} VERSION: ${{ github.ref_name }}
run: | run: |
+141 -5
View File
@@ -319,7 +319,7 @@ Completed in the working tree (uncommitted with the control pass). Headless **74
to the entrance. to the entrance.
- [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`. - [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`.
## Player control, focus ring, and radial action menu ## Player control, focus circle, and radial action menu
Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke
(`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots. (`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots.
@@ -334,13 +334,13 @@ Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed s
- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse - [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse
capture and RMB does nothing. capture and RMB does nothing.
### Focus and head ring ### Focus and focus circle
- [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion - [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion
ray skips building hits. `Tab` still cycles 12 m. ray skips building hits. `Tab` still cycles 12 m.
- [x] 8-frame miss grace so walk-bob does not drop focus. - [x] 8-frame miss grace so walk-bob does not drop focus.
- [x] Billboarded torus on `Rig/Head`; gold idle, brighter while the radial is open; `Label3D` - [x] Billboarded blue circle in front of the face on `Rig/Head`; brighter while the radial is
`display_name`. HUD target line uses the display name, not the node name. open; `Label3D` `display_name`. HUD target line uses the display name, not the node name.
- [x] Screen-center crosshair. - [x] Screen-center crosshair.
### Radial HUD ### Radial HUD
@@ -364,10 +364,20 @@ Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed s
- [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel` - [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel`
(Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`. (Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`.
- [x] Windowed smoke: focus ring + name, wheel open/step/confirm/cancel - [x] Windowed smoke: focus circle + name, wheel open/step/confirm/cancel
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure). (`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists. - [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
## Visual polish — Skyline backdrop transparency
Found during the control smoke test; verified with PIL analysis.
- [x] Regenerated `assets/textures/game_ready/skyline.png` with transparent background
(commented out opaque border line 234 in `tools/generate_textures.py`).
- [x] Integrated backdrop into `WorldBuilder` via `_build_skyline_backdrop()`: creates a
PlaneMesh with T_SKYLINE texture at Vector3(-80, 12, -50) behind the building.
- [x] PIL verification confirms RGBA mode with transparent pixels in bottom rows.
## Runtime boot and spawn fixes ## Runtime boot and spawn fixes
Found while running the windowed smoke; all verified with a boot run and regenerated Found while running the windowed smoke; all verified with a boot run and regenerated
@@ -390,3 +400,129 @@ screenshots.
- [x] Removed the skyline backdrop planes (opaque black texture). Tracked as a follow-up in - [x] Removed the skyline backdrop planes (opaque black texture). Tracked as a follow-up in
`TODO.md`. `TODO.md`.
- [x] README screenshots regenerated with the fixed eye height and entrance staging. - [x] README screenshots regenerated with the fixed eye height and entrance staging.
## World and presentation follow-ups (found during the control smoke)
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
- [x] The skyline backdrop was removed: `assets/textures/ai/game_ready/skyline.png` has an
opaque black background, so the planes rendered as black slabs with window dots. Re-add
the backdrop only with a transparent asset or a keyed shader.
- [x] Guard settles ~0.5 m above the ground after spawning on the landing. Check the capsule
offset and the spawn `y`, and verify eye height while walking the steps.
- [x] Steps and the landing are visual-only (no collider). The guard walks through them; add
simple step colliders or restage the landing so it does not intersect the walk path.
- [x] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up.
- [x] Focus circle and name tag are small at 2–3 m. Consider a larger circle / font after a
real playtest.
- [x] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short
despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
## Session shell
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
The game boots into a live 900 s shift. `Save` declares `settings.json` and never uses it.
`V` cycles venues by restarting the shift.
- [x] Startup menu: pick venue, start shift, quit. Keyboard + click. Replaces `V` as the
player-facing path (`V` may stay as a debug bind).
- [x] Pause overlay when `Esc` releases the mouse (or a dedicated pause key): resume, restart
shift, settings, quit to menu.
- [x] Settings: mouse look sensitivity, persist through `Save` / `settings.json`. Load on boot.
- [x] Shift review "Run another shift" returns to the venue menu, not a silent restart of the
same scenario.
## Documentation drift
Completed 2026-10-31 (working tree).
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
refreshed for the control pass (77 tests).
- [x] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
uses `src/test/test_runner.gd` and `.gitea/workflows/build.yml`.
- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed.
## Remaining PLAN verbs (radial exists)
Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_verbs.gd` exercises all five verbs.
- [x] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
radial slice if it ships.
- [x] "I recognize this person" is a player decision recorded as evidence, not a pre-attached
artifact on every staff/guest type. Staff still auto-recognize when `override_access` is
empty (`AccessLoadout`).
- [x] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay
is currently always on; toggling should write an incident event.
- [x] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
sends `apply_player_action(BLOCKING)`.
- [x] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
## Crowd presentation
Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd.gd` verifies rubbernecking and panic clearing. A 16-NPC entrance measured ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling was added at the current cap.
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
- [x] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
incident (rubberneck). On `crowd_panic`, they move away from the door.
- [x] Profile a busy entrance with textured materials and props; add LOD or culling for distant
NPCs if frame time drops.
## Emergency aftermath
Completed 2026-10-31 (working tree). Headless **113/113**; `src/test/smoke_emergency.gd` covers the unjustified-call investigation, the evacuation aftermath, and the review copy.
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
wants evacuation, a closed street, and an investigation when the call was unjustified.
- [x] `evacuation` outcome actually clears the entrance (NPCs leave, door stays locked).
- [x] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift
review and applies the existing false-report / reputation hit.
- [x] Review copy names the responder outcome, not only battery and crowd %.
## Body-camera replay
Completed 2026-10-31 (working tree). Headless **120/120**; `src/test/smoke_timeline.gd` opens the timeline from the review, steps through every event, and returns to the review.
- [x] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
Scrub or step through; no live 3D replay required in the first version.
## NPC theater
Completed 2026-10-31 (working tree). Headless **127/127**; verified visually with a staged sit/photo/perform lineup.
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
is in the working tree. Bodies still idle-stand at those spots.
- [x] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
stays put and plays the hat/sign.
## Audio
Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio.gd` checks the bed follows mood and the radio crackles, headless and windowed (real audio server).
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
- [x] Loop a low crowd bed that rises with `CrowdSystem.mood`.
- [x] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
an audio server).
## Accessibility
Completed 2026-10-31 (working tree). Headless **139/139**; verified visually at 1.4x font scale with the shape meter showing.
- [x] Escalation colors that still read in grayscale (shape or label, not hue alone).
- [x] HUD font scale in settings.
## Scenario content still missing vs PLAN lists
Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scenario_content.gd` spawns the refusal event and the celebrity arrival through the real signal path.
- [x] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
refuses wand screening; late arrival during a performance.
- [x] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
crowd spike (more rubberneckers, not only one `arrive` spawn).
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
at least one authored event.
+153 -11
View File
@@ -16,18 +16,17 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Handoff (read this first) ## Handoff (read this first)
- Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable). - Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable).
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **77/77**. Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **88/88**.
Session shell smoke: `godot --headless --path . --script res://src/test/smoke_session_shell.gd` → exit 0.
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0. Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
- `main` is **7 commits ahead of origin**, all committed locally, **not pushed**. The newest commit - `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
is the control pass + boot/spawn fixes (access loadout, focus cone, action wheel, named input CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
actions, NPC rig, camera/building fixes, scheduler rate, NPC cap, screenshots, docs). - Next work: the remaining TODO sections — distribution host setting (`REQUIRE_SIGNIN_VIEW`),
- Nothing is left uncommitted. Ask before pushing if you did not author the earlier commits. open polish (`Strings.fmt` placeholders), the vision-model screenshot judge, and editor tooling.
- Next work, in order: (1) session shell in `TODO.md`; (2) world follow-ups listed at the top of
`TODO.md` (guard floats ~0.5 m, step colliders, skyline asset, marquee size); (3) push / release
when the user asks.
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg. - Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
## Current State ## Current State
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy. - Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
@@ -37,7 +36,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
`hud.controls` / `hud.screen_first` updated. `hud.controls` / `hud.screen_first` updated.
- Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`, - Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`,
accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard. accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard.
`NpcAvatar` in group `npc`; neck/hair/eyes/shoes; torus head ring + `Label3D` name; `set_focus_visual`. `NpcAvatar` in group `npc`; neck/hair/eyes/shoes; blue focus circle + `Label3D` name; `set_focus_visual`.
`src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB `src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB
cancel). Crosshair CanvasLayer. Emergency still `R` only. Number keys 1–6 when the wheel is closed. cancel). Crosshair CanvasLayer. Emergency still `R` only. Number keys 1–6 when the wheel is closed.
- Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB), - Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB),
@@ -46,8 +45,138 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
spawns in front of the door facing the street; building volume moved to z ∈ [-8,-4] so the door, spawns in front of the door facing the street; building volume moved to z ∈ [-8,-4] so the door,
awning, and marquee are visible; step treads rise toward the door; `ShiftCoordinator` pulls the awning, and marquee are visible; step treads rise toward the door; `ShiftCoordinator` pulls the
event scheduler once per simulated second (was every physics frame → >1200 NPCs in seconds); event scheduler once per simulated second (was every physics frame → >1200 NPCs in seconds);
`GameManager.MAX_LIVE_NPCS = 16` caps live avatars; skyline backdrop removed (opaque black asset). `GameManager.MAX_LIVE_NPCS = 16` caps live avatars.
- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX. - **Session shell (2026-10-31, uncommitted):** the game now boots into `StartMenu`
(`src/game/ui/start_menu.gd`, layer 20) instead of a live shift. `GameManager._on_venue_selected()`
builds the scenario and calls `_start_shift()`; `_shift_running` gates input and `_refresh_freeze()`.
Esc with nothing open now pauses (`PauseOverlay`, layer 18) → resume / restart / settings /
quit-to-menu. `SettingsPanel` (layer 22) writes `look_sensitivity` + `hud_font_scale` through
`Save.save_settings()` → `user://settings.json`; `Save.load_settings()` merges over
`Save.DEFAULT_SETTINGS` on boot and `GameManager._apply_settings()` pushes them into the guard/HUD.
Shift review "Run another shift" now calls `_show_start_menu()` (not a silent restart). `V` stays a
debug venue bind. Smoke: `src/test/smoke_session_shell.gd` (menu → shift → pause → settings → menu).
- **World follow-ups closed (2026-10-31):** skyline backdrop restored with `T_SKYLINE`
(`assets/textures/skyline.png`, regenerated RGBA with a transparent sky) and an
`TRANSPARENCY_ALPHA_SCISSOR` unshaded material (`_skyline_mat`); steps + landing are a
`StaticBody3D` with per-tread `BoxShape3D` colliders; marquee `font_size` 160 / outline 24; focus
circle (blue disc in front of the face) + name tag 48px; `NpcAvatar.ephemeral`/`lifetime_s` despawns routine traffic
(authored events stay). Guard spawn height verified on the landing (guard_y=0.84).
- **PLAN verbs (2026-10-31, uncommitted):** the radial now has 12 slices
(`Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio`).
`eye_contact` reuses the Level 1 reaction table via `_apply_interaction`. Recognition is a player
decision: `NPCController.player_recognized` + `IncidentRecorder.add_access_evidence`, and
`GameManager._artifacts_for()` prepends a `RECOGNITION` artifact only after the player declares
it. The body camera is a real flag (`GameManager.body_camera_on`, `Hud.set_body_camera`), off by
default, `B` toggles it, and a formal warning arms it and writes a `body_camera` incident event.
Blocking also calls `_station_between()` to physically place the guard 0.9 m from the NPC toward
the entrance. `_request_reason()` logs the NPC's seeded `dialogue_lines` pick.
- **NPC navigation was dead (fixed 2026-10-31):** avatars never moved in the live game, for three
stacked reasons. (1) `agent.target_position` was re-assigned every physics frame, which
invalidates the path so `get_next_path_position()` keeps returning the current point — only set
it when the target changes. (2) Nav path points sit on the navmesh plane (y=0) while bodies stand
at y≈0.5, so a 3D "are we there" test always looked 0.5 m away and the horizontal direction was
zero — compare horizontally. (3) `path[0]` is the old start point, so picking any point "far
enough away" can pick the point behind you and oscillate — pick the first point that is
**closer to the goal** than the current position. `_nav_try_frames`/`_move_direct` fallback is
still there.
- **Crowd reactions listen on the EventBus:** `CrowdSystem.apply_event()` emits
`event_bus.crowd_changed`; its own `changed` signal is never emitted. `GameManager._connect_crowd()`
therefore connects to `coordinator.event_bus.crowd_changed` (reconnect after every
`coordinator.setup()`, which recreates both crowd and event bus).
- **Crowd presentation:** `NpcAvatar.start_rubberneck(at)` (idle bystanders away from the door stop
and face the incident for 12 s) and `start_flee(from)` (panic: walk away at 2.4 m/s, then free
themselves). Someone `awaiting_interaction` within 3 m of the door is "engaged" and does not
rubberneck. Use `position` (not `global_position`) for that distance check so it also works in
headless tests outside the tree.
- **Perf:** a busy 16-NPC entrance measured ~1.2 ms/frame (824 fps with vsync off, 800 samples),
so LOD/culling is not warranted at `MAX_LIVE_NPCS = 16`. Re-measure before raising the cap.
- **Incident recording was dead:** `IncidentRecorder.start_incident()` was never called, so every
`add_event`/`add_access_evidence` silently no-opped and the review always showed 0 incidents.
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
before building the review.
- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
`NPCController.refuses_screening`; `_toggle_wand_screening` logs `wand.refused`, records a
`wand_refused` event, applies a small prop hit, and does not open the panel). `film_boundary`
is a new intent that maps to `steps_end` so the influencer stands past the posted line.
- **Accessibility (2026-10-31):** escalation text is `"<name> [###--] L3/5"` via
`Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses
`add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`,
which pushed the right-anchored clock label off-screen. Settings load before `_build_ui()`, so
`_ready()` re-applies player settings after the HUD exists.
- **Audio (2026-10-31):** `AudioFeedback.start_crowd_bed()` builds a 4 s looping `AudioStreamWAV`
(one-pole-filtered noise + 68/103 Hz drone, seeded) and `set_crowd_level(mood)` maps 0..1 to
-34..-12 dB. `play_radio_open/close` push short filtered-noise bursts. All procedural; with a
dummy (headless) audio server the calls are no-ops. `_free_player()` returns null when `_ready`
has not run (tests), and callers handle null.
- **NPC theater (2026-10-31):** `NpcAvatar.theater_pose()` maps `intent` + `arrived` to
idle/sit/photo/film/perform. Pure helpers `crouch_for_pose`, `prop_lift_for_pose`,
`sway_for_pose` keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward
(negative X pitch; positive looked like kneeling). `_photo_prop` is set for influencer/tourist
cameras, `_sign_prop` for protester/performer boards; their base Y values are captured at build
time so the animation offsets from rest.
- **Body-camera timeline (2026-10-31):** `src/game/ui/event_timeline.gd` flattens
`review.incidents.incidents[].events` into one time-ordered list (`flatten`), formats each row
with `describe`, and steps with Left/Right or the buttons. `ShiftCoordinator.started_at_ms`
gives the review an absolute start for mm:ss stamps. `GameManager._open_timeline` hides the
review dim (two stacked overlays made the screen nearly black) and `_timeline_from_review`
restores it on close. Smoke: `src/test/smoke_timeline.gd`.
- **Radio dispatch was dead after the first shift:** `coordinator.setup()` builds a new
`RadioSystem`, so the `responder_arrived` connection made in `_ready` was lost on every venue
switch/restart (responders never resolved anything). `_connect_radio()` now re-wires it next to
`_connect_crowd()` after every setup. Same class of bug as the crowd signal.
- **Emergency aftermath:** `ShiftCoordinator.responder_outcomes` / `investigations` feed the review.
`evacuation` (emergency call, low danger, no weapon) locks the door, makes every NPC flee, marks
them `leave_requested`, and applies a `security_failure` hit. An emergency placed while
`current_npc_truth` has no weapon and `danger_level <= 1` appends an investigation note, records
`investigation_opened`, and applies `false_report` -6.
- **Skyline backdrop (2026-10-31):** `PlaneMesh` defaults to a **horizontal** XZ plane — set
`mesh.orientation = PlaneMesh.FACE_Z` or the "backdrop" renders as a band overhead. The skyline
is now a ring of four 400x60 planes at 200 m (center y=30, so building bases meet the ground),
`TEXTURE_FILTER` default, `TRANSPARENCY_ALPHA_SCISSOR` with threshold **0.5** (0.3 left faint
mip dashes across the sky), unshaded with a lifted albedo (`Color(2.6,2.7,3.0)`) so it is not
pure black. Ground is 500x500 so no sky gap shows under the buildings. The `skyline.png` import
needs `mipmaps/generate=true`.
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
- Host sign-in follow-up remains deferred.
- **Release publishing was broken since v0.2.2 (fixed 2026-10-31):** `publish_release.sh`
only built `AUTH` in the `GITEA_TOKEN` branch; the `~/.git-credentials` branch set `CRED`
and then used unbound `${AUTH[@]}` under `set -u`, so every API call failed and the tag
job ended "could not create or find release". The repo also has **no** `GITEA_TOKEN`
secret (`/actions/secrets` is empty) and the runner has no git credentials. Fixes: the
script sets `AUTH=(-u "$CRED")`; the workflow falls back to `github.token`
(`secrets.GITEA_TOKEN || github.token`) — but `github.token` is **not** provided by this
Gitea/act runner, so the env dump showed no token and the v0.2.10 tag build failed again.
Final fix: `publish_release.sh` falls back to the **local git extraheader** that
`actions/checkout` writes (`git config --local --get-regexp '^http\..*\.extraheader$'`),
which carries the runner's basic-auth credentials for the same server. Verified locally by
adding that extraheader and publishing v0.2.10 (all five artifacts). **v0.2.9 and v0.2.10
are published**; v0.2.4–v0.2.8 tags have no artifacts. Verify the CI path on the next tag.
- **CI publishing verified (2026-10-31):** tagging v0.2.12 produced a release with all five
artifacts automatically (no repo secret), confirming the checkout-extraheader fallback.
- **Host access (2026-10-31):** `root@192.168.88.30` now works from this workstation (key auth),
host name `gitea`, Gitea 28.2.0. Maintenance applied: `apt-get clean`, journald capped to
`SystemMaxUse=300M` (`/etc/systemd/journald.conf.d/99-size.conf`, journal ~133M), disk at
~7.6G free of 16G.
- **Host disk resolved (2026-10-31):** the Gitea VM root filesystem was expanded from 7.8G to
16G (`/dev/mapper/pve-vm--500--disk--0`, now ~7.8G free). The pruned **v0.2.0** and **v0.2.9**
releases were restored from the local `build/` artifacts and verified (4 and 5 assets).
`git.allucanget.biz` runs on this host as user `zwitschi`; root@192.168.88.30 is **not**
reachable from this workstation (publickey denied; password auth closes the banner), so host
maintenance must go through a `zwitschi` login with sudo.
- **Host disk full (2026-10-31, historical):** the v0.2.11 tag build created release id 27 and logged the
uploads, but they failed with `HTTP 500 Attachments.Save: write … no space left on device`
(`/dev/mapper/pve-vm--500--disk--0`, 7.8G, 100%, 0 free). `git push` then failed too
("unpacker error"). Unblocked by deleting the superseded **v0.2.0** and **v0.2.9** releases
via the API (~565 MB of assets), then publishing v0.2.11 locally with the fixed script
(verified 5 assets). Remaining ~150 MB is tight — the next release needs host cleanup on
192.168.88.30: `sudo journalctl --vacuum-size=100M` (journal 837 MB), `sudo apt-get clean`
(91 MB), clear `/var/lib/gitea/data/attachments/tmp`. SSH works as `zwitschi`; `sudo -n`
needs a password. **Releases now:** v0.2.11, v0.2.10, v0.2.1 (5 assets each); v0.2.0 and
v0.2.9 entries were pruned. PLAN.md + ADRs 0007–0010 still describe the intended UX.
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage - **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push. and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines - NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
@@ -136,6 +265,17 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio - NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
easter egg); check `git status` before committing and leave foreign changes untouched unless asked. easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
- Screen-design references (`tools/screen_designs.py`, tests in `tools/test_screen_designs.py`):
`generate` turns the screen descriptions in `docs/` (README captions, ADRs 0007–0010,
PLAN, architecture) into reference images under `docs/screens/designs/` — `--draft`
draws offline Pillow wireframes, `--ai` generates realistic 3D scenes / in-game UI via
OpenRouter (default model `openai/gpt-5-image-mini`). Screens are tagged `scene` or
`ui`; prompts get a shared art-direction suffix, and requests use a 3:2 landscape
source cropped to 16:9 (not a square centre-crop). `compare` checks `docs/screenshots/*.png` for aspect/size/
blank-frame and coarse layout/palette/detail drift. Drift distances are warnings, not
failures: a mockup never matches a real render, so semantic composition is a follow-up
vision-model judge (tracked in TODO).
## Known Issues / Notes ## Known Issues / Notes
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is - Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
@@ -166,6 +306,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Open Questions ## Open Questions
- Add a vision-model (LLM) judge on top of the screen-design comparison: pixel and
perceptual metrics cannot decide whether a screenshot's composition is correct.
- 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 beyond English. - Localization target languages beyond English.
- macOS notarization approach for broad distribution. - macOS notarization approach for broad distribution.
+14 -12
View File
@@ -432,13 +432,15 @@ The venue-driven architecture maps onto Godot as follows:
## Testing ## Testing
- Unit and scenario tests use the GUT (Godot Unit Test) framework. - Unit and scenario tests use a lightweight custom headless runner
(`src/test/test_runner.gd`). GUT was evaluated but requires the editor plugin.
- Tests run in headless mode so rules, access verification, escalation, and scoring can be verified without rendering or user input. - Tests run in headless mode so rules, access verification, escalation, and scoring can be verified without rendering or user input.
## Version Control and CI ## Version Control and CI
- Source is tracked with git. - Source is tracked with git.
- GitHub Actions runs headless Godot tests and, in later stages, exports desktop builds. - Gitea Actions (`.gitea/workflows/build.yml`) runs headless Godot tests on push/PR and exports
Linux/Windows/macOS desktop builds plus the NSIS installer and AppImage on tags.
## Planned Commands ## Planned Commands
@@ -446,7 +448,7 @@ The following commands apply once the Godot project is scaffolded:
- Open the editor: `godot --editor --path .` - Open the editor: `godot --editor --path .`
- Run the game: `godot --path .` - Run the game: `godot --path .`
- Run tests headlessly via GUT. - Run tests headlessly: `godot --headless --path . res://src/test/test_runner.tscn`
## Export Targets ## Export Targets
@@ -456,7 +458,7 @@ Desktop is the first target: Windows, Linux, and macOS. Web export is a later op
The prototype already walks and looks in first person, but the control scheme fights itself and targeting is a hairline. This pass replaces that with three things that belong together: solid locomotion, a visible focus on a person, and a round action menu for what to do with them. The prototype already walks and looks in first person, but the control scheme fights itself and targeting is a hairline. This pass replaces that with three things that belong together: solid locomotion, a visible focus on a person, and a round action menu for what to do with them.
Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and head ring, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation. Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and focus circle, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation.
### What is wrong today ### What is wrong today
@@ -494,17 +496,17 @@ Replace the hairline ray as the primary picker.
- `Tab` still cycles people inside 12 m as the aim-free path. Cycling sets the same focused person the cone would. - `Tab` still cycles people inside 12 m as the aim-free path. Cycling sets the same focused person the cone would.
- Clearing: look away until the head leaves the circle for a few frames (small grace so a bob does not drop them), or they walk out of range. - Clearing: look away until the head leaves the circle for a few frames (small grace so a bob does not drop them), or they walk out of range.
### Head ring ### Focus circle
When a person is in focus, draw a thin ring around their head. When a person is in focus, draw a blue, semi-transparent circle in front of their face.
- Attach to the avatar `Head` node, sitting just above the skull, billboarded to the camera so it stays a circle on screen. - Attach to the avatar `Head` node, just in front of the face, billboarded to the camera so it stays a circle on screen.
- Slow spin is fine; no ping-pong scale spam. - Slow spin is fine; no ping-pong scale spam.
- Idle focus: pale gold, modest size. Radial open on that person: brighter, slightly larger. - Idle focus: blue, semi-transparent, modest size. Radial open on that person: brighter, slightly larger.
- Optional small name under the ring (`display_name` only, not the archetype cheat `Violent Threat` in the world). The existing HUD target line can keep the archetype for the prototype. - Optional small name under the circle (`display_name` only, not the archetype cheat `Violent Threat` in the world). The existing HUD target line can keep the archetype for the prototype.
- One ring at a time. Everyone else has none. - One circle at a time. Everyone else has none.
Implementation sketch: `MeshInstance3D` torus or a thin unshaded ring mesh on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D torus z-fights on hats. Implementation sketch: a thin unshaded `MeshInstance3D` circle (flat disc) in front of the face on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D circle z-fights on hats.
### Round action HUD ### Round action HUD
@@ -543,7 +545,7 @@ A doughnut / pie menu centered on the screen, opened on the focused person.
**Cancel** **Cancel**
- `Esc` or right mouse button: hide the menu, no action, no recorder event, recapture mouse. - `Esc` or right mouse button: hide the menu, no action, no recorder event, recapture mouse.
- Focus and the head ring stay as they were. - Focus and the focus circle stay as they were.
Number keys `1`–`6` may remain as shortcuts that skip the wheel so a smoke-test script still works. They must not fire while the wheel is open (the wheel owns `A` / `D` / `Space`). Document both in the README. Number keys `1`–`6` may remain as shortcuts that skip the wheel so a smoke-test script still works. They must not fire while the wheel is open (the wheel owns `A` / `D` / `Space`). Document both in the README.
+115 -23
View File
@@ -28,8 +28,10 @@ The central design goal is simple:
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> **Latest: v0.2.1** (2026-10-31) — built and published by Gitea Actions from the > **Latest: v0.2.13** — authored scenario content: Joke Shop access edge cases, Studio filming
> `v0.2.1` tag, including the Windows installer and Linux AppImage. Releases are built automatically when a `vX.Y.Z` tag is pushed > boundaries and celebrity crowd spikes, and Building events that use the body-camera and
> request-reason verbs.
> Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline > `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
> (NSIS + appimagetool) and uploads with the portable binaries. > (NSIS + appimagetool) and uploads with the portable binaries.
@@ -179,6 +181,39 @@ python3 -m unittest discover -s tools -p 'test_*.py'
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks. The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
### Screen design references
Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the
screen descriptions in `docs/` (README captions, ADRs 0007–0010, `PLAN.md`, and
`architecture.md`) into reference images under `docs/screens/designs/`, then uses
those images as the expected state to check the game's captures.
Each screen is tagged `scene` (a realistic first-person 3D world view) or `ui`
(an in-game panel overlaid on a softened scene), and the generator appends a
shared art-direction suffix so the frames read as one coherent game. Requests use
a 3:2 landscape source and crop to the 16:9 capture dimensions.
```bash
# Deterministic wireframes (offline, no API key)
python3 tools/screen_designs.py generate --draft
# Realistic 3D scenes and in-game screen designs via OpenRouter
# (requires OPENROUTER_API_KEY)
python3 tools/screen_designs.py generate
# Compare docs/screenshots/*.png against the designs
python3 tools/screen_designs.py compare
python3 tools/screen_designs.py compare --json
```
`compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank
frame, and large layout/palette/detail distance. It is a regression gate, not a
semantic judge — pixel and perceptual metrics cannot decide whether a frame's
composition is *right*, so a vision-model pass is the documented follow-up (see
`TODO.md`). Regenerate the designs after changing screen layout, then re-run
`compare` after regenerating the game screenshots with
`src/test/capture_screenshots.gd`.
### Setup ### Setup
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH. 1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
@@ -202,8 +237,29 @@ godot --headless --path . --script res://src/test/test_runner.gd
# Save a screenshot to user:// (windowed) # Save a screenshot to user:// (windowed)
godot --path . --script res://src/test/take_screenshot.gd godot --path . --script res://src/test/take_screenshot.gd
# Windowed control smoke: focus ring + action wheel, saves PNGs, exits 1 on failure # Windowed control smoke: focus circle + action wheel, saves PNGs, exits 1 on failure
godot --path . --script res://src/test/smoke_focus_radial.gd godot --path . --script res://src/test/smoke_focus_radial.gd
# Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
godot --headless --path . --script res://src/test/smoke_session_shell.gd
# PLAN-verb smoke: recognize, request reason, body camera, warning, blocking
godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance
godot --headless --path . --script res://src/test/smoke_crowd.gd
# Emergency smoke: unjustified call, investigation note, evacuation aftermath
godot --headless --path . --script res://src/test/smoke_emergency.gd
# Body-camera timeline smoke: review → timeline → step through → back to review
godot --headless --path . --script res://src/test/smoke_timeline.gd
# Audio smoke: crowd bed follows mood, radio open/close crackle
godot --headless --path . --script res://src/test/smoke_audio.gd
# Scenario-content smoke: wand refusal, celebrity crowd spike
godot --headless --path . --script res://src/test/smoke_scenario_content.gd
``` ```
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
@@ -213,10 +269,12 @@ The unit test runner is the project's own lightweight harness (no editor plugin
| Input | Action | | Input | Action |
| --- | --- | | --- | --- |
| W/A/S/D + mouse | Move and look | | W/A/S/D + mouse | Move and look |
| Space / left click | Action wheel for the focused person | | Space / left click | Action wheel for the focused person (Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio) |
| A / D or ←/→ | Step through the action wheel (while it is open) | | A / D or ←/→ | Step through the action wheel (while it is open) |
| Space / left click | Confirm the highlighted action (while the wheel is open) | | Space / left click | Confirm the highlighted action (while the wheel is open) |
| Esc / right click | Close the top panel; Esc with nothing open releases / captures the mouse | | Esc / right click | Close the top panel; Esc with nothing open pauses the shift (Esc again resumes) |
| ← / → or ↑ / ↓ | Navigate the venue menu and the pause / settings panels |
| Enter | Start the highlighted venue from the menu, or confirm in a panel |
| 1 / 2 / 3 | Quick gestures without the wheel: raise hand, point, head shake (Level 1) | | 1 / 2 / 3 | Quick gestures without the wheel: raise hand, point, head shake (Level 1) |
| Q | Polite direction (Level 1) | | Q | Polite direction (Level 1) |
| 4 | Formal warning (Level 2) | | 4 | Formal warning (Level 2) |
@@ -226,9 +284,10 @@ The unit test runner is the project's own lightweight harness (no editor plugin
| E | Inspect access artifacts / close inspection | | E | Inspect access artifacts / close inspection |
| C | Handheld wand screening (wand venues only) | | C | Handheld wand screening (wand venues only) |
| A / D | Allow / deny entry during inspection or wand check | | A / D | Allow / deny entry during inspection or wand check |
| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) | | V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
| 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) | | G | Toggle flashlight (widens NPC perception at night) |
| B | Toggle the body camera; a formal warning switches it on automatically |
| L | Lock / unlock the restricted door (thwarts forced entry) | | L | Lock / unlock the restricted door (thwarts forced entry) |
| F | Toggle debug overlay | | F | Toggle debug overlay |
@@ -253,31 +312,64 @@ 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: **77 passing** (access rules and venue-correct access loadout, 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, focus cone and action wheel) - Headless unit tests: **149 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content)
- 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 - 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, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
- Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) - Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
- Session shell: venue start menu (keyboard + mouse), Esc pause overlay (resume / restart / settings / quit to menu), settings panel with mouse-look sensitivity and HUD font scale persisted to `user://settings.json`, and "Run another shift" returning to the venue menu
- World presentation: skyline backdrop rendered with an alpha material, walkable step and landing colliders, larger marquee text, and larger focus circle / name tag
- Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers
- PLAN verbs: eye contact (distinct Level 1 cue), recognizing a person as a player decision recorded as access evidence, a body-camera flag that a formal warning arms (and `B` toggles), blocking that physically stations the guard between the person and the door, and a Level 3 request-reason ask answered from the NPC's `dialogue_lines`
- Incidents are now actually opened on focus and closed at shift end, so player actions, access evidence, escalation levels, and unresolved interactions reach the review (previously every recorded event was dropped)
- Crowd presentation: aggressive behavior and responder arrivals make idle bystanders stop and face the incident; panic sends people away from the door (and they leave the scene). A busy 16-NPC entrance measured ~1.2 ms/frame, so no LOD/culling was needed at the current cap
- Distant skyline ring: four alpha-scissored planes at 200 m read as a hazy city on the horizon (the baked backdrop used to show as a black slab), with a larger ground plane so the buildings meet the street
- Flashlight aims straight ahead and lights the person/door being checked instead of the ground in front of the guard
- Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations
- Body-camera timeline: the shift review opens a text timeline of every recorded incident event (time, person, action/outcome) that you step through with the arrow keys or buttons. No live 3D replay yet
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
- Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout)
- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
Not yet implemented: body-camera replay timeline, venue selection startup menu, pause/settings shell, eye-contact and request-reason verbs, crowd rubbernecking, emergency aftermath, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist ### Smoke Test Checklist
1. `godot --path .` starts the game without script errors. 1. `godot --path .` starts the game on the venue menu without script errors.
2. The guard moves and looks around; the building, steps, and sidewalk are visible. 2. Choosing a venue (click or ↑/↓ + Enter) starts that venue's shift and hides the menu.
3. NPCs spawn and approach the entrance during the first minutes. 3. The guard moves and looks around; the building, steps, and sidewalk are visible.
4. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line. 4. The guard's camera sits at eye height and the guard stands on the landing, not inside the building.
5. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry. 5. Walking into the steps carries the guard up onto the landing (steps have colliders).
6. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand. 6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab.
7. Pressing `Q` produces a reaction log entry and reputation feedback. 7. The `STEP LINE` marquee is readable from the sidewalk.
8. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision. 8. NPCs spawn and approach the entrance during the first minutes.
9. `6` opens the assistance form; submitting shows dispatch feedback. 9. Looking at an NPC inside the focus cone shows a blue focus circle in front of the face, the person's name, and the HUD target line.
10. `R` opens the emergency form; the button must be held ~1.6s or the call cancels. 10. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry.
11. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked). 11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
12. `F` toggles the debug overlay with NPC states and shift stats. 12. Pressing `Q` produces a reaction log entry and reputation feedback.
13. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run). 13. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision.
14. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history. 14. `6` opens the assistance form; submitting shows dispatch feedback.
15. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
16. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
17. `F` toggles the debug overlay with NPC states and shift stats.
18. The wheel's `Look` slice is eye contact (Level 1) and logs as its own action.
19. `Reason` asks why the person is there and logs their `dialogue_lines` reply.
20. `Recognize` records the guard's recognition as access evidence; pressing it twice says so.
21. `Warn` turns the body camera on (REC overlay appears); `B` toggles it manually.
22. `Block` moves the guard between the person and the door.
23. The shift review reports the incident count, including interactions left unresolved.
24. Escalating on someone (or a responder arriving) makes idle bystanders stop and face the incident.
25. Repeated alarms panic the crowd and nearby people clear away from the door instead of standing around.
26. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart.
27. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
28. An unjustified emergency call (calm person, no weapon) logs an investigation and costs reputation.
29. After an evacuation outcome the door is locked and NPCs are leaving the entrance.
30. The shift review's "Body camera timeline" button steps through the recorded events (Left/Right or the buttons) and Esc returns to the review.
31. The escalation label shows a `[###--] L3/5` shape meter as well as the level name, and stays readable in grayscale.
32. Changing "HUD font scale" in Settings resizes the HUD text and persists across a restart.
33. When the shift timer reaches 0, the shift review screen shows grade, incident summary, responder outcomes, investigations, and persisted shift history; "Run another shift" returns to the venue menu.
## Development Workflow ## Development Workflow
+32 -106
View File
@@ -1,114 +1,10 @@
# TODO # TODO
Godot 4.3 prototype. Release **v0.2.1** is live on the host. The working tree (uncommitted) Godot 4.3 prototype. Release **v0.2.13** is tagged. Headless suite **149/149**, plus the session,
has the access-loadout fix and the ADR-0007–0010 control pass; headless suite **77/77** and a PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and windowed focus/radial smokes.
windowed focus/radial smoke pass.
Host `REQUIRE_SIGNIN_VIEW` stays deferred. Host `REQUIRE_SIGNIN_VIEW` stays deferred.
## World and presentation follow-ups (found during the control smoke)
- [ ] The skyline backdrop was removed: `assets/textures/ai/game_ready/skyline.png` has an
opaque black background, so the planes rendered as black slabs with window dots. Re-add
the backdrop only with a transparent asset or a keyed shader.
- [ ] Guard settles ~0.5 m above the ground after spawning on the landing. Check the capsule
offset and the spawn `y`, and verify eye height while walking the steps.
- [ ] Steps and the landing are visual-only (no collider). The guard walks through them; add
simple step colliders or restage the landing so it does not intersect the walk path.
- [ ] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up.
- [ ] Focus ring and name tag are small at 2–3 m. Consider a larger torus / font after a
real playtest.
- [ ] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short
despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
## Session shell
The game boots into a live 900 s shift. `Save` declares `settings.json` and never uses it.
`V` cycles venues by restarting the shift.
- [ ] Startup menu: pick venue, start shift, quit. Keyboard + click. Replaces `V` as the
player-facing path (`V` may stay as a debug bind).
- [ ] Pause overlay when `Esc` releases the mouse (or a dedicated pause key): resume, restart
shift, settings, quit to menu.
- [ ] Settings: mouse look sensitivity, persist through `Save` / `settings.json`. Load on boot.
- [ ] Shift review "Run another shift" returns to the venue menu, not a silent restart of the
same scenario.
## Remaining PLAN verbs (radial exists)
- [ ] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
radial slice if it ships.
- [ ] "I recognize this person" is a player decision recorded as evidence, not a pre-attached
artifact on every staff/guest type. Staff still auto-recognize when `override_access` is
empty (`AccessLoadout`).
- [ ] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay
is currently always on; toggling should write an incident event.
- [ ] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
sends `apply_player_action(BLOCKING)`.
- [ ] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
## Crowd presentation
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
- [ ] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
incident (rubberneck). On `crowd_panic`, they move away from the door.
- [ ] Profile a busy entrance with textured materials and props; add LOD or culling for distant
NPCs if frame time drops.
## Emergency aftermath
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
wants evacuation, a closed street, and an investigation when the call was unjustified.
- [ ] `evacuation` outcome actually clears the entrance (NPCs leave, door stays locked).
- [ ] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift
review and applies the existing false-report / reputation hit.
- [ ] Review copy names the responder outcome, not only battery and crowd %.
## Body-camera replay
- [ ] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
Scrub or step through; no live 3D replay required in the first version.
## NPC theater
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
is in the working tree. Bodies still idle-stand at those spots.
- [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
stays put and plays the hat/sign.
## Audio
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
- [ ] Loop a low crowd bed that rises with `CrowdSystem.mood`.
- [ ] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
an audio server).
## Accessibility
- [ ] Escalation colors that still read in grayscale (shape or label, not hue alone).
- [ ] HUD font scale in settings.
## Scenario content still missing vs PLAN lists
- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
refuses wand screening; late arrival during a performance.
- [ ] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
crowd spike (more rubberneckers, not only one `arrive` spawn).
- [ ] Building: "activate body camera" and "request reason" once those verbs exist, used in
at least one authored event.
## Documentation drift
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
refreshed for the control pass (77 tests).
- [ ] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
uses `src/test/test_runner.gd` and `.gitea/workflows/build.yml`.
- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed.
## Distribution follow-up (host config, deferred) ## Distribution follow-up (host config, deferred)
Verified via API+host credentials (2026-10-31): Verified via API+host credentials (2026-10-31):
@@ -120,10 +16,40 @@ Verified via API+host credentials (2026-10-31):
anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false` anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false`
under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads. under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads.
## Release pipeline follow-up
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
so every tag build since v0.2.2 failed to publish.
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
`GITEA_TOKEN` secret).
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
Gitea runner does not expose `github.token`).
- [x] Uploads now fail loudly and the release is verified to carry every artifact
(a silent HTTP 4xx used to leave an empty release with a green job).
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
- [ ] Optionally backfill releases for v0.2.4–v0.2.8 (their CI artifacts were not
retained; would need local rebuilds) or leave them superseded by the latest.
## Open Polish ## Open Polish
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting). - [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Screen design references (QA tooling)
- [x] `tools/screen_designs.py generate` writes one reference design per screen
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
- [ ] Vision-model judge for semantic screenshot comparison. Pixel and perceptual
metrics cannot decide whether a frame's composition is correct; a follow-up
should send the capture + reference design to a vision model and ask whether
they depict the same screen.
## Tooling (when an editor workflow exists) ## Tooling (when an editor workflow exists)
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays. - [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+37 -2
View File
@@ -1,6 +1,7 @@
{ {
"hud.controls": "LMB/Space action menu · A/D or arrows highlight · Space confirm · Esc/RMB cancel · R emergency · E inspect · C wand · Q polite · 1-6 shortcuts · Tab cycle · G light · L lock · V venue · F debug", "hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab cycle · G light · L lock · F debug",
"radial.raise_hand": "Stop", "radial.raise_hand": "Stop",
"radial.eye_contact": "Look",
"radial.point": "Point", "radial.point": "Point",
"radial.head_shake": "No", "radial.head_shake": "No",
"radial.polite": "Ask", "radial.polite": "Ask",
@@ -8,6 +9,8 @@
"radial.wand": "Wand", "radial.wand": "Wand",
"radial.warning": "Warn", "radial.warning": "Warn",
"radial.block": "Block", "radial.block": "Block",
"radial.reason": "Reason",
"radial.recognize": "Recognize",
"radial.radio": "Radio", "radial.radio": "Radio",
"hud.escalation_prefix": "Escalation: %s", "hud.escalation_prefix": "Escalation: %s",
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs", "hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
@@ -32,6 +35,9 @@
"radio.emergency_dispatched": "Emergency responder dispatched.", "radio.emergency_dispatched": "Emergency responder dispatched.",
"radio.easter_egg": "¿Dónde está la mota?", "radio.easter_egg": "¿Dónde está la mota?",
"responder.arrived": "Responder arrives and %ss the situation.", "responder.arrived": "Responder arrives and %ss the situation.",
"responder.evacuated": "The entrance is cleared and the door is locked while the block is secured.",
"investigation.unjustified": "Emergency call against %s reviewed: no weapon, low danger.",
"investigation.started": "Your emergency call is flagged for investigation.",
"react.comply": "%s complies and steps back.", "react.comply": "%s complies and steps back.",
"react.leave": "%s turns and leaves.", "react.leave": "%s turns and leaves.",
"react.argue": "%s argues with you.", "react.argue": "%s argues with you.",
@@ -42,6 +48,24 @@
"react.return_later": "%s leaves but may come back.", "react.return_later": "%s leaves but may come back.",
"react.call_someone": "%s calls someone for help.", "react.call_someone": "%s calls someone for help.",
"react.none": "No reaction (yet).", "react.none": "No reaction (yet).",
"bodycam.on": "Body camera on.",
"bodycam.off": "Body camera off.",
"bodycam.warning": "Formal warning recorded on the body camera.",
"recognition.declared": "You tell %s you recognize them. Logged as evidence.",
"recognition.already": "You have already recognized %s this shift.",
"reason.ask": "You ask %s why they are here.",
"reason.reply": "%s: \"%s\"",
"reason.silent": "%s says nothing and looks away.",
"hint.reservation_no_id": "On the reservation list but no ID — ask for ID, then check the list.",
"hint.borrowed_id": "The ID photo does not match the person. Verify before admitting.",
"hint.refuses_wand": "This guest refuses the wand screen. No screen, no entry.",
"hint.late_arrival": "Late arrival during the performance — check ID and ticket before opening the door.",
"hint.film_boundary": "Filming past the posted line. Ask their reason and point them back to the sidewalk.",
"hint.film_boundary_repeat": "Same crew again, still past the line. A formal warning is now reasonable.",
"hint.celebrity_arrival": "Celebrity arrival: the crowd is swelling. Keep the entrance clear.",
"hint.body_camera_reason": "They are filming the entrance. Ask their reason (Reason) and consider the body camera (B).",
"hint.ask_reason": "Unclear intent. Ask why they are here (Reason) before deciding anything else.",
"station.blocking": "You step between %s and the door.",
"perception.near": "%s notices you standing close.", "perception.near": "%s notices you standing close.",
"perception.aware": "%s notices you watching.", "perception.aware": "%s notices you watching.",
"door.locked": "The door is now locked.", "door.locked": "The door is now locked.",
@@ -50,5 +74,16 @@
"flashlight.on": "Flashlight on.", "flashlight.on": "Flashlight on.",
"flashlight.off": "Flashlight off.", "flashlight.off": "Flashlight off.",
"hud.venue_switched": "Venue: %s", "hud.venue_switched": "Venue: %s",
"wand.recharge": "Wand scanner is recharging…" "wand.recharge": "Wand scanner is recharging…",
"review.responder": "Responder outcomes: %s",
"review.investigations": "Investigations: %s",
"review.none": "none",
"timeline.title": "Body Camera Timeline",
"timeline.position": "Event %d of %d",
"timeline.empty": "No recorded events this shift.",
"timeline.prev": "◀ Previous",
"timeline.next": "Next ▶",
"timeline.close": "Close",
"timeline.hint": "Left/Right step through events · Esc closes · no live replay yet",
"review.timeline_button": "Body camera timeline"
} }
+24 -2
View File
@@ -37,8 +37,30 @@ The repository ships a Gitea Actions workflow (`.gitea/workflows/build.yml`):
Linux/Windows/macOS, artifact upload, and — on tags — release publication via Linux/Windows/macOS, artifact upload, and — on tags — release publication via
`tools/publish_release.sh`. `tools/publish_release.sh`.
For tag builds, set a repo secret named **`GITEA_TOKEN`** (write:repository scope). `tools/publish_release.sh` picks the first available credential, in order:
Without it the workflow still builds and tests, but the release publish step fails.
1. `GITEA_TOKEN` (repo secret, write:repository scope),
2. `GITEA_AUTH_HEADER` (a ready-made `Authorization: ...` header),
3. `~/.git-credentials` for the host,
4. the **local git extraheader** that `actions/checkout` configures on the CI job.
Option 4 is what makes tag builds publish without any repo secret: the runner
already injects basic-auth credentials for the checkout, and the script reuses that
header for the API (same server). `github.token` is *not* available in this Gitea
setup, so do not rely on it.
> **Disk space:** Gitea stores release assets under `/var/lib/gitea/data/attachments` on
> the host root filesystem (7.8 GB here). When it is full, asset uploads fail with
> `HTTP 500 … no space left on device` and even `git push` can fail with "unpacker error".
> `publish_release.sh` now verifies the asset count and exits non-zero, so this is visible.
>
> **Incident (2026-10-31):** `tools/publish_release.sh` never set `AUTH` in its
> `~/.git-credentials` branch, so every API call failed silently (`AUTH[@]` was
> unbound under `set -u`). Combined with no `GITEA_TOKEN` secret on the repo, tag
> builds from v0.2.2 to v0.2.8 produced installers but published nothing. The script
> is fixed (`AUTH=(-u "$CRED")`) and v0.2.9 was published locally with all five
> artifacts. If CI publishing fails again, run
> `./tools/publish_release.sh <version>` from a checkout with `~/.git-credentials`.
> **No `actions/cache` for now:** the runner has no local actions-cache service, so > **No `actions/cache` for now:** the runner has no local actions-cache service, so
> `actions/cache` times out against GitHub's cache API. We dropped it (Godot import > `actions/cache` times out against GitHub's cache API. We dropped it (Godot import
+5 -5
View File
@@ -1,4 +1,4 @@
# ADR-0008: Cone focus and head ring # ADR-0008: Cone focus and focus circle
- **Status:** Accepted - **Status:** Accepted
- **Date:** TBD - **Date:** TBD
@@ -19,7 +19,7 @@ Focus is a **cone**, not a ray.
- A few frames of grace keep walk-bob from dropping focus. - A few frames of grace keep walk-bob from dropping focus.
- `Tab` still cycles people inside 12 m and sets the same focused person. - `Tab` still cycles people inside 12 m and sets the same focused person.
When someone is in focus, draw a **thin ring** around their head, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Pale gold while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the ring. One ring at a time. When someone is in focus, draw a **blue, semi-transparent circle in front of the face**, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Blue while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the circle. One circle at a time.
The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it. The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it.
@@ -28,19 +28,19 @@ The world must not show the internal type name (`Trespasser`, `Violent Threat`).
### Positive ### Positive
- Looking near a person is enough; the player does not have to pin a capsule. - Looking near a person is enough; the player does not have to pin a capsule.
- The ring is visible in the scene, so the HUD is not the only focus cue. - The circle is visible in the scene, so the HUD is not the only focus cue.
- `Tab` remains the accessibility path and shares the same focused person. - `Tab` remains the accessibility path and shares the same focused person.
- The action menu (ADR-0009) has a clear subject. - The action menu (ADR-0009) has a clear subject.
### Negative ### Negative
- Two heads in the same circle can steal focus; center-distance is a heuristic, not a lock-on. - Two heads in the same circle can steal focus; center-distance is a heuristic, not a lock-on.
- Hats and protester signs can z-fight a 3D torus. A screen-space circle unprojected from the head is an allowed fallback. - Hats and protester signs can z-fight a 3D circle. A screen-space circle unprojected from the head is an allowed fallback.
- Occlusion rays can fail on unusual geometry; grace frames hide some of that. - Occlusion rays can fail on unusual geometry; grace frames hide some of that.
## Alternatives considered ## Alternatives considered
- **Keep the hairline ray:** Rejected because it drops people too easily and has no world mark. - **Keep the hairline ray:** Rejected because it drops people too easily and has no world mark.
- **Sticky lock-on until cancelled:** Rejected because the job is to watch a crowd, not duel one person. - **Sticky lock-on until cancelled:** Rejected because the job is to watch a crowd, not duel one person.
- **Screen reticle only, no world ring:** Rejected; in a crowd the player needs a mark on the head, not only a HUD line. - **Screen reticle only, no world circle:** Rejected; in a crowd the player needs a mark on the face, not only a HUD line.
- **Click a person to select, then act:** Rejected for default play. The mouse is captured for look (ADR-0007). Click opens the action menu on whoever is already in the cone. - **Click a person to select, then act:** Rejected for default play. The mouse is captured for look (ADR-0007). Click opens the action menu on whoever is already in the cone.
+2 -2
View File
@@ -31,7 +31,7 @@ While the menu is open, walk and look freeze (ADR-0007) and the cursor is visibl
**Confirm:** click a slice, or `Space` on the highlighted slice. The menu closes, the mouse is recaptured, and that action runs. Inspect, wand, and radio assist close the wheel and then open the existing panels. Walk and look stay frozen until that panel closes. **Confirm:** click a slice, or `Space` on the highlighted slice. The menu closes, the mouse is recaptured, and that action runs. Inspect, wand, and radio assist close the wheel and then open the existing panels. Walk and look stay frozen until that panel closes.
**Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the head ring stay. The mouse is recaptured. **Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the focus circle stay. The mouse is recaptured.
Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so existing smoke tests still work. They must not fire while the wheel is open. Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so existing smoke tests still work. They must not fire while the wheel is open.
@@ -54,7 +54,7 @@ Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so exist
## Alternatives considered ## Alternatives considered
- **Hold-to-open, release-to-confirm (GTA / Destiny):** Rejected. The requested input is click or Space to open, then click or Space to confirm, with Esc / RMB to abort. That is a toggle, not a hold. - **Hold-to-open, release-to-confirm (GTA / Destiny):** Rejected. The requested input is click or Space to open, then click or Space to confirm, with Esc / RMB to abort. That is a toggle, not a hold.
- **Vertical list / command menu:** Rejected because a round layout around a focused person matches the head ring and reads faster in a crowd. - **Vertical list / command menu:** Rejected because a round layout around a focused person matches the focus circle and reads faster in a crowd.
- **One context-sensitive Use key:** Rejected; the game is about choosing among levels, not a single default verb. - **One context-sensitive Use key:** Rejected; the game is about choosing among levels, not a single default verb.
- **Emergency on the wheel:** Rejected because a misclick would dispatch armed response. ADR-0002 already keeps that path rare and confirmed. - **Emergency on the wheel:** Rejected because a misclick would dispatch armed response. ADR-0002 already keeps that path rare and confirmed.
- **Drop number-key shortcuts immediately:** Deferred. They remain as a closed-menu fallback until the wheel is proven in play. - **Drop number-key shortcuts immediately:** Deferred. They remain as a closed-menu fallback until the wheel is proven in play.
+2 -2
View File
@@ -21,7 +21,7 @@ Make the body read as a person at conversation distance:
- Shoes. - Shoes.
- Hair cap whose color comes from the visual seed. - Hair cap whose color comes from the visual seed.
- Two dark eye spheres so the head has a front. - Two dark eye spheres so the head has a front.
- Named `Head` node (`Rig/Head`) as the attach point for the focus ring (ADR-0008) and the focus cone. - Named `Head` node (`Rig/Head`) as the attach point for the focus circle (ADR-0008) and the focus cone.
- Collision capsule scaled to the visible height so the cone hits the body the player sees. - Collision capsule scaled to the visible height so the cone hits the body the player sees.
Keep archetype props, height / brightness variation, walk bob, and the notice head-turn. Keep archetype props, height / brightness variation, walk bob, and the notice head-turn.
@@ -49,5 +49,5 @@ AI clothing textures stay out unless a map is already the right size and is wire
- **Skinned Mixamo / GLB characters now:** Rejected. Cost and pipeline exceed this pass; the sim only needs a readable body and a head node. - **Skinned Mixamo / GLB characters now:** Rejected. Cost and pipeline exceed this pass; the sim only needs a readable body and a head node.
- **Billboard sprites:** Rejected; first-person movement walks around people, and sprites fail at profile views. - **Billboard sprites:** Rejected; first-person movement walks around people, and sprites fail at profile views.
- **Keep colored capsules as-is:** Rejected; the head ring and cone need a real head, and capsules do not read as people at two metres. - **Keep colored capsules as-is:** Rejected; the focus circle and cone need a real head, and capsules do not read as people at two metres.
- **World labels with archetype names:** Rejected because they leak sim truth and skip observation. - **World labels with archetype names:** Rejected because they leak sim truth and skip observation.
+1 -1
View File
@@ -153,7 +153,7 @@ Architecture decisions are recorded as ADRs:
- [ADR-0005: Event-based incident recording](adr/0005-event-based-incident-recording.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) - [ADR-0006: Godot 4 with GDScript](adr/0006-godot-4-gdscript.md)
- [ADR-0007: Locomotion and input layers](adr/0007-locomotion-and-input-layers.md) - [ADR-0007: Locomotion and input layers](adr/0007-locomotion-and-input-layers.md)
- [ADR-0008: Cone focus and head ring](adr/0008-cone-focus-and-head-ring.md) - [ADR-0008: Cone focus and focus circle](adr/0008-cone-focus-and-head-ring.md)
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md) - [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md) - [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 953 KiB

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.1" config/version="0.2.13"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+26
View File
@@ -37,3 +37,29 @@ func save_shift_result(result: Dictionary) -> Error:
var results := load_shift_results() var results := load_shift_results()
results.append(result) results.append(result)
return _save_json(SHIFT_RESULTS_FILE, results) return _save_json(SHIFT_RESULTS_FILE, results)
## Defaults for player settings. Kept in code so a missing or partial
## settings.json still produces a usable config.
const DEFAULT_SETTINGS := {
"look_sensitivity": 0.0022,
"hud_font_scale": 1.0,
"ai_difficulty": "normal",
}
## Load player settings, merged over the defaults. Returns a Dictionary.
func load_settings() -> Dictionary:
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
var data = _load_json(SETTINGS_FILE)
if data is Dictionary:
for key in data.keys():
if merged.has(key):
merged[key] = data[key]
return merged
## Persist player settings. Returns Error.
func save_settings(settings: Dictionary) -> Error:
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
for key in settings.keys():
if merged.has(key):
merged[key] = settings[key]
return _save_json(SETTINGS_FILE, merged)
+126
View File
@@ -343,6 +343,28 @@ static func hollywood_building() -> VenueScenario:
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35), _building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25), _building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24), _building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
{
"id": "building_filming_boundary",
"phase": Rules.Phase.MIDDAY,
"kind": "scripted",
"npc_type": Rules.NpcType.INFLUENCER,
"intent": "film",
"time_range_s": Vector2(300, 600),
"probability": 0.4,
"base_weight": 1.0,
"hint": "hint.body_camera_reason",
},
{
"id": "building_unclear_intent",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.CONFUSED_VISITOR,
"intent": "enter",
"time_range_s": Vector2(600, 780),
"probability": 0.4,
"base_weight": 1.0,
"hint": "hint.ask_reason",
},
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26), _building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15), _building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16), _building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
@@ -437,6 +459,67 @@ static func joke_shop() -> VenueScenario:
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18), _joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}), _joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}),
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12), _joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
{
"id": "joke_guest_reservation_no_id",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(620, 860),
"probability": 0.45,
"base_weight": 1.0,
"override_access": {
"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID,
"has_id": false, "has_ticket": false,
},
"hint": "hint.reservation_no_id",
},
{
"id": "joke_borrowed_id",
"phase": Rules.Phase.MIDDAY,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(320, 700),
"probability": 0.4,
"base_weight": 1.0,
"override_access": {
"has_id": true, "id_status": Rules.ArtifactStatus.MISMATCHED,
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
},
"hint": "hint.borrowed_id",
},
{
"id": "joke_refuses_wand",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(650, 880),
"probability": 0.4,
"base_weight": 1.0,
"refuses_screening": true,
"override_access": {
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
},
"hint": "hint.refuses_wand",
},
{
"id": "joke_late_arrival",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "arrive",
"time_range_s": Vector2(700, 860),
"probability": 0.35,
"base_weight": 1.0,
"override_access": {
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
},
"hint": "hint.late_arrival",
},
_joke_staff_entry(Vector2(0, 900), 0.25), _joke_staff_entry(Vector2(0, 900), 0.25),
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15), _joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
_joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15), _joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15),
@@ -515,6 +598,45 @@ static func tv_studio() -> VenueScenario:
"base_weight": 1.0, "base_weight": 1.0,
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT}, "override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
}, },
{
"id": "studio_film_boundary",
"phase": Rules.Phase.MIDDAY,
"kind": "scripted",
"npc_type": Rules.NpcType.INFLUENCER,
"intent": "film_boundary",
"time_range_s": Vector2(330, 560),
"probability": 0.5,
"base_weight": 1.0,
"hint": "hint.film_boundary",
},
{
"id": "studio_film_boundary_repeat",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.INFLUENCER,
"intent": "film_boundary",
"time_range_s": Vector2(640, 830),
"probability": 0.45,
"base_weight": 1.0,
"hint": "hint.film_boundary_repeat",
},
{
"id": "studio_celebrity_arrival",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "arrive",
"time_range_s": Vector2(620, 760),
"probability": 0.5,
"base_weight": 1.0,
"override_access": {"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID},
"extra_spawns": [
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
{"npc_type": Rules.NpcType.INFLUENCER, "intent": "film"},
],
"hint": "hint.celebrity_arrival",
},
{ {
"id": "studio_follow_through", "id": "studio_follow_through",
"phase": Rules.Phase.EVENING, "phase": Rules.Phase.EVENING,
@@ -587,6 +709,10 @@ static func _joke_routine(id: String, npc_type: int, intent: String, time_range:
static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary: static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
return _building_routine(id, npc_type, intent, time_range, prob) return _building_routine(id, npc_type, intent, time_range, prob)
## Scripted film-boundary intent: the influencer stands past the posted line.
static func _film_boundary_spot() -> String:
return "steps_end"
static func _phase_for_range(time_range: Vector2, duration: float) -> int: static func _phase_for_range(time_range: Vector2, duration: float) -> int:
var start := time_range.x / duration var start := time_range.x / duration
if start < 0.34: if start < 0.34:
+14
View File
@@ -45,6 +45,20 @@ func apply_event(event_type: String) -> void:
"event": event_type, "event": event_type,
}) })
## Presentation cue for the avatars after a crowd event (PLAN: Public Crowd
## Reaction). "watch" = rubberneck at the incident, "flee" = move away from the
## door, "" = no reaction. Pure so it is headless-testable.
static func reaction_for_event(event_type: String) -> String:
match event_type:
"aggressive_behavior", "visible_weapon", "emergency_responder":
return "watch"
_:
return ""
## True once the crowd is panicking hard enough that people should clear out.
func should_flee() -> bool:
return crowd_panic
func summary() -> Dictionary: func summary() -> Dictionary:
return { return {
"mood": mood, "mood": mood,
+13
View File
@@ -78,6 +78,19 @@ func close_incident(npc_id: String, outcome: String) -> Dictionary:
func open_incidents_count() -> int: func open_incidents_count() -> int:
return _open_incidents.size() return _open_incidents.size()
## True when an incident is already open for this NPC (avoids duplicating one
## incident every time the player re-focuses the same person).
func has_open_incident(npc_id: String) -> bool:
return _open_incidents.has(npc_id)
## Close every still-open incident at shift end so the review does not silently
## drop interactions the player never resolved. Returns how many were closed.
func close_all_open(outcome: String = "unresolved") -> int:
var ids: Array = _open_incidents.keys()
for npc_id in ids:
close_incident(str(npc_id), outcome)
return ids.size()
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
return { return {
"incidents": incidents.duplicate(true), "incidents": incidents.duplicate(true),
+17
View File
@@ -34,6 +34,13 @@ var guard_noticed := false
## Whether this NPC has passed the handheld wand screening (The Joke Shop). ## Whether this NPC has passed the handheld wand screening (The Joke Shop).
var screened := false var screened := false
## Authored event flag: this person refuses the wand screen (Joke Shop).
var refuses_screening := false
## Whether the guard has declared they recognize this person (a player decision
## recorded as evidence, PLAN.md: Authorized Access).
var player_recognized := false
## Last reply the NPC gave when asked for their reason for entry.
var last_reason_reply := ""
## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means ## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means
## venue/type defaults from AccessLoadout. ## venue/type defaults from AccessLoadout.
var access_override: Dictionary = {} var access_override: Dictionary = {}
@@ -150,6 +157,16 @@ func tick_return(delta: float) -> bool:
func decide_leave() -> bool: func decide_leave() -> bool:
return leave_requested return leave_requested
## Level 3 ask: request the person's reason for entry (PLAN.md). Pure and
## headless-testable; returns the line this NPC answers with ("" if silent).
func request_reason() -> String:
var lines: Array[String] = profile.dialogue_lines if profile else []
if lines.is_empty():
last_reason_reply = ""
return ""
last_reason_reply = lines[rng.randi_range(0, lines.size() - 1)]
return last_reason_reply
## 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.
+14
View File
@@ -21,6 +21,8 @@ signal phase_changed(phase: int)
var scenario: VenueScenario = null var scenario: VenueScenario = null
var duration_s: float = 900.0 var duration_s: float = 900.0
var elapsed_s: float = 0.0 var elapsed_s: float = 0.0
## Wall-clock start, so the shift review can show event times as mm:ss.
var started_at_ms: int = 0
var current_phase: int = Rules.Phase.EARLY var current_phase: int = Rules.Phase.EARLY
var running := false var running := false
@@ -32,6 +34,10 @@ var crowd := CrowdSystem.new()
var scheduler := EventScheduler.new() var scheduler := EventScheduler.new()
var spawned_count := 0 var spawned_count := 0
## Emergency aftermath (PLAN.md: Level 5). Responder resolutions and any
## investigation notes for calls that were not justified.
var responder_outcomes: Array[String] = []
var investigations: Array[String] = []
var spawn_seed := 0 var spawn_seed := 0
var _shift_finished := false var _shift_finished := false
## The scheduler rolls once per simulated second. `tick()` runs at physics rate, ## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
@@ -45,6 +51,8 @@ func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int =
spawn_seed = p_seed spawn_seed = p_seed
scheduler.setup(scenario, p_seed) scheduler.setup(scenario, p_seed)
event_bus = EventBus.new() event_bus = EventBus.new()
responder_outcomes.clear()
investigations.clear()
radio = RadioSystem.new() radio = RadioSystem.new()
reputation = ReputationEvaluator.new() reputation = ReputationEvaluator.new()
crowd = CrowdSystem.new() crowd = CrowdSystem.new()
@@ -59,6 +67,7 @@ func start() -> void:
return return
running = true running = true
_shift_finished = false _shift_finished = false
started_at_ms = Time.get_ticks_msec()
elapsed_s = 0.0 elapsed_s = 0.0
_spawn_accum_s = 0.0 _spawn_accum_s = 0.0
current_phase = _phase_for_time(0.0) current_phase = _phase_for_time(0.0)
@@ -119,6 +128,8 @@ func _finish_shift() -> void:
return return
_shift_finished = true _shift_finished = true
running = false running = false
# Interactions the player never resolved still belong in this shift's review.
recorder.close_all_open("unresolved")
var review := build_review() var review := build_review()
event_bus.shift_state_changed.emit({"running": false, "review": review}) event_bus.shift_state_changed.emit({"running": false, "review": review})
shift_ended.emit(review) shift_ended.emit(review)
@@ -129,6 +140,7 @@ func build_review() -> Dictionary:
"scenario_id": scenario.id if scenario else "", "scenario_id": scenario.id if scenario else "",
"duration_s": duration_s, "duration_s": duration_s,
"elapsed_s": elapsed_s, "elapsed_s": elapsed_s,
"started_at_ms": started_at_ms,
"phase_at_end": current_phase, "phase_at_end": current_phase,
"spawned_count": spawned_count, "spawned_count": spawned_count,
"incidents": recorder.to_dict(), "incidents": recorder.to_dict(),
@@ -138,6 +150,8 @@ func build_review() -> Dictionary:
"battery": radio.battery, "battery": radio.battery,
"emergency_cooldown_s": radio.emergency_cooldown_remaining, "emergency_cooldown_s": radio.emergency_cooldown_remaining,
}, },
"responder_outcomes": responder_outcomes.duplicate(),
"investigations": investigations.duplicate(),
"completed_at_ms": Time.get_ticks_msec(), "completed_at_ms": Time.get_ticks_msec(),
} }
+3 -1
View File
@@ -63,7 +63,9 @@ func _spot_for_intent(intent: String) -> String:
return "entrance" return "entrance"
"photo", "film", "perform", "wander", "roam": "photo", "film", "perform", "wander", "roam":
return "sidewalk" return "sidewalk"
"sit": "sit", "film_boundary":
# film_boundary: an influencer ignoring the posted filming line, so they
# stand at the steps instead of the permitted sidewalk spot.
return "steps_end" return "steps_end"
_: _:
return "sidewalk" return "sidewalk"
+99
View File
@@ -8,6 +8,11 @@ extends Node
const SAMPLE_RATE := 22050.0 const SAMPLE_RATE := 22050.0
var _players: Array[AudioStreamPlayer] = [] var _players: Array[AudioStreamPlayer] = []
var _bed_player: AudioStreamPlayer = null
## Observable state so headless tests and the HUD can see what was requested.
var crowd_level: float = 0.0
var bed_playing := false
var radio_crackles := 0
func _ready() -> void: func _ready() -> void:
# Pre-create a few players so overlapping cues don't cut each other off. # Pre-create a few players so overlapping cues don't cut each other off.
@@ -30,6 +35,43 @@ func play_escalation(level: int) -> void:
var freq := 440.0 - float(level) * 40.0 var freq := 440.0 - float(level) * 40.0
_play_tone(maxf(freq, 180.0), 0.18, 0.6) _play_tone(maxf(freq, 180.0), 0.18, 0.6)
## Start the looping crowd bed (low murmur). Called once per shift; safe to call
## again. Volume then follows `set_crowd_level`.
func start_crowd_bed() -> void:
if _bed_player == null:
_bed_player = AudioStreamPlayer.new()
_bed_player.name = "CrowdBed"
add_child(_bed_player)
_bed_player.stream = _build_crowd_stream()
_bed_player.volume_db = _level_to_db(crowd_level)
_bed_player.play()
bed_playing = true
func stop_crowd_bed() -> void:
if _bed_player:
_bed_player.stop()
bed_playing = false
## Crowd mood (0..1) drives the bed volume: quiet when calm, louder when the
## street is agitated or panicking.
func set_crowd_level(mood: float) -> void:
crowd_level = clampf(mood, 0.0, 1.0)
if _bed_player:
_bed_player.volume_db = _level_to_db(crowd_level)
static func _level_to_db(mood: float) -> float:
return lerpf(-34.0, -12.0, clampf(mood, 0.0, 1.0))
## Radio open cue: a short burst of filtered crackle.
func play_radio_open() -> void:
radio_crackles += 1
_play_noise(0.09, 0.35)
## Radio close cue: a lower, shorter click.
func play_radio_close() -> void:
radio_crackles += 1
_play_noise(0.06, 0.25, 1400.0)
## Play a denial/error cue (low buzz). ## Play a denial/error cue (low buzz).
func play_deny() -> void: func play_deny() -> void:
_play_tone(220.0, 0.20, 0.5) _play_tone(220.0, 0.20, 0.5)
@@ -53,7 +95,64 @@ func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0)
var sample := sin(TAU * freq * t) * 0.6 var sample := sin(TAU * freq * t) * 0.6
playback.push_frame(Vector2(sample, sample)) playback.push_frame(Vector2(sample, sample))
## Deterministic low murmur for the crowd bed: filtered noise plus a low drone.
func _build_crowd_stream() -> AudioStreamWAV:
const SECONDS := 4.0
var frames := int(SAMPLE_RATE * SECONDS)
var data := PackedByteArray()
data.resize(frames * 2)
var rng := RandomNumberGenerator.new()
rng.seed = 20261031
var smooth := 0.0
for i in frames:
var t := float(i) / SAMPLE_RATE
# Low-passed noise (a simple one-pole filter) sounds like distant talking.
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), 0.02)
var drone := sin(TAU * 68.0 * t) * 0.25 + sin(TAU * 103.0 * t) * 0.12
var sample := (smooth * 2.2 + drone) * 0.35
var v := int(clampf(sample, -1.0, 1.0) * 32767.0)
data[i * 2] = v & 0xFF
data[i * 2 + 1] = (v >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = int(SAMPLE_RATE)
stream.stereo = false
stream.data = data
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
stream.loop_begin = 0
stream.loop_end = frames
return stream
## Short filtered noise burst used for the radio crackle.
func _play_noise(duration: float, volume: float, cutoff_hz: float = 3200.0) -> void:
var player := _free_player()
if player == null:
return
var frames := int(SAMPLE_RATE * duration)
var data := PackedByteArray()
data.resize(frames * 2)
var rng := RandomNumberGenerator.new()
rng.seed = 7
var smooth := 0.0
var alpha := clampf(cutoff_hz / SAMPLE_RATE, 0.01, 0.9)
for i in frames:
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), alpha)
var env := 1.0 - float(i) / float(maxi(frames, 1))
var v := int(clampf(smooth * env, -1.0, 1.0) * 32767.0)
data[i * 2] = v & 0xFF
data[i * 2 + 1] = (v >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = int(SAMPLE_RATE)
stream.stereo = false
stream.data = data
player.stream = stream
player.volume_db = linear_to_db(volume)
player.play()
func _free_player() -> AudioStreamPlayer: func _free_player() -> AudioStreamPlayer:
if _players.is_empty():
return null
for player in _players: for player in _players:
if not player.playing: if not player.playing:
return player return player
+477 -13
View File
@@ -9,6 +9,7 @@ const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd") const AccessLoadout := preload("res://src/core/access_loadout.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 DoorState := preload("res://src/core/door_state.gd")
const CrowdSystem := preload("res://src/core/crowd_system.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")
@@ -23,10 +24,15 @@ const Hud := preload("res://src/game/ui/hud.gd")
const ActionRadial := preload("res://src/game/ui/action_radial.gd") const ActionRadial := preload("res://src/game/ui/action_radial.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")
const StartMenu := preload("res://src/game/ui/start_menu.gd")
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
var world_builder: WorldBuilder = null var world_builder: WorldBuilder = null
var door := DoorState.new() var door := DoorState.new()
var flashlight_on := false var flashlight_on := false
var body_camera_on := false
var coordinator: ShiftCoordinator = null var coordinator: ShiftCoordinator = null
var guard: GuardController = null var guard: GuardController = null
var _sun: DirectionalLight3D = null var _sun: DirectionalLight3D = null
@@ -62,6 +68,13 @@ var hud: Hud = null
var radial: ActionRadial = null var radial: ActionRadial = null
var radio_panel: RadioPanel = null var radio_panel: RadioPanel = null
var shift_review_panel: ShiftReview = null var shift_review_panel: ShiftReview = null
var start_menu: StartMenu = null
var pause_overlay: PauseOverlay = null
var settings_panel: SettingsPanel = null
var event_timeline: EventTimeline = null
var _timeline_from_review := false
var _shift_running := false
var player_settings: Dictionary = {}
var _focused_avatar: Node3D = null var _focused_avatar: Node3D = null
var audio: AudioFeedback = null var audio: AudioFeedback = null
var verifier := AccessVerifier.new() var verifier := AccessVerifier.new()
@@ -99,6 +112,7 @@ var _nearby_index := -1
func _ready() -> void: func _ready() -> void:
_door_rng.randomize() _door_rng.randomize()
player_settings = Save.load_settings() if Save else {}
scene_root = Node3D.new() scene_root = Node3D.new()
scene_root.name = "SceneRoot" scene_root.name = "SceneRoot"
add_child(scene_root) add_child(scene_root)
@@ -115,6 +129,7 @@ func _ready() -> void:
guard.rotation.y = PI guard.rotation.y = PI
guard.picked_target.connect(_on_guard_target) guard.picked_target.connect(_on_guard_target)
guard.cleared_target.connect(_on_guard_clear) guard.cleared_target.connect(_on_guard_clear)
_apply_settings(player_settings)
selected_scenario = _build_scenario(active_scenario_id) selected_scenario = _build_scenario(active_scenario_id)
scenario_duration_s = selected_scenario.shift_duration_s scenario_duration_s = selected_scenario.shift_duration_s
@@ -122,12 +137,17 @@ func _ready() -> void:
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.npc_spawn_requested.connect(_spawn_npc) coordinator.npc_spawn_requested.connect(_spawn_npc)
coordinator.shift_ended.connect(_on_shift_ended) coordinator.shift_ended.connect(_on_shift_ended)
coordinator.radio.responder_arrived.connect(_on_responder_arrived) _connect_radio()
_connect_crowd()
_build_ui() _build_ui()
coordinator.start() # The HUD does not exist yet when settings are loaded, so push them again now
# that it does (otherwise the saved font scale is ignored until the next apply).
_apply_settings(player_settings)
_last_phase = -1 _last_phase = -1
_apply_phase_lighting() _apply_phase_lighting()
# Boot into the venue menu; the shift starts when the player picks a venue.
_show_start_menu()
var selected_scenario: VenueScenario = null var selected_scenario: VenueScenario = null
@@ -148,10 +168,13 @@ func _build_guard() -> GuardController:
flashlight.name = "Flashlight" flashlight.name = "Flashlight"
flashlight.visible = false flashlight.visible = false
flashlight.position = Vector3(0, -0.15, -0.4) flashlight.position = Vector3(0, -0.15, -0.4)
flashlight.rotation_degrees = Vector3(-10, 0, 0) # Aim straight ahead: tilting it down made the cone hit the ground right in
flashlight.spot_range = 18.0 # front of the guard instead of the person or door being checked.
flashlight.spot_angle = 28.0 flashlight.rotation_degrees = Vector3.ZERO
flashlight.light_energy = 2.5 flashlight.spot_range = 22.0
flashlight.spot_angle = 30.0
flashlight.light_energy = 4.0
flashlight.spot_attenuation = 0.6
head.add_child(flashlight) head.add_child(flashlight)
var collider := CollisionShape3D.new() var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new() var shape := CapsuleShape3D.new()
@@ -179,20 +202,114 @@ func switch_scenario(id: String) -> void:
restart_shift() restart_shift()
func restart_shift() -> void: func restart_shift() -> void:
shift_review_panel.hide() if shift_review_panel:
shift_review_panel.hide()
_reset_shift_state()
_start_shift()
## Clear all per-shift runtime state (NPCs, incidents, timers).
func _reset_shift_state() -> void:
for n in npcs: for n in npcs:
n.queue_free() if is_instance_valid(n):
n.queue_free()
npcs.clear() npcs.clear()
npc_counter = 0 npc_counter = 0
inspector_current_events = [] inspector_current_events = []
current_incident_active = false current_incident_active = false
current_incident_npc_id = "" current_incident_npc_id = ""
targeted_npc = null
_focused_avatar = null
_nearby_index = -1
inspection_open = false
wand_open = false
_wand_cooldown = 0.0
body_camera_on = false
if hud:
hud.set_body_camera(false)
scenario_duration_s = selected_scenario.shift_duration_s scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.start() _connect_radio()
_connect_crowd()
_last_phase = -1 _last_phase = -1
_apply_phase_lighting() _apply_phase_lighting()
## Begin (or resume) the shift clock and recapture the mouse.
func _start_shift() -> void:
_shift_running = true
if audio:
audio.start_crowd_bed()
audio.set_crowd_level(coordinator.crowd.mood)
coordinator.start()
if hud:
hud.set_shift_state(coordinator, selected_scenario)
_refresh_freeze()
func _show_start_menu() -> void:
_shift_running = false
if pause_overlay:
pause_overlay.close()
if shift_review_panel:
shift_review_panel.hide()
if guard:
guard.input_locked = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
if start_menu:
start_menu.open()
func _on_venue_selected(scenario_id: String) -> void:
if start_menu and start_menu.visible:
start_menu.close()
active_scenario_id = scenario_id
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
selected_scenario = _build_scenario(scenario_id)
if hud:
hud.set_wand_icon(false)
_reset_shift_state()
_start_shift()
## Apply loaded settings to the guard and HUD.
func _apply_settings(settings: Dictionary) -> void:
player_settings = settings
if guard:
guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022))
if hud and hud.has_method("set_font_scale"):
hud.set_font_scale(float(settings.get("hud_font_scale", 1.0)))
func _on_settings_applied(settings: Dictionary) -> void:
_apply_settings(settings)
if start_menu and start_menu.visible:
start_menu.open()
elif _shift_running:
_open_pause()
func _open_settings() -> void:
if start_menu:
start_menu.close()
if pause_overlay:
pause_overlay.close()
if settings_panel:
settings_panel.open(player_settings)
func _open_pause() -> void:
if pause_overlay == null or not _shift_running:
return
if _close_top_modal():
pass
pause_overlay.open()
if guard:
guard.input_locked = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _close_pause() -> void:
if pause_overlay:
pause_overlay.close()
_refresh_freeze()
func _quit_to_menu() -> void:
if pause_overlay:
pause_overlay.close()
_show_start_menu()
var inspector_current_events: Array = [] var inspector_current_events: Array = []
func _build_ui() -> void: func _build_ui() -> void:
@@ -211,12 +328,62 @@ func _build_ui() -> void:
radio_panel.submitted.connect(_on_radio_submitted) radio_panel.submitted.connect(_on_radio_submitted)
radio_panel.cancelled.connect(func(): radio_panel.cancelled.connect(func():
radio_panel.hide() radio_panel.hide()
if audio:
audio.play_radio_close()
hud.set_action_hint("") hud.set_action_hint("")
_refresh_freeze()) _refresh_freeze())
shift_review_panel = ShiftReview.new() shift_review_panel = ShiftReview.new()
add_child(shift_review_panel) add_child(shift_review_panel)
shift_review_panel.hide() shift_review_panel.hide()
shift_review_panel.restart_requested.connect(restart_shift) shift_review_panel.restart_requested.connect(_on_review_restart)
shift_review_panel.timeline_requested.connect(_open_timeline)
shift_review_panel.visible = false
pause_overlay = PauseOverlay.new()
add_child(pause_overlay)
pause_overlay.hide()
pause_overlay.resume_requested.connect(_close_pause)
pause_overlay.restart_requested.connect(restart_shift)
pause_overlay.settings_requested.connect(_open_settings)
pause_overlay.quit_to_menu_requested.connect(_quit_to_menu)
settings_panel = SettingsPanel.new()
add_child(settings_panel)
settings_panel.hide()
settings_panel.applied.connect(_on_settings_applied)
event_timeline = EventTimeline.new()
add_child(event_timeline)
event_timeline.hide()
event_timeline.closed.connect(_on_timeline_closed)
start_menu = StartMenu.new()
add_child(start_menu)
start_menu.hide()
start_menu.venue_selected.connect(_on_venue_selected)
start_menu.settings_requested.connect(_open_settings)
start_menu.quit_requested.connect(func(): get_tree().quit())
## Body-camera timeline from the review screen (ADR-0005 events, no 3D replay).
func _open_timeline(review: Dictionary) -> void:
if event_timeline == null:
return
# Hide the review dim so the timeline is not stacked under two overlays.
_timeline_from_review = shift_review_panel != null and shift_review_panel.visible
if shift_review_panel:
shift_review_panel.visible = false
event_timeline.show_timeline(review)
_refresh_freeze()
func _on_timeline_closed() -> void:
if _timeline_from_review and shift_review_panel:
shift_review_panel.visible = true
_timeline_from_review = false
_refresh_freeze()
## "Run another shift" on the review screen returns to the venue menu.
func _on_review_restart() -> void:
_show_start_menu()
func _add_crosshair() -> void: func _add_crosshair() -> void:
var layer := CanvasLayer.new() var layer := CanvasLayer.new()
@@ -309,8 +476,22 @@ func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dict
items.append(str(item)) items.append(str(item))
spawn_profile.carried_items = items spawn_profile.carried_items = items
avatar.setup(spawn_profile, intent, seed_value, spawn_pos, target_pos, door_pos, npc_id, override) avatar.setup(spawn_profile, intent, seed_value, spawn_pos, target_pos, door_pos, npc_id, override)
# Authored events stay for the whole shift; routine traffic cycles out.
avatar.ephemeral = event.get("kind", "routine") == "routine"
avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60)
avatar.get_npc_controller().refuses_screening = bool(event.get("refuses_screening", false))
scene_root.add_child(avatar) scene_root.add_child(avatar)
npcs.append(avatar) npcs.append(avatar)
# Authored events can point the player at the right verb (PLAN.md verbs).
if event.has("hint") and hud:
hud.log(Strings.lookup(str(event["hint"])))
# Celebrity arrivals draw extra bystanders, not just one avatar.
for extra in event.get("extra_spawns", []):
var extra_profile := selected_scenario.profile_for(extra.get("npc_type", Rules.NpcType.TOURIST))
if extra_profile == null or npcs.size() >= MAX_LIVE_NPCS:
continue
_spawn_npc(extra_profile, str(extra.get("intent", "photo")),
"%s_w%d" % [npc_id, npcs.size()], {"kind": "routine"})
## Drop references to freed avatars so the cap and Tab cycling stay accurate. ## Drop references to freed avatars so the cap and Tab cycling stay accurate.
func _purge_npcs() -> void: func _purge_npcs() -> void:
@@ -345,12 +526,98 @@ func _on_responder_arrived(call: Dictionary) -> void:
scene_root.add_child(avatar) scene_root.add_child(avatar)
npcs.append(avatar) npcs.append(avatar)
hud.log(Strings.fmt("responder.arrived", [outcome])) hud.log(Strings.fmt("responder.arrived", [outcome]))
coordinator.responder_outcomes.append(outcome)
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), { coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
"type": "responder_action", "type": "responder_action",
"outcome": outcome, "outcome": outcome,
"call_type": call.get("type", "assist"), "call_type": call.get("type", "assist"),
}) })
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism") coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
if outcome == "evacuation":
_evacuate_entrance()
## Responder outcome "evacuation": the entrance is cleared and the door stays
## locked while the block is secured (PLAN.md: Level 5 Emergency Response).
func _evacuate_entrance() -> void:
if door and not door.locked:
door.set_locked(true)
if world_builder:
world_builder.set_door_locked(true)
if hud:
hud.set_door(true)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
for npc in npcs:
if not is_instance_valid(npc):
continue
if npc.has_method("start_flee"):
npc.start_flee(door_pos)
var controller: Object = npc.get_npc_controller() if npc.has_method("get_npc_controller") else null
if controller != null:
coordinator.recorder.add_event(controller.npc_id, {
"type": "evacuation",
"outcome": "evacuated",
})
if controller.leave_requested == false:
controller.leave_requested = true
coordinator.reputation.apply("security_failure", -3.0, "entrance evacuated by responder")
if hud:
hud.log(Strings.lookup("responder.evacuated"))
## Responder dispatch. `coordinator.setup()` builds a new RadioSystem for each
## shift, so the arrival signal must be re-connected after every setup or radio
## calls are silently resolved by nobody.
func _connect_radio() -> void:
if coordinator == null or coordinator.radio == null:
return
if not coordinator.radio.responder_arrived.is_connected(_on_responder_arrived):
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
## Crowd presentation (PLAN: Public Crowd Reaction). The crowd system is a
## mood/attention float; this turns its events into avatar behavior.
func _connect_crowd() -> void:
# CrowdSystem routes its updates through the EventBus (`event_bus.crowd_changed`);
# its own `changed` signal is not emitted by apply_event().
if coordinator == null or coordinator.event_bus == null:
return
if not coordinator.event_bus.crowd_changed.is_connected(_on_crowd_changed):
coordinator.event_bus.crowd_changed.connect(_on_crowd_changed)
func _on_crowd_changed(payload: Dictionary) -> void:
var event_type := str(payload.get("event", ""))
var panic := bool(payload.get("panic", false))
if audio:
audio.set_crowd_level(float(payload.get("mood", 0.0)))
# Panic wins: people clear away from the entrance.
if panic or event_type == "crowd_panic":
_flee_crowd()
return
if CrowdSystem.reaction_for_event(event_type) == "watch":
_rubberneck_crowd()
func _incident_point() -> Vector3:
if targeted_npc is Node3D and is_instance_valid(targeted_npc):
return (targeted_npc as Node3D).global_position
if guard:
return guard.global_position
return Vector3(0, 0, 1.2)
## Idle bystanders stop and face the incident instead of walking on.
func _rubberneck_crowd() -> void:
var point := _incident_point()
for npc in npcs:
if not is_instance_valid(npc) or not npc.has_method("start_rubberneck"):
continue
if npc == targeted_npc:
continue
npc.start_rubberneck(point)
## Panic: everyone nearby moves away from the door.
func _flee_crowd() -> void:
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
for npc in npcs:
if not is_instance_valid(npc) or not npc.has_method("start_flee"):
continue
npc.start_flee(door_pos)
func _responder_profile() -> NpcProfile: func _responder_profile() -> NpcProfile:
var p := NpcProfile.new() var p := NpcProfile.new()
@@ -362,6 +629,9 @@ func _responder_profile() -> NpcProfile:
return p return p
func _on_shift_ended(review: Dictionary) -> void: func _on_shift_ended(review: Dictionary) -> void:
_shift_running = false
if audio:
audio.stop_crowd_bed()
# Persist the finished shift locally (Save autoload, user:// JSON). # Persist the finished shift locally (Save autoload, user:// JSON).
if Save: if Save:
Save.save_shift_result(review) Save.save_shift_result(review)
@@ -394,6 +664,11 @@ func _begin_incident(avatar: Node3D) -> void:
current_incident_npc_id = controller.npc_id current_incident_npc_id = controller.npc_id
current_incident_active = true current_incident_active = true
current_npc_truth = _read_npc_truth(controller) current_npc_truth = _read_npc_truth(controller)
# Open a structured incident so player actions, access evidence, and
# escalation levels are actually recorded (ADR-0005). Without this the
# recorder drops every event and the review reports zero incidents.
if not coordinator.recorder.has_open_incident(controller.npc_id):
coordinator.recorder.start_incident(controller.npc_id, "player_focus")
var profile: Object = controller.profile var profile: Object = controller.profile
var label := "%s — %s" % [ var label := "%s — %s" % [
profile.display_name if profile else "?", profile.display_name if profile else "?",
@@ -476,6 +751,8 @@ func _handle_key(event: InputEvent) -> void:
_toggle_flashlight() _toggle_flashlight()
KEY_L: KEY_L:
_toggle_door_lock() _toggle_door_lock()
KEY_B:
_toggle_body_camera()
KEY_V: KEY_V:
_cycle_venue() _cycle_venue()
_: _:
@@ -510,13 +787,22 @@ func _handle_action_key(keycode: Key) -> void:
_: _:
pass pass
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc toggles mouse ## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc pauses the
## capture; RMB does nothing so it never grabs the mouse by accident. ## shift (or resumes it when paused); RMB does nothing so it never grabs the
## mouse by accident.
func _handle_cancel(from_mouse: bool) -> void: func _handle_cancel(from_mouse: bool) -> void:
if _close_top_modal(): if _close_top_modal():
return return
if from_mouse: if from_mouse:
return return
if settings_panel and settings_panel.visible:
return
if pause_overlay and pause_overlay.visible:
_close_pause()
return
if _shift_running and not (start_menu and start_menu.visible):
_open_pause()
return
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else: else:
@@ -538,9 +824,14 @@ func _close_top_modal() -> bool:
return true return true
if radio_panel and radio_panel.visible: if radio_panel and radio_panel.visible:
radio_panel.hide() radio_panel.hide()
if audio:
audio.play_radio_close()
hud.set_action_hint("") hud.set_action_hint("")
_refresh_freeze() _refresh_freeze()
return true return true
if event_timeline and event_timeline.visible:
event_timeline.close()
return true
return false return false
func _open_radial() -> void: func _open_radial() -> void:
@@ -585,6 +876,8 @@ func _on_radial_confirmed(slice_id: String) -> void:
match slice_id: match slice_id:
"raise_hand": "raise_hand":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand") _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
"eye_contact":
_eye_contact()
"point": "point":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point") _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
"head_shake": "head_shake":
@@ -599,6 +892,10 @@ func _on_radial_confirmed(slice_id: String) -> void:
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning") _apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
"block": "block":
_apply_interaction(Rules.Level.BLOCKING, "blocking") _apply_interaction(Rules.Level.BLOCKING, "blocking")
"reason":
_request_reason()
"recognize":
_recognize_target()
"radio": "radio":
_open_radio(false) _open_radio(false)
@@ -610,6 +907,14 @@ func _refresh_freeze() -> void:
freeze = true freeze = true
if shift_review_panel and shift_review_panel.visible: if shift_review_panel and shift_review_panel.visible:
freeze = true freeze = true
if pause_overlay and pause_overlay.visible:
freeze = true
if settings_panel and settings_panel.visible:
freeze = true
if event_timeline and event_timeline.visible:
freeze = true
if not _shift_running:
freeze = true
if guard: if guard:
guard.input_locked = freeze guard.input_locked = freeze
if freeze: if freeze:
@@ -627,6 +932,29 @@ func _toggle_flashlight() -> void:
hud.set_flashlight(flashlight_on) hud.set_flashlight(flashlight_on)
hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off")) hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off"))
## Toggle the body camera (PLAN Level 2). The overlay is only visible while the
## camera is on, and turning it on or off is written to the incident log.
func _toggle_body_camera() -> void:
_set_body_camera(not body_camera_on, "manual")
func _set_body_camera(on: bool, reason: String) -> void:
if body_camera_on == on:
return
body_camera_on = on
if hud:
hud.set_body_camera(on)
var controller: Object = _targeted_controller()
if controller != null:
_ensure_incident(controller)
if coordinator and current_incident_active:
coordinator.recorder.add_event(current_incident_npc_id, {
"type": "body_camera",
"on": on,
"reason": reason,
})
if hud:
hud.log(Strings.lookup("bodycam.on") if on else Strings.lookup("bodycam.off"))
## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart ## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart
## FORCE_ENTRY attempts during the reaction flow. ## FORCE_ENTRY attempts during the reaction flow.
func _toggle_door_lock() -> void: func _toggle_door_lock() -> void:
@@ -678,6 +1006,7 @@ func _apply_interaction(level: int, action_name: String) -> void:
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
if controller == null or coordinator == null: if controller == null or coordinator == null:
return return
_ensure_incident(controller)
var outcome: Dictionary = controller.apply_player_action(level) var outcome: Dictionary = controller.apply_player_action(level)
var npc_id: String = controller.npc_id var npc_id: String = controller.npc_id
coordinator.recorder.add_event(npc_id, { coordinator.recorder.add_event(npc_id, {
@@ -688,11 +1017,88 @@ func _apply_interaction(level: int, action_name: String) -> void:
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id]) coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
if level >= Rules.Level.FORMAL_WARNING: if level >= Rules.Level.FORMAL_WARNING:
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name]) coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
# A formal warning is the documented point where the body camera / security
# light goes on: it creates a record of the interaction (PLAN Level 2).
if level == Rules.Level.FORMAL_WARNING and not body_camera_on:
_set_body_camera(true, "formal warning")
# Blocking also means physically standing between the person and the door
# (PLAN Level 3), not just sending the escalation level.
if level == Rules.Level.BLOCKING:
_station_between(controller, controller.profile.display_name if controller.profile else "them")
hud.set_escalation(level) hud.set_escalation(level)
if audio: if audio:
audio.play_escalation(level) audio.play_escalation(level)
_react_to_outcome(controller, outcome) _react_to_outcome(controller, outcome)
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
## uses the same Level 1 reaction table but is logged as its own action.
func _eye_contact() -> void:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "eye contact")
## "I recognize this person" is the player's own decision, recorded as access
## evidence rather than pre-attached to every staff/guest type (PLAN.md).
func _recognize_target() -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var name: String = controller.profile.display_name if controller.profile else "them"
if controller.player_recognized:
hud.log(Strings.fmt("recognition.already", [name]))
return
_ensure_incident(controller)
controller.player_recognized = true
coordinator.recorder.add_access_evidence(controller.npc_id, {
"kind": "recognition",
"source": "guard_decision",
"recognized": true,
})
coordinator.recorder.add_event(controller.npc_id, {
"type": "recognition_decision",
"recognized": true,
"source": "player",
})
hud.log(Strings.fmt("recognition.declared", [name]))
## Level 3 ask: request the person's reason for entry. The reply comes from the
## NPC's authored `dialogue_lines`; silence is a valid, telling answer.
func _request_reason() -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var name: String = controller.profile.display_name if controller.profile else "They"
_ensure_incident(controller)
var reply: String = controller.request_reason()
coordinator.recorder.add_event(controller.npc_id, {
"type": "reason_requested",
"reply": reply,
})
hud.set_escalation(Rules.Level.BLOCKING)
hud.log(Strings.fmt("reason.ask", [name]))
if reply == "":
hud.log(Strings.fmt("reason.silent", [name]))
else:
hud.log(Strings.fmt("reason.reply", [name, reply]))
## Physically move the guard between the person and the door (PLAN Level 3).
func _station_between(controller: Object, name: String) -> void:
if guard == null or targeted_npc == null:
return
var npc_pos: Vector3 = (targeted_npc as Node3D).global_position
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
var to_door := door_pos - npc_pos
to_door.y = 0.0
if to_door.length() < 0.05:
return
var station: Vector3 = npc_pos + to_door.normalized() * 0.9
station.y = guard.global_position.y
guard.global_position = station
var look_at_point := Vector3(npc_pos.x, guard.global_position.y, npc_pos.z)
if (look_at_point - guard.global_position).length() > 0.05:
guard.look_at(look_at_point, Vector3.UP)
hud.log(Strings.fmt("station.blocking", [name]))
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void: func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
var reaction: int = outcome.get("reaction", -1) var reaction: int = outcome.get("reaction", -1)
var name: String = (controller.profile.display_name if controller.profile else "Person") var name: String = (controller.profile.display_name if controller.profile else "Person")
@@ -745,9 +1151,13 @@ func _open_radio(emergency: bool) -> void:
return return
radio_panel.open(controller, emergency, current_npc_truth) radio_panel.open(controller, emergency, current_npc_truth)
hud.set_action_hint("Fill in the radio report on screen.") hud.set_action_hint("Fill in the radio report on screen.")
if audio:
audio.play_radio_open()
_refresh_freeze() _refresh_freeze()
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void: func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
if audio:
audio.play_radio_close()
hud.set_action_hint("") hud.set_action_hint("")
_refresh_freeze() _refresh_freeze()
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report) var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
@@ -763,12 +1173,33 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
if emergency: if emergency:
coordinator.crowd.apply_event("emergency_responder") coordinator.crowd.apply_event("emergency_responder")
hud.log(Strings.lookup("radio.emergency_dispatched")) hud.log(Strings.lookup("radio.emergency_dispatched"))
# A call that the situation did not justify is reviewed afterwards
# (PLAN.md: Level 5 consequences).
if _is_unjustified_emergency():
var who: String = "the person"
var controller: Object = _targeted_controller()
if controller and controller.profile:
who = str(controller.profile.display_name)
coordinator.investigations.append(Strings.fmt("investigation.unjustified", [who]))
coordinator.recorder.add_event(current_incident_npc_id, {
"type": "investigation_opened",
"reason": "unjustified emergency call",
})
coordinator.reputation.apply("false_report", -6.0, "unjustified emergency call")
hud.log(Strings.lookup("investigation.started"))
else: else:
coordinator.crowd.apply_event("calm_professionalism") coordinator.crowd.apply_event("calm_professionalism")
if audio: if audio:
audio.play_radio_response() audio.play_radio_response()
_maybe_radio_easter_egg() _maybe_radio_easter_egg()
## True when an emergency call is not supported by the situation: no weapon
## seen and low danger (PLAN.md: unjustified call consequences).
func _is_unjustified_emergency() -> bool:
var weapons := bool(current_npc_truth.get("weapons_seen", false))
var danger := int(current_npc_truth.get("danger_level", 0))
return not weapons and danger <= 1
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?" ## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
## over the radio. Fires on a small random chance after a successful call. ## over the radio. Fires on a small random chance after a successful call.
func _maybe_radio_easter_egg() -> void: func _maybe_radio_easter_egg() -> void:
@@ -818,6 +1249,16 @@ func _toggle_wand_screening() -> void:
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
if controller == null: if controller == null:
return return
if controller.refuses_screening:
# Authored event: this guest refuses the screen (Joke Shop content).
hud.set_wand_icon(false)
hud.log(Strings.fmt("wand.refused", [controller.profile.display_name if controller.profile else "The guest"]))
coordinator.recorder.add_event(controller.npc_id, {
"type": "wand_refused",
"outcome": "refused",
})
coordinator.reputation.apply("prop", -1.0, "guest refused screening")
return
var profile: Object = controller.profile var profile: Object = controller.profile
var items: Array = profile.carried_items if profile else [] var items: Array = profile.carried_items if profile else []
var result: Dictionary = wand_screener.screen(items) var result: Dictionary = wand_screener.screen(items)
@@ -938,7 +1379,19 @@ func _artifacts_for(controller: Object) -> Array:
var profile: NpcProfile = controller.profile as NpcProfile var profile: NpcProfile = controller.profile as NpcProfile
var override: Dictionary = controller.access_override var override: Dictionary = controller.access_override
var policy = selected_scenario.access_policy if selected_scenario else null var policy = selected_scenario.access_policy if selected_scenario else null
return AccessLoadout.build(profile, policy, override) var artifacts: Array = AccessLoadout.build(profile, policy, override)
# The guard's own recognition is evidence the player created, so it is
# appended here rather than baked into every staff/guest artifact set.
if controller.player_recognized and not _has_recognition_artifact(artifacts):
artifacts.push_front(AccessArtifact.new(
Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true, "source": "player"}))
return artifacts
func _has_recognition_artifact(artifacts: Array) -> bool:
for artifact in artifacts:
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.RECOGNITION:
return true
return false
func _read_npc_truth(controller: Object) -> Dictionary: func _read_npc_truth(controller: Object) -> Dictionary:
var profile: Object = controller.profile var profile: Object = controller.profile
@@ -955,6 +1408,17 @@ func _targeted_controller() -> Object:
return targeted_npc.get_npc_controller() return targeted_npc.get_npc_controller()
return null return null
## Guarantee an open incident for this NPC before recording evidence, so verbs
## work even if the player acted without a prior focus change.
func _ensure_incident(controller: Object) -> void:
if controller == null or coordinator == null:
return
if coordinator.recorder.has_open_incident(controller.npc_id):
return
coordinator.recorder.start_incident(controller.npc_id, "player_action")
current_incident_npc_id = controller.npc_id
current_incident_active = true
func _close_incident(outcome: String) -> void: func _close_incident(outcome: String) -> void:
if current_incident_active and not current_incident_npc_id.is_empty(): if current_incident_active and not current_incident_npc_id.is_empty():
coordinator.recorder.close_incident(current_incident_npc_id, outcome) coordinator.recorder.close_incident(current_incident_npc_id, outcome)
+228 -40
View File
@@ -12,6 +12,19 @@ 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
## Seconds idle at an arrived waypoint. Ordinary traffic lives for a while, then
## despawns so the entrance does not fill with standers (TODO: NPC crowd cap).
var idle_s := 0.0
var lifetime_s := 90.0
var ephemeral := true
## Crowd presentation (PLAN: Public Crowd Reaction). "watch" rubbernecks at the
## incident, "flee" clears away from it, "" is normal navigation.
var reaction_mode := ""
var reaction_point := Vector3.ZERO
var _reaction_timer := 0.0
const RUBBERNECK_S := 12.0
const FLEE_S := 20.0
const FLEE_SPEED := 2.4
var agent: NavigationAgent3D = null var agent: NavigationAgent3D = null
## Set once the nav agent proves it has a path; direct movement is the fallback. ## Set once the nav agent proves it has a path; direct movement is the fallback.
var _nav_usable := false var _nav_usable := false
@@ -76,8 +89,15 @@ var _rig: Node3D = null
var _head: Node3D = null var _head: Node3D = null
var _bob_phase := 0.0 var _bob_phase := 0.0
var _guard_ref: Node3D = null var _guard_ref: Node3D = null
var _ring: MeshInstance3D = null var _focus_circle: MeshInstance3D = null
var _name_tag: Label3D = null var _name_tag: Label3D = null
## Theater props and limbs for the idle poses (ADR-0010 rig).
var _photo_prop: MeshInstance3D = null
var _sign_prop: MeshInstance3D = null
var _legs: Array[MeshInstance3D] = []
var _theater_t := 0.0
var _photo_prop_base_y := 0.0
var _sign_prop_base_y := 0.0
const SKIN_TONE := Color(0.88, 0.72, 0.60) const SKIN_TONE := Color(0.88, 0.72, 0.60)
const PANTS_COLOR := Color(0.22, 0.22, 0.26) const PANTS_COLOR := Color(0.22, 0.22, 0.26)
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)] const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
@@ -98,8 +118,8 @@ func _build_body(npc_type: int) -> void:
# Torso # Torso
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color) _add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
# Legs # Legs
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR) _legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR))
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR) _legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
# Arms # Arms
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0) _add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0) _add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
@@ -116,48 +136,51 @@ func _build_body(npc_type: int) -> void:
_rig.add_child(_head) _rig.add_child(_head)
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14)) _add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14)) _add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
_build_focus_ring() _build_focus_circle()
_add_props(npc_type) _add_props(npc_type)
func _build_focus_ring() -> void: func _build_focus_circle() -> void:
_ring = MeshInstance3D.new() _focus_circle = MeshInstance3D.new()
_ring.name = "FocusRing" _focus_circle.name = "FocusCircle"
var torus := TorusMesh.new() # Blue, semi-transparent disc floating in front of the face (ADR-0008).
torus.inner_radius = 0.16 # Billboarded so it always reads as a circle on screen.
torus.outer_radius = 0.20 var disc := CylinderMesh.new()
torus.rings = 12 disc.top_radius = 0.15
torus.ring_segments = 16 disc.bottom_radius = 0.15
_ring.mesh = torus disc.height = 0.001
disc.radial_segments = 24
_focus_circle.mesh = disc
var mat := StandardMaterial3D.new() var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9) mat.albedo_color = Color(0.25, 0.55, 1.0, 0.45)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_ring.material_override = mat mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
_ring.position = Vector3(0, 0.28, 0) _focus_circle.material_override = mat
_ring.visible = false _focus_circle.position = Vector3(0, 0, 0.30)
_head.add_child(_ring) _focus_circle.visible = false
_head.add_child(_focus_circle)
_name_tag = Label3D.new() _name_tag = Label3D.new()
_name_tag.name = "NameTag" _name_tag.name = "NameTag"
_name_tag.text = npc.profile.display_name if npc and npc.profile else "" _name_tag.text = npc.profile.display_name if npc and npc.profile else ""
_name_tag.font_size = 28 _name_tag.font_size = 48
_name_tag.pixel_size = 0.004 _name_tag.pixel_size = 0.004
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED _name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_name_tag.position = Vector3(0, 0.42, 0) _name_tag.position = Vector3(0, 0.58, 0)
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9) _name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
_name_tag.visible = false _name_tag.visible = false
_head.add_child(_name_tag) _head.add_child(_name_tag)
func set_focus_visual(focused: bool, menu_open: bool = false) -> void: func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
if _ring: if _focus_circle:
_ring.visible = focused _focus_circle.visible = focused
var mat := _ring.material_override as StandardMaterial3D var mat := _focus_circle.material_override as StandardMaterial3D
if mat: if mat:
mat.albedo_color = Color(1.0, 0.92, 0.35, 1.0) if menu_open else Color(0.95, 0.82, 0.28, 0.85) mat.albedo_color = Color(0.35, 0.65, 1.0, 0.7) if menu_open else Color(0.25, 0.55, 1.0, 0.45)
_ring.scale = Vector3.ONE * (1.15 if menu_open else 1.0) _focus_circle.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
if _name_tag: if _name_tag:
_name_tag.visible = focused _name_tag.visible = focused
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void: func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D:
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
var mesh := CapsuleMesh.new() var mesh := CapsuleMesh.new()
mesh.radius = radius mesh.radius = radius
@@ -167,6 +190,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
mi.rotation_degrees.z = z_rot mi.rotation_degrees.z = z_rot
mi.material_override = _material(color) mi.material_override = _material(color)
parent.add_child(mi) parent.add_child(mi)
return mi
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void: func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
@@ -188,7 +212,8 @@ func _add_props(npc_type: int) -> void:
var chest_y := 1.25 var chest_y := 1.25
match npc_type: match npc_type:
Rules.NpcType.INFLUENCER: Rules.NpcType.INFLUENCER:
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1)) _photo_prop = _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
_photo_prop_base_y = _photo_prop.position.y
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0)) _add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
Rules.NpcType.DELIVERY: Rules.NpcType.DELIVERY:
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25)) _add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
@@ -203,15 +228,22 @@ func _add_props(npc_type: int) -> void:
cone.material_override = hat cone.material_override = hat
cone.position = Vector3(0, 0.26, 0) cone.position = Vector3(0, 0.26, 0)
_head.add_child(cone) _head.add_child(cone)
# Hat in one hand and a small board in the other: the performer plays
# to the sidewalk once at the waypoint.
_add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25))
_sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75))
_sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.PROTESTER: Rules.NpcType.PROTESTER:
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32)) _add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95)) _sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
_sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.TOURIST: Rules.NpcType.TOURIST:
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45)) _photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
_photo_prop_base_y = _photo_prop.position.y
Rules.NpcType.CONFUSED_VISITOR: Rules.NpcType.CONFUSED_VISITOR:
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8)) _add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void: func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
var mesh := BoxMesh.new() var mesh := BoxMesh.new()
mesh.size = size mesh.size = size
@@ -219,31 +251,153 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
mi.position = pos mi.position = pos
mi.material_override = _material(color) mi.material_override = _material(color)
parent.add_child(mi) parent.add_child(mi)
return mi
## Rubberneck: idle people stop and turn toward the incident. Returns false when
## this NPC is too engaged (walking, being talked to, already leaving) to watch.
func start_rubberneck(at: Vector3) -> bool:
if not arrived or npc.leave_requested or leave_requested_by_lifetime:
return false
# Someone actually waiting at the entrance is being dealt with, not a bystander.
# Avatars live under the identity SceneRoot, so `position` matches world space
# and also works before the node enters the tree (tests).
if awaiting_interaction and (position - door_position).length() < 3.0:
return false
if reaction_mode == "flee":
return false
reaction_mode = "watch"
reaction_point = at
_reaction_timer = RUBBERNECK_S
velocity = Vector3.ZERO
return true
## Panic: clear away from the incident (usually the door) in a straight line.
func start_flee(from: Vector3) -> void:
if npc.leave_requested or leave_requested_by_lifetime:
return
reaction_mode = "flee"
reaction_point = from
_reaction_timer = FLEE_S
var away := global_position - from
away.y = 0.0
if away.length() < 0.5:
away = Vector3(0.0, 0.0, 1.0)
target_position = global_position + away.normalized() * 25.0
target_position.y = 0.5
arrived = false
awaiting_interaction = false
func reaction_is_active() -> bool:
return reaction_mode != ""
func _tick_reaction(delta: float) -> void:
if reaction_mode == "":
return
_reaction_timer -= delta
if reaction_mode == "flee":
if _reaction_timer <= 0.0 or (global_position - target_position).length() < 1.5:
reaction_mode = ""
queue_free()
return
if _reaction_timer <= 0.0:
reaction_mode = ""
## Turn the body toward the reaction point without moving.
func _face_reaction_point(delta: float) -> void:
var to_point := reaction_point - global_position
to_point.y = 0.0
if to_point.length() < 0.1:
return
var target_yaw := atan2(-to_point.x, -to_point.z)
rotation.y = lerp_angle(rotation.y, target_yaw, clampf(delta * 4.0, 0.0, 1.0))
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null: if npc == null or npc.fsm == null:
return return
_advance_fsm(delta) _advance_fsm(delta)
_tick_reaction(delta)
if reaction_mode == "watch":
velocity = Vector3.ZERO
_face_reaction_point(delta)
_animate(delta)
return
if reaction_mode == "flee":
_move_toward_target(delta)
_animate(delta)
return
_move_toward_target(delta) _move_toward_target(delta)
_animate(delta) _animate(delta)
## Procedural walk bob + head-toward-guard cue when the NPC is aware. ## Procedural walk bob + head-toward-guard cue when the NPC is aware.
## Theater pose for the current intent once the NPC has stopped walking.
## "sit" at the steps, "photo" for camera props, "perform" for the hat/sign,
## "film" for a slow pan, otherwise "idle".
func theater_pose() -> String:
if not arrived:
return "idle"
match npc.intent:
"sit":
return "sit"
"photo":
return "photo"
"film":
return "film"
"perform":
return "perform"
_:
return "idle"
## How far the rig drops for a seated pose. Pure so it is headless-testable.
static func crouch_for_pose(pose: String) -> float:
return -0.34 if pose == "sit" else 0.0
## Vertical lift of a held camera prop. `phase` is seconds; the raise is brief so
## it reads as taking a photo rather than a permanently raised arm.
static func prop_lift_for_pose(pose: String, phase: float) -> float:
if pose != "photo" and pose != "film":
return 0.0
return maxf(0.0, sin(phase * 1.7)) * 0.16
## Sway amount for a performer playing to the sidewalk.
static func sway_for_pose(pose: String, phase: float) -> float:
if pose != "perform":
return 0.0
return sin(phase * 2.2) * 0.09
func _animate(delta: float) -> void: func _animate(delta: float) -> void:
if _rig == null: if _rig == null:
return return
var speed := Vector2(velocity.x, velocity.z).length() var speed := Vector2(velocity.x, velocity.z).length()
var pose := theater_pose()
var crouch := crouch_for_pose(pose)
if speed > 0.3: if speed > 0.3:
_bob_phase += delta * (6.0 + speed) _bob_phase += delta * (6.0 + speed)
_rig.position.y = sin(_bob_phase) * 0.035 _rig.position.y = sin(_bob_phase) * 0.035
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04 _rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
elif pose != "idle" or not is_zero_approx(_rig.position.y):
# Idle theater: seated, photographing, filming, or performing.
_theater_t += delta
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
var lift := prop_lift_for_pose(pose, _theater_t)
if _photo_prop:
_photo_prop.position.y = _photo_prop_base_y + lift
_photo_prop.rotation.x = -lift * 1.4
var sway := sway_for_pose(pose, _theater_t)
if _sign_prop:
_sign_prop.rotation.z = sway
_sign_prop.position.y = _sign_prop_base_y + absf(sway) * 0.25
if _legs.size() == 2:
# Negative pitch swings the legs forward (toward the way the body faces).
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
else: else:
_bob_phase = 0.0 _bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2) _rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2) _rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
if _ring and _ring.visible: # The focus circle billboards via its material; no per-frame look_at needed.
var cam := get_viewport().get_camera_3d() if get_viewport() else null
if cam:
_ring.look_at(cam.global_position, Vector3.UP)
# Attention cue: head turns toward the guard when awareness was raised. # Attention cue: head turns toward the guard when awareness was raised.
if _head != null and npc.guard_noticed: if _head != null and npc.guard_noticed:
if _guard_ref == null or not is_instance_valid(_guard_ref): if _guard_ref == null or not is_instance_valid(_guard_ref):
@@ -257,25 +411,40 @@ func _animate(delta: float) -> void:
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1) _head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
func _advance_fsm(delta: float) -> void: func _advance_fsm(delta: float) -> void:
# Routine traffic despawns after a while once it has finished its walk, so
# the crowd cap is not permanently occupied by idle standers.
if ephemeral and arrived and npc.fsm.current_state != "LEAVING":
idle_s += delta
if idle_s >= lifetime_s:
leave_requested_by_lifetime = true
return
# Returning-trespasser: an NPC directed away may circle back to the door. # Returning-trespasser: an NPC directed away may circle back to the door.
if npc.tick_return(delta): if npc.tick_return(delta):
arrived = false arrived = false
awaiting_interaction = false awaiting_interaction = false
target_position = door_position target_position = door_position
return return
if npc.leave_requested: if npc.leave_requested or leave_requested_by_lifetime:
var away := global_position + (global_position - spawn_position).normalized() * 40.0 var away := global_position + (global_position - spawn_position).normalized() * 40.0
away.y = 0.5 away.y = 0.5
target_position = away target_position = away
if (global_position - away).length() < 2.0:
queue_free()
return return
if arrived and not awaiting_interaction: if arrived and not awaiting_interaction:
awaiting_interaction = true awaiting_interaction = true
var leave_requested_by_lifetime := false
func _move_toward_target(delta: float) -> void: func _move_toward_target(delta: float) -> void:
if agent == null: if agent == null:
_move_direct(delta) _move_direct(delta)
return return
agent.target_position = target_position # Re-assigning the target every physics frame invalidates the path, so
# get_next_path_position() keeps returning the current position and the
# avatar never moves. Only update the agent when the target changes.
if agent.target_position != target_position:
agent.target_position = target_position
if agent.is_navigation_finished(): if agent.is_navigation_finished():
if (global_position - target_position).length() < 3.0: if (global_position - target_position).length() < 3.0:
_arrive() _arrive()
@@ -292,14 +461,32 @@ func _move_toward_target(delta: float) -> void:
_move_direct(delta) _move_direct(delta)
return return
var next := agent.get_next_path_position() var next := agent.get_next_path_position()
# Godot reports the current position as the first path point (on the navmesh
# plane, so below the body) and only advances past it once the agent is within
# path_desired_distance. Steering at the point we are standing on gives a
# zero-length direction, and steering at any point merely "far enough" away can
# pick the old start behind us. Follow the first path point that is genuinely
# closer to the destination than we are.
var path := agent.get_current_navigation_path()
if path.size() >= 2:
var goal: Vector3 = path[path.size() - 1]
var here_to_goal := Vector2(goal.x - global_position.x, goal.z - global_position.z).length()
for i in range(1, path.size()):
var candidate: Vector3 = path[i]
candidate.y = global_position.y
var candidate_to_goal := Vector2(goal.x - candidate.x, goal.z - candidate.z).length()
if candidate_to_goal < here_to_goal - 0.05:
next = candidate
break
var dir := (next - global_position) var dir := (next - global_position)
dir.y = 0.0 dir.y = 0.0
if dir.length() < 0.2: if dir.length() < 0.2:
velocity = Vector3.ZERO velocity = Vector3.ZERO
return return
dir = dir.normalized() dir = dir.normalized()
velocity.x = dir.x * 1.3 var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
velocity.z = dir.z * 1.3 velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP) look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
move_and_slide() move_and_slide()
@@ -313,8 +500,9 @@ func _move_direct(delta: float) -> void:
_arrive() _arrive()
return return
var dir := to_target.normalized() var dir := to_target.normalized()
velocity.x = dir.x * 1.3 var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
velocity.z = dir.z * 1.3 velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP) look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
move_and_slide() move_and_slide()
+9 -3
View File
@@ -10,6 +10,7 @@ const Strings := preload("res://src/core/strings.gd")
const SLICE_DEFS := [ const SLICE_DEFS := [
{"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"}, {"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"},
{"id": "eye_contact", "label_key": "radial.eye_contact", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "eye contact"},
{"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"}, {"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"},
{"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"}, {"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"},
{"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"}, {"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"},
@@ -17,6 +18,8 @@ const SLICE_DEFS := [
{"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"}, {"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"},
{"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"}, {"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"},
{"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"}, {"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"},
{"id": "reason", "label_key": "radial.reason", "level": Rules.Level.BLOCKING, "action": "request reason"},
{"id": "recognize", "label_key": "radial.recognize", "level": -1, "action": "recognize"},
{"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"}, {"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"},
] ]
@@ -146,8 +149,8 @@ func _on_wheel_draw() -> void:
return return
var center := _wheel.size * 0.5 var center := _wheel.size * 0.5
var n := SLICE_DEFS.size() var n := SLICE_DEFS.size()
var outer := 150.0 var outer := 174.0
var inner := 58.0 var inner := 64.0
for i in n: for i in n:
var a0 := -PI / 2.0 + TAU * float(i) / float(n) var a0 := -PI / 2.0 + TAU * float(i) / float(n)
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n) var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
@@ -160,7 +163,10 @@ func _on_wheel_draw() -> void:
var mid := (a0 + a1) * 0.5 var mid := (a0 + a1) * 0.5
var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5) var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5)
var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"])) var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"]))
_wheel.draw_string(ThemeDB.fallback_font, label_pos + Vector2(-28, 4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color.WHITE) # With 12 slices the label box is tight: center it on the slice midpoint.
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12)
_wheel.draw_string(font, label_pos - Vector2(text_size.x * 0.5, -4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color.WHITE)
func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void: func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void:
var pts := PackedVector2Array() var pts := PackedVector2Array()
+211
View File
@@ -0,0 +1,211 @@
class_name EventTimeline
extends CanvasLayer
## Body-camera timeline for the shift review (PLAN.md: Evidence and Body Camera).
## First version is a text timeline of the structured incident events ADR-0005
## already stores: step through them with the arrow keys or the buttons. No live
## 3D replay.
signal closed()
const Strings := preload("res://src/core/strings.gd")
var events: Array[Dictionary] = []
var index := 0
var _list_label: Label = null
var _detail_label: Label = null
var _counter_label: Label = null
var _root: Control = null
func _init() -> void:
name = "EventTimeline"
layer = 32
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "TimelineRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.9)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(760, 0)
column.add_theme_constant_override("separation", 8)
center.add_child(column)
var title := Label.new()
title.text = Strings.lookup("timeline.title")
title.add_theme_font_size_override("font_size", 26)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_counter_label = Label.new()
_counter_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_counter_label.add_theme_color_override("font_color", Color(0.72, 0.78, 0.9))
column.add_child(_counter_label)
_detail_label = Label.new()
_detail_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_detail_label.custom_minimum_size = Vector2(760, 48)
_detail_label.add_theme_font_size_override("font_size", 18)
column.add_child(_detail_label)
_list_label = Label.new()
_list_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
_list_label.autowrap_mode = TextServer.AUTOWRAP_OFF
_list_label.custom_minimum_size = Vector2(760, 0)
_list_label.add_theme_font_size_override("font_size", 13)
_list_label.add_theme_color_override("font_color", Color(0.8, 0.84, 0.9))
column.add_child(_list_label)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 10)
column.add_child(row)
var prev := Button.new()
prev.name = "PrevButton"
prev.text = Strings.lookup("timeline.prev")
prev.pressed.connect(func(): step(-1))
row.add_child(prev)
var next := Button.new()
next.name = "NextButton"
next.text = Strings.lookup("timeline.next")
next.pressed.connect(func(): step(1))
row.add_child(next)
var close := Button.new()
close.name = "CloseButton"
close.text = Strings.lookup("timeline.close")
close.pressed.connect(_on_close)
row.add_child(close)
var hint := Label.new()
hint.text = Strings.lookup("timeline.hint")
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
## Flatten every incident's event stream into one time-ordered list.
func flatten(review: Dictionary) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var incidents: Dictionary = review.get("incidents", {})
var list: Array = incidents.get("incidents", [])
for incident in list:
var incident_id := str(incident.get("id", ""))
var npc_id := str(incident.get("npc_id", ""))
for event in incident.get("events", []):
var row: Dictionary = (event as Dictionary).duplicate()
row["incident_id"] = incident_id
row["npc_id"] = npc_id
out.append(row)
out.sort_custom(func(a, b): return int(a.get("at_ms", 0)) < int(b.get("at_ms", 0)))
return out
func show_timeline(review: Dictionary) -> void:
events = flatten(review)
index = 0
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
_refresh()
func step(direction: int) -> void:
if events.is_empty():
return
index = clampi(index + direction, 0, events.size() - 1)
_refresh()
func close() -> void:
visible = false
_root.visible = false
closed.emit()
func _on_close() -> void:
close()
func _refresh() -> void:
if events.is_empty():
_counter_label.text = Strings.lookup("timeline.empty")
_detail_label.text = ""
_list_label.text = ""
return
var event: Dictionary = events[index]
_counter_label.text = Strings.fmt("timeline.position", [index + 1, events.size()])
_detail_label.text = describe(event)
var lines: Array[String] = []
var from := maxi(0, index - 6)
var to := mini(events.size(), from + 13)
for i in range(from, to):
var marker := ">" if i == index else " "
lines.append("%s %s" % [marker, describe(events[i])])
_list_label.text = "\n".join(lines)
## One-line summary of a recorded event. Public so it is headless-testable.
func describe(event: Dictionary) -> String:
var type := str(event.get("type", "event"))
var npc := str(event.get("npc_id", ""))
var stamp := _stamp(int(event.get("at_ms", 0)))
var detail := ""
match type:
"player_action":
detail = "%s (L%d)" % [str(event.get("action", "")), int(event.get("level", 0))]
"access_check":
var evidence: Dictionary = event.get("evidence", {})
detail = "decided %s (verified %s)" % [
"allow" if bool(evidence.get("decided", false)) else "deny",
"yes" if bool(evidence.get("verified_granted", false)) else "no",
]
"recognition_decision":
detail = "recognized"
"reason_requested":
var reply := str(event.get("reply", ""))
detail = "reason: \"%s\"" % (reply if reply != "" else "no answer")
"body_camera":
detail = "body camera %s (%s)" % [
"on" if bool(event.get("on", false)) else "off",
str(event.get("reason", "")),
]
"responder_action":
detail = "%s response: %s" % [str(event.get("call_type", "assist")), str(event.get("outcome", ""))]
"evacuation":
detail = "evacuated"
"investigation_opened":
detail = "investigation: %s" % str(event.get("reason", ""))
_:
detail = type
return "%s %s %s" % [stamp, npc, detail]
func _stamp(at_ms: int) -> String:
if at_ms <= 0:
return "--:--"
var total := at_ms / 1000
return "%02d:%02d" % [total / 60, total % 60]
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_LEFT, KEY_A:
step(-1)
get_viewport().set_input_as_handled()
KEY_RIGHT, KEY_D:
step(1)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
close()
get_viewport().set_input_as_handled()
+52 -4
View File
@@ -47,6 +47,10 @@ var escalation_label: Label
var _debug_enabled := false var _debug_enabled := false
var _log_lines: Array[String] = [] var _log_lines: Array[String] = []
var _cam_overlay: Control = null
var _body_camera_on := false
var _font_scale := 1.0
var _base_font_sizes: Dictionary = {}
func _init() -> void: func _init() -> void:
name = "HUD" name = "HUD"
@@ -138,11 +142,19 @@ func _init() -> void:
add_child(vignette_rect) add_child(vignette_rect)
# --- body-camera overlay (REC + shift clock + corner brackets, top right) --- # --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
# Toggled by the body-camera flag (PLAN Level 2); off until the guard activates
# it or issues a formal warning.
_cam_overlay = Control.new()
_cam_overlay.name = "BodyCameraOverlay"
_cam_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
_cam_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_cam_overlay.visible = false
add_child(_cam_overlay)
var cam_box := VBoxContainer.new() var cam_box := VBoxContainer.new()
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT) cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
cam_box.position = Vector2(-180, 50) cam_box.position = Vector2(-180, 50)
cam_box.alignment = BoxContainer.ALIGNMENT_END cam_box.alignment = BoxContainer.ALIGNMENT_END
add_child(cam_box) _cam_overlay.add_child(cam_box)
var rec_row := HBoxContainer.new() var rec_row := HBoxContainer.new()
rec_row.alignment = BoxContainer.ALIGNMENT_END rec_row.alignment = BoxContainer.ALIGNMENT_END
cam_box.add_child(rec_row) cam_box.add_child(rec_row)
@@ -279,6 +291,32 @@ func set_wand_icon(visible: bool) -> void:
if wand_icon: if wand_icon:
wand_icon.visible = visible wand_icon.visible = visible
## Show or hide the body-camera overlay (REC + clock + brackets). The flag is a
## record of the interaction (PLAN Level 2), not a permanent HUD fixture.
func set_body_camera(on: bool) -> void:
_body_camera_on = on
if _cam_overlay:
_cam_overlay.visible = on
func is_body_camera_on() -> bool:
return _body_camera_on
## Scale the whole HUD text block (accessibility settings).
## Resize HUD text by changing font sizes (not Control.scale, which overflows
## anchored labels and breaks the right-aligned layout).
func set_font_scale(scale: float) -> void:
var clamped := clampf(scale, 0.75, 1.75)
_font_scale = clamped
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
for label in scaled:
if label == null:
continue
if not _base_font_sizes.has(label):
_base_font_sizes[label] = label.get_theme_font_size("font_size")
var base: int = int(_base_font_sizes[label])
label.add_theme_font_size_override("font_size", maxi(8, int(round(float(base) * clamped))))
func _make_icon(texture: Texture2D) -> TextureRect: func _make_icon(texture: Texture2D) -> TextureRect:
var tr := TextureRect.new() var tr := TextureRect.new()
tr.texture = texture tr.texture = texture
@@ -296,6 +334,7 @@ func _add_corner_brackets(color: Color) -> void:
var corners := [ var corners := [
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF), Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
] ]
var parent: Node = _cam_overlay if _cam_overlay != null else self
for i in 4: for i in 4:
var c: Vector2 = corners[i] var c: Vector2 = corners[i]
var flip_x: bool = (i % 2) == 1 var flip_x: bool = (i % 2) == 1
@@ -305,13 +344,13 @@ func _add_corner_brackets(color: Color) -> void:
h.size = Vector2(LEN, TH) h.size = Vector2(LEN, TH)
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0)) h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
h.mouse_filter = Control.MOUSE_FILTER_IGNORE h.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(h) parent.add_child(h)
var v := ColorRect.new() var v := ColorRect.new()
v.color = color v.color = color
v.size = Vector2(TH, LEN) v.size = Vector2(TH, LEN)
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0)) v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
v.mouse_filter = Control.MOUSE_FILTER_IGNORE v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v) parent.add_child(v)
func set_action_hint(text: String) -> void: func set_action_hint(text: String) -> void:
hint_label.text = text hint_label.text = text
@@ -320,9 +359,18 @@ func set_action_hint(text: String) -> void:
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility). ## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
## Green → yellow → orange → red as the escalation level rises. ## Green → yellow → orange → red as the escalation level rises.
## Grayscale-safe escalation meter: filled/empty blocks plus the level number, so
## the cue does not rely on hue alone (accessibility).
static func escalation_meter(level: int) -> String:
var clamped := clampi(level, 0, 5)
var filled := ""
for i in 5:
filled += "#" if i < clamped else "-"
return "[%s] L%d/5" % [filled, clamped]
func set_escalation(level: int) -> void: func set_escalation(level: int) -> void:
var name: String = Rules.LEVEL_NAMES.get(level, "Observation") var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name]) escalation_label.text = "%s %s" % [Strings.fmt("hud.escalation_prefix", [name]), escalation_meter(level)]
var tint := Color(0.6, 0.9, 0.6) var tint := Color(0.6, 0.9, 0.6)
match level: match level:
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE: Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
+84
View File
@@ -0,0 +1,84 @@
class_name PauseOverlay
extends CanvasLayer
## Shift pause overlay: resume, restart the shift, open settings, or quit to the
## venue menu. Opened with Esc from the world (or a dedicated pause action) and
## while the overlay is open the guard is frozen.
signal resume_requested()
signal restart_requested()
signal settings_requested()
signal quit_to_menu_requested()
var _root: Control = null
var _resume_button: Button = null
func _ready() -> void:
layer = 18
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "PauseRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var bg := ColorRect.new()
bg.color = Color(0.02, 0.03, 0.05, 0.82)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(bg)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(320, 0)
column.add_theme_constant_override("separation", 10)
center.add_child(column)
var title := Label.new()
title.text = "Paused"
title.add_theme_font_size_override("font_size", 36)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_resume_button = _add_button(column, "Resume", "ResumeButton", func(): resume_requested.emit())
_add_button(column, "Restart shift", "RestartButton", func(): restart_requested.emit())
_add_button(column, "Settings", "SettingsButton", func(): settings_requested.emit())
_add_button(column, "Quit to menu", "MenuButton", func(): quit_to_menu_requested.emit())
var hint := Label.new()
hint.text = "Esc resumes"
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
func _add_button(parent: Control, text: String, node_name: String, cb: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.text = text
button.custom_minimum_size = Vector2(320, 44)
button.pressed.connect(cb)
parent.add_child(button)
return button
func open() -> void:
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
if _resume_button:
_resume_button.grab_focus()
func close() -> void:
visible = false
_root.visible = false
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
resume_requested.emit()
get_viewport().set_input_as_handled()
+154
View File
@@ -0,0 +1,154 @@
class_name SettingsPanel
extends CanvasLayer
## Settings: mouse look sensitivity and HUD font scale, persisted through the
## `Save` autoload into `user://settings.json`.
##
## Emits `applied(settings)` so the game manager can push values into the guard
## and HUD as soon as the player confirms.
signal applied(settings: Dictionary)
signal closed()
var _root: Control = null
var _sensitivity: HSlider = null
var _font_scale: HSlider = null
var _sensitivity_value: Label = null
var _font_scale_value: Label = null
var settings: Dictionary = {}
func _ready() -> void:
layer = 22
_build()
hide()
func _init() -> void:
layer = 22
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "SettingsRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var bg := ColorRect.new()
bg.color = Color(0.02, 0.03, 0.05, 0.9)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(bg)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(420, 0)
column.add_theme_constant_override("separation", 12)
center.add_child(column)
var title := Label.new()
title.text = "Settings"
title.add_theme_font_size_override("font_size", 32)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
var sens_row := VBoxContainer.new()
column.add_child(sens_row)
_sensitivity_value = _add_label_row(sens_row, "Mouse look sensitivity")
_sensitivity = _add_slider(sens_row, 0.0005, 0.0060, 0.0001)
_sensitivity.value_changed.connect(_on_sensitivity_changed)
var font_row := VBoxContainer.new()
column.add_child(font_row)
_font_scale_value = _add_label_row(font_row, "HUD font scale")
_font_scale = _add_slider(font_row, 0.75, 1.75, 0.05)
_font_scale.value_changed.connect(_on_font_scale_changed)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
column.add_child(row)
var save_button := Button.new()
save_button.name = "ApplyButton"
save_button.text = "Apply"
save_button.pressed.connect(_on_apply)
row.add_child(save_button)
var cancel_button := Button.new()
cancel_button.name = "CancelButton"
cancel_button.text = "Cancel"
cancel_button.pressed.connect(_on_cancel)
row.add_child(cancel_button)
func _add_label_row(parent: Control, text: String) -> Label:
var row := HBoxContainer.new()
parent.add_child(row)
var label := Label.new()
label.text = text
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
var value := Label.new()
value.add_theme_color_override("font_color", Color(0.75, 0.8, 0.9))
row.add_child(value)
return value
func _add_slider(parent: Control, min_v: float, max_v: float, step: float) -> HSlider:
var slider := HSlider.new()
slider.min_value = min_v
slider.max_value = max_v
slider.step = step
slider.custom_minimum_size = Vector2(420, 24)
parent.add_child(slider)
return slider
func open(current: Dictionary) -> void:
settings = current.duplicate(true)
var sens: float = float(settings.get("look_sensitivity", 0.0022))
var scale: float = float(settings.get("hud_font_scale", 1.0))
if _sensitivity:
_sensitivity.set_value_no_signal(clampf(sens, _sensitivity.min_value, _sensitivity.max_value))
if _font_scale:
_font_scale.set_value_no_signal(clampf(scale, _font_scale.min_value, _font_scale.max_value))
_sync_labels()
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func close() -> void:
visible = false
_root.visible = false
func _sync_labels() -> void:
if _sensitivity_value and _sensitivity:
_sensitivity_value.text = "%.4f" % _sensitivity.value
if _font_scale_value and _font_scale:
_font_scale_value.text = "%.2fx" % _font_scale.value
func _on_sensitivity_changed(value: float) -> void:
settings["look_sensitivity"] = value
_sync_labels()
func _on_font_scale_changed(value: float) -> void:
settings["hud_font_scale"] = value
_sync_labels()
func _on_apply() -> void:
if Save:
Save.save_settings(settings)
applied.emit(settings)
close()
closed.emit()
func _on_cancel() -> void:
close()
closed.emit()
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
_on_cancel()
get_viewport().set_input_as_handled()
+18
View File
@@ -2,7 +2,13 @@ class_name ShiftReview
extends CanvasLayer extends CanvasLayer
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review). ## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
const Strings := preload("res://src/core/strings.gd")
signal restart_requested() signal restart_requested()
signal timeline_requested(review: Dictionary)
## Kept so the timeline button can hand the full review back to the game manager.
var _review: Dictionary = {}
var report_label: Label var report_label: Label
@@ -32,6 +38,13 @@ func _init() -> void:
report_label.custom_minimum_size = Vector2(720, 0) report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label) center.add_child(report_label)
var timeline := Button.new()
timeline.name = "TimelineButton"
timeline.text = Strings.lookup("review.timeline_button")
timeline.pressed.connect(func(): timeline_requested.emit(_review))
timeline.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(timeline)
var restart := Button.new() var restart := Button.new()
restart.text = "Run another shift" restart.text = "Run another shift"
restart.pressed.connect(func(): restart_requested.emit()) restart.pressed.connect(func(): restart_requested.emit())
@@ -45,10 +58,13 @@ func _init() -> void:
center.add_child(quit_btn) center.add_child(quit_btn)
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void: func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
_review = review
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", {})
var radio: Dictionary = review.get("radio", {}) var radio: Dictionary = review.get("radio", {})
var outcomes: Array = review.get("responder_outcomes", [])
var investigations: Array = review.get("investigations", [])
var incident_list: Array = incidents.get("incidents", []) var incident_list: Array = incidents.get("incidents", [])
var peaceful := 0 var peaceful := 0
for inc in incident_list: for inc in incident_list:
@@ -78,6 +94,8 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
"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)],
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))], "Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
Strings.fmt("review.responder", [", ".join(outcomes) if outcomes.size() > 0 else Strings.lookup("review.none")]),
Strings.fmt("review.investigations", [" | ".join(investigations) if investigations.size() > 0 else Strings.lookup("review.none")]),
"", "",
"Highlights:", "Highlights:",
] ]
+140
View File
@@ -0,0 +1,140 @@
class_name StartMenu
extends CanvasLayer
## Startup menu: pick a venue, start the shift, open settings, or quit.
## Keyboard and mouse both work. Replaces `V` as the player-facing venue path
## (the `V` bind stays as a debug shortcut).
signal venue_selected(scenario_id: String)
signal settings_requested()
signal quit_requested()
const VENUES := [
{"id": "hollywood_building", "label": "Hollywood Boulevard", "blurb": "Building entrance, tourists and late-night trouble."},
{"id": "joke_shop", "label": "The Joke Shop", "blurb": "Guest list, IDs and wand screening."},
{"id": "tv_studio", "label": "Late Night TV Studio", "blurb": "Credentials, filming boundary, celebrity arrivals."},
]
var _index := 0
var _buttons: Array[Button] = []
var _root: Control = null
func _ready() -> void:
layer = 20
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "StartMenuRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var bg := ColorRect.new()
bg.color = Color(0.03, 0.04, 0.07, 0.96)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(bg)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.name = "Column"
column.custom_minimum_size = Vector2(460, 0)
column.add_theme_constant_override("separation", 14)
center.add_child(column)
var title := Label.new()
title.text = "STEP LINE"
title.add_theme_font_size_override("font_size", 44)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
var subtitle := Label.new()
subtitle.text = "Pick a venue to start your shift."
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
column.add_child(subtitle)
for i in VENUES.size():
var venue: Dictionary = VENUES[i]
var button := Button.new()
button.name = "Venue_%s" % venue["id"]
button.text = "%s\n%s" % [venue["label"], venue["blurb"]]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = Vector2(460, 62)
button.pressed.connect(_on_venue_pressed.bind(i))
column.add_child(button)
_buttons.append(button)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 10)
column.add_child(spacer)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
column.add_child(row)
var settings_button := Button.new()
settings_button.name = "SettingsButton"
settings_button.text = "Settings"
settings_button.pressed.connect(func(): settings_requested.emit())
row.add_child(settings_button)
var quit_button := Button.new()
quit_button.name = "QuitButton"
quit_button.text = "Quit"
quit_button.pressed.connect(func(): quit_requested.emit())
row.add_child(quit_button)
var hint := Label.new()
hint.text = "Up/Down to choose, Enter to start, Esc to quit"
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
_focus_index(0)
func _focus_index(i: int) -> void:
if _buttons.is_empty():
return
_index = clampi(i, 0, _buttons.size() - 1)
_buttons[_index].grab_focus()
func open() -> void:
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
_focus_index(_index)
func close() -> void:
visible = false
_root.visible = false
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_UP, KEY_W:
_focus_index(_index - 1)
get_viewport().set_input_as_handled()
KEY_DOWN, KEY_S:
_focus_index(_index + 1)
get_viewport().set_input_as_handled()
KEY_ENTER, KEY_KP_ENTER, KEY_SPACE:
_on_venue_pressed(_index)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
quit_requested.emit()
get_viewport().set_input_as_handled()
func _on_venue_pressed(i: int) -> void:
if i < 0 or i >= VENUES.size():
return
var venue: Dictionary = VENUES[i]
close()
venue_selected.emit(str(venue["id"]))
+79 -11
View File
@@ -5,6 +5,7 @@ const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png") const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png") const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png") const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
const T_SKYLINE := preload("res://assets/textures/skyline.png")
## steps, building facade, restricted door, sidewalk, pavement, and street edge. ## steps, building facade, restricted door, sidewalk, pavement, and street edge.
## No art assets required; all geometry is BoxMesh with flat materials. ## No art assets required; all geometry is BoxMesh with flat materials.
@@ -25,6 +26,7 @@ func _init(root_node: Node3D) -> void:
func build() -> void: func build() -> void:
_build_ground() _build_ground()
_build_building() _build_building()
_build_skyline_backdrop()
_build_marquee() _build_marquee()
_build_steps() _build_steps()
_build_sidewalk() _build_sidewalk()
@@ -55,9 +57,11 @@ func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_sh
func _build_ground() -> void: func _build_ground() -> void:
var ground := StaticBody3D.new() var ground := StaticBody3D.new()
ground.name = "Ground" ground.name = "Ground"
_add_box(root, Vector3(160, 0.5, 160), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26)) # Large enough that the ground meets the distant skyline ring (no gap of sky
# under the buildings when looking down the boulevard).
_add_box(root, Vector3(500, 0.5, 500), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(160, 1, 160) col.size = Vector3(500, 1, 500)
var shape_owner := ground.create_shape_owner(ground) var shape_owner := ground.create_shape_owner(ground)
ground.shape_owner_add_shape(shape_owner, col) ground.shape_owner_add_shape(shape_owner, col)
root.add_child(ground) root.add_child(ground)
@@ -90,10 +94,9 @@ func _build_building() -> void:
root.add_child(building) root.add_child(building)
func _build_steps() -> void: func _build_steps() -> void:
# Steps are visual only (no collider) so NPCs and the guard can walk up the # Steps have collision so NPCs and the guard can walk up the entrance.
# entrance on the flat navigation mesh; the ground and building provide the # The ground and building provide the actual collision boundaries.
# actual collision boundaries. var steps := StaticBody3D.new()
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
@@ -101,9 +104,22 @@ func _build_steps() -> void:
for i in 4: for i in 4:
var y: float = i * height var y: float = i * height
var z: float = 4.0 * tread - i * tread var z: float = 4.0 * tread - i * tread
_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)) var w: float = step_w - i * 0.35
# Landing at the top _add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45)) # Add collision shape to each tread
var tread_col := BoxShape3D.new()
tread_col.size = Vector3(w, height, tread)
var owner_id := steps.create_shape_owner(steps)
steps.shape_owner_add_shape(owner_id, tread_col)
steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z)))
# Landing at the top with collision
var landing_y: float = 4 * height + 0.1
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
var landing_col := BoxShape3D.new()
landing_col.size = Vector3(9, 0.2, 3)
var landing_owner := steps.create_shape_owner(steps)
steps.shape_owner_add_shape(landing_owner, landing_col)
steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5)))
root.add_child(steps) root.add_child(steps)
func _build_sidewalk() -> void: func _build_sidewalk() -> void:
@@ -150,6 +166,53 @@ func _build_navmesh() -> void:
region.navigation_mesh = mesh region.navigation_mesh = mesh
root.add_child(region) root.add_child(region)
func _build_skyline_backdrop() -> void:
## Distant skyline surrounding the block. The texture's sky is transparent
## (alpha scissor), so only building silhouettes show. Four planes form a
## ring: a single plane placed behind the building reads as a floating black
## band from the sidewalk.
var backdrop := Node3D.new()
backdrop.name = "SkylineBackdrop"
var mat := StandardMaterial3D.new()
mat.albedo_texture = T_SKYLINE
mat.texture_repeat = true
mat.uv1_scale = Vector3(3.0, 1.0, 1.0)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
mat.alpha_scissor_threshold = 0.5
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
# Unshaded silhouettes read as pure black at 255-scale; lift the albedo so the
# distant skyline sits closer to a hazy daytime city.
mat.albedo_color = Color(2.6, 2.7, 3.0, 1.0)
_skyline_mat = mat
var mesh := PlaneMesh.new()
# PlaneMesh defaults to a horizontal (XZ) plane; the skyline must stand up.
mesh.orientation = PlaneMesh.FACE_Z
mesh.size = Vector2(400, 60)
# Far enough that the skyline reads as background and its top edge stays out
# of the middle of the sky.
const DIST := 200.0
# The texture keeps its buildings in the lower part of the image, so the plane
# center sits at half its height: the building bases meet the ground.
const CENTER_Y := 30.0
var placements := [
[Vector3(0, CENTER_Y, -DIST), 0.0],
[Vector3(0, CENTER_Y, DIST), 0.0],
[Vector3(-DIST, CENTER_Y, 0), PI / 2.0],
[Vector3(DIST, CENTER_Y, 0), PI / 2.0],
]
for placement in placements:
var plane := MeshInstance3D.new()
plane.mesh = mesh
plane.material_override = mat
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
plane.position = placement[0]
plane.rotation.y = placement[1]
backdrop.add_child(plane)
root.add_child(backdrop)
var _skyline_mat: StandardMaterial3D = null
func _build_marquee() -> void: func _build_marquee() -> void:
## Striped awning above the door, then the neon sign strip + label. ## Striped awning above the door, then the neon sign strip + label.
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING) var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
@@ -167,9 +230,9 @@ func _build_marquee() -> void:
marquee_label.text = "STEP LINE" marquee_label.text = "STEP LINE"
marquee_label.position = Vector3(0, 7.3, -3.55) marquee_label.position = Vector3(0, 7.3, -3.55)
marquee_label.modulate = Color(1, 0.6, 0.9) marquee_label.modulate = Color(1, 0.6, 0.9)
marquee_label.font_size = 72 marquee_label.font_size = 160
marquee_label.pixel_size = 0.004 marquee_label.pixel_size = 0.004
marquee_label.outline_size = 12 marquee_label.outline_size = 24
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5) marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
marquee_label.no_depth_test = false marquee_label.no_depth_test = false
@@ -264,6 +327,11 @@ func _plain_material(color: Color) -> StandardMaterial3D:
func _build_guard_spawn() -> void: func _build_guard_spawn() -> void:
var marker := Marker3D.new() var marker := Marker3D.new()
marker.name = "GuardSpawn" marker.name = "GuardSpawn"
# Guard spawns on the landing at the top of the steps, facing the street.
# The landing is at y=4*height+0.1 where height=0.16, so y=0.76
# The guard's head offset is 1.6m, so spawn at y=0.76-1.6=-0.84 to place feet on ground
# But we want the guard at eye-level with the sidewalk, so adjust to y=0.1 for feet
# Then the head will be at y=1.7 (1.6 offset + 0.1 base)
marker.position = Vector3(0, 0.1, -2.0) marker.position = Vector3(0, 0.1, -2.0)
root.add_child(marker) root.add_child(marker)
+18 -10
View File
@@ -31,6 +31,10 @@ func _process(_delta: float) -> bool:
quit(1) quit(1)
return true return true
if gm != null and not camera_staged: if gm != null and not camera_staged:
# The game boots into the venue menu now; start the Hollywood shift first.
var menu = gm.get("start_menu")
if menu != null and menu.visible:
gm.call("_on_venue_selected", "hollywood_building")
gm.guard.global_position = Vector3(0, 0.1, 10.5) gm.guard.global_position = Vector3(0, 0.1, 10.5)
gm.guard.rotation.y = 0.0 gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0) gm.guard.head.rotation.x = deg_to_rad(2.0)
@@ -52,7 +56,8 @@ func _advance() -> void:
state = 1 state = 1
state_frames = 0 state_frames = 0
1: 1:
# Wait for a nearby NPC so the interaction HUD has a target. # Wait for a nearby NPC so the interaction HUD has a target. NPCs now
# walk in from the street, so this can take a few simulated seconds.
if gm != null and gm.npcs.size() > 0: if gm != null and gm.npcs.size() > 0:
gm.call("_cycle_nearby_target") gm.call("_cycle_nearby_target")
_face_target() _face_target()
@@ -83,24 +88,27 @@ func _advance() -> void:
# Close the radio form, then jump the shift to the LATE phase. # Close the radio form, then jump the shift to the LATE phase.
if gm != null: if gm != null:
gm.radio_panel.hide() gm.radio_panel.hide()
gm.guard.rotation.y = 0.0 gm.hud.set_action_hint("")
gm.guard.head.rotation.x = deg_to_rad(2.0)
gm.coordinator.elapsed_s = 750.0 gm.coordinator.elapsed_s = 750.0
state = 5 state = 5
state_frames = 0 state_frames = 0
5: 5:
# Let the phase transition (and street lamps / marquee) settle, then # Stage the avenue view down the boulevard (not into the wall) and let
# take the night avenue shot. # the night lighting and street lamps settle.
if state_frames == 1 and gm != null:
gm.guard.rotation.y = deg_to_rad(-70)
gm.guard.head.rotation.x = deg_to_rad(3.0)
if state_frames >= 90: if state_frames >= 90:
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0)
_shoot("05-late-night-avenue.png") _shoot("05-late-night-avenue.png")
if gm != null:
gm.call("_toggle_flashlight")
state = 6 state = 6
state_frames = 0 state_frames = 0
6: 6:
if state_frames >= 40: # Face the entrance again and switch the flashlight on for the night set.
if state_frames == 1 and gm != null:
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(4.0)
gm.call("_toggle_flashlight")
if state_frames >= 30:
_shoot("06-night-flashlight.png") _shoot("06-night-flashlight.png")
state = 7 state = 7
quit(0) quit(0)
+85
View File
@@ -0,0 +1,85 @@
extends SceneTree
## Audio smoke: starts a shift, verifies the crowd bed is playing and follows mood
## events, and that opening/closing the radio triggers crackle cues.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_audio.gd
## (Headless uses a dummy audio server; the calls must still be safe.)
const MAX_FRAMES := 900
var frame := 0
var gm: Node = null
var started := false
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout")
return true
if frame < 120:
return false
match step:
0:
_check(gm.audio.bed_playing, "the crowd bed is playing during the shift")
var before: float = gm.audio.crowd_level
gm.coordinator.crowd.apply_event("aggressive_behavior")
_check(gm.audio.crowd_level > before, "an aggressive event raises the bed level")
gm.coordinator.crowd.apply_event("emergency_responder")
_check(gm.audio.crowd_level > before, "the responder keeps it up")
step = 1
1:
if gm.npcs.is_empty():
return false
gm.call("_on_guard_target", gm.npcs[0])
var crackles: int = gm.audio.radio_crackles
gm.call("_open_radio", false)
_check(gm.audio.radio_crackles == crackles + 1, "opening the radio crackles once")
gm.call("_on_radio_submitted", {
"npc_id": gm.current_incident_npc_id,
"location": "entrance",
"description": "tourist",
"behavior": "standing",
"weapons_seen": false,
"danger_level": 1,
}, false)
_check(gm.audio.radio_crackles == crackles + 2, "closing the radio crackles once")
gm.coordinator.call("_finish_shift")
_check(not gm.audio.bed_playing, "the bed stops when the shift ends")
step = 2
2:
_finish()
return true
return false
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: crowd bed and radio crackle")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+114
View File
@@ -0,0 +1,114 @@
extends SceneTree
## Crowd-presentation smoke: starts a shift, waits for bystanders to settle, then
## checks that an aggressive crowd event makes idle NPCs stop and watch, and that
## panic sends them away from the door.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_crowd.gd
## Exit code 0 = pass, 1 = failure.
const MAX_FRAMES := 2400
var frame := 0
var gm: Node = null
var started := false
var phase := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout before crowd reactions were observed")
return true
match phase:
0:
if frame < 300:
return false
# Wait until at least one settled bystander is away from the door (someone
# waiting at the entrance is being dealt with and must not rubberneck).
if _bystander_count() == 0:
return false
gm.coordinator.crowd.apply_event("aggressive_behavior")
var watching := _count_mode("watch")
_check(watching > 0, "aggressive behavior makes idle NPCs watch (%d watching)" % watching)
phase = 1
1:
if frame < 320:
return false
var door: Vector3 = gm.selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
for npc in gm.npcs:
if is_instance_valid(npc) and npc.has_method("reaction_is_active"):
pass
gm.coordinator.crowd.apply_event("visible_weapon")
gm.coordinator.crowd.apply_event("aggressive_behavior")
gm.coordinator.crowd.apply_event("aggressive_behavior")
gm.coordinator.crowd.apply_event("emergency_responder")
_check(gm.coordinator.crowd.should_flee(), "stacked alarms panic the crowd")
var fleeing := _count_mode("flee")
_check(fleeing > 0, "panic sends NPCs away from the door (%d fleeing)" % fleeing)
var away_ok := true
for npc in gm.npcs:
if not is_instance_valid(npc) or npc.reaction_mode != "flee":
continue
if (npc.target_position - door).length() <= (npc.global_position - door).length():
away_ok = false
_check(away_ok, "every fleeing NPC targets a point farther from the door")
phase = 2
_:
_finish()
return true
return false
func _bystander_count() -> int:
var n := 0
for npc in gm.npcs:
if not is_instance_valid(npc) or not npc.arrived:
continue
if npc.awaiting_interaction and npc.global_position.distance_to(npc.door_position) < 3.0:
continue
n += 1
return n
func _settled_count() -> int:
var n := 0
for npc in gm.npcs:
if is_instance_valid(npc) and npc.arrived:
n += 1
return n
func _count_mode(mode: String) -> int:
var n := 0
for npc in gm.npcs:
if is_instance_valid(npc) and npc.reaction_mode == mode:
n += 1
return n
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: crowd rubberneck and panic flee")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+94
View File
@@ -0,0 +1,94 @@
extends SceneTree
## Emergency-aftermath smoke: places an unjustified emergency call, checks the
## investigation note and reputation hit, forces the responder to arrive, and
## verifies the evacuation outcome locks the door and clears the entrance.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_emergency.gd
## Exit code 0 = pass, 1 = failure.
const MAX_FRAMES := 900
var frame := 0
var gm: Node = null
var started := false
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout before the emergency aftermath ran")
return true
if frame < 200:
return false
if step == 0:
if gm.npcs.is_empty():
return false
gm.call("_on_guard_target", gm.npcs[0])
# A tourist: no weapon, low danger.
gm.current_npc_truth = {"weapons_seen": false, "danger_level": 1}
var rating_before: float = gm.coordinator.reputation.rating
gm.call("_on_radio_submitted", {
"npc_id": gm.current_incident_npc_id,
"location": "entrance",
"description": "tourist",
"behavior": "standing",
"weapons_seen": false,
"danger_level": 1,
}, true)
_check(gm.coordinator.investigations.size() == 1, "unjustified emergency opens one investigation")
_check(gm.coordinator.reputation.rating < rating_before, "unjustified emergency loses reputation")
_check(gm.coordinator.radio.pending_calls.size() == 1, "the emergency call is pending")
_check(not gm.door.locked, "the door is open before the responder arrives")
step = 1
if step == 1:
# Fast-forward the arrival delay.
gm.coordinator.radio.tick(30.0)
_check(gm.coordinator.radio.pending_calls.is_empty(), "the responder arrived")
_check(gm.coordinator.responder_outcomes.has("evacuation"), "low-danger emergency resolves as evacuation")
_check(gm.door.locked, "evacuation locks the door")
var clearing := 0
for npc in gm.npcs:
if is_instance_valid(npc) and (npc.reaction_mode == "flee" or npc.npc.leave_requested):
clearing += 1
_check(clearing > 0, "evacuation sends people away from the entrance (%d clearing)" % clearing)
var review: Dictionary = gm.coordinator.build_review()
_check((review.get("review", review).get("responder_outcomes", []) as Array).has("evacuation"),
"the review carries the responder outcome")
_check((review.get("investigations", []) as Array).size() == 1, "the review carries the investigation note")
step = 2
if step == 2:
_finish()
return true
return false
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: emergency aftermath (investigation, evacuation, review)")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+15 -3
View File
@@ -1,5 +1,5 @@
extends SceneTree extends SceneTree
## Windowed smoke helper for the ADR-0007–0010 control pass (focus ring + action ## Windowed smoke helper for the ADR-0007–0010 control pass (focus circle + action
## wheel). Boots the real main scene, stages one NPC in front of the guard, opens ## wheel). Boots the real main scene, stages one NPC in front of the guard, opens
## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or ## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or
## radial state did not come up, so it can be used as a smoke check. ## radial state did not come up, so it can be used as a smoke check.
@@ -15,12 +15,23 @@ var state := 0
var state_frames := 0 var state_frames := 0
var stage_npc: Node3D = null var stage_npc: Node3D = null
var failures: Array[String] = [] var failures: Array[String] = []
var _shift_started := false
func _initialize() -> void: func _initialize() -> void:
root.size = Vector2i(1280, 720) root.size = Vector2i(1280, 720)
var packed: PackedScene = load("res://scenes/main.tscn") var packed: PackedScene = load("res://scenes/main.tscn")
root.add_child(packed.instantiate()) root.add_child(packed.instantiate())
## The game now boots into the venue menu, so the smoke starts a shift first.
func _start_shift_if_needed() -> void:
if _shift_started or gm == null:
return
var menu = gm.get("start_menu")
if menu != null and menu.visible:
gm.call("_on_venue_selected", "hollywood_building")
_shift_started = true
print("smoke: started hollywood_building shift from the menu")
func _process(_delta: float) -> bool: func _process(_delta: float) -> bool:
frames += 1 frames += 1
if gm == null: if gm == null:
@@ -39,6 +50,7 @@ func _advance() -> void:
state_frames += 1 state_frames += 1
match state: match state:
0: 0:
_start_shift_if_needed()
# Wait for the guard to settle on the ground, then grab one NPC. # Wait for the guard to settle on the ground, then grab one NPC.
if state_frames >= 40 and gm.npcs.size() > 0: if state_frames >= 40 and gm.npcs.size() > 0:
print("smoke: spawn guard_y=%.2f z=%.2f" % [ print("smoke: spawn guard_y=%.2f z=%.2f" % [
@@ -64,7 +76,7 @@ func _advance() -> void:
(gm.guard.global_position as Vector3).y, (gm.guard.global_position as Vector3).y,
(gm.targeted_npc.global_position as Vector3).y, (gm.targeted_npc.global_position as Vector3).y,
]) ])
_shoot("smoke-01-focus-ring.png") _shoot("smoke-01-focus-circle.png")
gm.call("_open_radial") gm.call("_open_radial")
state = 2 state = 2
state_frames = 0 state_frames = 0
@@ -115,7 +127,7 @@ func _fail(message: String) -> void:
func _finish() -> void: func _finish() -> void:
if failures.is_empty(): if failures.is_empty():
print("SMOKE OK: focus ring + action wheel opened and cancelled") print("SMOKE OK: focus circle + action wheel opened and cancelled")
quit(0) quit(0)
else: else:
for message in failures: for message in failures:
+84
View File
@@ -0,0 +1,84 @@
extends SceneTree
## PLAN-verb smoke: starts a shift, focuses one person, then exercises the verbs
## added after the radial pass — recognize, request reason, body camera, formal
## warning (which must arm the body camera), and blocking (which must move the
## guard between the person and the door). Also checks that an incident is
## actually recorded, then closing it at shift end.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
## Exit code 0 = pass, 1 = failure.
const MAX_FRAMES := 1200
var frame := 0
var gm: Node = null
var started := false
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout before the verbs ran")
return true
if frame < 200:
return false
if step == 0:
if gm.npcs.is_empty():
_fail("no NPCs spawned to test the verbs on")
return true
var npc: Node = gm.npcs[0]
gm.call("_on_guard_target", npc)
var controller: Object = npc.get_npc_controller()
gm.call("_recognize_target")
_check(controller.player_recognized, "recognize marks the person as recognized")
gm.call("_request_reason")
_check(typeof(controller.last_reason_reply) == TYPE_STRING, "reason request returns a reply")
gm.call("_set_body_camera", false, "smoke")
_check(not gm.body_camera_on, "body camera can be switched off")
gm.call("_apply_interaction", 2, "formal warning")
_check(gm.body_camera_on, "formal warning arms the body camera")
var before: Vector3 = gm.guard.global_position
gm.call("_apply_interaction", 3, "blocking")
_check(before.distance_to(gm.guard.global_position) > 0.05, "blocking moves the guard")
_check(gm.coordinator.recorder.open_incidents_count() > 0 or gm.coordinator.recorder.incidents.size() > 0,
"an incident was recorded")
gm.coordinator.call("_finish_shift")
_check(gm.coordinator.recorder.open_incidents_count() == 0, "open incidents close at shift end")
_check(gm.coordinator.recorder.incidents.size() > 0, "shift history keeps the incident")
step = 1
if step == 1:
_finish()
return true
return false
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: PLAN verbs (recognize, reason, body camera, warning, blocking)")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+91
View File
@@ -0,0 +1,91 @@
extends SceneTree
## Scenario-content smoke: forces the authored Joke Shop refusal event and the
## Studio celebrity arrival through the real spawn path, then checks the extra
## bystanders, the hint log, and that a screening refusal is handled.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_scenario_content.gd
const MAX_FRAMES := 900
var frame := 0
var gm: Node = null
var started := false
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "joke_shop")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout")
return true
if frame < 120:
return false
match step:
0:
# Force the refusal event through the normal spawn signal.
var event := _find_event(gm.selected_scenario.event_pool, "joke_refuses_wand")
_check(not event.is_empty(), "the refusal event exists in the Joke Shop pool")
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
gm.coordinator.npc_spawn_requested.emit(guest, "enter", "smoke_refuser", event)
_check(gm.npcs.size() > 0, "the refusing guest spawned")
gm.call("_on_guard_target", gm.npcs[gm.npcs.size() - 1])
var controller: Object = gm.npcs[gm.npcs.size() - 1].get_npc_controller()
_check(controller.refuses_screening, "the refusal flag reached the controller")
gm.call("_toggle_wand_screening")
_check(not gm.wand_open, "screening does not open for someone who refuses")
step = 1
1:
# Studio: celebrity arrival should bring extra bystanders.
gm.call("_on_venue_selected", "tv_studio")
step = 2
2:
if frame % 20 != 0:
return false
var event := _find_event(gm.selected_scenario.event_pool, "studio_celebrity_arrival")
var before: int = gm.npcs.size()
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
gm.coordinator.npc_spawn_requested.emit(guest, "arrive", "smoke_celeb", event)
var added: int = gm.npcs.size() - before
_check(added >= 4, "celebrity arrival spawns the guest plus bystanders (%d)" % added)
step = 3
3:
_finish()
return true
return false
func _find_event(pool: Array, id: String) -> Dictionary:
for event in pool:
if str((event as Dictionary).get("id", "")) == id:
return event
return {}
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: authored scenario content")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+63
View File
@@ -0,0 +1,63 @@
extends SceneTree
## Session-shell smoke: boot the main scene, confirm the venue menu is showing,
## start a Joke Shop shift, open the pause overlay and settings, then return to
## the menu. Exits non-zero on failure.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_session_shell.gd
## godot --path . --script res://src/test/smoke_session_shell.gd (windowed)
var _frames := 0
var _manager = null
var _main = null
func _initialize() -> void:
var packed: PackedScene = load("res://scenes/main.tscn")
if packed == null:
printerr("cannot load main scene")
quit(1)
return
_main = packed.instantiate()
root.add_child(_main)
print("boot: main scene added")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 10:
_manager = _main.get_node_or_null("GameManager")
if _manager.start_menu == null or not _manager.start_menu.visible:
printerr("start menu not visible at boot")
quit(1)
return true
print("boot: start menu visible")
_manager._on_venue_selected("joke_shop")
if _frames == 200:
if _manager.npcs.size() == 0:
printerr("no NPCs spawned after shift start")
quit(1)
return true
print("boot: shift running, npcs=%d" % _manager.npcs.size())
_manager._open_pause()
if _frames == 210:
if _manager.pause_overlay == null or not _manager.pause_overlay.visible:
printerr("pause overlay did not open")
quit(1)
return true
print("boot: pause overlay visible")
_manager._open_settings()
if _frames == 220:
if _manager.settings_panel == null or not _manager.settings_panel.visible:
printerr("settings panel did not open")
quit(1)
return true
print("boot: settings visible")
_manager.settings_panel._on_cancel()
_manager._quit_to_menu()
if _frames == 230:
if not _manager.start_menu.visible:
printerr("quit-to-menu did not return to the start menu")
quit(1)
return true
print("SMOKE PASS: menu -> shift -> pause -> settings -> menu")
quit(0)
return true
return false
+83
View File
@@ -0,0 +1,83 @@
extends SceneTree
## Body-camera timeline smoke: records a few real interactions, opens the review's
## timeline, steps through every event, and closes back to the review.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_timeline.gd
const MAX_FRAMES := 900
var frame := 0
var gm: Node = null
var started := false
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout")
return true
if frame < 200:
return false
match step:
0:
if gm.npcs.is_empty():
return false
gm.call("_on_guard_target", gm.npcs[0])
gm.call("_apply_interaction", 1, "eye contact")
gm.call("_request_reason")
gm.call("_apply_interaction", 2, "formal warning")
var review: Dictionary = gm.coordinator.build_review()
# Real flow: the review screen shows first, then its timeline button.
gm.shift_review_panel.show_review(review, gm.selected_scenario, [])
var button: Button = gm.shift_review_panel.find_child("TimelineButton", true, false) as Button
_check(button != null, "review offers a body-camera timeline button")
if button:
button.pressed.emit()
var count: int = gm.event_timeline.events.size()
_check(count >= 3, "timeline collected the interaction events (%d)" % count)
_check(gm.event_timeline.visible, "timeline is showing")
_check(not gm.shift_review_panel.visible, "review is hidden behind the timeline")
_check(gm.guard.input_locked, "the guard is frozen while the timeline is open")
for i in count + 2:
gm.event_timeline.step(1)
_check(gm.event_timeline.index == count - 1, "stepping clamps at the last event")
gm.event_timeline.close()
step = 1
1:
_check(not gm.event_timeline.visible, "timeline closes")
_check(gm.shift_review_panel.visible, "closing returns to the review")
_finish()
return true
return false
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: body-camera timeline")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+64
View File
@@ -0,0 +1,64 @@
extends RefCounted
## Accessibility: escalation cues that survive grayscale (shape + level, not hue
## alone) and the HUD font scale from settings.
const Hud := preload("res://src/game/ui/hud.gd")
const Rules := preload("res://src/core/rules.gd")
func test_meter_shape_and_level() -> bool:
var a := TestUtils.check_eq(Hud.escalation_meter(0), "[-----] L0/5", "calm shows an empty meter")
var b := TestUtils.check_eq(Hud.escalation_meter(3), "[###--] L3/5", "three filled blocks at blocking")
var c := TestUtils.check_eq(Hud.escalation_meter(5), "[#####] L5/5", "emergency fills the meter")
var d := TestUtils.check_eq(Hud.escalation_meter(99), "[#####] L5/5", "levels clamp at 5")
var e := TestUtils.check_eq(Hud.escalation_meter(-2), "[-----] L0/5", "levels clamp at 0")
return a and b and c and d and e
func test_meter_is_not_hue_only() -> bool:
## Every level must differ in shape/text, independent of colour.
var seen: Array[String] = []
for level in 6:
var meter: String = Hud.escalation_meter(level)
if seen.has(meter):
return TestUtils.fail("levels %d and %d share a meter" % [level, seen.find(meter)])
seen.append(meter)
return TestUtils.check(seen.size() == 6, "all six levels have distinct meters")
func test_escalation_label_includes_meter_and_name() -> bool:
var hud: Node = Hud.new()
hud.set_escalation(Rules.Level.FORMAL_WARNING)
var text: String = hud.escalation_label.text
var a := TestUtils.check(text.contains("Formal Warning"), "label names the level")
var b := TestUtils.check(text.contains("[##---]"), "label shows the shape meter")
var c := TestUtils.check(text.contains("L2/5"), "label shows the numeric level")
hud.free()
return a and b and c
func test_font_scale_applies_to_core_labels() -> bool:
var hud: Node = Hud.new()
var base_top: int = hud.top_label.get_theme_font_size("font_size")
var base_log: int = hud.log_label.get_theme_font_size("font_size")
var base_esc: int = hud.escalation_label.get_theme_font_size("font_size")
var base_mode: int = hud.mode_label.get_theme_font_size("font_size")
hud.set_font_scale(1.4)
var a := TestUtils.check(hud.top_label.get_theme_font_size("font_size") > base_top, "top label grows")
var b := TestUtils.check(hud.log_label.get_theme_font_size("font_size") > base_log, "log grows")
var c := TestUtils.check(hud.escalation_label.get_theme_font_size("font_size") > base_esc, "escalation label grows")
var d := TestUtils.check(hud.mode_label.get_theme_font_size("font_size") > base_mode, "mode label grows")
# Scaling is by font size, not Control.scale, so anchored labels keep layout.
var e := TestUtils.check(is_equal_approx(hud.top_label.scale.x, 1.0), "labels are not Control-scaled")
hud.set_font_scale(9.0)
var capped: int = hud.top_label.get_theme_font_size("font_size")
var f := TestUtils.check(capped <= int(round(float(base_top) * 1.75)) + 1, "scale clamps at 1.75")
hud.free()
return a and b and c and d and e and f
func test_settings_font_scale_is_applied_after_ui_exists() -> bool:
## The HUD is created after settings load, so the manager must re-apply.
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("_apply_settings(player_settings)"), "settings are applied on boot")
var build_idx := src.find("_build_ui()")
var apply_idx := src.find("_apply_settings(player_settings)", build_idx)
var b := TestUtils.check(apply_idx > build_idx, "the second apply happens after the HUD is built")
var c := TestUtils.check(src.contains("hud.set_font_scale(float(settings.get("),
"settings drive the HUD font scale")
return a and b and c
+78
View File
@@ -0,0 +1,78 @@
extends RefCounted
## Audio feedback: a looping crowd bed that follows the crowd mood, and radio
## crackle on open/close. All procedural, no audio assets, headless-safe.
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
const CrowdSystem := preload("res://src/core/crowd_system.gd")
func test_bed_volume_follows_mood() -> bool:
var quiet: float = AudioFeedback._level_to_db(0.0)
var loud: float = AudioFeedback._level_to_db(1.0)
var mid: float = AudioFeedback._level_to_db(0.5)
var a := TestUtils.check(quiet < -30.0, "a calm street keeps the bed quiet")
var b := TestUtils.check(loud > quiet, "panic raises the bed")
var c := TestUtils.check(mid > quiet and mid < loud, "the rise is gradual")
return a and b and c
func test_crowd_level_is_clamped() -> bool:
var audio: Node = AudioFeedback.new()
audio.set_crowd_level(-1.0)
var a := TestUtils.check_eq(audio.crowd_level, 0.0, "negative mood clamps to 0")
audio.set_crowd_level(3.0)
var b := TestUtils.check_eq(audio.crowd_level, 1.0, "over-unity mood clamps to 1")
audio.free()
return a and b
func test_crowd_stream_loops() -> bool:
var audio: Node = AudioFeedback.new()
var stream: AudioStreamWAV = audio._build_crowd_stream()
var a := TestUtils.check(stream != null, "a crowd stream is generated")
var b := TestUtils.check_eq(stream.loop_mode, AudioStreamWAV.LOOP_FORWARD, "the bed loops")
var c := TestUtils.check(stream.data.size() > 1000, "the bed has audio data")
var d := TestUtils.check(stream.data.size() == stream.loop_end * 2, "loop covers the whole buffer")
audio.free()
return a and b and c and d
func test_crowd_bed_is_headless_safe() -> bool:
# No tree, no audio server: none of these may crash.
var audio: Node = AudioFeedback.new()
audio.start_crowd_bed()
var a := TestUtils.check(audio.bed_playing, "start marks the bed as playing")
audio.set_crowd_level(0.78)
var b := TestUtils.check(is_equal_approx(audio.crowd_level, 0.78), "level is stored")
audio.stop_crowd_bed()
var c := TestUtils.check(not audio.bed_playing, "stop clears the playing flag")
audio.free()
return a and b and c
func test_radio_crackle_methods() -> bool:
var audio: Node = AudioFeedback.new()
audio.play_radio_open()
audio.play_radio_close()
var a := TestUtils.check_eq(audio.radio_crackles, 2, "open and close each count a crackle")
var b := TestUtils.check(audio._free_player() == null, "no players exist before _ready, and that is safe")
audio.free()
return a and b
func test_game_manager_wires_audio() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("audio.start_crowd_bed()"), "the bed starts with the shift")
var b := TestUtils.check(src.contains("audio.set_crowd_level(float(payload.get("),
"crowd events drive the bed")
var c := TestUtils.check(src.contains("audio.stop_crowd_bed()"), "the bed stops at shift end")
var d := TestUtils.check(src.contains("audio.play_radio_open()"), "opening the radio crackles")
var e := TestUtils.check(src.contains("audio.play_radio_close()"), "closing the radio crackles")
return a and b and c and d and e
func test_mood_events_reach_the_bed() -> bool:
## The bed level is fed from the same payload the crowd reactions use.
var crowd := CrowdSystem.new()
var seen: Array = []
crowd.changed.connect(func(_p): pass)
crowd.apply_event("aggressive_behavior")
seen.append(crowd.mood)
crowd.apply_event("emergency_responder")
seen.append(crowd.mood)
var a := TestUtils.check(seen[1] > seen[0], "mood rises with a responder arrival")
crowd = null
return a
+106
View File
@@ -0,0 +1,106 @@
extends RefCounted
## Crowd presentation: rubbernecking on aggressive/emergency events and clearing
## out when the crowd panics (PLAN: Public Crowd Reaction).
const CrowdSystem := preload("res://src/core/crowd_system.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const Rules := preload("res://src/core/rules.gd")
func _profile() -> NpcProfile:
var p := NpcProfile.new()
p.id = "bystander"
p.display_name = "Bystander"
p.npc_type = Rules.NpcType.TOURIST
return p
func _avatar() -> Node:
var avatar: Node = NpcAvatar.new()
avatar.setup(_profile(), "photo", 99, Vector3(4, 0.5, 8), Vector3(2, 0.5, 12))
return avatar
func test_reaction_for_event() -> bool:
var a := TestUtils.check_eq(CrowdSystem.reaction_for_event("aggressive_behavior"), "watch", "aggression draws watchers")
var b := TestUtils.check_eq(CrowdSystem.reaction_for_event("emergency_responder"), "watch", "responder draws watchers")
var c := TestUtils.check_eq(CrowdSystem.reaction_for_event("visible_weapon"), "watch", "a weapon draws watchers")
var d := TestUtils.check_eq(CrowdSystem.reaction_for_event("de_escalation"), "", "de-escalation is not a watch cue")
var e := TestUtils.check_eq(CrowdSystem.reaction_for_event("calm_professionalism"), "", "calm is not a watch cue")
return a and b and c and d and e
func test_panic_flag_and_flee_cue() -> bool:
var crowd := CrowdSystem.new()
var a := TestUtils.check(not crowd.should_flee(), "crowd starts calm")
crowd.apply_event("visible_weapon")
crowd.apply_event("aggressive_behavior")
crowd.apply_event("aggressive_behavior")
crowd.apply_event("emergency_responder")
var b := TestUtils.check(crowd.should_flee(), "stacked alarms reach panic")
var c := TestUtils.check_eq(crowd.summary().get("panic"), true, "summary reports panic")
var calm := CrowdSystem.new()
calm.apply_event("de_escalation")
calm.apply_event("calm_professionalism")
var d := TestUtils.check(not calm.should_flee(), "a calm crowd does not flee")
crowd = null
calm = null
return a and b and c and d
func test_idle_bystander_rubbernecks() -> bool:
var avatar := _avatar()
# Still walking: should refuse so traffic is not frozen mid-approach.
var walking := TestUtils.check(not avatar.start_rubberneck(Vector3(0, 1, 1)), "walkers keep walking")
avatar.arrived = true
var accepted := TestUtils.check(avatar.start_rubberneck(Vector3(0, 1, 1)), "idle bystander watches")
var mode := TestUtils.check_eq(avatar.reaction_mode, "watch", "watch mode is set")
var active := TestUtils.check(avatar.reaction_is_active(), "reaction is active")
avatar.free()
return walking and accepted and mode and active
func test_engaged_person_does_not_rubberneck() -> bool:
var avatar := _avatar()
avatar.arrived = true
avatar.awaiting_interaction = true
# Not in a tree, so use local position (global_position is a no-op here).
avatar.position = avatar.door_position
var ok := TestUtils.check(not avatar.start_rubberneck(Vector3.ZERO), "someone waiting at the door does not rubberneck")
# The same person further away is an ordinary bystander and does watch.
avatar.position = avatar.door_position + Vector3(8, 0, 0)
var far_ok := TestUtils.check(avatar.start_rubberneck(Vector3.ZERO), "a bystander away from the door still watches")
avatar.free()
return ok and far_ok
func test_panic_moves_people_away_from_the_door() -> bool:
var avatar := _avatar()
avatar.position = Vector3(1, 0.5, 2)
var door := Vector3(0, 0.5, 1.2)
avatar.start_flee(door)
var a := TestUtils.check_eq(avatar.reaction_mode, "flee", "flee mode is set")
var b := TestUtils.check((avatar.target_position - door).length() > (avatar.position - door).length(),
"flee target is farther from the door than the start")
var c := TestUtils.check(not avatar.awaiting_interaction, "fleeing cancels waiting at the entrance")
avatar.free()
return a and b and c
func test_crowd_updates_arrive_through_the_event_bus() -> bool:
## CrowdSystem.apply_event() emits EventBus.crowd_changed, not its own
## `changed` signal; wiring the wrong one silently disables crowd reactions.
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var ok := TestUtils.check(src.contains("event_bus.crowd_changed.connect(_on_crowd_changed)"),
"GameManager listens on EventBus.crowd_changed")
var bus: Object = load("res://src/core/event_bus.gd").new()
var seen: Array = []
bus.crowd_changed.connect(func(payload): seen.append(payload.get("event", "")))
var crowd := CrowdSystem.new()
crowd.set_event_bus(bus)
crowd.apply_event("aggressive_behavior")
var delivers := TestUtils.check_eq(seen, ["aggressive_behavior"], "apply_event reaches the bus listener")
bus = null
crowd = null
return ok and delivers
func test_leaving_npc_does_not_flee() -> bool:
var avatar := _avatar()
avatar.npc.leave_requested = true
avatar.start_flee(Vector3.ZERO)
var ok := TestUtils.check_eq(avatar.reaction_mode, "", "someone already leaving is left alone")
avatar.free()
return ok
+103
View File
@@ -0,0 +1,103 @@
extends RefCounted
## Emergency aftermath: responder outcomes reach the review, evacuation clears the
## entrance and locks the door, and an unjustified call opens an investigation.
const Rules := preload("res://src/core/rules.gd")
const RadioSystem := preload("res://src/core/radio_system.gd")
const Coordinator := preload("res://src/core/shift_coordinator.gd")
const Factory := preload("res://src/content/scenario_factory.gd")
const Strings := preload("res://src/core/strings.gd")
func _coordinator() -> Object:
var c := Coordinator.new()
c.setup(Factory.hollywood_building(), 60.0, 4242)
return c
func test_responder_outcomes_reach_the_review() -> bool:
var c := _coordinator()
c.responder_outcomes.append("arrest")
c.responder_outcomes.append("command")
var review: Dictionary = c.build_review()
var outcomes: Array = review.get("responder_outcomes", [])
var a := TestUtils.check_eq(outcomes, ["arrest", "command"], "responder outcomes are in the review")
var b := TestUtils.check(review.has("investigations"), "review carries an investigations list")
c = null
return a and b
func test_outcomes_reset_with_a_new_shift() -> bool:
var c := _coordinator()
c.responder_outcomes.append("evacuation")
c.investigations.append("note")
c.setup(Factory.hollywood_building(), 60.0, 1)
var a := TestUtils.check_eq(c.responder_outcomes.size(), 0, "outcomes reset on setup")
var b := TestUtils.check_eq(c.investigations.size(), 0, "investigations reset on setup")
c = null
return a and b
func test_evacuation_is_the_emergency_default() -> bool:
var call := {"type": "emergency", "report": {"danger_level": 2, "weapons_seen": false}}
var a := TestUtils.check_eq(RadioSystem.responder_outcome(call), "restraint", "danger 2 restrains")
var call2 := {"type": "emergency", "report": {"danger_level": 1, "weapons_seen": false}}
var b := TestUtils.check_eq(RadioSystem.responder_outcome(call2), "evacuation", "low-danger emergency evacuates")
var call3 := {"type": "assist", "report": {"danger_level": 1, "weapons_seen": false}}
var c := TestUtils.check_eq(RadioSystem.responder_outcome(call3), "command", "assistance just commands")
var call4 := {"type": "emergency", "report": {"danger_level": 3, "weapons_seen": true}}
var d := TestUtils.check_eq(RadioSystem.responder_outcome(call4), "arrest", "weapons arrest")
return a and b and c and d
func test_evacuation_wiring_locks_the_door_and_clears_npcs() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _evacuate_entrance"), "evacuation has a handler")
var b := TestUtils.check(src.contains('if outcome == "evacuation":'), "arrival routes evacuation")
var c := TestUtils.check(src.contains("door.set_locked(true)"), "evacuation locks the door")
var d := TestUtils.check(src.contains("npc.start_flee(door_pos)"), "evacuation sends NPCs away")
var e := TestUtils.check(src.contains("controller.leave_requested = true"), "evacuated NPCs are marked as leaving")
return a and b and c and d and e
func test_unjustified_emergency_is_detected() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _is_unjustified_emergency"), "unjustified check exists")
var b := TestUtils.check(src.contains("return not weapons and danger <= 1"), "no weapon and low danger is unjustified")
var c := TestUtils.check(src.contains("coordinator.investigations.append"), "an investigation note is recorded")
var d := TestUtils.check(src.contains('"type": "investigation_opened"'), "the incident log records the investigation")
var e := TestUtils.check(src.contains('coordinator.reputation.apply("false_report"'), "the false-report hit applies")
return a and b and c and d and e
func test_radio_arrival_reconnects_after_setup() -> bool:
## coordinator.setup() builds a new RadioSystem, so the arrival handler must be
## re-connected each shift or dispatched responders never resolve anything.
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _connect_radio"), "radio wiring has a helper")
var b := TestUtils.check(src.contains("coordinator.radio.responder_arrived.connect(_on_responder_arrived)"),
"the helper connects the arrival handler")
var c := TestUtils.check(src.contains("_connect_radio()"), "shifts call the helper")
return a and b and c
func test_review_strings_exist() -> bool:
var a := TestUtils.check(Strings.lookup("review.responder") != "review.responder", "responder line exists")
var b := TestUtils.check(Strings.lookup("review.investigations") != "review.investigations", "investigation line exists")
var c := TestUtils.check(Strings.lookup("review.none") != "review.none", "empty placeholder exists")
var d := TestUtils.check(Strings.lookup("responder.evacuated") != "responder.evacuated", "evacuation log line exists")
var e := TestUtils.check(Strings.lookup("investigation.started") != "investigation.started", "investigation log line exists")
return a and b and c and d and e
func test_review_renders_outcomes_and_investigations() -> bool:
var Review := load("res://src/game/ui/shift_review.gd")
var panel: Node = Review.new()
var review := {
"scenario_id": "hollywood_building",
"reputation": {"rating": 61.0, "grade": "C", "modifiers": []},
"incidents": {"incidents": []},
"crowd": {"mood": 0.4, "panic": false},
"radio": {"battery": 70.0, "emergency_cooldown_s": 0.0},
"spawned_count": 3,
"responder_outcomes": ["evacuation"],
"investigations": ["Emergency call reviewed"],
}
panel.show_review(review, Factory.hollywood_building(), [])
var text: String = panel.report_label.text
var a := TestUtils.check(text.contains("evacuation"), "review names the responder outcome")
var b := TestUtils.check(text.contains("Emergency call reviewed"), "review shows the investigation note")
var c := TestUtils.check(text.contains("Investigations:"), "investigations line is labelled")
panel.free()
return a and b and c
+120
View File
@@ -0,0 +1,120 @@
extends RefCounted
## Body-camera timeline: the review screen steps through recorded incident events
## (ADR-0005). No live 3D replay in this version.
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
const Strings := preload("res://src/core/strings.gd")
const Coordinator := preload("res://src/core/shift_coordinator.gd")
const Factory := preload("res://src/content/scenario_factory.gd")
func _review() -> Dictionary:
return {
"started_at_ms": 1000,
"incidents": {
"open_count": 0,
"incidents": [
{
"id": "inc_a",
"npc_id": "npc_a",
"outcome": "peaceful",
"events": [
{"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000},
{"type": "body_camera", "on": true, "reason": "formal warning", "at_ms": 8000},
],
},
{
"id": "inc_b",
"npc_id": "npc_b",
"outcome": "left",
"events": [
{"type": "reason_requested", "reply": "I have a ticket.", "at_ms": 5000},
{"type": "access_check", "evidence": {"decided": true, "verified_granted": true}, "at_ms": 9000},
],
},
],
},
"reputation": {"rating": 70.0, "grade": "C", "modifiers": []},
"crowd": {"mood": 0.1, "panic": false},
"radio": {"battery": 100.0, "emergency_cooldown_s": 0.0},
}
func test_flatten_orders_events_across_incidents() -> bool:
var timeline: Node = EventTimeline.new()
var flat: Array = timeline.flatten(_review())
var a := TestUtils.check_eq(flat.size(), 4, "every recorded event is flattened")
var stamps: Array[int] = []
for row in flat:
stamps.append(int(row.get("at_ms", 0)))
var sorted := stamps.duplicate()
sorted.sort()
var b := TestUtils.check_eq(stamps, sorted, "events are time ordered")
var c := TestUtils.check_eq(flat[0].get("npc_id", ""), "npc_a", "event keeps its NPC id")
var d := TestUtils.check_eq(flat[1].get("npc_id", ""), "npc_b", "second incident interleaves")
var e := TestUtils.check_eq(flat[0].get("incident_id", ""), "inc_a", "event keeps its incident id")
timeline.free()
return a and b and c and d and e
func test_describe_covers_event_types() -> bool:
var timeline: Node = EventTimeline.new()
var a := TestUtils.check(timeline.describe({"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000}).contains("eye contact (L1)"), "player action line")
var b := TestUtils.check(timeline.describe({"type": "body_camera", "on": true, "reason": "formal warning"}).contains("body camera on"), "body camera line")
var c := TestUtils.check(timeline.describe({"type": "reason_requested", "reply": "I have a ticket."}).contains("I have a ticket."), "reason reply line")
var d := TestUtils.check(timeline.describe({"type": "access_check", "evidence": {"decided": false, "verified_granted": true}}).contains("deny"), "access decision line")
var e := TestUtils.check(timeline.describe({"type": "responder_action", "call_type": "emergency", "outcome": "evacuation"}).contains("evacuation"), "responder line")
var f := TestUtils.check(timeline.describe({"type": "investigation_opened", "reason": "unjustified"}).contains("investigation"), "investigation line")
timeline.free()
return a and b and c and d and e and f
func test_empty_timeline_is_safe() -> bool:
var timeline: Node = EventTimeline.new()
var flat: Array = timeline.flatten({})
var a := TestUtils.check_eq(flat.size(), 0, "no incidents means no events")
timeline.show_timeline({})
var b := TestUtils.check_eq(timeline.index, 0, "index stays at zero when empty")
timeline.step(1)
var c := TestUtils.check_eq(timeline.index, 0, "stepping an empty timeline does nothing")
timeline.free()
return a and b and c
func test_stepping_clamps() -> bool:
var timeline: Node = EventTimeline.new()
timeline.show_timeline(_review())
timeline.step(-1)
var a := TestUtils.check_eq(timeline.index, 0, "cannot step before the first event")
timeline.step(1)
var b := TestUtils.check_eq(timeline.index, 1, "next advances one event")
timeline.step(99)
var c := TestUtils.check_eq(timeline.index, 3, "cannot step past the last event")
timeline.free()
return a and b and c
func test_review_records_a_shift_start_stamp() -> bool:
var coordinator: Object = Coordinator.new()
coordinator.setup(Factory.hollywood_building(), 30.0, 7)
coordinator.start()
var review: Dictionary = coordinator.build_review()
var a := TestUtils.check(int(review.get("started_at_ms", 0)) > 0, "review carries the shift start stamp")
coordinator = null
return a
func test_timeline_strings_exist() -> bool:
var ok := true
for key in ["timeline.title", "timeline.position", "timeline.empty", "timeline.prev",
"timeline.next", "timeline.close", "timeline.hint", "review.timeline_button"]:
if Strings.lookup(key) == key:
ok = false
return TestUtils.check(ok, "timeline UI strings are localized")
func test_review_button_opens_the_timeline() -> bool:
var Review := load("res://src/game/ui/shift_review.gd")
var panel: Node = Review.new()
var got := {"review": {}}
panel.timeline_requested.connect(func(review): got["review"] = review)
panel.show_review(_review(), Factory.hollywood_building(), [])
var button: Button = panel.find_child("TimelineButton", true, false) as Button
var a := TestUtils.check(button != null, "review screen has a timeline button")
if button:
button.pressed.emit()
var b := TestUtils.check(not (got["review"] as Dictionary).is_empty(), "pressing it hands the review to the timeline")
panel.free()
return a and b
+9 -1
View File
@@ -32,7 +32,9 @@ func test_radial_wrap_and_skip_wand() -> bool:
var a := TestUtils.check(menu.selected != start, "step wrap moves selection") var a := TestUtils.check(menu.selected != start, "step wrap moves selection")
var b := TestUtils.check(menu.is_slice_enabled(menu.selected), "landed on an enabled slice") var b := TestUtils.check(menu.is_slice_enabled(menu.selected), "landed on an enabled slice")
# Walk forward until we would hit wand; it must skip. # Walk forward until we would hit wand; it must skip.
menu.selected = 4 var wand_index := _slice_index("wand")
var warning_index := _slice_index("warning")
menu.selected = wand_index - 1
menu.step(1) menu.step(1)
var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none") var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none")
menu.free() menu.free()
@@ -91,6 +93,12 @@ func test_cancel_has_right_mouse() -> bool:
found = true found = true
return TestUtils.check(found, "cancel action includes right mouse button") return TestUtils.check(found, "cancel action includes right mouse button")
func _slice_index(id: String) -> int:
for i in ActionRadial.SLICE_DEFS.size():
if str(ActionRadial.SLICE_DEFS[i]["id"]) == id:
return i
return -1
func _action_has_key(action: String, keycode: Key) -> bool: func _action_has_key(action: String, keycode: Key) -> bool:
if not InputMap.has_action(action): if not InputMap.has_action(action):
return false return false
+98
View File
@@ -0,0 +1,98 @@
extends RefCounted
## NPC theater: seated guests, camera raises, and performers playing to the
## sidewalk once their intent's waypoint is reached (PLAN.md: NPC Behavior Types).
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const Rules := preload("res://src/core/rules.gd")
func _avatar(intent: String, npc_type: int = Rules.NpcType.TOURIST) -> Node:
var p := NpcProfile.new()
p.id = "theater"
p.display_name = "Theater"
p.npc_type = npc_type
var avatar: Node = NpcAvatar.new()
avatar.setup(p, intent, 1234, Vector3(2, 0.5, 8), Vector3(0, 0.5, 6))
return avatar
func test_pose_per_intent() -> bool:
var cases := {
"sit": "sit",
"photo": "photo",
"film": "film",
"perform": "perform",
"enter": "idle",
}
var ok := true
for intent in cases.keys():
var avatar := _avatar(str(intent))
avatar.arrived = true
if avatar.theater_pose() != cases[intent]:
ok = false
avatar.free()
return TestUtils.check(ok, "each intent maps to its theater pose once arrived")
func test_walking_npc_has_no_pose() -> bool:
var avatar := _avatar("sit")
var a := TestUtils.check_eq(avatar.theater_pose(), "idle", "a walking NPC does not sit yet")
avatar.arrived = true
var b := TestUtils.check_eq(avatar.theater_pose(), "sit", "arriving starts the pose")
avatar.free()
return a and b
func test_only_sit_crouches() -> bool:
var a := TestUtils.check(is_equal_approx(NpcAvatar.crouch_for_pose("sit"), -0.34), "sit lowers the rig")
var b := TestUtils.check_eq(NpcAvatar.crouch_for_pose("photo"), 0.0, "photo keeps standing")
var c := TestUtils.check_eq(NpcAvatar.crouch_for_pose("perform"), 0.0, "perform keeps standing")
var d := TestUtils.check_eq(NpcAvatar.crouch_for_pose("idle"), 0.0, "idle keeps standing")
return a and b and c and d
func test_photo_lift_is_brief() -> bool:
var raised := 0.0
var flat := 0.0
for i in 120:
var phase := float(i) / 60.0
var lift: float = NpcAvatar.prop_lift_for_pose("photo", phase)
raised = maxf(raised, lift)
if i == 0:
flat = lift
var a := TestUtils.check(raised > 0.1, "the camera prop lifts while photographing")
var b := TestUtils.check(raised <= 0.16 + 0.001, "the lift stays small")
var c := TestUtils.check_eq(flat, 0.0, "the lift starts at rest")
var d := TestUtils.check_eq(NpcAvatar.prop_lift_for_pose("sit", 0.5), 0.0, "seated guests do not lift a prop")
return a and b and c and d
func test_performer_sways_only_when_performing() -> bool:
var peak := 0.0
for i in 120:
peak = maxf(peak, absf(NpcAvatar.sway_for_pose("perform", float(i) / 60.0)))
var a := TestUtils.check(peak > 0.05, "the performer's sign sways")
var b := TestUtils.check_eq(NpcAvatar.sway_for_pose("photo", 1.0), 0.0, "photographers do not sway")
var c := TestUtils.check_eq(NpcAvatar.sway_for_pose("sit", 1.0), 0.0, "seated guests do not sway")
return a and b and c
func test_photo_prop_is_wired_for_camera_archetypes() -> bool:
var influencer := _avatar("photo", Rules.NpcType.INFLUENCER)
var tourist := _avatar("photo", Rules.NpcType.TOURIST)
var performer := _avatar("perform", Rules.NpcType.PERFORMER)
var a := TestUtils.check(influencer._photo_prop != null, "influencer carries a camera prop")
var b := TestUtils.check(tourist._photo_prop != null, "tourist carries a camera prop")
var c := TestUtils.check(performer._sign_prop != null, "performer carries a sign prop")
influencer.free()
tourist.free()
performer.free()
return a and b and c
func test_sitting_rig_actually_lowers() -> bool:
var avatar := _avatar("sit")
avatar.arrived = true
for i in 40:
avatar._animate(0.016)
var a := TestUtils.check(avatar._rig.position.y < -0.2, "the rig settles into the crouch")
var legs_bent := TestUtils.check(absf(avatar._legs[0].rotation.x) > deg_to_rad(30.0), "legs bend for the seat")
avatar.arrived = false
for i in 60:
avatar._animate(0.016)
var b := TestUtils.check(avatar._rig.position.y > -0.1, "standing back up lifts the rig")
avatar.free()
return a and legs_bent and b
+21
View File
@@ -30,3 +30,24 @@ func test_load_returns_array_when_empty_or_missing() -> bool:
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array") var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
save.free() save.free()
return ok return ok
func test_settings_round_trip() -> bool:
var save := _save_instance()
var err: int = save.save_settings({"look_sensitivity": 0.0035, "hud_font_scale": 1.25})
var a := TestUtils.check_eq(err, OK, "save_settings succeeds")
var loaded: Dictionary = save.load_settings()
var b := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0035), "round-trips sensitivity")
var c := TestUtils.check(is_equal_approx(float(loaded.get("hud_font_scale", 0.0)), 1.25), "round-trips font scale")
# Restore defaults so later runs are not affected.
save.save_settings(save.DEFAULT_SETTINGS)
save.free()
return a and b and c
func test_settings_defaults_present() -> bool:
var save := _save_instance()
var defaults: Dictionary = save.load_settings()
var ok := TestUtils.check(
defaults.has("look_sensitivity") and defaults.has("hud_font_scale"),
"settings have documented defaults")
save.free()
return ok
+132
View File
@@ -0,0 +1,132 @@
extends RefCounted
## PLAN verbs added after the radial pass: eye contact, recognition as a player
## decision, the body-camera flag, blocking station, and requesting a reason.
const Rules := preload("res://src/core/rules.gd")
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
const IncidentRecorder := preload("res://src/core/incident_recorder.gd")
const NPCController := preload("res://src/core/npc_controller.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
func _slice_index(id: String) -> int:
for i in ActionRadial.SLICE_DEFS.size():
if str(ActionRadial.SLICE_DEFS[i]["id"]) == id:
return i
return -1
func test_radial_has_new_verbs() -> bool:
var a := TestUtils.check(_slice_index("eye_contact") >= 0, "eye contact slice exists")
var b := TestUtils.check(_slice_index("reason") >= 0, "request-reason slice exists")
var c := TestUtils.check(_slice_index("recognize") >= 0, "recognize slice exists")
return a and b and c
func test_eye_contact_is_level_one() -> bool:
var def: Dictionary = ActionRadial.SLICE_DEFS[_slice_index("eye_contact")]
var ok := TestUtils.check_eq(int(def.get("level", -1)), Rules.Level.SIMPLE_DETERRENCE,
"eye contact is a Level 1 action")
var action_ok := TestUtils.check_eq(str(def.get("action", "")), "eye contact", "logs as eye contact")
return ok and action_ok
func test_every_slice_has_a_string_key() -> bool:
var strings = load("res://src/core/strings.gd")
var ok := true
for def in ActionRadial.SLICE_DEFS:
var key: String = str(def.get("label_key", ""))
if key == "":
ok = false
continue
if strings.lookup(key) == key:
ok = false
return TestUtils.check(ok, "every radial slice resolves a localized label")
func _profile(name: String, lines: Array[String] = []) -> NpcProfile:
var p := NpcProfile.new()
p.id = "test"
p.display_name = name
p.dialogue_lines = lines
return p
func test_request_reason_uses_dialogue_lines() -> bool:
var p := _profile("Visitor", ["I have a ticket for tonight.", "I am meeting a friend."])
var npc := NPCController.new()
npc.setup("npc_1", p, "enter", 4242)
var a := TestUtils.check_eq(npc.request_reason(), npc.last_reason_reply, "reply is stored")
var b := TestUtils.check_eq(npc.last_reason_reply, p.dialogue_lines[0], "seeded pick is deterministic")
var c := TestUtils.check(not npc.last_reason_reply.is_empty(), "reply is non-empty")
npc = null
return a and b and c
func test_request_reason_silent_without_lines() -> bool:
var npc := NPCController.new()
npc.setup("npc_2", _profile("Silent"), "enter", 7)
var reply: String = npc.request_reason()
var ok := TestUtils.check_eq(reply, "", "no dialogue lines means silence")
npc = null
return ok
func test_player_recognition_is_not_default() -> bool:
var p := _profile("Staffer")
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
var npc := NPCController.new()
npc.setup("npc_3", p, "enter", 11)
var a := TestUtils.check(not npc.player_recognized, "guests are not pre-recognized")
var override := {}
var artifacts := AccessLoadout.build(p, null, override)
var has_recognition := false
for artifact in artifacts:
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.RECOGNITION:
has_recognition = true
var b := TestUtils.check(not has_recognition, "guest artifact set has no recognition")
npc = null
return a and b
func test_recorder_starts_and_closes_incidents() -> bool:
var recorder := IncidentRecorder.new()
var a := TestUtils.check(not recorder.has_open_incident("npc_a"), "no incident before start")
recorder.start_incident("npc_a", "player_focus")
var b := TestUtils.check(recorder.has_open_incident("npc_a"), "incident opens")
recorder.add_event("npc_a", {"type": "recognition_decision", "recognized": true})
var c := TestUtils.check_eq(recorder.incidents.size(), 0, "open incident is not yet in history")
var closed: int = recorder.close_all_open("unresolved")
var d := TestUtils.check_eq(closed, 1, "close_all_open closes the open incident")
var e := TestUtils.check_eq(recorder.incidents.size(), 1, "closed incident lands in history")
var events: Array = recorder.incidents[0].get("events", [])
var f := TestUtils.check_eq(events.size(), 1, "recorded event survived the close")
recorder = null
return a and b and c and d and e and f
func test_shift_end_closes_open_incidents_into_review() -> bool:
var Coordinator := load("res://src/core/shift_coordinator.gd")
var Factory := load("res://src/content/scenario_factory.gd")
var coordinator = Coordinator.new()
coordinator.setup(Factory.hollywood_building(), 60.0, 99)
coordinator.start()
coordinator.recorder.start_incident("npc_x", "player_action")
coordinator.recorder.add_event("npc_x", {"type": "reason_requested", "reply": ""})
coordinator.call("_finish_shift")
var review: Dictionary = coordinator.build_review()
var incidents: Dictionary = review.get("incidents", {})
var list: Array = incidents.get("incidents", [])
var a := TestUtils.check_eq(list.size(), 1, "unresolved incident appears in the review")
var b := TestUtils.check_eq(str(list[0].get("outcome", "")), "unresolved", "outcome is unresolved")
var open := TestUtils.check_eq(int(incidents.get("open_count", -1)), 0, "nothing left open")
coordinator = null
return a and b and open
func test_body_camera_not_on_by_default() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _set_body_camera"), "body camera has a setter")
var b := TestUtils.check(src.contains("KEY_B"), "B toggles the body camera")
var c := TestUtils.check(
src.contains("level == Rules.Level.FORMAL_WARNING and not body_camera_on"),
"a formal warning turns the body camera on")
return a and b and c
func test_blocking_stations_the_guard() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _station_between"), "blocking has a stationing step")
var b := TestUtils.check(src.contains("_station_between(controller"), "blocking calls it")
var c := TestUtils.check(src.contains("to_door.normalized() * 0.9"), "station point is between NPC and door")
return a and b and c
+143
View File
@@ -0,0 +1,143 @@
extends RefCounted
## Authored scenario content added to match the PLAN lists: Joke Shop access
## edge cases, Studio filming boundaries and celebrity crowd spikes, and Building
## events that point at the body-camera / request-reason verbs.
const Factory := preload("res://src/content/scenario_factory.gd")
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
func _events(scenario_id: String) -> Array:
var scenario: Resource
match scenario_id:
"joke_shop":
scenario = Factory.joke_shop()
"tv_studio":
scenario = Factory.tv_studio()
_:
scenario = Factory.hollywood_building()
return scenario.event_pool
func _event(scenario_id: String, id: String) -> Dictionary:
for event in _events(scenario_id):
if str(event.get("id", "")) == id:
return event
return {}
func test_joke_shop_has_the_missing_edge_cases() -> bool:
var a := TestUtils.check(not _event("joke_shop", "joke_guest_reservation_no_id").is_empty(),
"reservation list without ID exists")
var b := TestUtils.check(not _event("joke_shop", "joke_borrowed_id").is_empty(),
"borrowed/mismatched ID exists")
var c := TestUtils.check(not _event("joke_shop", "joke_refuses_wand").is_empty(),
"guest who refuses the wand exists")
var d := TestUtils.check(not _event("joke_shop", "joke_late_arrival").is_empty(),
"late arrival during the performance exists")
return a and b and c and d
func test_reservation_without_id_is_denied() -> bool:
var event := _event("joke_shop", "joke_guest_reservation_no_id")
var profile := _guest_profile()
var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {}))
var verifier := AccessVerifier.new()
var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false)
var a := TestUtils.check(not result.get("granted", true), "a reservation without ID is not admitted")
var b := TestUtils.check(not (result.get("issues", []) as Array).is_empty(), "the denial names the missing artifact")
return a and b
func test_borrowed_id_is_mismatched() -> bool:
var event := _event("joke_shop", "joke_borrowed_id")
var profile := _guest_profile()
var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {}))
var found_mismatch := false
for artifact in artifacts:
if artifact.kind == Rules.AccessKind.ID and artifact.status == Rules.ArtifactStatus.MISMATCHED:
found_mismatch = true
var verifier := AccessVerifier.new()
var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false)
var a := TestUtils.check(found_mismatch, "the ID is flagged mismatched")
var b := TestUtils.check(not result.get("granted", true), "a mismatched ID is not admitted")
return a and b
func test_refuses_wand_event_sets_the_flag() -> bool:
var event := _event("joke_shop", "joke_refuses_wand")
var ok := TestUtils.check(bool(event.get("refuses_screening", false)), "the event marks the refusal")
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var wired := TestUtils.check(src.contains("refuses_screening = bool(event.get("),
"the spawn copies the flag onto the controller")
var handled := TestUtils.check(src.contains("if controller.refuses_screening:"),
"the wand screen handles a refusal")
return ok and wired and handled
func test_studio_filming_boundary_repeats() -> bool:
var first := _event("tv_studio", "studio_film_boundary")
var repeat := _event("tv_studio", "studio_film_boundary_repeat")
var a := TestUtils.check(not first.is_empty(), "first boundary event exists")
var b := TestUtils.check(not repeat.is_empty(), "repeat boundary event exists")
var c := TestUtils.check_eq(str(first.get("intent", "")), "film_boundary", "first uses the boundary intent")
var d := TestUtils.check_eq(str(repeat.get("intent", "")), "film_boundary", "repeat uses the boundary intent")
var e := TestUtils.check(int(repeat.get("time_range_s", Vector2.ZERO).x) > int(first.get("time_range_s", Vector2.ZERO).x),
"the repeat happens later in the shift")
return a and b and c and d and e
func test_film_boundary_stands_at_the_steps() -> bool:
var scenario := Factory.tv_studio()
var spot: Vector3 = scenario.waypoint_for_intent("film_boundary")
var sidewalk: Vector3 = scenario.waypoint_for_intent("film")
var a := TestUtils.check(spot != sidewalk, "the boundary spot differs from the permitted sidewalk spot")
var b := TestUtils.check_eq(spot, scenario._location_or_default("steps_end", Vector3.ZERO),
"the influencer stands at the steps, past the line")
return a and b
func test_celebrity_arrival_spawns_a_crowd() -> bool:
var event := _event("tv_studio", "studio_celebrity_arrival")
var extras: Array = event.get("extra_spawns", [])
var a := TestUtils.check(extras.size() >= 3, "celebrity arrival adds several bystanders")
var types: Array = []
for extra in extras:
types.append(int((extra as Dictionary).get("npc_type", -1)))
var b := TestUtils.check(types.has(Rules.NpcType.TOURIST), "tourists gather")
var c := TestUtils.check(types.has(Rules.NpcType.INFLUENCER), "an influencer films it")
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var d := TestUtils.check(src.contains('event.get("extra_spawns", [])'), "the spawner honours extra_spawns")
return a and b and c and d
func test_building_events_point_at_the_new_verbs() -> bool:
var camera_event := _event("hollywood_building", "building_filming_boundary")
var reason_event := _event("hollywood_building", "building_unclear_intent")
var a := TestUtils.check(str(camera_event.get("hint", "")).contains("body"), "an event hints at the body camera")
var b := TestUtils.check(str(reason_event.get("hint", "")).contains("reason"), "an event hints at requesting a reason")
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var c := TestUtils.check(src.contains('if event.has("hint")'), "spawning logs the authored hint")
return a and b and c
func test_every_authored_hint_resolves() -> bool:
var missing: Array[String] = []
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
for event in scenario.event_pool:
var key := str((event as Dictionary).get("hint", ""))
if key != "" and Strings.lookup(key) == key:
missing.append(key)
return TestUtils.check(missing.is_empty(), "all authored hints have strings (missing: %s)" % str(missing))
func test_new_events_have_unique_ids() -> bool:
# Event ids only need to be unique within a venue.
var problems: Array[String] = []
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
var seen := {}
for event in scenario.event_pool:
var id := str((event as Dictionary).get("id", ""))
if seen.has(id):
problems.append("%s:%s" % [scenario.id, id])
seen[id] = true
return TestUtils.check(problems.is_empty(), "event ids are unique per venue (dupes: %s)" % str(problems))
func _guest_profile() -> NpcProfile:
var p := NpcProfile.new()
p.id = "guest"
p.display_name = "Guest"
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
return p
+100
View File
@@ -0,0 +1,100 @@
extends RefCounted
## Session shell (start menu, pause overlay, settings) and routine-traffic
## despawn. These are headless-shape tests: they instantiate the UI scripts and
## check state transitions rather than rendering.
const StartMenu := preload("res://src/game/ui/start_menu.gd")
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
func test_start_menu_lists_all_venues() -> bool:
var menu: Node = StartMenu.new()
# Buttons are created in _ready; build them without a tree by calling _build.
menu._build()
var ids: Array[String] = []
for venue in StartMenu.VENUES:
ids.append(str(venue["id"]))
var a := TestUtils.check_eq(ids.size(), 3, "three venues offered")
var b := TestUtils.check(ids.has("hollywood_building"), "Hollywood Blvd present")
var c := TestUtils.check(ids.has("joke_shop"), "Joke Shop present")
var d := TestUtils.check(ids.has("tv_studio"), "TV Studio present")
menu.free()
return a and b and c and d
func test_start_menu_emits_selected_venue() -> bool:
var menu: Node = StartMenu.new()
menu._build()
var got := {"id": ""}
menu.venue_selected.connect(func(id): got["id"] = id)
menu._on_venue_pressed(1)
var a := TestUtils.check_eq(got["id"], "joke_shop", "clicking venue 2 selects the Joke Shop")
var b := TestUtils.check(not menu.visible, "menu closes after a venue is picked")
menu.free()
return a and b
func test_start_menu_clamps_focus() -> bool:
var menu: Node = StartMenu.new()
menu._build()
menu._focus_index(-5)
var low: int = menu._index
menu._focus_index(99)
var high: int = menu._index
menu.free()
return TestUtils.check_eq(low, 0, "focus clamps at the top") \
and TestUtils.check_eq(high, StartMenu.VENUES.size() - 1, "focus clamps at the bottom")
func test_pause_overlay_buttons_emit() -> bool:
var overlay: Node = PauseOverlay.new()
overlay._build()
var seen: Array[String] = []
overlay.resume_requested.connect(func(): seen.append("resume"))
overlay.restart_requested.connect(func(): seen.append("restart"))
overlay.settings_requested.connect(func(): seen.append("settings"))
overlay.quit_to_menu_requested.connect(func(): seen.append("menu"))
for node_name in ["ResumeButton", "RestartButton", "SettingsButton", "MenuButton"]:
var button: Button = overlay.find_child(node_name, true, false) as Button
if button:
button.pressed.emit()
overlay.free()
var expected := ["resume", "restart", "settings", "menu"]
return TestUtils.check_eq(seen, expected, "pause overlay exposes resume/restart/settings/menu")
func test_settings_panel_persists_values() -> bool:
var panel: Node = SettingsPanel.new()
panel._build()
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
panel._on_sensitivity_changed(0.004)
panel._on_font_scale_changed(1.3)
var applied := {"settings": {}}
panel.applied.connect(func(s): applied["settings"] = s)
panel._on_apply()
var s: Dictionary = applied["settings"]
var a := TestUtils.check(is_equal_approx(float(s.get("look_sensitivity", 0.0)), 0.004), "sensitivity applied")
var b := TestUtils.check(is_equal_approx(float(s.get("hud_font_scale", 0.0)), 1.3), "font scale applied")
var c := TestUtils.check(not panel.visible, "panel closes on apply")
# Restore defaults written by the apply call.
if Save:
Save.save_settings(Save.DEFAULT_SETTINGS)
panel.free()
return a and b and c
func test_settings_panel_cancel_discards() -> bool:
var panel: Node = SettingsPanel.new()
panel._build()
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
panel._on_sensitivity_changed(0.005)
var applied := {"count": 0}
panel.applied.connect(func(_s): applied["count"] += 1)
panel._on_cancel()
var a := TestUtils.check(not panel.visible, "cancel closes the panel")
var b := TestUtils.check_eq(applied["count"], 0, "cancel does not apply")
panel.free()
return a and b
func test_routine_traffic_is_ephemeral() -> bool:
var avatar: Node = NpcAvatar.new()
var a := TestUtils.check(avatar.ephemeral, "avatars default to ephemeral")
var b := TestUtils.check(avatar.lifetime_s > 0.0, "ephemeral traffic has a lifetime")
avatar.free()
return a and b
+30
View File
@@ -78,6 +78,36 @@ func test_asset_textures_present_and_pow2() -> bool:
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h]) return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
return true return true
func test_steps_have_colliders() -> bool:
## Steps were visual-only and the guard walked through them (TODO).
var root := Node3D.new()
WorldBuilder.new(root).build()
var steps := root.find_child("Steps", true, false)
var a := TestUtils.check(steps != null, "Steps node exists")
var b := TestUtils.check(steps is StaticBody3D, "Steps are a physics body")
var shape_count := 0
if steps is StaticBody3D:
var body := steps as StaticBody3D
shape_count = body.get_shape_owners().size()
var c := TestUtils.check(shape_count >= 5, "four treads + landing have colliders (got %d)" % shape_count)
root.free()
return a and b and c
func test_skyline_backdrop_uses_alpha() -> bool:
## The opaque skyline texture made the plane render as a black slab (TODO).
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var backdrop := root.find_child("SkylineBackdrop", true, false)
var a := TestUtils.check(backdrop != null, "SkylineBackdrop exists")
var mat: StandardMaterial3D = builder._skyline_mat
var b := TestUtils.check(mat != null, "skyline material captured")
var c := TestUtils.check(
mat != null and mat.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED,
"skyline material uses alpha, not an opaque slab")
root.free()
return a and b and c
func test_door_indicator_built() -> bool: func test_door_indicator_built() -> bool:
var root := Node3D.new() var root := Node3D.new()
var builder := WorldBuilder.new(root) var builder := WorldBuilder.new(root)
+3 -2
View File
@@ -221,7 +221,7 @@ def skyline():
x = -40 x = -40
while x < w + 40: while x < w + 40:
bw = rnd.randint(64, 170) bw = rnd.randint(64, 170)
bh = rnd.randint(150, 400) bh = rnd.randint(90, 165)
d.rectangle([x, h - bh, x + bw, h], fill=build_color) d.rectangle([x, h - bh, x + bw, h], fill=build_color)
# few lit windows # few lit windows
for _ in range(bw * bh // 900): for _ in range(bw * bh // 900):
@@ -231,7 +231,8 @@ def skyline():
d.rectangle([wx, wy, wx + 6, wy + 8], d.rectangle([wx, wy, wx + 6, wy + 8],
fill=(255, 200, 120, 220)) fill=(255, 200, 120, 220))
x += bw + rnd.randint(10, 46) x += bw + rnd.randint(10, 46)
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) # Opaque bottom border removed for transparency - planes will now render as silhouettes
# d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) # commented out - was causing opaque black slabs
save(img, "skyline.png") save(img, "skyline.png")
+51 -4
View File
@@ -16,8 +16,17 @@ HOST="${HOST:-https://git.allucanget.biz}"
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}" VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
TAG="v$VERSION" TAG="v$VERSION"
# AUTH is a curl argument list. Populated by the first available method:
# 1. GITEA_TOKEN explicit token (repo secret in CI)
# 2. GITEA_AUTH_HEADER a ready-made "Authorization: ..." header
# 3. ~/.git-credentials basic auth for HOST
# 4. the checkout's git extraheader (Gitea Actions runners inject one for the
# job; this is what makes tag builds publish without any repo secret)
AUTH=()
if [[ -n "${GITEA_TOKEN:-}" ]]; then if [[ -n "${GITEA_TOKEN:-}" ]]; then
AUTH=("Authorization: token $GITEA_TOKEN") AUTH=(-H "Authorization: token $GITEA_TOKEN")
elif [[ -n "${GITEA_AUTH_HEADER:-}" ]]; then
AUTH=(-H "$GITEA_AUTH_HEADER")
elif [[ -f "$HOME/.git-credentials" ]]; then elif [[ -f "$HOME/.git-credentials" ]]; then
CRED="$(python3 -c " CRED="$(python3 -c "
import os import os
@@ -28,8 +37,23 @@ for l in open(os.path.expanduser('~/.git-credentials')):
echo "error: no credentials for $HOST in ~/.git-credentials" >&2 echo "error: no credentials for $HOST in ~/.git-credentials" >&2
exit 1 exit 1
fi fi
else # curl basic auth. (Previously only CRED was set, so ${AUTH[@]} was unbound
echo "error: set GITEA_TOKEN or provide ~/.git-credentials" >&2 # under `set -u` and every API call failed silently.)
AUTH=(-u "$CRED")
fi
if [[ ${#AUTH[@]} -eq 0 ]]; then
# Gitea Actions: actions/checkout configures a local extraheader with the
# runner's credentials. Reuse it for the API (the same server, so the header is
# valid for $HOST too).
GIT_HEADER="$(git config --local --get-regexp '^http\..*\.extraheader$' 2>/dev/null | head -n1 | cut -d' ' -f2- || true)"
if [[ -n "${GIT_HEADER:-}" ]]; then
AUTH=(-H "$GIT_HEADER")
fi
fi
if [[ ${#AUTH[@]} -eq 0 ]]; then
echo "error: no credentials. Set GITEA_TOKEN, GITEA_AUTH_HEADER, or provide ~/.git-credentials." >&2
exit 1 exit 1
fi fi
@@ -75,8 +99,31 @@ for f in "${all_artifacts[@]}"; do
exit 1 exit 1
fi fi
echo "==> Uploading $(basename "$f")" echo "==> Uploading $(basename "$f")"
curl -s -X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" >/dev/null # Fail loudly: a silent 4xx here used to leave a release with no artifacts while
# the job still reported success.
code="$(curl -sS -o /tmp/release-upload.json -w '%{http_code}' --retry 2 \
-X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" || true)"
if [[ "$code" != "201" && "$code" != "200" ]]; then
echo "error: upload of $(basename "$f") failed with HTTP $code" >&2
cat /tmp/release-upload.json >&2 || true
echo "" >&2
exit 1
fi
done done
# Verify the release actually carries the artifacts we just sent.
have="$(curl -sS "${AUTH[@]}" "$API/$RELEASE_ID" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
print(0); raise SystemExit
print(len(d.get('assets', [])))" 2>/dev/null || echo 0)"
if [[ "${have:-0}" -lt "${#all_artifacts[@]}" ]]; then
echo "error: release has $have assets, expected ${#all_artifacts[@]}" >&2
exit 1
fi
echo "Verified $have artifacts on the release"
echo "" echo ""
echo "Release live: $HOST/$REPO/releases/tag/$TAG" echo "Release live: $HOST/$REPO/releases/tag/$TAG"
+621
View File
@@ -0,0 +1,621 @@
#!/usr/bin/env python3
"""Generate screen-design reference images for Step Line and compare captures.
This tool turns the screen descriptions that already live in `docs/`
(README screenshot captions, ADRs 0007-0010, PLAN.md, architecture.md, and the
HUD strings in `content/strings.en.json`) into reference "design" images, then
uses those images as the expected state to check the game's captured
screenshots against.
Two commands:
generate write one reference image per screen to docs/screens/designs/.
Two modes:
--ai realistic 3D game scenes and in-game screen designs via
the OpenRouter image API (default; requires
OPENROUTER_API_KEY, same as tools/asset_pipeline.py).
--draft deterministic wireframe sketches drawn with Pillow
(no network or API key; good for CI and layout checks).
compare compare docs/screenshots/*.png against the reference designs and
flag gross drift. Semantic composition is judged with coarse,
Pillow-only signals (aspect ratio, frame size, blank-frame
contrast, grayscale MAE, colour-histogram distance, edge density).
These catch "wrong screen / blank frame / wrong time of day"
regressions; they are NOT a pixel-exact or semantic match. A
vision-model pass is the documented next step (see PLAN.md).
Run from the repo root:
python3 tools/screen_designs.py generate
python3 tools/screen_designs.py generate --draft
python3 tools/screen_designs.py compare
"""
import argparse
import os
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
import generate_textures
ROOT = Path(__file__).resolve().parent.parent
DESIGNS_DIR = ROOT / "docs" / "screens" / "designs"
SCREENSHOTS_DIR = ROOT / "docs" / "screenshots"
# Reference "design" dimensions — match the capture tool and the README gallery.
WIDTH, HEIGHT = 1280, 720
ASPECT = 16 / 9
# Default image model for screen mockups (validated role-aware model, per
# MEMORY.md; seedream rejects small sizes and ignores transparency, so we use
# the model asset_pipeline.py already trusts for non-alpha roles).
DEFAULT_MODEL = "openai/gpt-5-image-mini"
# Shared art direction, appended to every prompt so the generated frames read
# as one coherent game. "scene" entries are first-person 3D world views;
# "ui" entries are in-game panels overlaid on a softened scene.
SCENE_STYLE = (
"rendered in a modern 3D game engine, first-person perspective, cinematic "
"lighting, physically based materials, soft shadows, ambient occlusion, "
"subtle depth of field, photorealistic detail, no watermark or UI clutter"
)
UI_STYLE = (
"clean modern in-game user interface, dark translucent panels, readable "
"sans-serif labels, consistent game UI colour theme, crisp vector text"
)
# Landscape source ratio requested from the API before cropping to 16:9.
# 3:2 is the validated non-square ratio for the default model (see MEMORY.md).
SOURCE_ASPECT = "3:2"
# ---------------------------------------------------------------------------
# Screen catalogue. Each entry is grounded in a doc; `prompt` feeds --ai and
# `regions` feed --draft (normalised rects + a short label). `ref` names the
# captured screenshot, if one exists, so `compare` can pair them up.
# ---------------------------------------------------------------------------
SCREENS = [
{
"name": "venue_overview",
"kind": "scene",
"title": "Venue overview",
"source": "README screenshot 01",
"ref": "01-venue-overview.png",
"description": "First-person view from the guard post at the top of the "
"steps: Hollywood Boulevard entrance, wide concrete steps, "
"sidewalk, street lamps, planters, crosswalk, and building "
"signage/marquee under early-morning light.",
"prompt": (
"Realistic 3D first-person game scene: a security guard's view from "
"the top of wide concrete steps outside a grand Hollywood Boulevard "
"building entrance, early-morning golden light, sidewalk with street "
"lamps, planters, a crosswalk and distant pedestrians, detailed brick "
"facade with a striped awning and glowing marquee sign"),
"regions": [
(0.00, 0.55, 1.00, 1.00, "steps + sidewalk"),
(0.20, 0.08, 0.80, 0.60, "building facade / marquee"),
(0.05, 0.60, 0.20, 0.75, "street lamp"),
(0.78, 0.60, 0.95, 0.75, "street lamp"),
],
},
{
"name": "targeting_npc",
"kind": "scene",
"title": "Targeting an NPC",
"source": "README screenshot 02 · ADR-0008 cone focus + focus circle",
"ref": "02-targeting-npc.png",
"description": "An approaching NPC is in the focus cone: a blue "
"semi-transparent circle in front of their face, a display "
"name under the circle, and the HUD shows a target line "
"with the interaction hint.",
"prompt": (
"Realistic 3D first-person game scene: a friendly pedestrian "
"standing on a city sidewalk, a blue semi-transparent circle in front "
"of their face as a game selection marker, a small name label under "
"the circle, minimal HUD target text at the bottom, daytime"),
"regions": [
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
(0.44, 0.10, 0.56, 0.16, "face circle"),
(0.05, 0.85, 0.95, 0.95, "HUD: name + hint"),
],
},
{
"name": "access_inspection",
"kind": "ui",
"title": "Access inspection",
"source": "README screenshot 03 · ADR-0003 access verification",
"ref": "03-access-inspection.png",
"description": "An inspection panel shows the access artifact (wristband / "
"ID / ticket / credential / guest list) with its colour, "
"pattern, date, and event marking, plus allow and deny "
"prompts.",
"prompt": (
"Realistic 3D game screenshot with an in-game access inspection panel "
"overlaid: a centered card showing a wristband or ID credential with a "
"photo placeholder, colour band, date and event marking, and two "
"buttons labelled Allow and Deny, dark translucent panel over a "
"softly blurred city street background"),
"regions": [
(0.25, 0.10, 0.75, 0.90, "inspection panel"),
(0.30, 0.18, 0.70, 0.60, "artifact card"),
(0.30, 0.65, 0.48, 0.72, "allow"),
(0.52, 0.65, 0.70, 0.72, "deny"),
],
},
{
"name": "radio_report",
"kind": "ui",
"title": "Radio report",
"source": "README screenshot 04 · PLAN level 4 radio assistance",
"ref": "04-radio-report.png",
"description": "A radio dispatch panel with fields for location, "
"description, behaviour, whether weapons were seen, and "
"whether anyone is in immediate danger, plus a send action.",
"prompt": (
"Realistic 3D game screenshot with an in-game radio dispatch panel "
"overlaid: form fields for location, description, behaviour, a threat "
"assessment and whether anyone is in danger, and a Send button, dark "
"translucent panel over a softly blurred city street background"),
"regions": [
(0.30, 0.08, 0.70, 0.92, "radio panel"),
(0.34, 0.16, 0.66, 0.30, "location / description"),
(0.34, 0.34, 0.66, 0.56, "behaviour / weapons / danger"),
(0.42, 0.60, 0.58, 0.66, "send"),
],
},
{
"name": "late_night_avenue",
"kind": "scene",
"title": "Late night avenue",
"source": "README screenshot 05",
"ref": "05-late-night-avenue.png",
"description": "Night-time view down Hollywood Boulevard: street lamps "
"lit, marquee glowing, dark blue street, no flashlight.",
"prompt": (
"Realistic 3D first-person game scene at night: a dark Hollywood "
"Boulevard avenue receding into the distance, lit street lamps, a "
"glowing neon marquee sign, deep blue night sky, wet reflective "
"pavement, no flashlight"),
"regions": [
(0.00, 0.55, 1.00, 1.00, "dark avenue"),
(0.15, 0.25, 0.85, 0.55, "glowing marquee"),
(0.10, 0.45, 0.30, 0.70, "lit street lamp"),
(0.70, 0.45, 0.90, 0.70, "lit street lamp"),
],
},
{
"name": "night_flashlight",
"kind": "scene",
"title": "Night flashlight",
"source": "README screenshot 06",
"ref": "06-night-flashlight.png",
"description": "Night shift: the guard's flashlight beam cuts through the "
"dark toward the entrance, illuminating the steps and door.",
"prompt": (
"Realistic 3D first-person game scene at night: a flashlight beam "
"cutting through darkness toward a building entrance and steps, a "
"bright spotlight pool illuminating the door, dark surroundings and "
"deep shadows"),
"regions": [
(0.35, 0.30, 0.65, 0.80, "flashlight beam pool"),
(0.40, 0.20, 0.60, 0.35, "lit door"),
(0.00, 0.00, 1.00, 0.25, "dark sky"),
],
},
{
"name": "radial_menu",
"kind": "ui",
"title": "Radial action menu",
"source": "ADR-0009 · content/strings.en.json",
"ref": None,
"description": "A round doughnut menu centred on screen, 12 slices in "
"de-escalation order: Stop, Look, Point, No, Ask, Inspect, "
"Wand, Warn, Block, Reason, Recognize, Radio. The focused "
"person's display name sits in the centre.",
"prompt": (
"Realistic 3D game screenshot with an in-game radial action menu "
"overlaid: a round doughnut wheel centered on screen with twelve "
"labelled slices reading Stop, Look, Point, No, Ask, Inspect, Wand, "
"Warn, Block, Reason, Recognize, Radio, one slice highlighted, a name "
"in the center of the ring, over a softly blurred city street scene"),
"regions": [
(0.38, 0.12, 0.62, 0.88, "radial wheel (12 slices)"),
(0.46, 0.44, 0.54, 0.56, "name"),
(0.62, 0.12, 0.70, 0.20, "highlight"),
],
},
{
"name": "shift_review",
"kind": "ui",
"title": "Shift review",
"source": "PLAN reputation / shift review · architecture.md ShiftReview",
"ref": None,
"description": "End-of-shift performance review: access decisions, "
"escalation choices, incident list, battery and crowd "
"outcomes, and reputation/score feedback.",
"prompt": (
"Realistic 3D game end-of-shift review screen: a full-screen "
"performance summary panel listing incident decisions, escalation "
"choices, a reputation score, crowd reaction and battery use, with a "
"Continue button, dark themed game UI"),
"regions": [
(0.30, 0.06, 0.70, 0.94, "review panel"),
(0.36, 0.14, 0.64, 0.24, "score / reputation"),
(0.36, 0.28, 0.64, 0.70, "incident list"),
(0.42, 0.76, 0.58, 0.84, "continue"),
],
},
{
"name": "start_menu",
"kind": "ui",
"title": "Start menu",
"source": "MEMORY session shell (StartMenu, layer 20)",
"ref": None,
"description": "The boot screen: a venue-selection menu (Hollywood "
"Boulevard, The Joke Shop, TV Studio) before a shift.",
"prompt": (
"Realistic 3D game start menu screen: a game title and three venue "
"selection buttons labelled Hollywood Boulevard, The Joke Shop and TV "
"Studio, dark themed menu over a softly blurred city street backdrop"),
"regions": [
(0.25, 0.10, 0.75, 0.26, "title"),
(0.36, 0.36, 0.64, 0.44, "Hollywood Boulevard"),
(0.36, 0.48, 0.64, 0.56, "The Joke Shop"),
(0.36, 0.60, 0.64, 0.68, "TV Studio"),
],
},
{
"name": "pause_overlay",
"kind": "ui",
"title": "Pause overlay",
"source": "MEMORY session shell (PauseOverlay, layer 18)",
"ref": None,
"description": "Pause menu: resume, restart, settings, and quit-to-menu.",
"prompt": (
"Realistic 3D game pause menu overlay: buttons labelled Resume, "
"Restart, Settings and Quit to Menu, dark translucent overlay over a "
"paused, softly blurred city street scene"),
"regions": [
(0.38, 0.26, 0.62, 0.74, "pause menu"),
(0.42, 0.32, 0.58, 0.38, "Resume"),
(0.42, 0.42, 0.58, 0.48, "Restart"),
(0.42, 0.52, 0.58, 0.58, "Settings"),
(0.42, 0.62, 0.58, 0.68, "Quit to Menu"),
],
},
{
"name": "settings_panel",
"kind": "ui",
"title": "Settings",
"source": "MEMORY session shell (SettingsPanel, layer 22)",
"ref": None,
"description": "Settings: look sensitivity and HUD font scale controls.",
"prompt": (
"Realistic 3D game settings screen: sliders labelled look sensitivity "
"and HUD font scale, dark themed settings panel"),
"regions": [
(0.30, 0.16, 0.70, 0.84, "settings panel"),
(0.36, 0.30, 0.64, 0.40, "look sensitivity"),
(0.36, 0.46, 0.64, 0.56, "HUD font scale"),
],
},
{
"name": "emergency_radio",
"kind": "ui",
"title": "Emergency radio",
"source": "PLAN level 5 · strings radio.emergency_dispatched",
"ref": None,
"description": "Hold-to-confirm emergency radio panel with a confirmation "
"prompt and a cooldown indicator.",
"prompt": (
"Realistic 3D game emergency radio hold-to-confirm panel: a large "
"warning prompt with a hold progress bar and a cooldown label, red "
"accent, dark translucent overlay over a softly blurred city street "
"scene"),
"regions": [
(0.30, 0.28, 0.70, 0.72, "emergency panel"),
(0.36, 0.36, 0.64, 0.46, "confirm prompt"),
(0.38, 0.52, 0.62, 0.60, "hold progress"),
(0.36, 0.64, 0.64, 0.68, "cooldown"),
],
},
]
def resolve_path(value):
path = Path(value).expanduser()
return path if path.is_absolute() else ROOT / path
def _design_path(name):
return DESIGNS_DIR / f"{name}.png"
# ---------------------------------------------------------------------------
# Draft (wireframe) rendering — deterministic, offline.
# ---------------------------------------------------------------------------
def _draw_wireframe(screen, path):
image = Image.new("RGB", (WIDTH, HEIGHT), (24, 26, 38))
draw = ImageDraw.Draw(image)
title_font = generate_textures.font(52)
body_font = generate_textures.font(24)
label_font = generate_textures.font(26)
draw.text((40, 28), screen["title"], font=title_font, fill=(240, 240, 245))
draw.text((40, 98), screen["source"], font=body_font, fill=(150, 155, 175))
# wrap the description into a small caption block
words = screen["description"].split()
lines, line = [], ""
for word in words:
if draw.textlength(line + " " + word, font=body_font) < WIDTH - 80:
line = (line + " " + word).strip()
else:
lines.append(line)
line = word
lines.append(line)
for index, text in enumerate(lines[:4]):
draw.text((40, 132 + index * 30), text,
font=body_font, fill=(120, 125, 145))
for region in screen["regions"]:
x0, y0, x1, y1, label = region
box = (int(x0 * WIDTH), int(y0 * HEIGHT),
int(x1 * WIDTH), int(y1 * HEIGHT))
draw.rounded_rectangle(box, radius=10,
outline=(120, 130, 170), width=2)
cx = (box[0] + box[2]) / 2
cy = (box[1] + box[3]) / 2
draw.text((cx - draw.textlength(label, font=label_font) / 2,
cy - 14), label, font=label_font, fill=(170, 180, 210))
image.save(path)
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
print(f"wrote {shown} {image.size}")
# ---------------------------------------------------------------------------
# AI generation (reuses the OpenRouter backend from generate_textures).
# ---------------------------------------------------------------------------
def _compose_prompt(screen):
"""Append the shared art direction to a screen's content prompt."""
style = UI_STYLE if screen.get("kind") == "ui" else SCENE_STYLE
return f"{screen['prompt']}. {style}. 16:9 widescreen composition."
def _generate_ai(screen, model, request_size):
prompt = _compose_prompt(screen)
# Request a landscape source and crop to the 16:9 capture dimensions rather
# than a square that loses framing when centre-cropped.
image = generate_textures.generate_image(
prompt, model, request_size, transparent=False,
aspect_ratio=SOURCE_ASPECT)
image = ImageOps.fit(image, (WIDTH, HEIGHT),
method=Image.Resampling.LANCZOS)
if image.mode in ("RGBA", "LA", "P"):
image = image.convert("RGB")
path = _design_path(screen["name"])
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path)
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
print(f"wrote {shown} {image.size}")
def generate(args):
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
failed = []
for screen in SCREENS:
print(f"generating {screen['name']} ...")
try:
if args.draft:
_draw_wireframe(screen, _design_path(screen["name"]))
else:
_generate_ai(screen, args.model, args.request_size)
except SystemExit as exc:
failed.append(screen["name"])
print(f" FAILED {screen['name']}: {exc}", file=sys.stderr)
print("done")
if failed:
print(f"failed: {', '.join(failed)}", file=sys.stderr)
return 1
return 0
# ---------------------------------------------------------------------------
# Comparison metrics (Pillow only; no numpy/scikit-image dependency).
# ---------------------------------------------------------------------------
def _load_rgb(path):
with Image.open(path) as image:
image.load()
return image.convert("RGB")
def _grayscale(image):
return ImageOps.grayscale(image)
def _mae(left, right):
"""Mean absolute error between two same-size grayscale images, 0..255."""
left_gray = _grayscale(left).resize((256, 144))
right_gray = _grayscale(right).resize((256, 144))
diff = ImageChops.difference(left_gray, right_gray)
return ImageStat.Stat(diff).mean[0]
def _histogram_distance(left, right):
"""Normalised L1 distance between 3x256-bin colour histograms, 0..1."""
left_hist = left.resize((256, 144)).histogram()
right_hist = right.resize((256, 144)).histogram()
total = sum(left_hist)
if total == 0:
return 1.0
distance = sum(abs(a - b) for a, b in zip(left_hist, right_hist))
return distance / (2 * total)
def _edge_density(image):
"""Mean brightness of an edge-detected frame; higher = more detail."""
edges = _grayscale(image).filter(ImageFilter.FIND_EDGES)
return ImageStat.Stat(edges).mean[0]
def _contrast(image):
"""Standard deviation of grayscale brightness; low = near-blank frame."""
return ImageStat.Stat(_grayscale(image)).stddev[0]
def _aspect_ok(image, tolerance=0.12):
width, height = image.size
actual = width / height
return abs(actual - ASPECT) / ASPECT <= tolerance
def compare_screenshot(reference, capture):
"""Compare one capture against its reference design; return a dict report."""
ref = _load_rgb(reference)
cap = _load_rgb(capture)
width, height = cap.size
report = {
"capture": str(capture),
"reference": str(reference),
"size": [width, height],
}
errors, warnings = [], []
if width < WIDTH or height < HEIGHT:
errors.append(f"too small: {width}x{height}; minimum {WIDTH}x{HEIGHT}")
if not _aspect_ok(cap):
errors.append(f"wrong aspect ratio: {width / height:.3f}; "
f"expected about {ASPECT:.3f}")
contrast = _contrast(cap)
report["contrast"] = round(contrast, 1)
if contrast < 5.0:
errors.append(f"near-blank frame: contrast {contrast:.1f} below 5.0")
mae = _mae(ref, cap)
hist = _histogram_distance(ref, cap)
edge_ref = _edge_density(ref)
edge_cap = _edge_density(cap)
report["mae"] = round(mae, 1)
report["hist_distance"] = round(hist, 3)
report["edge_density_ref"] = round(edge_ref, 1)
report["edge_density_cap"] = round(edge_cap, 1)
# Coarse drift warnings (not hard failures: an AI mockup will never match a
# real render pixel-for-pixel, but a wildly different frame will still
# stand out on these signals).
if mae > 90:
warnings.append(
f"grayscale MAE {mae:.1f} is high (very different layout)")
if hist > 0.75:
warnings.append(f"colour histogram distance {hist:.3f} is high "
"(very different palette / time of day)")
edge_delta = abs(edge_cap - edge_ref)
report["edge_delta"] = round(edge_delta, 1)
if edge_delta > 18:
warnings.append(f"edge density differs by {edge_delta:.1f} "
"(very different amount of detail)")
report["quality"] = "bad" if errors else "good"
report["errors"] = errors
report["warnings"] = warnings
return report
def compare(args):
if not SCREENSHOTS_DIR.is_dir():
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}; "
"run src/test/capture_screenshots.gd first")
captures = sorted(SCREENSHOTS_DIR.glob("*.png"))
if not captures:
sys.exit(f"error: no .png screenshots found in {SCREENSHOTS_DIR}")
refs = {screen["name"]: screen["ref"]
for screen in SCREENS if screen["ref"]}
results = []
for capture in captures:
name = capture.name
# find the screen whose ref matches this capture filename
design_name = next((n for n, ref in refs.items() if ref == name), None)
if design_name is None:
results.append({"capture": str(capture), "reference": None,
"errors": [f"no design reference for {name}"],
"warnings": [], "quality": "bad"})
continue
reference = _design_path(design_name)
if not reference.is_file():
results.append({"capture": str(capture),
"reference": str(reference),
"errors": [f"missing reference design "
f"({reference.name}); run generate first"],
"warnings": [], "quality": "bad"})
continue
results.append(compare_screenshot(reference, capture))
if args.json:
import json
print(json.dumps(results, indent=2))
else:
good_count = sum(r["quality"] == "good" for r in results)
for result in results:
ref = (Path(result["reference"]).name
if result.get("reference") else "-")
size = ("x".join(map(str, result["size"]))
if result.get("size") else "unreadable")
print(f"{result['quality'].upper():4} {result['capture']}")
print(f" ref={ref} size={size} "
f"mae={result.get('mae', '-')} "
f"hist={result.get('hist_distance', '-')}")
for message in result["errors"] + result["warnings"]:
print(f" - {message}")
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
return 1 if any(r["quality"] == "bad" for r in results) else 0
# ---------------------------------------------------------------------------
def build_parser():
parser = argparse.ArgumentParser(
description="Generate and compare Step Line screen-design references.")
commands = parser.add_subparsers(dest="command", required=True)
gen = commands.add_parser(
"generate", help="write reference design images to docs/screens/designs/")
mode = gen.add_mutually_exclusive_group()
mode.add_argument("--draft", action="store_true",
help="draw deterministic wireframes (no API/network)")
mode.add_argument("--ai", action="store_true",
help="generate mockups via OpenRouter (default)")
gen.add_argument("--model", default=DEFAULT_MODEL)
gen.add_argument("--request-size", type=int,
default=generate_textures.GEN_SIZE)
gen.set_defaults(handler=generate, draft=False)
cmp = commands.add_parser(
"compare", help="compare docs/screenshots/*.png against the designs")
cmp.add_argument("--json", action="store_true")
cmp.set_defaults(handler=compare)
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
try:
result = args.handler(args)
return result if isinstance(result, int) else 0
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+90
View File
@@ -0,0 +1,90 @@
import tempfile
import unittest
from pathlib import Path
from PIL import Image
import screen_designs
class ScreenDesignsTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def test_catalogue_is_well_formed(self):
names = [screen["name"] for screen in screen_designs.SCREENS]
self.assertEqual(len(names), len(set(names)),
"screen names must be unique")
refs = [screen["ref"] for screen in screen_designs.SCREENS
if screen["ref"]]
self.assertEqual(len(refs), len(set(refs)),
"screenshot refs must be unique")
for screen in screen_designs.SCREENS:
self.assertTrue(screen["prompt"].strip())
self.assertIn(screen["kind"], ("scene", "ui"))
self.assertTrue(screen["regions"])
for region in screen["regions"]:
x0, y0, x1, y1, label = region
self.assertLess(x0, x1)
self.assertLess(y0, y1)
self.assertTrue(0 <= x0 <= 1 and 0 <= x1 <= 1)
self.assertTrue(0 <= y0 <= 1 and 0 <= y1 <= 1)
self.assertTrue(label.strip())
def test_draft_wireframe_matches_reference_dimensions(self):
output = self.root / "draft.png"
screen = screen_designs.SCREENS[0]
screen_designs._draw_wireframe(screen, output)
with Image.open(output) as image:
self.assertEqual(image.size,
(screen_designs.WIDTH, screen_designs.HEIGHT))
def test_compose_prompt_appends_kind_specific_style(self):
scene = {"kind": "scene", "prompt": "a street"}
ui = {"kind": "ui", "prompt": "a panel"}
self.assertIn(screen_designs.SCENE_STYLE,
screen_designs._compose_prompt(scene))
self.assertIn(screen_designs.UI_STYLE,
screen_designs._compose_prompt(ui))
self.assertIn("16:9", screen_designs._compose_prompt(scene))
def test_mae_is_zero_for_identical_images(self):
path = self.root / "identical.png"
Image.new("RGB", (1280, 720), (40, 80, 120)).save(path)
left = screen_designs._load_rgb(path)
right = screen_designs._load_rgb(path)
self.assertAlmostEqual(screen_designs._mae(left, right), 0.0, places=1)
def test_histogram_distance_is_zero_for_identical_images(self):
path = self.root / "identical.png"
Image.new("RGB", (1280, 720), (40, 80, 120)).save(path)
left = screen_designs._load_rgb(path)
right = screen_designs._load_rgb(path)
self.assertAlmostEqual(
screen_designs._histogram_distance(left, right), 0.0, places=3)
def test_blank_frame_is_flagged_as_bad(self):
reference = self.root / "ref.png"
capture = self.root / "cap.png"
Image.new("RGB", (1280, 720), (200, 200, 200)).save(reference)
Image.new("RGB", (1280, 720), (0, 0, 0)).save(capture)
report = screen_designs.compare_screenshot(reference, capture)
self.assertEqual(report["quality"], "bad")
self.assertTrue(any("near-blank" in e for e in report["errors"]))
def test_wrong_aspect_is_flagged_as_bad(self):
reference = self.root / "ref.png"
capture = self.root / "cap.png"
Image.new("RGB", (1280, 720), (100, 120, 140)).save(reference)
Image.new("RGB", (640, 640), (100, 120, 140)).save(capture)
report = screen_designs.compare_screenshot(reference, capture)
self.assertEqual(report["quality"], "bad")
self.assertTrue(any("aspect" in e for e in report["errors"]))
if __name__ == "__main__":
unittest.main()