diff --git a/DONE.md b/DONE.md index f991966..b741a69 100644 --- a/DONE.md +++ b/DONE.md @@ -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. - [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 %. diff --git a/MEMORY.md b/MEMORY.md index af3558d..f0e5c16 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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: `TODO.md` emergency aftermath (evacuation, unjustified-call investigation), then - body-camera replay timeline and NPC theater poses. +- 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 @@ -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 `_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), @@ -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 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 @@ -222,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. diff --git a/README.md b/README.md index 519adbd..d336299 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ The central design goal is simple: **All releases:** -> **Latest: v0.2.4** — crowd presentation (rubbernecking and panic), the distant skyline ring, -> working NPC navigation, and a refreshed screenshot gallery. +> **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 @@ -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. @@ -214,6 +241,9 @@ 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. @@ -266,7 +296,7 @@ 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: **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 - 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`) @@ -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 - 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, 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 @@ -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. 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. 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 diff --git a/TODO.md b/TODO.md index c3ef239..27e0116 100644 --- a/TODO.md +++ b/TODO.md @@ -1,21 +1,11 @@ # TODO -Godot 4.3 prototype. Release **v0.2.4** is tagged; CI builds the installers and AppImage. -Headless suite **105/105**, plus `smoke_session_shell.gd`, `smoke_plan_verbs.gd`, -`smoke_crowd.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. -## 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). @@ -66,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. diff --git a/content/strings.en.json b/content/strings.en.json index 2fda0b6..e71bc4a 100644 --- a/content/strings.en.json +++ b/content/strings.en.json @@ -35,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.", @@ -62,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" } diff --git a/docs/screens/designs/access_inspection.png b/docs/screens/designs/access_inspection.png new file mode 100644 index 0000000..2342910 Binary files /dev/null and b/docs/screens/designs/access_inspection.png differ diff --git a/docs/screens/designs/emergency_radio.png b/docs/screens/designs/emergency_radio.png new file mode 100644 index 0000000..1e0cce6 Binary files /dev/null and b/docs/screens/designs/emergency_radio.png differ diff --git a/docs/screens/designs/late_night_avenue.png b/docs/screens/designs/late_night_avenue.png new file mode 100644 index 0000000..9fcc94b Binary files /dev/null and b/docs/screens/designs/late_night_avenue.png differ diff --git a/docs/screens/designs/night_flashlight.png b/docs/screens/designs/night_flashlight.png new file mode 100644 index 0000000..bf78ec1 Binary files /dev/null and b/docs/screens/designs/night_flashlight.png differ diff --git a/docs/screens/designs/pause_overlay.png b/docs/screens/designs/pause_overlay.png new file mode 100644 index 0000000..3957586 Binary files /dev/null and b/docs/screens/designs/pause_overlay.png differ diff --git a/docs/screens/designs/radial_menu.png b/docs/screens/designs/radial_menu.png new file mode 100644 index 0000000..aae52d4 Binary files /dev/null and b/docs/screens/designs/radial_menu.png differ diff --git a/docs/screens/designs/radio_report.png b/docs/screens/designs/radio_report.png new file mode 100644 index 0000000..8851748 Binary files /dev/null and b/docs/screens/designs/radio_report.png differ diff --git a/docs/screens/designs/settings_panel.png b/docs/screens/designs/settings_panel.png new file mode 100644 index 0000000..9ba403c Binary files /dev/null and b/docs/screens/designs/settings_panel.png differ diff --git a/docs/screens/designs/shift_review.png b/docs/screens/designs/shift_review.png new file mode 100644 index 0000000..783a7d3 Binary files /dev/null and b/docs/screens/designs/shift_review.png differ diff --git a/docs/screens/designs/start_menu.png b/docs/screens/designs/start_menu.png new file mode 100644 index 0000000..a4bb298 Binary files /dev/null and b/docs/screens/designs/start_menu.png differ diff --git a/docs/screens/designs/targeting_npc.png b/docs/screens/designs/targeting_npc.png new file mode 100644 index 0000000..c8e25ae Binary files /dev/null and b/docs/screens/designs/targeting_npc.png differ diff --git a/docs/screens/designs/venue_overview.png b/docs/screens/designs/venue_overview.png new file mode 100644 index 0000000..46c013d Binary files /dev/null and b/docs/screens/designs/venue_overview.png differ diff --git a/project.godot b/project.godot index 36d82b2..757104d 100644 --- a/project.godot +++ b/project.godot @@ -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.4" +config/version="0.2.5" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") diff --git a/src/core/shift_coordinator.gd b/src/core/shift_coordinator.gd index 3fb8938..27245b3 100644 --- a/src/core/shift_coordinator.gd +++ b/src/core/shift_coordinator.gd @@ -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() @@ -140,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(), } diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index e156d9f..0db8cd9 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -134,7 +134,7 @@ 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() @@ -222,6 +222,7 @@ func _reset_shift_state() -> void: 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() @@ -480,12 +481,51 @@ 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. @@ -1073,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: diff --git a/src/game/ui/shift_review.gd b/src/game/ui/shift_review.gd index 23e8ba6..721a565 100644 --- a/src/game/ui/shift_review.gd +++ b/src/game/ui/shift_review.gd @@ -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:", ] diff --git a/src/test/smoke_emergency.gd b/src/test/smoke_emergency.gd new file mode 100644 index 0000000..e2ad4f8 --- /dev/null +++ b/src/test/smoke_emergency.gd @@ -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) diff --git a/src/test/tests/test_emergency_aftermath.gd b/src/test/tests/test_emergency_aftermath.gd new file mode 100644 index 0000000..7d141d7 --- /dev/null +++ b/src/test/tests/test_emergency_aftermath.gd @@ -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 diff --git a/tools/screen_designs.py b/tools/screen_designs.py new file mode 100644 index 0000000..96b7078 --- /dev/null +++ b/tools/screen_designs.py @@ -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()) diff --git a/tools/test_screen_designs.py b/tools/test_screen_designs.py new file mode 100644 index 0000000..18873a3 --- /dev/null +++ b/tools/test_screen_designs.py @@ -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()