Add emergency aftermath: evacuation, investigation, review copy
- Responder outcomes now reach the shift review (ShiftCoordinator.responder_outcomes), and the review screen names them. - "evacuation" (emergency call, low danger, no weapon) actually clears the entrance: the door is locked, every NPC flees and is marked as leaving, and a security_failure reputation hit applies. - An emergency call the situation did not justify (no weapon, danger <= 1) appends an investigation note, records investigation_opened, and applies the existing false_report hit. The review lists the investigations. - Fix dispatch dying after the first shift: coordinator.setup() builds a new RadioSystem, so the responder_arrived connection made in _ready was lost on every venue switch/restart. _connect_radio() re-wires it beside _connect_crowd(). Tests/docs - Headless suite 105 -> 113 (test_emergency_aftermath.gd), new smoke_emergency.gd covering the investigation, evacuation aftermath, and review copy. - README controls/status/smoke checklist, TODO section moved to DONE, MEMORY notes. - Version 0.2.5.
No files matched your search
@@ -473,3 +473,16 @@ Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd
|
|||||||
incident (rubberneck). On `crowd_panic`, they move away from the door.
|
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
|
- [x] Profile a busy entrance with textured materials and props; add LOD or culling for distant
|
||||||
NPCs if frame time drops.
|
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.
|
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;
|
- `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.
|
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||||
- Next work: `TODO.md` emergency aftermath (evacuation, unjustified-call investigation), then
|
- Next work: `TODO.md` body-camera replay timeline, then NPC theater poses (sit/photo/perform)
|
||||||
body-camera replay timeline and NPC theater poses.
|
and audio.
|
||||||
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
@@ -94,6 +94,15 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
||||||
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
||||||
before building the review.
|
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
|
- **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
|
`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),
|
is now a ring of four 400x60 planes at 200 m (center y=30, so building bases meet the ground),
|
||||||
@@ -192,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
|
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
|
||||||
easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
|
easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
|
||||||
|
|
||||||
|
- Screen-design references (`tools/screen_designs.py`, tests in `tools/test_screen_designs.py`):
|
||||||
|
`generate` turns the screen descriptions in `docs/` (README captions, ADRs 0007–0010,
|
||||||
|
PLAN, architecture) into reference images under `docs/screens/designs/` — `--draft`
|
||||||
|
draws offline Pillow wireframes, `--ai` generates 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
|
## Known Issues / Notes
|
||||||
|
|
||||||
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
|
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
|
||||||
@@ -222,6 +240,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
- Add a vision-model (LLM) judge on top of the screen-design comparison: pixel and
|
||||||
|
perceptual metrics cannot decide whether a screenshot's composition is correct.
|
||||||
- Whether the responder avatar should be playable/controllable or purely scripted.
|
- Whether the responder avatar should be playable/controllable or purely scripted.
|
||||||
- Localization target languages beyond English.
|
- Localization target languages beyond English.
|
||||||
- macOS notarization approach for broad distribution.
|
- macOS notarization approach for broad distribution.
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ The central design goal is simple:
|
|||||||
|
|
||||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||||
|
|
||||||
> **Latest: v0.2.4** — crowd presentation (rubbernecking and panic), the distant skyline ring,
|
> **Latest: v0.2.5** — emergency aftermath (evacuation clears the entrance, unjustified calls open
|
||||||
> working NPC navigation, and a refreshed screenshot gallery.
|
> an investigation, the review names the responder outcome).
|
||||||
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||||
@@ -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.
|
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
|
### Setup
|
||||||
|
|
||||||
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
||||||
@@ -214,6 +241,9 @@ godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
|||||||
|
|
||||||
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance
|
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance
|
||||||
godot --headless --path . --script res://src/test/smoke_crowd.gd
|
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.
|
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
||||||
@@ -266,7 +296,7 @@ Implemented so far:
|
|||||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||||
- Headless unit tests: **105 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)
|
- 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
|
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||||
- Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
- Control pass: focus cone on NPC heads with a billboarded 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`)
|
||||||
@@ -278,10 +308,11 @@ Implemented so far:
|
|||||||
- 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
|
- 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
|
- 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
|
- 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
|
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
||||||
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
||||||
|
|
||||||
Not yet implemented: body-camera replay timeline, 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
|
### Smoke Test Checklist
|
||||||
|
|
||||||
@@ -312,7 +343,9 @@ Not yet implemented: body-camera replay timeline, emergency aftermath, editor-ba
|
|||||||
25. Repeated alarms panic the crowd and nearby people clear away from the door instead of standing around.
|
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.
|
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).
|
27. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
||||||
28. 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.
|
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
|
## Development Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Godot 4.3 prototype. Release **v0.2.4** is tagged; CI builds the installers and AppImage.
|
Godot 4.3 prototype. Release **v0.2.5** is tagged. Headless suite **113/113**, plus
|
||||||
Headless suite **105/105**, plus `smoke_session_shell.gd`, `smoke_plan_verbs.gd`,
|
`smoke_session_shell.gd`, `smoke_plan_verbs.gd`, `smoke_crowd.gd`, `smoke_emergency.gd`, and the
|
||||||
`smoke_crowd.gd`, and the windowed focus/radial smoke.
|
windowed focus/radial smoke.
|
||||||
|
|
||||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
||||||
|
|
||||||
## 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
|
## Body-camera replay
|
||||||
|
|
||||||
- [ ] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
|
- [ ] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
|
||||||
@@ -66,6 +56,18 @@ Verified via API+host credentials (2026-10-31):
|
|||||||
|
|
||||||
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
||||||
|
|
||||||
|
## Screen design references (QA tooling)
|
||||||
|
|
||||||
|
- [x] `tools/screen_designs.py generate` writes one reference design per screen
|
||||||
|
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
|
||||||
|
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
|
||||||
|
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
|
||||||
|
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
|
||||||
|
- [ ] Vision-model judge for semantic screenshot comparison. Pixel and perceptual
|
||||||
|
metrics cannot decide whether a frame's composition is correct; a follow-up
|
||||||
|
should send the capture + reference design to a vision model and ask whether
|
||||||
|
they depict the same screen.
|
||||||
|
|
||||||
## Tooling (when an editor workflow exists)
|
## Tooling (when an editor workflow exists)
|
||||||
|
|
||||||
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
|
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
||||||
"radio.easter_egg": "¿Dónde está la mota?",
|
"radio.easter_egg": "¿Dónde está la mota?",
|
||||||
"responder.arrived": "Responder arrives and %ss the situation.",
|
"responder.arrived": "Responder arrives and %ss the situation.",
|
||||||
|
"responder.evacuated": "The entrance is cleared and the door is locked while the block is secured.",
|
||||||
|
"investigation.unjustified": "Emergency call against %s reviewed: no weapon, low danger.",
|
||||||
|
"investigation.started": "Your emergency call is flagged for investigation.",
|
||||||
"react.comply": "%s complies and steps back.",
|
"react.comply": "%s complies and steps back.",
|
||||||
"react.leave": "%s turns and leaves.",
|
"react.leave": "%s turns and leaves.",
|
||||||
"react.argue": "%s argues with you.",
|
"react.argue": "%s argues with you.",
|
||||||
@@ -62,5 +65,8 @@
|
|||||||
"flashlight.on": "Flashlight on.",
|
"flashlight.on": "Flashlight on.",
|
||||||
"flashlight.off": "Flashlight off.",
|
"flashlight.off": "Flashlight off.",
|
||||||
"hud.venue_switched": "Venue: %s",
|
"hud.venue_switched": "Venue: %s",
|
||||||
"wand.recharge": "Wand scanner is recharging…"
|
"wand.recharge": "Wand scanner is recharging…",
|
||||||
|
"review.responder": "Responder outcomes: %s",
|
||||||
|
"review.investigations": "Investigations: %s",
|
||||||
|
"review.none": "none"
|
||||||
}
|
}
|
||||||
|
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 |
@@ -8,7 +8,7 @@ config_version=5
|
|||||||
|
|
||||||
config/name="Step Line"
|
config/name="Step Line"
|
||||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||||
config/version="0.2.4"
|
config/version="0.2.5"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ var crowd := CrowdSystem.new()
|
|||||||
var scheduler := EventScheduler.new()
|
var scheduler := EventScheduler.new()
|
||||||
|
|
||||||
var spawned_count := 0
|
var spawned_count := 0
|
||||||
|
## Emergency aftermath (PLAN.md: Level 5). Responder resolutions and any
|
||||||
|
## investigation notes for calls that were not justified.
|
||||||
|
var responder_outcomes: Array[String] = []
|
||||||
|
var investigations: Array[String] = []
|
||||||
var spawn_seed := 0
|
var spawn_seed := 0
|
||||||
var _shift_finished := false
|
var _shift_finished := false
|
||||||
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
|
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
|
||||||
@@ -45,6 +49,8 @@ func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int =
|
|||||||
spawn_seed = p_seed
|
spawn_seed = p_seed
|
||||||
scheduler.setup(scenario, p_seed)
|
scheduler.setup(scenario, p_seed)
|
||||||
event_bus = EventBus.new()
|
event_bus = EventBus.new()
|
||||||
|
responder_outcomes.clear()
|
||||||
|
investigations.clear()
|
||||||
radio = RadioSystem.new()
|
radio = RadioSystem.new()
|
||||||
reputation = ReputationEvaluator.new()
|
reputation = ReputationEvaluator.new()
|
||||||
crowd = CrowdSystem.new()
|
crowd = CrowdSystem.new()
|
||||||
@@ -140,6 +146,8 @@ func build_review() -> Dictionary:
|
|||||||
"battery": radio.battery,
|
"battery": radio.battery,
|
||||||
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
|
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
|
||||||
},
|
},
|
||||||
|
"responder_outcomes": responder_outcomes.duplicate(),
|
||||||
|
"investigations": investigations.duplicate(),
|
||||||
"completed_at_ms": Time.get_ticks_msec(),
|
"completed_at_ms": Time.get_ticks_msec(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func _ready() -> void:
|
|||||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||||
coordinator.shift_ended.connect(_on_shift_ended)
|
coordinator.shift_ended.connect(_on_shift_ended)
|
||||||
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
_connect_radio()
|
||||||
_connect_crowd()
|
_connect_crowd()
|
||||||
|
|
||||||
_build_ui()
|
_build_ui()
|
||||||
@@ -222,6 +222,7 @@ func _reset_shift_state() -> void:
|
|||||||
hud.set_body_camera(false)
|
hud.set_body_camera(false)
|
||||||
scenario_duration_s = selected_scenario.shift_duration_s
|
scenario_duration_s = selected_scenario.shift_duration_s
|
||||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||||
|
_connect_radio()
|
||||||
_connect_crowd()
|
_connect_crowd()
|
||||||
_last_phase = -1
|
_last_phase = -1
|
||||||
_apply_phase_lighting()
|
_apply_phase_lighting()
|
||||||
@@ -480,12 +481,51 @@ func _on_responder_arrived(call: Dictionary) -> void:
|
|||||||
scene_root.add_child(avatar)
|
scene_root.add_child(avatar)
|
||||||
npcs.append(avatar)
|
npcs.append(avatar)
|
||||||
hud.log(Strings.fmt("responder.arrived", [outcome]))
|
hud.log(Strings.fmt("responder.arrived", [outcome]))
|
||||||
|
coordinator.responder_outcomes.append(outcome)
|
||||||
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
|
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
|
||||||
"type": "responder_action",
|
"type": "responder_action",
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"call_type": call.get("type", "assist"),
|
"call_type": call.get("type", "assist"),
|
||||||
})
|
})
|
||||||
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
|
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
|
||||||
|
if outcome == "evacuation":
|
||||||
|
_evacuate_entrance()
|
||||||
|
|
||||||
|
## Responder outcome "evacuation": the entrance is cleared and the door stays
|
||||||
|
## locked while the block is secured (PLAN.md: Level 5 Emergency Response).
|
||||||
|
func _evacuate_entrance() -> void:
|
||||||
|
if door and not door.locked:
|
||||||
|
door.set_locked(true)
|
||||||
|
if world_builder:
|
||||||
|
world_builder.set_door_locked(true)
|
||||||
|
if hud:
|
||||||
|
hud.set_door(true)
|
||||||
|
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||||
|
for npc in npcs:
|
||||||
|
if not is_instance_valid(npc):
|
||||||
|
continue
|
||||||
|
if npc.has_method("start_flee"):
|
||||||
|
npc.start_flee(door_pos)
|
||||||
|
var controller: Object = npc.get_npc_controller() if npc.has_method("get_npc_controller") else null
|
||||||
|
if controller != null:
|
||||||
|
coordinator.recorder.add_event(controller.npc_id, {
|
||||||
|
"type": "evacuation",
|
||||||
|
"outcome": "evacuated",
|
||||||
|
})
|
||||||
|
if controller.leave_requested == false:
|
||||||
|
controller.leave_requested = true
|
||||||
|
coordinator.reputation.apply("security_failure", -3.0, "entrance evacuated by responder")
|
||||||
|
if hud:
|
||||||
|
hud.log(Strings.lookup("responder.evacuated"))
|
||||||
|
|
||||||
|
## Responder dispatch. `coordinator.setup()` builds a new RadioSystem for each
|
||||||
|
## shift, so the arrival signal must be re-connected after every setup or radio
|
||||||
|
## calls are silently resolved by nobody.
|
||||||
|
func _connect_radio() -> void:
|
||||||
|
if coordinator == null or coordinator.radio == null:
|
||||||
|
return
|
||||||
|
if not coordinator.radio.responder_arrived.is_connected(_on_responder_arrived):
|
||||||
|
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
||||||
|
|
||||||
## Crowd presentation (PLAN: Public Crowd Reaction). The crowd system is a
|
## Crowd presentation (PLAN: Public Crowd Reaction). The crowd system is a
|
||||||
## mood/attention float; this turns its events into avatar behavior.
|
## mood/attention float; this turns its events into avatar behavior.
|
||||||
@@ -1073,12 +1113,33 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
|||||||
if emergency:
|
if emergency:
|
||||||
coordinator.crowd.apply_event("emergency_responder")
|
coordinator.crowd.apply_event("emergency_responder")
|
||||||
hud.log(Strings.lookup("radio.emergency_dispatched"))
|
hud.log(Strings.lookup("radio.emergency_dispatched"))
|
||||||
|
# A call that the situation did not justify is reviewed afterwards
|
||||||
|
# (PLAN.md: Level 5 consequences).
|
||||||
|
if _is_unjustified_emergency():
|
||||||
|
var who: String = "the person"
|
||||||
|
var controller: Object = _targeted_controller()
|
||||||
|
if controller and controller.profile:
|
||||||
|
who = str(controller.profile.display_name)
|
||||||
|
coordinator.investigations.append(Strings.fmt("investigation.unjustified", [who]))
|
||||||
|
coordinator.recorder.add_event(current_incident_npc_id, {
|
||||||
|
"type": "investigation_opened",
|
||||||
|
"reason": "unjustified emergency call",
|
||||||
|
})
|
||||||
|
coordinator.reputation.apply("false_report", -6.0, "unjustified emergency call")
|
||||||
|
hud.log(Strings.lookup("investigation.started"))
|
||||||
else:
|
else:
|
||||||
coordinator.crowd.apply_event("calm_professionalism")
|
coordinator.crowd.apply_event("calm_professionalism")
|
||||||
if audio:
|
if audio:
|
||||||
audio.play_radio_response()
|
audio.play_radio_response()
|
||||||
_maybe_radio_easter_egg()
|
_maybe_radio_easter_egg()
|
||||||
|
|
||||||
|
## True when an emergency call is not supported by the situation: no weapon
|
||||||
|
## seen and low danger (PLAN.md: unjustified call consequences).
|
||||||
|
func _is_unjustified_emergency() -> bool:
|
||||||
|
var weapons := bool(current_npc_truth.get("weapons_seen", false))
|
||||||
|
var danger := int(current_npc_truth.get("danger_level", 0))
|
||||||
|
return not weapons and danger <= 1
|
||||||
|
|
||||||
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
|
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
|
||||||
## over the radio. Fires on a small random chance after a successful call.
|
## over the radio. Fires on a small random chance after a successful call.
|
||||||
func _maybe_radio_easter_egg() -> void:
|
func _maybe_radio_easter_egg() -> void:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ class_name ShiftReview
|
|||||||
extends CanvasLayer
|
extends CanvasLayer
|
||||||
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
|
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
|
||||||
|
|
||||||
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
|
||||||
signal restart_requested()
|
signal restart_requested()
|
||||||
|
|
||||||
var report_label: Label
|
var report_label: Label
|
||||||
@@ -49,6 +51,8 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
|
|||||||
var incidents: Dictionary = review.get("incidents", {})
|
var incidents: Dictionary = review.get("incidents", {})
|
||||||
var crowd: Dictionary = review.get("crowd", {})
|
var crowd: Dictionary = review.get("crowd", {})
|
||||||
var radio: Dictionary = review.get("radio", {})
|
var radio: Dictionary = review.get("radio", {})
|
||||||
|
var outcomes: Array = review.get("responder_outcomes", [])
|
||||||
|
var investigations: Array = review.get("investigations", [])
|
||||||
var incident_list: Array = incidents.get("incidents", [])
|
var incident_list: Array = incidents.get("incidents", [])
|
||||||
var peaceful := 0
|
var peaceful := 0
|
||||||
for inc in incident_list:
|
for inc in incident_list:
|
||||||
@@ -78,6 +82,8 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
|
|||||||
"NPCs encountered: %d" % review.get("spawned_count", 0),
|
"NPCs encountered: %d" % review.get("spawned_count", 0),
|
||||||
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
|
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
|
||||||
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
|
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
|
||||||
|
Strings.fmt("review.responder", [", ".join(outcomes) if outcomes.size() > 0 else Strings.lookup("review.none")]),
|
||||||
|
Strings.fmt("review.investigations", [" | ".join(investigations) if investigations.size() > 0 else Strings.lookup("review.none")]),
|
||||||
"",
|
"",
|
||||||
"Highlights:",
|
"Highlights:",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -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()
|
||||||