Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
921bab796e | ||
|
|
71b0b53705 | ||
|
|
f5ad1642b5 |
No files matched your search
@@ -445,3 +445,44 @@ Completed 2026-10-31 (working tree).
|
||||
- [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 %.
|
||||
@@ -21,8 +21,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
|
||||
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
||||
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||
- Next work: remaining PLAN verbs in `TODO.md` (eye contact, recognize decision, warning body-cam
|
||||
flag, blocking station, request reason), then crowd presentation (rubberneck + evacuation).
|
||||
- Next work: `TODO.md` body-camera replay timeline, then NPC theater poses (sit/photo/perform)
|
||||
and audio.
|
||||
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||
|
||||
## Current State
|
||||
@@ -60,6 +60,56 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
`StaticBody3D` with per-tread `BoxShape3D` colliders; marquee `font_size` 160 / outline 24; focus
|
||||
torus 0.26–0.34 + 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.
|
||||
- **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. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
||||
@@ -151,6 +201,15 @@ 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
|
||||
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 OpenRouter mockups (default model
|
||||
`openai/gpt-5-image-mini`). `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
|
||||
|
||||
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
|
||||
@@ -181,6 +240,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
|
||||
## 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.
|
||||
- Localization target languages beyond English.
|
||||
- macOS notarization approach for broad distribution.
|
||||
|
||||
@@ -28,9 +28,9 @@ The central design goal is simple:
|
||||
|
||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||
|
||||
> **Latest: v0.2.2** — session shell (venue menu, pause overlay, persisted settings) and the
|
||||
> world presentation follow-ups (walkable steps, transparent skyline, larger marquee and focus
|
||||
> ring). Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||
> **Latest: v0.2.5** — emergency aftermath (evacuation clears the entrance, unjustified calls open
|
||||
> an investigation, the review names the responder outcome).
|
||||
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||
> (NSIS + appimagetool) and uploads with the portable binaries.
|
||||
@@ -180,6 +180,33 @@ 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.
|
||||
|
||||
### 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.
|
||||
|
||||
```bash
|
||||
# Deterministic wireframes (offline, no API key)
|
||||
python3 tools/screen_designs.py generate --draft
|
||||
|
||||
# Photorealistic mockups 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
|
||||
|
||||
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
||||
@@ -208,6 +235,15 @@ 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
|
||||
```
|
||||
|
||||
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
||||
@@ -217,7 +253,7 @@ The unit test runner is the project's own lightweight harness (no editor plugin
|
||||
| Input | Action |
|
||||
| --- | --- |
|
||||
| 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) |
|
||||
| Space / left click | Confirm the highlighted action (while the wheel is open) |
|
||||
| Esc / right click | Close the top panel; Esc with nothing open pauses the shift (Esc again resumes) |
|
||||
@@ -235,6 +271,7 @@ The unit test runner is the project's own lightweight harness (no editor plugin
|
||||
| V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
|
||||
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
||||
| 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) |
|
||||
| F | Toggle debug overlay |
|
||||
|
||||
@@ -259,17 +296,23 @@ Implemented so far:
|
||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||
- Headless unit tests: **88 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)
|
||||
- Headless unit tests: **113 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)
|
||||
- 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
|
||||
- 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`)
|
||||
- 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 ring / 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
|
||||
- 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)
|
||||
|
||||
Not yet implemented: body-camera replay timeline, 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: body-camera replay timeline, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||
|
||||
### Smoke Test Checklist
|
||||
|
||||
@@ -290,9 +333,19 @@ Not yet implemented: body-camera replay timeline, eye-contact and request-reason
|
||||
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. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart.
|
||||
19. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
||||
20. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history; "Run another shift" returns to the venue menu.
|
||||
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. 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
|
||||
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
# TODO
|
||||
|
||||
Godot 4.3 prototype. Release **v0.2.2** is tagged from the session-shell commit; CI builds the
|
||||
installers and AppImage. Headless suite **88/88**, plus `smoke_session_shell.gd` and the windowed
|
||||
focus/radial smoke.
|
||||
Godot 4.3 prototype. Release **v0.2.5** is tagged. Headless suite **113/113**, plus
|
||||
`smoke_session_shell.gd`, `smoke_plan_verbs.gd`, `smoke_crowd.gd`, `smoke_emergency.gd`, and the
|
||||
windowed focus/radial smoke.
|
||||
|
||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
||||
|
||||
## 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).
|
||||
@@ -88,6 +56,18 @@ Verified via API+host credentials (2026-10-31):
|
||||
|
||||
- [ ] 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)
|
||||
|
||||
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -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.eye_contact": "Look",
|
||||
"radial.point": "Point",
|
||||
"radial.head_shake": "No",
|
||||
"radial.polite": "Ask",
|
||||
@@ -8,6 +9,8 @@
|
||||
"radial.wand": "Wand",
|
||||
"radial.warning": "Warn",
|
||||
"radial.block": "Block",
|
||||
"radial.reason": "Reason",
|
||||
"radial.recognize": "Recognize",
|
||||
"radial.radio": "Radio",
|
||||
"hud.escalation_prefix": "Escalation: %s",
|
||||
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
||||
@@ -32,6 +35,9 @@
|
||||
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
||||
"radio.easter_egg": "¿Dónde está la mota?",
|
||||
"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.leave": "%s turns and leaves.",
|
||||
"react.argue": "%s argues with you.",
|
||||
@@ -42,6 +48,15 @@
|
||||
"react.return_later": "%s leaves but may come back.",
|
||||
"react.call_someone": "%s calls someone for help.",
|
||||
"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.",
|
||||
"station.blocking": "You step between %s and the door.",
|
||||
"perception.near": "%s notices you standing close.",
|
||||
"perception.aware": "%s notices you watching.",
|
||||
"door.locked": "The door is now locked.",
|
||||
@@ -50,5 +65,8 @@
|
||||
"flashlight.on": "Flashlight on.",
|
||||
"flashlight.off": "Flashlight off.",
|
||||
"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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 953 KiB After Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -8,7 +8,7 @@ config_version=5
|
||||
|
||||
config/name="Step Line"
|
||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||
config/version="0.2.2"
|
||||
config/version="0.2.5"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||
|
||||
|
||||
@@ -45,6 +45,20 @@ func apply_event(event_type: String) -> void:
|
||||
"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:
|
||||
return {
|
||||
"mood": mood,
|
||||
|
||||
@@ -78,6 +78,19 @@ func close_incident(npc_id: String, outcome: String) -> Dictionary:
|
||||
func open_incidents_count() -> int:
|
||||
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:
|
||||
return {
|
||||
"incidents": incidents.duplicate(true),
|
||||
|
||||
@@ -34,6 +34,11 @@ var guard_noticed := false
|
||||
|
||||
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
|
||||
var screened := 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
|
||||
## venue/type defaults from AccessLoadout.
|
||||
var access_override: Dictionary = {}
|
||||
@@ -150,6 +155,16 @@ func tick_return(delta: float) -> bool:
|
||||
func decide_leave() -> bool:
|
||||
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
|
||||
## result describing how the NPC reacts to the guard's presence. Pure and
|
||||
## headless-testable; the presentation layer feeds it the real distance.
|
||||
|
||||
@@ -32,6 +32,10 @@ var crowd := CrowdSystem.new()
|
||||
var scheduler := EventScheduler.new()
|
||||
|
||||
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 _shift_finished := false
|
||||
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
|
||||
@@ -45,6 +49,8 @@ func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int =
|
||||
spawn_seed = p_seed
|
||||
scheduler.setup(scenario, p_seed)
|
||||
event_bus = EventBus.new()
|
||||
responder_outcomes.clear()
|
||||
investigations.clear()
|
||||
radio = RadioSystem.new()
|
||||
reputation = ReputationEvaluator.new()
|
||||
crowd = CrowdSystem.new()
|
||||
@@ -119,6 +125,8 @@ func _finish_shift() -> void:
|
||||
return
|
||||
_shift_finished = true
|
||||
running = false
|
||||
# Interactions the player never resolved still belong in this shift's review.
|
||||
recorder.close_all_open("unresolved")
|
||||
var review := build_review()
|
||||
event_bus.shift_state_changed.emit({"running": false, "review": review})
|
||||
shift_ended.emit(review)
|
||||
@@ -138,6 +146,8 @@ func build_review() -> Dictionary:
|
||||
"battery": radio.battery,
|
||||
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
|
||||
},
|
||||
"responder_outcomes": responder_outcomes.duplicate(),
|
||||
"investigations": investigations.duplicate(),
|
||||
"completed_at_ms": Time.get_ticks_msec(),
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.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 RadioSystem := preload("res://src/core/radio_system.gd")
|
||||
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||
@@ -30,6 +31,7 @@ const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
|
||||
var world_builder: WorldBuilder = null
|
||||
var door := DoorState.new()
|
||||
var flashlight_on := false
|
||||
var body_camera_on := false
|
||||
var coordinator: ShiftCoordinator = null
|
||||
var guard: GuardController = null
|
||||
var _sun: DirectionalLight3D = null
|
||||
@@ -132,7 +134,8 @@ func _ready() -> void:
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
coordinator.shift_ended.connect(_on_shift_ended)
|
||||
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
||||
_connect_radio()
|
||||
_connect_crowd()
|
||||
|
||||
_build_ui()
|
||||
_last_phase = -1
|
||||
@@ -159,10 +162,13 @@ func _build_guard() -> GuardController:
|
||||
flashlight.name = "Flashlight"
|
||||
flashlight.visible = false
|
||||
flashlight.position = Vector3(0, -0.15, -0.4)
|
||||
flashlight.rotation_degrees = Vector3(-10, 0, 0)
|
||||
flashlight.spot_range = 18.0
|
||||
flashlight.spot_angle = 28.0
|
||||
flashlight.light_energy = 2.5
|
||||
# Aim straight ahead: tilting it down made the cone hit the ground right in
|
||||
# front of the guard instead of the person or door being checked.
|
||||
flashlight.rotation_degrees = Vector3.ZERO
|
||||
flashlight.spot_range = 22.0
|
||||
flashlight.spot_angle = 30.0
|
||||
flashlight.light_energy = 4.0
|
||||
flashlight.spot_attenuation = 0.6
|
||||
head.add_child(flashlight)
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
@@ -211,8 +217,13 @@ func _reset_shift_state() -> void:
|
||||
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
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
_connect_radio()
|
||||
_connect_crowd()
|
||||
_last_phase = -1
|
||||
_apply_phase_lighting()
|
||||
|
||||
@@ -470,12 +481,96 @@ func _on_responder_arrived(call: Dictionary) -> void:
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
hud.log(Strings.fmt("responder.arrived", [outcome]))
|
||||
coordinator.responder_outcomes.append(outcome)
|
||||
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
|
||||
"type": "responder_action",
|
||||
"outcome": outcome,
|
||||
"call_type": call.get("type", "assist"),
|
||||
})
|
||||
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))
|
||||
# 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:
|
||||
var p := NpcProfile.new()
|
||||
@@ -520,6 +615,11 @@ func _begin_incident(avatar: Node3D) -> void:
|
||||
current_incident_npc_id = controller.npc_id
|
||||
current_incident_active = true
|
||||
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 label := "%s — %s" % [
|
||||
profile.display_name if profile else "?",
|
||||
@@ -602,6 +702,8 @@ func _handle_key(event: InputEvent) -> void:
|
||||
_toggle_flashlight()
|
||||
KEY_L:
|
||||
_toggle_door_lock()
|
||||
KEY_B:
|
||||
_toggle_body_camera()
|
||||
KEY_V:
|
||||
_cycle_venue()
|
||||
_:
|
||||
@@ -720,6 +822,8 @@ func _on_radial_confirmed(slice_id: String) -> void:
|
||||
match slice_id:
|
||||
"raise_hand":
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
|
||||
"eye_contact":
|
||||
_eye_contact()
|
||||
"point":
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
|
||||
"head_shake":
|
||||
@@ -734,6 +838,10 @@ func _on_radial_confirmed(slice_id: String) -> void:
|
||||
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
|
||||
"block":
|
||||
_apply_interaction(Rules.Level.BLOCKING, "blocking")
|
||||
"reason":
|
||||
_request_reason()
|
||||
"recognize":
|
||||
_recognize_target()
|
||||
"radio":
|
||||
_open_radio(false)
|
||||
|
||||
@@ -768,6 +876,29 @@ func _toggle_flashlight() -> void:
|
||||
hud.set_flashlight(flashlight_on)
|
||||
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
|
||||
## FORCE_ENTRY attempts during the reaction flow.
|
||||
func _toggle_door_lock() -> void:
|
||||
@@ -819,6 +950,7 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null or coordinator == null:
|
||||
return
|
||||
_ensure_incident(controller)
|
||||
var outcome: Dictionary = controller.apply_player_action(level)
|
||||
var npc_id: String = controller.npc_id
|
||||
coordinator.recorder.add_event(npc_id, {
|
||||
@@ -829,11 +961,88 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
||||
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
||||
if level >= Rules.Level.FORMAL_WARNING:
|
||||
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)
|
||||
if audio:
|
||||
audio.play_escalation(level)
|
||||
_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:
|
||||
var reaction: int = outcome.get("reaction", -1)
|
||||
var name: String = (controller.profile.display_name if controller.profile else "Person")
|
||||
@@ -904,12 +1113,33 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
if emergency:
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
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:
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
if audio:
|
||||
audio.play_radio_response()
|
||||
_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?"
|
||||
## over the radio. Fires on a small random chance after a successful call.
|
||||
func _maybe_radio_easter_egg() -> void:
|
||||
@@ -1079,7 +1309,19 @@ func _artifacts_for(controller: Object) -> Array:
|
||||
var profile: NpcProfile = controller.profile as NpcProfile
|
||||
var override: Dictionary = controller.access_override
|
||||
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:
|
||||
var profile: Object = controller.profile
|
||||
@@ -1096,6 +1338,17 @@ func _targeted_controller() -> Object:
|
||||
return targeted_npc.get_npc_controller()
|
||||
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:
|
||||
if current_incident_active and not current_incident_npc_id.is_empty():
|
||||
coordinator.recorder.close_incident(current_incident_npc_id, outcome)
|
||||
|
||||
@@ -17,6 +17,14 @@ var arrived := false
|
||||
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
|
||||
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
||||
var _nav_usable := false
|
||||
@@ -225,10 +233,78 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(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:
|
||||
if npc == null or npc.fsm == null:
|
||||
return
|
||||
_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)
|
||||
_animate(delta)
|
||||
|
||||
@@ -264,7 +340,7 @@ func _animate(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.state != "LEAVING":
|
||||
if ephemeral and arrived and npc.fsm.current_state != "LEAVING":
|
||||
idle_s += delta
|
||||
if idle_s >= lifetime_s:
|
||||
leave_requested_by_lifetime = true
|
||||
@@ -291,7 +367,11 @@ func _move_toward_target(delta: float) -> void:
|
||||
if agent == null:
|
||||
_move_direct(delta)
|
||||
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 (global_position - target_position).length() < 3.0:
|
||||
_arrive()
|
||||
@@ -308,14 +388,32 @@ func _move_toward_target(delta: float) -> void:
|
||||
_move_direct(delta)
|
||||
return
|
||||
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)
|
||||
dir.y = 0.0
|
||||
if dir.length() < 0.2:
|
||||
velocity = Vector3.ZERO
|
||||
return
|
||||
dir = dir.normalized()
|
||||
velocity.x = dir.x * 1.3
|
||||
velocity.z = dir.z * 1.3
|
||||
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
||||
velocity.x = dir.x * speed
|
||||
velocity.z = dir.z * speed
|
||||
velocity.y -= 9.8 * delta
|
||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||
move_and_slide()
|
||||
@@ -329,8 +427,9 @@ func _move_direct(delta: float) -> void:
|
||||
_arrive()
|
||||
return
|
||||
var dir := to_target.normalized()
|
||||
velocity.x = dir.x * 1.3
|
||||
velocity.z = dir.z * 1.3
|
||||
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
||||
velocity.x = dir.x * speed
|
||||
velocity.z = dir.z * speed
|
||||
velocity.y -= 9.8 * delta
|
||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||
move_and_slide()
|
||||
|
||||
@@ -10,6 +10,7 @@ const Strings := preload("res://src/core/strings.gd")
|
||||
|
||||
const SLICE_DEFS := [
|
||||
{"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": "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"},
|
||||
@@ -17,6 +18,8 @@ const SLICE_DEFS := [
|
||||
{"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": "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"},
|
||||
]
|
||||
|
||||
@@ -146,8 +149,8 @@ func _on_wheel_draw() -> void:
|
||||
return
|
||||
var center := _wheel.size * 0.5
|
||||
var n := SLICE_DEFS.size()
|
||||
var outer := 150.0
|
||||
var inner := 58.0
|
||||
var outer := 174.0
|
||||
var inner := 64.0
|
||||
for i in n:
|
||||
var a0 := -PI / 2.0 + TAU * float(i) / 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 label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5)
|
||||
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:
|
||||
var pts := PackedVector2Array()
|
||||
|
||||
@@ -47,6 +47,8 @@ var escalation_label: Label
|
||||
|
||||
var _debug_enabled := false
|
||||
var _log_lines: Array[String] = []
|
||||
var _cam_overlay: Control = null
|
||||
var _body_camera_on := false
|
||||
|
||||
func _init() -> void:
|
||||
name = "HUD"
|
||||
@@ -138,11 +140,19 @@ func _init() -> void:
|
||||
add_child(vignette_rect)
|
||||
|
||||
# --- 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()
|
||||
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
cam_box.position = Vector2(-180, 50)
|
||||
cam_box.alignment = BoxContainer.ALIGNMENT_END
|
||||
add_child(cam_box)
|
||||
_cam_overlay.add_child(cam_box)
|
||||
var rec_row := HBoxContainer.new()
|
||||
rec_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
cam_box.add_child(rec_row)
|
||||
@@ -279,6 +289,16 @@ func set_wand_icon(visible: bool) -> void:
|
||||
if wand_icon:
|
||||
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).
|
||||
func set_font_scale(scale: float) -> void:
|
||||
var clamped := clampf(scale, 0.75, 1.75)
|
||||
@@ -308,6 +328,7 @@ func _add_corner_brackets(color: Color) -> void:
|
||||
var corners := [
|
||||
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:
|
||||
var c: Vector2 = corners[i]
|
||||
var flip_x: bool = (i % 2) == 1
|
||||
@@ -317,13 +338,13 @@ func _add_corner_brackets(color: Color) -> void:
|
||||
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.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(h)
|
||||
parent.add_child(h)
|
||||
var v := ColorRect.new()
|
||||
v.color = color
|
||||
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.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(v)
|
||||
parent.add_child(v)
|
||||
|
||||
func set_action_hint(text: String) -> void:
|
||||
hint_label.text = text
|
||||
|
||||
@@ -2,6 +2,8 @@ class_name ShiftReview
|
||||
extends CanvasLayer
|
||||
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
|
||||
|
||||
const Strings := preload("res://src/core/strings.gd")
|
||||
|
||||
signal restart_requested()
|
||||
|
||||
var report_label: Label
|
||||
@@ -49,6 +51,8 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
|
||||
var incidents: Dictionary = review.get("incidents", {})
|
||||
var crowd: Dictionary = review.get("crowd", {})
|
||||
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 peaceful := 0
|
||||
for inc in incident_list:
|
||||
@@ -78,6 +82,8 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
|
||||
"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)],
|
||||
"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:",
|
||||
]
|
||||
|
||||
@@ -57,9 +57,11 @@ func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_sh
|
||||
func _build_ground() -> void:
|
||||
var ground := StaticBody3D.new()
|
||||
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()
|
||||
col.size = Vector3(160, 1, 160)
|
||||
col.size = Vector3(500, 1, 500)
|
||||
var shape_owner := ground.create_shape_owner(ground)
|
||||
ground.shape_owner_add_shape(shape_owner, col)
|
||||
root.add_child(ground)
|
||||
@@ -165,29 +167,49 @@ func _build_navmesh() -> void:
|
||||
root.add_child(region)
|
||||
|
||||
func _build_skyline_backdrop() -> void:
|
||||
## Skyline silhouette backdrop behind the building. The texture has a
|
||||
## transparent sky, so only the building silhouettes show; the material must
|
||||
## be set up for alpha or the plane renders as an opaque slab (ADR/TODO).
|
||||
## 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 plane := MeshInstance3D.new()
|
||||
var mesh := PlaneMesh.new()
|
||||
mesh.size = Vector2(240, 60) # wide, low horizon
|
||||
plane.mesh = mesh
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_texture = T_SKYLINE
|
||||
mat.albedo_texture_repeat = true
|
||||
mat.uv1_scale = Vector3(2.0, 1.0, 1.0)
|
||||
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.3
|
||||
mat.alpha_scissor_threshold = 0.5
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
plane.material_override = mat
|
||||
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
plane.position = Vector3(0, 18, -60) # behind and above the building
|
||||
backdrop.add_child(plane)
|
||||
root.add_child(backdrop)
|
||||
# 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
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ func _process(_delta: float) -> bool:
|
||||
quit(1)
|
||||
return true
|
||||
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.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||
@@ -52,7 +56,8 @@ func _advance() -> void:
|
||||
state = 1
|
||||
state_frames = 0
|
||||
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:
|
||||
gm.call("_cycle_nearby_target")
|
||||
_face_target()
|
||||
@@ -83,24 +88,27 @@ func _advance() -> void:
|
||||
# Close the radio form, then jump the shift to the LATE phase.
|
||||
if gm != null:
|
||||
gm.radio_panel.hide()
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||
gm.hud.set_action_hint("")
|
||||
gm.coordinator.elapsed_s = 750.0
|
||||
state = 5
|
||||
state_frames = 0
|
||||
5:
|
||||
# Let the phase transition (and street lamps / marquee) settle, then
|
||||
# take the night avenue shot.
|
||||
# Stage the avenue view down the boulevard (not into the wall) and let
|
||||
# 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:
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||
_shoot("05-late-night-avenue.png")
|
||||
if gm != null:
|
||||
gm.call("_toggle_flashlight")
|
||||
state = 6
|
||||
state_frames = 0
|
||||
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")
|
||||
state = 7
|
||||
quit(0)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -32,7 +32,9 @@ func test_radial_wrap_and_skip_wand() -> bool:
|
||||
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")
|
||||
# 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)
|
||||
var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none")
|
||||
menu.free()
|
||||
@@ -91,6 +93,12 @@ func test_cancel_has_right_mouse() -> bool:
|
||||
found = true
|
||||
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:
|
||||
if not InputMap.has_action(action):
|
||||
return false
|
||||
|
||||
@@ -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
|
||||
@@ -221,7 +221,7 @@ def skyline():
|
||||
x = -40
|
||||
while x < w + 40:
|
||||
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)
|
||||
# few lit windows
|
||||
for _ in range(bw * bh // 900):
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/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 design per screen to docs/screens/designs/.
|
||||
Two modes:
|
||||
--ai photorealistic mockups 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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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",
|
||||
"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": (
|
||||
"First-person video game screenshot, security guard standing at the "
|
||||
"top of wide concrete steps outside a Hollywood Boulevard building "
|
||||
"entrance, daytime early morning light, sidewalk with street lamps, "
|
||||
"planters and a crosswalk, building facade with awning and marquee "
|
||||
"signage, clean UI-free gameplay view, 16:9"),
|
||||
"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",
|
||||
"title": "Targeting an NPC",
|
||||
"source": "README screenshot 02 · ADR-0008 cone focus + head ring",
|
||||
"ref": "02-targeting-npc.png",
|
||||
"description": "An approaching NPC is in the focus cone: a thin pale-gold "
|
||||
"ring billboards around their head, a display name under "
|
||||
"the ring, and the HUD shows a target line with the "
|
||||
"interaction hint.",
|
||||
"prompt": (
|
||||
"First-person video game screenshot, a pedestrian NPC walking toward "
|
||||
"a security guard on a city street, a thin pale-gold ring floating "
|
||||
"around the NPC's head as a selection marker, small name label under "
|
||||
"the ring, minimal HUD text at the bottom of the screen, daytime, 16:9"),
|
||||
"regions": [
|
||||
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
|
||||
(0.44, 0.10, 0.56, 0.16, "head ring"),
|
||||
(0.05, 0.85, 0.95, 0.95, "HUD: name + hint"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "access_inspection",
|
||||
"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": (
|
||||
"First-person video game screenshot with a centered access inspection "
|
||||
"panel: a 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 city street "
|
||||
"scene, 16:9"),
|
||||
"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",
|
||||
"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": (
|
||||
"First-person video game screenshot with a radio dispatch report "
|
||||
"panel open: form fields for location, description, behaviour, "
|
||||
"weapons seen, and immediate danger, with a send button, dark "
|
||||
"translucent panel over a city street scene, 16:9"),
|
||||
"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",
|
||||
"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": (
|
||||
"First-person video game screenshot at night, a dark Hollywood "
|
||||
"Boulevard avenue with lit street lamps, a glowing marquee sign, "
|
||||
"deep blue night sky, no flashlight beam, 16:9"),
|
||||
"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",
|
||||
"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": (
|
||||
"First-person video game screenshot at night, a flashlight beam "
|
||||
"cutting through darkness toward a building entrance and steps, "
|
||||
"bright spotlight pool on the door, dark surroundings, 16:9"),
|
||||
"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",
|
||||
"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": (
|
||||
"First-person video game screenshot with a radial pie menu centered "
|
||||
"on screen, twelve labelled slices in a circle reading Stop, Look, "
|
||||
"Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, "
|
||||
"Radio, a highlighted slice, a name in the center of the ring, "
|
||||
"over a city street scene, 16:9"),
|
||||
"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",
|
||||
"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": (
|
||||
"Video game end-of-shift review screen: a performance summary panel "
|
||||
"listing incident decisions, escalation choices, reputation score, "
|
||||
"crowd reaction and battery use, with a continue button, dark themed "
|
||||
"UI, 16:9"),
|
||||
"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",
|
||||
"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": (
|
||||
"Video game start menu screen: a title and three venue selection "
|
||||
"buttons labelled Hollywood Boulevard, The Joke Shop, and TV Studio, "
|
||||
"dark themed menu over a city street backdrop, 16:9"),
|
||||
"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",
|
||||
"title": "Pause overlay",
|
||||
"source": "MEMORY session shell (PauseOverlay, layer 18)",
|
||||
"ref": None,
|
||||
"description": "Pause menu: resume, restart, settings, and quit-to-menu.",
|
||||
"prompt": (
|
||||
"Video game pause menu overlay: buttons labelled Resume, Restart, "
|
||||
"Settings, and Quit to Menu, dark translucent overlay over a paused "
|
||||
"city street scene, 16:9"),
|
||||
"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",
|
||||
"title": "Settings",
|
||||
"source": "MEMORY session shell (SettingsPanel, layer 22)",
|
||||
"ref": None,
|
||||
"description": "Settings: look sensitivity and HUD font scale controls.",
|
||||
"prompt": (
|
||||
"Video game settings screen: sliders labelled look sensitivity and "
|
||||
"HUD font scale, dark themed settings panel, 16:9"),
|
||||
"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",
|
||||
"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": (
|
||||
"Video 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 city street, 16:9"),
|
||||
"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 _generate_ai(screen, model, request_size):
|
||||
image = generate_textures.generate_image(
|
||||
screen["prompt"], model, request_size, transparent=False,
|
||||
aspect_ratio="1:1")
|
||||
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)
|
||||
print(f"wrote {path.relative_to(ROOT)} {image.size}")
|
||||
|
||||
|
||||
def generate(args):
|
||||
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for screen in SCREENS:
|
||||
print(f"generating {screen['name']} ...")
|
||||
if args.draft:
|
||||
_draw_wireframe(screen, _design_path(screen["name"]))
|
||||
else:
|
||||
_generate_ai(screen, args.model, args.request_size)
|
||||
print("done")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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())
|
||||
@@ -0,0 +1,80 @@
|
||||
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.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_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()
|
||||