Compare commits

...
3 Commits
Author SHA1 Message Date
zwitschi f763cff744 Export venues as editor-authorable .tres resources
Full build / Import + headless tests (push) Successful in 34s
Full build / Full desktop build + optional release (push) Successful in 3m51s
- tools/export_venues.gd (SceneTree script) saves the three factory scenarios to
  content/venues/<id>.tres via ResourceSaver. The factory stays the source of truth;
  the exported files are what an editor workflow would edit and ship.
- New tests load the files back and check that id, name, event pool, profiles,
  policy (ID/wand), equipment, door-staff lines, shift phases, and locations all
  survive. They also fail when the exported files are missing or stale, so the
  export is re-run after content changes.
- GUT is consciously not adopted: the custom headless runner already covers 157
  assertions without an editor plugin and keeps CI dependency-free. Recorded in
  DONE.md.
- TODO: the planned backlog is complete; only optional future ideas remain.
- README/MEMORY updated. Version 0.2.16.
2026-10-31 22:16:55 +01:00
zwitschi 72b9378e69 Add a vision-model screenshot judge to the QA tooling
Full build / Import + headless tests (push) Successful in 32s
Full build / Full desktop build + optional release (push) Successful in 3m30s
`tools/screen_designs.py judge` sends each capture together with its concept
mockup to a vision model and asks whether the capture realises the same screen and
layout intent. This covers what pixel/perceptual metrics cannot judge.

- Prompt explicitly ignores art style, fidelity, fonts, and placeholder text, since
  the references are stylised mockups.
- Verdicts are classed match / review (mismatch below --fail-confidence, default
  0.6) / diff (confident mismatch, exits non-zero) / unknown; --json, --only,
  --dry-run, --model supported.
- Reads the repo .env for OPENROUTER_API_KEY without overriding real env vars.
- Default judge model google/gemini-2.5-flash-lite; openai/gpt-4o-mini verified too.

Tests/docs
- 7 new offline tests in tools/test_screen_designs.py (request payload embeds both
  images, JSON/fenced/parts/non-JSON parsing, threshold classification, .env
  loading); 30 Python tests pass.
- Live run: 3 captures match, 3 review, 0 confident diffs.
- README, TODO section moved to DONE, MEMORY. Version 0.2.15.
2026-10-31 22:11:45 +01:00
zwitschi 74f14014b2 Enable anonymous downloads on the host and harden Strings.fmt
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Successful in 3m31s
Host (applied with root@192.168.88.30)
- [service] REQUIRE_SIGNIN_VIEW = false in /etc/gitea/app.ini (backup written
  alongside), Gitea restarted. Verified anonymously: repo page 200, API 200, and a
  release asset downloads at full size (50.8 MB AppImage).

Localization safety (Open Polish)
- Strings.placeholder_count() counts conversions and ignores %% escapes.
- Strings.fmt() validates the count and fails safe (warns, returns the template)
  instead of raising an engine error when a key's placeholders drift.
- Strings.validate() lints the table for a trailing %, unsupported conversions, and
  mixed positional/sequential placeholders.
- Tests cover the counter, the fail-safe path, a clean table, and a formatting pass
  over every placeholder key with type-correct arguments.

Docs
- TODO: distribution, release-pipeline, and open-polish sections are complete and
  moved to DONE; the v0.2.4-v0.2.8 backfill is closed as superseded.
- README/MEMORY updated. Version 0.2.14.
2026-10-31 22:02:24 +01:00
14 changed files with 2834 additions and 61 deletions

No files matched your search

+69
View File
@@ -526,3 +526,72 @@ Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scena
crowd spike (more rubberneckers, not only one `arrive` spawn). crowd spike (more rubberneckers, not only one `arrive` spawn).
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in - [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
at least one authored event. at least one authored event.
## Distribution follow-up (host config)
Completed 2026-10-31 (working tree). Headless **153/153**.
Verified via API+host credentials (2026-10-31):
- [x] Repo is **public** (`private:false`) and account has admin rights.
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
AppImage).
- [x] **Host setting applied**: `[service] REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
(backup written alongside), Gitea restarted. Verified anonymously: repo page 200, API 200,
and a release asset downloads with the full 50.8 MB AppImage.
## Release pipeline follow-up
Completed 2026-10-31 (working tree). Headless **153/153**.
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
so every tag build since v0.2.2 failed to publish.
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
`GITEA_TOKEN` secret).
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
Gitea runner does not expose `github.token`).
- [x] Uploads now fail loudly and the release is verified to carry every artifact
(a silent HTTP 4xx used to leave an empty release with a green job).
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
- [x] Backfill decision: v0.2.4–v0.2.8 stay superseded; their tags and source remain, and the
release page points at the current build. No rebuilds planned.
## Open Polish
Completed 2026-10-31 (working tree). Headless **153/153**.
- [x] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Screen design references (QA tooling)
Completed 2026-10-31 (working tree). 30 Python tooling tests pass; the live judge run classed 3 captures as match and 3 as review (concept mockups are more stylised), with no confident mismatch.
- [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).
- [x] Vision-model judge for semantic screenshot comparison: `judge` sends the
capture + reference design to a vision model, classes verdicts as
match/review/diff with a confidence threshold, supports `--dry-run`, `--json`,
`--only`, and `--model`, and reads the repo `.env` for the API key.
## Tooling (when an editor workflow exists)
Completed 2026-10-31 (working tree). Headless **157/157**; the exported `.tres` venues round-trip through the resource loader.
- [x] Author venues as `.tres` resources: `tools/export_venues.gd` writes
`content/venues/<id>.tres` from the factory, and a test loads them back to check
nothing is lost (events, profiles, policy, phases, door lines, locations).
- [x] GUT decision: **not adopted.** The custom headless runner (`src/test/test_runner.gd`)
already covers 157 assertions without an editor plugin, runs in CI, and keeps the
project dependency-free; adding GUT would duplicate coverage and require the editor.
+24 -3
View File
@@ -21,8 +21,9 @@ 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: the remaining TODO sections — distribution host setting (`REQUIRE_SIGNIN_VIEW`), - **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
open polish (`Strings.fmt` placeholders), the vision-model screenshot judge, and editor tooling. now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, web
export). GUT was consciously not adopted (custom runner covers it without an editor plugin).
- 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
@@ -95,6 +96,24 @@ 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.
- **Editor-ready venues (2026-10-31):** `tools/export_venues.gd` (a SceneTree script) saves the
three factory scenarios to `content/venues/<id>.tres` with `ResourceSaver`; the `.tres` files
round-trip through `ResourceLoader` (events, profiles, policy, shift phases, door lines,
locations). `test_venue_resources.gd` also fails if the exported files are missing or stale
relative to the factory, so re-run the export after changing `ScenarioFactory`.
- **Vision judge (2026-10-31):** `python3 tools/screen_designs.py judge` sends the capture plus
its concept mockup to a vision model (`DEFAULT_JUDGE_MODEL = google/gemini-2.5-flash-lite`,
override with `--model`; `openai/gpt-4o-mini` also works). Reads the repo `.env` via
`_load_env_file()` (never overrides real env). Verdicts are classed `match` / `review`
(mismatch below `--fail-confidence`, default 0.6) / `diff` (confident mismatch → exit 1) /
`unknown`. `--dry-run`, `--json`, `--only` supported. The prompt explicitly ignores art style,
because the references are stylised mockups; the live run gave 3 match, 3 review, 0 diff.
Python tests: `cd tools && python3 -m unittest test_asset_pipeline test_screen_designs`.
- **String formatting safety (2026-10-31):** `Strings.placeholder_count()` counts `%` specs
(ignoring `%%`), `fmt()` warns and returns the template when the arg count does not match
(instead of raising an engine error), and `validate()` lints the table for trailing `%`,
unsupported conversions, and mixed positional/sequential placeholders. Tests cover all three
plus a formatting pass over every placeholder key with type-correct dummy args.
- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged - **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to `_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
@@ -141,7 +160,9 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
needs `mipmaps/generate=true`. needs `mipmaps/generate=true`.
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the - **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced. imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
- Host sign-in follow-up remains deferred. - Host sign-in follow-up is **done**: `REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
(backup `app.ini.bak-*` alongside), Gitea restarted; anonymous repo page/API return 200 and
release assets download in full. Reverse by setting it back to `true` and restarting.
- **Release publishing was broken since v0.2.2 (fixed 2026-10-31):** `publish_release.sh` - **Release publishing was broken since v0.2.2 (fixed 2026-10-31):** `publish_release.sh`
only built `AUTH` in the `GITEA_TOKEN` branch; the `~/.git-credentials` branch set `CRED` only built `AUTH` in the `GITEA_TOKEN` branch; the `~/.git-credentials` branch set `CRED`
and then used unbound `${AUTH[@]}` under `set -u`, so every API call failed and the tag and then used unbound `${AUTH[@]}` under `set -u`, so every API call failed and the tag
+25 -6
View File
@@ -28,9 +28,10 @@ The central design goal is simple:
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> **Latest: v0.2.13** — authored scenario content: Joke Shop access edge cases, Studio filming > Downloads are public (no sign-in required) since the host's instance-wide
> boundaries and celebrity crowd spikes, and Building events that use the body-camera and > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
> request-reason verbs. >
> **Latest: v0.2.16** — editor-ready `.tres` venues; the planned backlog is complete.
> 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
@@ -194,6 +195,9 @@ shared art-direction suffix so the frames read as one coherent game. Requests us
a 3:2 landscape source and crop to the 16:9 capture dimensions. a 3:2 landscape source and crop to the 16:9 capture dimensions.
```bash ```bash
# Export the built-in scenarios to editor-authorable .tres resources
godot --headless --path . --script res://tools/export_venues.gd
# Deterministic wireframes (offline, no API key) # Deterministic wireframes (offline, no API key)
python3 tools/screen_designs.py generate --draft python3 tools/screen_designs.py generate --draft
@@ -204,13 +208,26 @@ python3 tools/screen_designs.py generate
# Compare docs/screenshots/*.png against the designs # Compare docs/screenshots/*.png against the designs
python3 tools/screen_designs.py compare python3 tools/screen_designs.py compare
python3 tools/screen_designs.py compare --json python3 tools/screen_designs.py compare --json
# Ask a vision model whether each capture realises its design's screen and layout
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
python3 tools/screen_designs.py judge
python3 tools/screen_designs.py judge --model openai/gpt-4o-mini --json
python3 tools/screen_designs.py judge --only 03- # one capture
python3 tools/screen_designs.py judge --dry-run # build requests, no network
``` ```
`compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank `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 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 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 composition is *right*.
`TODO.md`). Regenerate the designs after changing screen layout, then re-run
`judge` covers that gap: it sends each capture together with its concept mockup to
a vision model and asks whether the capture realises the same screen and layout
intent. Art style, fidelity, fonts, and placeholder text are explicitly ignored.
Verdicts are classed as `match`, `review` (a mismatch below `--fail-confidence`,
default 0.6 — expected when a concept mockup is more stylised than the game), or
`diff` (a confident mismatch, which exits non-zero). Regenerate the designs after changing screen layout, then re-run
`compare` after regenerating the game screenshots with `compare` after regenerating the game screenshots with
`src/test/capture_screenshots.gd`. `src/test/capture_screenshots.gd`.
@@ -312,7 +329,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: **149 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content) - Headless unit tests: **157 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip)
- 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 blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) - Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
@@ -329,6 +346,8 @@ Implemented so far:
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint - NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server - Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
- Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout) - Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout)
- Editor-ready venues: `tools/export_venues.gd` writes `content/venues/<id>.tres` from `ScenarioFactory`, and the `.tres` files load back with events, profiles, policy, phases, door lines, and locations intact (the factory stays the source of truth)
- Localization safety: `Strings.fmt` validates the placeholder count before formatting and fails safe (warns, returns the template) instead of raising an engine error, `Strings.placeholder_count` ignores `%%`, and `Strings.validate()` lints the table for trailing `%`, unsupported conversions, and mixed positional/sequential placeholders
- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs - Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
+10 -51
View File
@@ -1,56 +1,15 @@
# TODO # TODO
Godot 4.3 prototype. Release **v0.2.13** is tagged. Headless suite **149/149**, plus the session, Godot 4.3 prototype. Latest release **v0.2.16**. Headless suite **157/157** plus 30 Python
PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and windowed focus/radial smokes. tooling tests; the session, PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and
windowed focus/radial smokes pass. Anonymous downloads are enabled on the host.
Host `REQUIRE_SIGNIN_VIEW` stays deferred. All planned backlog sections are complete (see `DONE.md`). Optional future ideas live below.
## Distribution follow-up (host config, deferred) ## Future ideas (optional, not scheduled)
Verified via API+host credentials (2026-10-31): - [ ] Live 3D body-camera replay (the timeline is text-only today).
- [ ] LOD/culling for NPCs if `MAX_LIVE_NPCS` grows past the measured 16-avatar budget.
- [x] Repo is **public** (`private:false`) and account has admin rights. - [ ] Additional venues or seasonal events authored from the new `.tres` files.
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer + - [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
AppImage). - [ ] Web export build target.
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false`
under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads.
## Release pipeline follow-up
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
so every tag build since v0.2.2 failed to publish.
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
`GITEA_TOKEN` secret).
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
Gitea runner does not expose `github.token`).
- [x] Uploads now fail loudly and the release is verified to carry every artifact
(a silent HTTP 4xx used to leave an empty release with a green job).
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
- [ ] Optionally backfill releases for v0.2.4–v0.2.8 (their CI artifacts were not
retained; would need local rebuilds) or leave them superseded by the latest.
## Open Polish
- [ ] 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.
- [ ] Optional later: author venues as `.tres` resources instead of `ScenarioFactory` functions.
+794
View File
@@ -0,0 +1,794 @@
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_2u28i"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_acokf"]
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_mqiqi"]
[sub_resource type="Resource" id="Resource_i8fga"]
script = ExtResource("1_2u28i")
required_artifacts = {
1: true
}
grant_if_any_required = false
minimum_age = 0
require_id = false
wand_screening_required = false
policy_summary = "Private property. Entry requires recognition or a valid wristband."
[sub_resource type="Resource" id="Resource_c7lhe"]
script = ExtResource("2_acokf")
id = "tourist"
display_name = "Tourist"
npc_type = 0
temperament = 0.1
base_compliance = 0.9
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_0t6ar"]
script = ExtResource("2_acokf")
id = "confused_visitor"
display_name = "Confused Visitor"
npc_type = 3
temperament = 0.2
base_compliance = 0.85
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_wyv8p"]
script = ExtResource("2_acokf")
id = "delivery"
display_name = "Delivery Worker"
npc_type = 10
temperament = 0.25
base_compliance = 0.8
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Where should I leave this?"])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_v2hc4"]
script = ExtResource("2_acokf")
id = "street_performer"
display_name = "Street Performer"
npc_type = 1
temperament = 0.45
base_compliance = 0.55
return_probability = 0.0
reactions_by_level = {
1: {
0: 55,
2: 15,
3: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_lg4td"]
script = ExtResource("2_acokf")
id = "influencer"
display_name = "Influencer"
npc_type = 2
temperament = 0.5
base_compliance = 0.5
return_probability = 0.0
reactions_by_level = {
1: {
0: 40,
2: 10,
3: 30,
4: 20
},
2: {
0: 30,
2: 20,
4: 35,
8: 15
},
3: {
0: 10,
1: 15,
2: 20,
4: 30,
8: 25
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ijl2s"]
script = ExtResource("2_acokf")
id = "intoxicated"
display_name = "Intoxicated Pedestrian"
npc_type = 4
temperament = 0.6
base_compliance = 0.4
return_probability = 0.0
reactions_by_level = {
1: {
0: 30,
2: 20,
3: 50
},
2: {
0: 40,
2: 35,
3: 15,
4: 10
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 1.8
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_eycmk"]
script = ExtResource("2_acokf")
id = "trespasser"
display_name = "Trespasser"
npc_type = 5
temperament = 0.55
base_compliance = 0.35
return_probability = 0.6
reactions_by_level = {
1: {
0: 30,
2: 25,
3: 45
},
2: {
0: 40,
2: 35,
6: 25
},
3: {
1: 35,
2: 25,
6: 15,
7: 10,
8: 15
},
4: {
1: 70,
7: 30
},
5: {
1: 30,
5: 50,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ggtpw"]
script = ExtResource("2_acokf")
id = "mental_crisis"
display_name = "Person in Crisis"
npc_type = 7
temperament = 0.7
base_compliance = 0.3
return_probability = 0.4
reactions_by_level = {
1: {
0: 25,
2: 20,
3: 55
},
2: {
0: 25,
2: 35,
3: 30,
5: 10
},
3: {
0: 25,
1: 15,
2: 30,
5: 30
},
4: {
1: 40,
5: 35,
7: 25
},
5: {
1: 25,
5: 60,
7: 15
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 2.4
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_oa64f"]
script = ExtResource("2_acokf")
id = "violent_threat"
display_name = "Violent Threat"
npc_type = 6
temperament = 0.95
base_compliance = 0.05
return_probability = 0.0
reactions_by_level = {
1: {
2: 25,
3: 45,
5: 30
},
2: {
2: 30,
5: 45,
7: 25
},
3: {
1: 20,
5: 45,
7: 35
},
4: {
1: 15,
5: 30,
7: 55
},
5: {
1: 30,
5: 40,
7: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_7t7dh"]
script = ExtResource("2_acokf")
id = "protester"
display_name = "Protester"
npc_type = 12
temperament = 0.5
base_compliance = 0.4
return_probability = 0.25
reactions_by_level = {
1: {
0: 25,
2: 35,
3: 25,
4: 15
},
2: {
0: 15,
2: 40,
3: 15,
4: 25,
8: 5
},
3: {
0: 10,
1: 25,
2: 25,
4: 25,
8: 15
},
4: {
1: 60,
2: 20,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_8tj1k"]
script = ExtResource("2_acokf")
id = "authorized_staff"
display_name = "Staff Member"
npc_type = 8
temperament = 0.15
base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 100
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_tnpxm"]
script = ExtResource("2_acokf")
id = "authorized_guest"
display_name = "Authorized Guest"
npc_type = 9
temperament = 0.2
base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 92,
2: 8
},
2: {
0: 88,
2: 12
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource]
script = ExtResource("3_mqiqi")
id = "hollywood_building"
display_name = "Hollywood Boulevard Building"
description = "Steps and entrance of a private building off Hollywood Boulevard."
access_policy = SubResource("Resource_i8fga")
equipment = Array[String](["wristband_policy", "flashlight", "radio"])
locations = {
"entrance": Vector3(0, 0, 1),
"guard_post": Vector3(0, 0, -3),
"queue_area": Vector3(0, 0, 6),
"sidewalk": Vector3(0, 0, 9),
"steps_end": Vector3(0, 0, 3)
}
npc_profiles = Array[ExtResource("2_acokf")]([SubResource("Resource_c7lhe"), SubResource("Resource_0t6ar"), SubResource("Resource_wyv8p"), SubResource("Resource_v2hc4"), SubResource("Resource_lg4td"), SubResource("Resource_ijl2s"), SubResource("Resource_eycmk"), SubResource("Resource_ggtpw"), SubResource("Resource_oa64f"), SubResource("Resource_7t7dh"), SubResource("Resource_8tj1k"), SubResource("Resource_tnpxm")])
event_pool = Array[Dictionary]([{
"base_weight": 1.0,
"id": "tourist_photo",
"intent": "photo",
"kind": "routine",
"npc_type": 0,
"phase": 0,
"probability": 0.42,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "protest_gather",
"intent": "protest",
"kind": "routine",
"npc_type": 12,
"phase": 0,
"probability": 0.18,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "tourist_sit",
"intent": "sit",
"kind": "routine",
"npc_type": 0,
"phase": 0,
"probability": 0.26,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "confused_enter",
"intent": "enter",
"kind": "routine",
"npc_type": 3,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "delivery_drop",
"intent": "deliver",
"kind": "routine",
"npc_type": 10,
"phase": 0,
"probability": 0.3,
"time_range_s": Vector2(0, 600)
}, {
"base_weight": 1.0,
"id": "staff_entry",
"intent": "enter",
"kind": "routine",
"npc_type": 8,
"phase": 0,
"probability": 0.35,
"time_range_s": Vector2(0, 600)
}, {
"base_weight": 1.0,
"id": "guest_entry",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "performer_show",
"intent": "perform",
"kind": "routine",
"npc_type": 1,
"phase": 0,
"probability": 0.24,
"time_range_s": Vector2(300, 800)
}, {
"base_weight": 1.0,
"hint": "hint.body_camera_reason",
"id": "building_filming_boundary",
"intent": "film",
"kind": "scripted",
"npc_type": 2,
"phase": 1,
"probability": 0.4,
"time_range_s": Vector2(300, 600)
}, {
"base_weight": 1.0,
"hint": "hint.ask_reason",
"id": "building_unclear_intent",
"intent": "enter",
"kind": "scripted",
"npc_type": 3,
"phase": 2,
"probability": 0.4,
"time_range_s": Vector2(600, 780)
}, {
"base_weight": 1.0,
"id": "influencer_film",
"intent": "film",
"kind": "routine",
"npc_type": 2,
"phase": 0,
"probability": 0.26,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "intoxicated_wander",
"intent": "wander",
"kind": "routine",
"npc_type": 4,
"phase": 1,
"probability": 0.15,
"time_range_s": Vector2(600, 900)
}, {
"base_weight": 1.0,
"id": "trespass_test",
"intent": "trespass",
"kind": "routine",
"npc_type": 5,
"phase": 0,
"probability": 0.16,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "crisis_event",
"intent": "crisis",
"kind": "scripted",
"npc_type": 7,
"phase": 2,
"probability": 0.5,
"time_range_s": Vector2(640, 740)
}, {
"base_weight": 1.0,
"id": "violent_emergency_event",
"intent": "threat",
"kind": "scripted",
"npc_type": 6,
"phase": 3,
"probability": 0.28,
"requires_emergency": true,
"time_range_s": Vector2(760, 880)
}])
shift_phases = {
0: {
"start_s": 0.0
},
1: {
"start_s": 300.0
},
2: {
"start_s": 600.0
},
3: {
"start_s": 700.0
}
}
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {}
+590
View File
@@ -0,0 +1,590 @@
[gd_resource type="Resource" script_class="VenueScenario" load_steps=13 format=3]
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_lxsgk"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_wfc6m"]
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_8xm7a"]
[sub_resource type="Resource" id="Resource_xha77"]
script = ExtResource("1_lxsgk")
required_artifacts = {
2: true,
3: true
}
grant_if_any_required = false
minimum_age = 21
require_id = true
wand_screening_required = true
policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened."
[sub_resource type="Resource" id="Resource_8ksyk"]
script = ExtResource("2_wfc6m")
id = "tourist"
display_name = "Tourist"
npc_type = 0
temperament = 0.1
base_compliance = 0.9
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_wt1op"]
script = ExtResource("2_wfc6m")
id = "confused_visitor"
display_name = "Confused Visitor"
npc_type = 3
temperament = 0.2
base_compliance = 0.85
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_g1jr2"]
script = ExtResource("2_wfc6m")
id = "influencer"
display_name = "Influencer"
npc_type = 2
temperament = 0.5
base_compliance = 0.5
return_probability = 0.0
reactions_by_level = {
1: {
0: 40,
2: 10,
3: 30,
4: 20
},
2: {
0: 30,
2: 20,
4: 35,
8: 15
},
3: {
0: 10,
1: 15,
2: 20,
4: 30,
8: 25
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ys0b0"]
script = ExtResource("2_wfc6m")
id = "intoxicated"
display_name = "Intoxicated Pedestrian"
npc_type = 4
temperament = 0.6
base_compliance = 0.4
return_probability = 0.0
reactions_by_level = {
1: {
0: 30,
2: 20,
3: 50
},
2: {
0: 40,
2: 35,
3: 15,
4: 10
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 1.8
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_bv0jw"]
script = ExtResource("2_wfc6m")
id = "trespasser"
display_name = "Trespasser"
npc_type = 5
temperament = 0.55
base_compliance = 0.35
return_probability = 0.6
reactions_by_level = {
1: {
0: 30,
2: 25,
3: 45
},
2: {
0: 40,
2: 35,
6: 25
},
3: {
1: 35,
2: 25,
6: 15,
7: 10,
8: 15
},
4: {
1: 70,
7: 30
},
5: {
1: 30,
5: 50,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_7vgx3"]
script = ExtResource("2_wfc6m")
id = "violent_threat"
display_name = "Violent Threat"
npc_type = 6
temperament = 0.95
base_compliance = 0.05
return_probability = 0.0
reactions_by_level = {
1: {
2: 25,
3: 45,
5: 30
},
2: {
2: 30,
5: 45,
7: 25
},
3: {
1: 20,
5: 45,
7: 35
},
4: {
1: 15,
5: 30,
7: 55
},
5: {
1: 30,
5: 40,
7: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_x43ed"]
script = ExtResource("2_wfc6m")
id = "authorized_staff"
display_name = "Staff Member"
npc_type = 8
temperament = 0.15
base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 100
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_07lsg"]
script = ExtResource("2_wfc6m")
id = "authorized_guest"
display_name = "Authorized Guest"
npc_type = 9
temperament = 0.2
base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 92,
2: 8
},
2: {
0: 88,
2: 12
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource]
script = ExtResource("3_8xm7a")
id = "joke_shop"
display_name = "The Joke Shop"
description = "Comedy club door: IDs, reservations, and wand screening."
access_policy = SubResource("Resource_xha77")
equipment = Array[String](["id_check", "guest_list", "wand", "radio"])
locations = {
"door_area": Vector3(0, 0, 3),
"entrance": Vector3(0, 0, 1),
"guard_post": Vector3(0, 0, -3),
"queue_area": Vector3(0, 0, 9),
"sidewalk": Vector3(0, 0, 9),
"steps_end": Vector3(0, 0, 3)
}
npc_profiles = Array[ExtResource("2_wfc6m")]([SubResource("Resource_8ksyk"), SubResource("Resource_wt1op"), SubResource("Resource_g1jr2"), SubResource("Resource_ys0b0"), SubResource("Resource_bv0jw"), SubResource("Resource_7vgx3"), SubResource("Resource_x43ed"), SubResource("Resource_07lsg")])
event_pool = Array[Dictionary]([{
"base_weight": 1.0,
"id": "guest_valid",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true
},
"phase": 0,
"probability": 0.3,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "guest_no_ticket",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": false
},
"phase": 0,
"probability": 0.18,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "guest_expired_id",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 1
},
"phase": 0,
"probability": 0.12,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "guest_no_id",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": false,
"has_ticket": true
},
"phase": 0,
"probability": 0.12,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"hint": "hint.reservation_no_id",
"id": "joke_guest_reservation_no_id",
"intent": "enter",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"guest_list_status": 0,
"has_guest_list": true,
"has_id": false,
"has_ticket": false
},
"phase": 2,
"probability": 0.45,
"time_range_s": Vector2(620, 860)
}, {
"base_weight": 1.0,
"hint": "hint.borrowed_id",
"id": "joke_borrowed_id",
"intent": "enter",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 3,
"ticket_status": 0
},
"phase": 1,
"probability": 0.4,
"time_range_s": Vector2(320, 700)
}, {
"base_weight": 1.0,
"hint": "hint.refuses_wand",
"id": "joke_refuses_wand",
"intent": "enter",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 0,
"ticket_status": 0
},
"phase": 2,
"probability": 0.4,
"refuses_screening": true,
"time_range_s": Vector2(650, 880)
}, {
"base_weight": 1.0,
"hint": "hint.late_arrival",
"id": "joke_late_arrival",
"intent": "arrive",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 0,
"ticket_status": 0
},
"phase": 2,
"probability": 0.35,
"time_range_s": Vector2(700, 860)
}, {
"base_weight": 1.0,
"id": "joke_staff",
"intent": "enter",
"kind": "routine",
"npc_type": 8,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "performer_show",
"intent": "perform",
"kind": "routine",
"npc_type": 1,
"phase": 0,
"probability": 0.15,
"time_range_s": Vector2(300, 800)
}, {
"base_weight": 1.0,
"id": "influencer_film",
"intent": "film",
"kind": "routine",
"npc_type": 2,
"phase": 0,
"probability": 0.15,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "joke_disruptive",
"intent": "force_entry",
"kind": "scripted",
"npc_type": 4,
"phase": 2,
"probability": 0.5,
"time_range_s": Vector2(650, 830)
}])
shift_phases = {
0: {
"start_s": 0.0
},
1: {
"start_s": 300.0
},
2: {
"start_s": 600.0
},
3: {
"start_s": 700.0
}
}
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {
"id_check": ["May I see your ID, please?", "Could you show me some identification?"],
"prohibited_item": ["I can't allow that item inside. Please leave it outside.", "That item isn't permitted in the venue."],
"refusal": ["I'm sorry, I can't let you in without that.", "Without a valid ID, I can't admit you tonight."],
"ticket_check": ["Your ticket or reservation, please.", "Do you have a ticket for tonight's show?"],
"wand_check": ["Please step forward for the wand check.", "Arms out, please — just a quick screening."]
}
+810
View File
@@ -0,0 +1,810 @@
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_xxhx3"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_fiebn"]
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_2dvuq"]
[sub_resource type="Resource" id="Resource_blmt0"]
script = ExtResource("1_xxhx3")
required_artifacts = {
1: true,
3: true,
4: true,
5: true
}
grant_if_any_required = true
minimum_age = 0
require_id = false
wand_screening_required = false
policy_summary = "Entry requires a wristband, credential, ticket, or guest-list confirmation."
[sub_resource type="Resource" id="Resource_oddac"]
script = ExtResource("2_fiebn")
id = "tourist"
display_name = "Tourist"
npc_type = 0
temperament = 0.1
base_compliance = 0.9
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_byir2"]
script = ExtResource("2_fiebn")
id = "confused_visitor"
display_name = "Confused Visitor"
npc_type = 3
temperament = 0.2
base_compliance = 0.85
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_3tt0b"]
script = ExtResource("2_fiebn")
id = "delivery"
display_name = "Delivery Worker"
npc_type = 10
temperament = 0.25
base_compliance = 0.8
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Where should I leave this?"])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ihxib"]
script = ExtResource("2_fiebn")
id = "street_performer"
display_name = "Street Performer"
npc_type = 1
temperament = 0.45
base_compliance = 0.55
return_probability = 0.0
reactions_by_level = {
1: {
0: 55,
2: 15,
3: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_c57iy"]
script = ExtResource("2_fiebn")
id = "influencer"
display_name = "Influencer"
npc_type = 2
temperament = 0.5
base_compliance = 0.5
return_probability = 0.0
reactions_by_level = {
1: {
0: 40,
2: 10,
3: 30,
4: 20
},
2: {
0: 30,
2: 20,
4: 35,
8: 15
},
3: {
0: 10,
1: 15,
2: 20,
4: 30,
8: 25
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_bk410"]
script = ExtResource("2_fiebn")
id = "intoxicated"
display_name = "Intoxicated Pedestrian"
npc_type = 4
temperament = 0.6
base_compliance = 0.4
return_probability = 0.0
reactions_by_level = {
1: {
0: 30,
2: 20,
3: 50
},
2: {
0: 40,
2: 35,
3: 15,
4: 10
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 1.8
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_jp52w"]
script = ExtResource("2_fiebn")
id = "trespasser"
display_name = "Trespasser"
npc_type = 5
temperament = 0.55
base_compliance = 0.35
return_probability = 0.6
reactions_by_level = {
1: {
0: 30,
2: 25,
3: 45
},
2: {
0: 40,
2: 35,
6: 25
},
3: {
1: 35,
2: 25,
6: 15,
7: 10,
8: 15
},
4: {
1: 70,
7: 30
},
5: {
1: 30,
5: 50,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_5kplf"]
script = ExtResource("2_fiebn")
id = "mental_crisis"
display_name = "Person in Crisis"
npc_type = 7
temperament = 0.7
base_compliance = 0.3
return_probability = 0.4
reactions_by_level = {
1: {
0: 25,
2: 20,
3: 55
},
2: {
0: 25,
2: 35,
3: 30,
5: 10
},
3: {
0: 25,
1: 15,
2: 30,
5: 30
},
4: {
1: 40,
5: 35,
7: 25
},
5: {
1: 25,
5: 60,
7: 15
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 2.4
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_6bscs"]
script = ExtResource("2_fiebn")
id = "violent_threat"
display_name = "Violent Threat"
npc_type = 6
temperament = 0.95
base_compliance = 0.05
return_probability = 0.0
reactions_by_level = {
1: {
2: 25,
3: 45,
5: 30
},
2: {
2: 30,
5: 45,
7: 25
},
3: {
1: 20,
5: 45,
7: 35
},
4: {
1: 15,
5: 30,
7: 55
},
5: {
1: 30,
5: 40,
7: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_n4lxh"]
script = ExtResource("2_fiebn")
id = "protester"
display_name = "Protester"
npc_type = 12
temperament = 0.5
base_compliance = 0.4
return_probability = 0.25
reactions_by_level = {
1: {
0: 25,
2: 35,
3: 25,
4: 15
},
2: {
0: 15,
2: 40,
3: 15,
4: 25,
8: 5
},
3: {
0: 10,
1: 25,
2: 25,
4: 25,
8: 15
},
4: {
1: 60,
2: 20,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_6st6l"]
script = ExtResource("2_fiebn")
id = "authorized_staff"
display_name = "Staff Member"
npc_type = 8
temperament = 0.15
base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 100
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_nf2oq"]
script = ExtResource("2_fiebn")
id = "authorized_guest"
display_name = "Authorized Guest"
npc_type = 9
temperament = 0.2
base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 92,
2: 8
},
2: {
0: 88,
2: 12
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource]
script = ExtResource("3_2dvuq")
id = "tv_studio"
display_name = "Hollywood Blvd Late Night TV Studio"
description = "Crowd, celebrities, and staff around a late-night studio."
access_policy = SubResource("Resource_blmt0")
equipment = Array[String](["wristband_policy", "guest_list", "radio", "flashlight"])
locations = {
"entrance": Vector3(0, 0, 1),
"guard_post": Vector3(0, 0, -3),
"queue_area": Vector3(0, 0, 9),
"sidewalk": Vector3(0, 0, 9),
"stage_door": Vector3(6, 0, -2),
"steps_end": Vector3(0, 0, 3)
}
npc_profiles = Array[ExtResource("2_fiebn")]([SubResource("Resource_oddac"), SubResource("Resource_byir2"), SubResource("Resource_3tt0b"), SubResource("Resource_ihxib"), SubResource("Resource_c57iy"), SubResource("Resource_bk410"), SubResource("Resource_jp52w"), SubResource("Resource_5kplf"), SubResource("Resource_6bscs"), SubResource("Resource_n4lxh"), SubResource("Resource_6st6l"), SubResource("Resource_nf2oq")])
event_pool = Array[Dictionary]([{
"base_weight": 1.0,
"id": "audience_ticket",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_ticket": true
},
"phase": 0,
"probability": 0.35,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "protest_outside",
"intent": "protest",
"kind": "routine",
"npc_type": 12,
"phase": 0,
"probability": 0.18,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "celebrity_arrival",
"intent": "arrive",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_guest_list": true
},
"phase": 0,
"probability": 0.2,
"time_range_s": Vector2(300, 700)
}, {
"base_weight": 1.0,
"id": "crew_shift",
"intent": "enter",
"kind": "routine",
"npc_type": 8,
"phase": 0,
"probability": 0.3,
"time_range_s": Vector2(0, 700)
}, {
"base_weight": 1.0,
"id": "tourist_photo",
"intent": "photo",
"kind": "routine",
"npc_type": 0,
"phase": 0,
"probability": 0.35,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "confused_enter",
"intent": "enter",
"kind": "routine",
"npc_type": 3,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "influencer_film",
"intent": "film",
"kind": "routine",
"npc_type": 2,
"phase": 0,
"probability": 0.28,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "trespass_blend",
"intent": "blend_in",
"kind": "routine",
"npc_type": 5,
"phase": 0,
"probability": 0.15,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "studio_counterfeit",
"intent": "counterfeit_credential",
"kind": "scripted",
"npc_type": 5,
"override_access": {
"credential_status": 2,
"wristband_status": 2
},
"phase": 2,
"probability": 0.55,
"time_range_s": Vector2(620, 780)
}, {
"base_weight": 1.0,
"hint": "hint.film_boundary",
"id": "studio_film_boundary",
"intent": "film_boundary",
"kind": "scripted",
"npc_type": 2,
"phase": 1,
"probability": 0.5,
"time_range_s": Vector2(330, 560)
}, {
"base_weight": 1.0,
"hint": "hint.film_boundary_repeat",
"id": "studio_film_boundary_repeat",
"intent": "film_boundary",
"kind": "scripted",
"npc_type": 2,
"phase": 2,
"probability": 0.45,
"time_range_s": Vector2(640, 830)
}, {
"base_weight": 1.0,
"extra_spawns": [{
"intent": "photo",
"npc_type": 0
}, {
"intent": "photo",
"npc_type": 0
}, {
"intent": "film",
"npc_type": 2
}],
"hint": "hint.celebrity_arrival",
"id": "studio_celebrity_arrival",
"intent": "arrive",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"guest_list_status": 0,
"has_guest_list": true
},
"phase": 2,
"probability": 0.5,
"time_range_s": Vector2(620, 760)
}, {
"base_weight": 1.0,
"id": "studio_follow_through",
"intent": "follow_authorized",
"kind": "scripted",
"npc_type": 5,
"override_access": {
"has_credential": false,
"has_guest_list": false,
"has_ticket": false,
"has_wristband": false
},
"phase": 2,
"probability": 0.45,
"time_range_s": Vector2(700, 860)
}])
shift_phases = {
0: {
"start_s": 0.0
},
1: {
"start_s": 300.0
},
2: {
"start_s": 600.0
},
3: {
"start_s": 700.0
}
}
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {}
+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.13" config/version="0.2.16"
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")
+57
View File
@@ -34,12 +34,69 @@ static func lookup(key: String) -> String:
_ensure_loaded() _ensure_loaded()
return TABLE.get(key, key) return TABLE.get(key, key)
## Number of `%` conversion placeholders in a template, ignoring `%%` escapes.
static func placeholder_count(template: String) -> int:
var count := 0
var i := 0
while i < template.length():
if template[i] == "%":
if i + 1 < template.length() and template[i + 1] == "%":
i += 2
continue
count += 1
i += 1
return count
## Format a string by key with positional arguments. ## Format a string by key with positional arguments.
##
## GDScript's `%` binds arguments by order, so a key whose placeholder count drifts
## from its call site would push an engine error. Validate first and fail safe: log
## a warning and return the raw template instead of crashing.
static func fmt(key: String, args: Array) -> String: static func fmt(key: String, args: Array) -> String:
var template := lookup(key) var template := lookup(key)
if template == key: if template == key:
return template return template
var needed := placeholder_count(template)
if needed != args.size():
push_warning("Strings.fmt(%s): template needs %d args, got %d" % [key, needed, args.size()])
return template
return template % args return template % args
## Lint the loaded table for formatting hazards. Returns an array of problems.
## Checks: trailing `%`, invalid conversions, and mixed positional (`%1$s`) and
## sequential placeholders in one template.
static func validate() -> Array[String]:
_ensure_loaded()
var problems: Array[String] = []
for key in TABLE.keys():
var template := str(TABLE[key])
var positional := template.contains("$")
var sequential := 0
var i := 0
while i < template.length():
if template[i] != "%":
i += 1
continue
if i + 1 >= template.length():
problems.append("%s: trailing %% escape" % key)
break
var next := template[i + 1]
if next == "%":
i += 2
continue
var j := i + 1
while j < template.length() and template[j] in "0123456789.$":
if template[j] == "$":
positional = true
j += 1
if j >= template.length() or not (template[j] in "sdfxXv"):
problems.append("%s: unsupported conversion near '%%%s'" % [key, template.substr(i + 1, 3)])
elif not positional:
sequential += 1
i = j + 1
if positional and sequential > 0:
problems.append("%s: mixes positional (%%1$s) and sequential (%%s) placeholders" % key)
return problems
func _init() -> void: func _init() -> void:
push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.") push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.")
+64
View File
@@ -23,6 +23,70 @@ func test_strings_json_backed_all_resolve() -> bool:
return TestUtils.fail("key %s resolves to itself — table entry missing" % key) return TestUtils.fail("key %s resolves to itself — table entry missing" % key)
return true return true
func test_placeholder_count() -> bool:
var a := TestUtils.check_eq(Strings.placeholder_count("no args"), 0, "plain text has no placeholders")
var b := TestUtils.check_eq(Strings.placeholder_count("%s complies"), 1, "one %s")
var c := TestUtils.check_eq(Strings.placeholder_count("%s: \"%s\""), 2, "two %s")
var d := TestUtils.check_eq(Strings.placeholder_count("100%% sure"), 0, "%% is an escape, not an argument")
var e := TestUtils.check_eq(Strings.placeholder_count("%.0f%% mood"), 1, "float plus percent sign")
return a and b and c and d and e
func test_fmt_fails_safe_on_arg_mismatch() -> bool:
## A drifting placeholder count must not raise an engine error at runtime.
var result: String = Strings.fmt("react.comply", [])
var a := TestUtils.check_eq(result, Strings.lookup("react.comply"), "wrong arg count returns the raw template")
var extra: String = Strings.fmt("react.comply", ["A", "B"])
var b := TestUtils.check_eq(extra, Strings.lookup("react.comply"), "too many args also fails safe")
return a and b
func test_table_validates_cleanly() -> bool:
var problems: Array = Strings.validate()
if not problems.is_empty():
return TestUtils.fail("string table format problems: %s" % str(problems))
return true
func test_every_placeholder_key_formats() -> bool:
## Feed each template one dummy arg per placeholder, with a type that matches
## the conversion (%s text, %d int, %f float). Nothing may be left unformatted.
var problems: Array[String] = []
for key in Strings.TABLE.keys():
var template: String = str(Strings.TABLE[key])
var specs: Array[String] = _specifiers(template)
if specs.is_empty():
continue
var args := []
for spec in specs:
var conv: String = spec.substr(spec.length() - 1)
if conv == "d" or conv == "x" or conv == "X":
args.append(7)
elif conv == "f":
args.append(1.5)
else:
args.append("x")
var out: String = Strings.fmt(key, args)
if out == template:
problems.append("%s did not format (%s)" % [key, str(specs)])
return TestUtils.check(problems.is_empty(), "all placeholder keys format (problems: %s)" % str(problems))
func _specifiers(template: String) -> Array[String]:
var out: Array[String] = []
var i := 0
while i < template.length():
if template[i] != "%":
i += 1
continue
if i + 1 < template.length() and template[i + 1] == "%":
i += 2
continue
var j := i + 1
while j < template.length() and template[j] in "0123456789.$":
j += 1
if j < template.length():
out.append(template.substr(i, j - i + 1))
j += 1
i = j
return out
func test_strings_cover_ui_keys() -> bool: func test_strings_cover_ui_keys() -> bool:
## Every key referenced by the HUD and game manager must exist. ## Every key referenced by the HUD and game manager must exist.
var required := [ var required := [
+62
View File
@@ -0,0 +1,62 @@
extends RefCounted
## Venues can be authored as `.tres` resources: the built-in factories export and
## round-trip without losing their data (optional editor workflow, TODO tooling).
const Factory := preload("res://src/content/scenario_factory.gd")
const Rules := preload("res://src/core/rules.gd")
const VenueScenario := preload("res://src/core/venue_scenario.gd")
func test_venues_round_trip_through_tres() -> bool:
var scenarios := [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]
for scenario in scenarios:
var path := "user://%s_roundtrip.tres" % scenario.id
var err: int = ResourceSaver.save(scenario, path)
if err != OK:
return TestUtils.fail("could not save %s (error %d)" % [scenario.id, err])
var loaded = ResourceLoader.load(path)
if not (loaded is VenueScenario):
return TestUtils.fail("%s did not load back as VenueScenario" % scenario.id)
var a := TestUtils.check_eq(loaded.id, scenario.id, "%s id survives" % scenario.id)
var b := TestUtils.check_eq(loaded.display_name, scenario.display_name, "%s name survives" % scenario.id)
var c := TestUtils.check_eq(loaded.event_pool.size(), scenario.event_pool.size(), "%s event pool survives" % scenario.id)
var d := TestUtils.check_eq(loaded.npc_profiles.size(), scenario.npc_profiles.size(), "%s profiles survive" % scenario.id)
var e := TestUtils.check_eq(loaded.waypoint_for_intent("sit"), scenario.waypoint_for_intent("sit"), "%s locations survive" % scenario.id)
if not (a and b and c and d and e):
return false
return true
func test_exported_tres_files_match_the_factory() -> bool:
## content/venues/*.tres is generated by tools/export_venues.gd; make sure it is
## present and not stale relative to the factory.
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
var path := "res://content/venues/%s.tres" % scenario.id
if not FileAccess.file_exists(path):
return TestUtils.fail("missing exported venue %s — run tools/export_venues.gd" % path)
var loaded = ResourceLoader.load(path)
if not (loaded is VenueScenario):
return TestUtils.fail("%s is not a VenueScenario" % path)
if loaded.event_pool.size() != scenario.event_pool.size():
return TestUtils.fail("%s is stale (events %d vs %d) — re-run tools/export_venues.gd" % [
path, loaded.event_pool.size(), scenario.event_pool.size()])
if not TestUtils.check_eq(loaded.shift_duration_s, scenario.shift_duration_s, "%s duration matches" % scenario.id):
return false
return true
func test_exported_policy_and_events_intact() -> bool:
var loaded = ResourceLoader.load("res://content/venues/joke_shop.tres")
var a := TestUtils.check(loaded != null and loaded.access_policy != null, "access policy survives export")
var b := TestUtils.check_eq(loaded.access_policy.require_id, true, "joke shop still requires ID")
var c := TestUtils.check_eq(loaded.access_policy.wand_screening_required, true, "wand screening still required")
var d := TestUtils.check_eq(loaded.equipment.has("wand"), true, "equipment survives export")
var ids: Array = []
for event in loaded.event_pool:
ids.append(str((event as Dictionary).get("id", "")))
var e := TestUtils.check(ids.has("joke_refuses_wand"), "authored events survive export")
var f := TestUtils.check(loaded.door_staff_lines.has("refusal"), "door-staff lines survive export")
return a and b and c and d and e and f
func test_exported_phases_survive() -> bool:
var loaded = ResourceLoader.load("res://content/venues/hollywood_building.tres")
var a := TestUtils.check(loaded.shift_phases.has(Rules.Phase.LATE), "late phase survives export")
var b := TestUtils.check_eq(int(loaded.shift_phases[Rules.Phase.LATE].get("start_s", -1)), 700, "late start preserved")
return a and b
+26
View File
@@ -0,0 +1,26 @@
extends SceneTree
## Export the built-in scenarios to `.tres` resources for editor authoring.
##
## Usage:
## godot --headless --path . --script res://tools/export_venues.gd
##
## Writes content/venues/<id>.tres. These mirror ScenarioFactory exactly; the
## factory stays the source of truth for the prototype, and the exported files are
## what an editor-based workflow would edit and ship.
const Factory := preload("res://src/content/scenario_factory.gd")
func _initialize() -> void:
var out_dir := "res://content/venues"
DirAccess.make_dir_recursive_absolute(out_dir)
var scenarios := [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]
var failures := 0
for scenario in scenarios:
var path := "%s/%s.tres" % [out_dir, scenario.id]
var err: int = ResourceSaver.save(scenario, path)
if err == OK:
print("wrote %s" % path)
else:
printerr("failed to write %s (error %d)" % [path, err])
failures += 1
quit(0 if failures == 0 else 1)
+210
View File
@@ -17,6 +17,12 @@ Two commands:
--draft deterministic wireframe sketches drawn with Pillow --draft deterministic wireframe sketches drawn with Pillow
(no network or API key; good for CI and layout checks). (no network or API key; good for CI and layout checks).
judge send each capture together with its reference design to a vision
model and ask whether they depict the same screen. Pixel and
perceptual metrics cannot judge composition; this closes that gap.
Requires OPENROUTER_API_KEY (falls back to the repo .env).
--dry-run build and print the requests without calling the API.
compare compare docs/screenshots/*.png against the reference designs and compare compare docs/screenshots/*.png against the reference designs and
flag gross drift. Semantic composition is judged with coarse, flag gross drift. Semantic composition is judged with coarse,
Pillow-only signals (aspect ratio, frame size, blank-frame Pillow-only signals (aspect ratio, frame size, blank-frame
@@ -33,6 +39,7 @@ Run from the repo root:
""" """
import argparse import argparse
import json
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -54,6 +61,10 @@ ASPECT = 16 / 9
# the model asset_pipeline.py already trusts for non-alpha roles). # the model asset_pipeline.py already trusts for non-alpha roles).
DEFAULT_MODEL = "openai/gpt-5-image-mini" DEFAULT_MODEL = "openai/gpt-5-image-mini"
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
# Shared art direction, appended to every prompt so the generated frames read # Shared art direction, appended to every prompt so the generated frames read
# as one coherent game. "scene" entries are first-person 3D world views; # as one coherent game. "scene" entries are first-person 3D world views;
# "ui" entries are in-game panels overlaid on a softened scene. # "ui" entries are in-game panels overlaid on a softened scene.
@@ -582,6 +593,193 @@ def compare(args):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Vision judge — semantic "same screen?" check against the reference designs.
# ---------------------------------------------------------------------------
JUDGE_INSTRUCTIONS = (
"You are reviewing screenshots of a low-poly 3D security-guard game. "
"IMAGE 1 is a CONCEPT MOCKUP of the intended screen (it may be painted in a "
"different art style and show different actors); IMAGE 2 is the ACTUAL "
"capture from the game. Different art style, fidelity, fonts, and character "
"models are EXPECTED and must be ignored.\n\n"
"Judge only whether IMAGE 2 realises the same SCREEN and LAYOUT INTENT as "
"IMAGE 1: is it the same kind of screen (world view, HUD overlay, modal "
"panel), are the main interface regions present in roughly the same places "
"(target line, inspection card, report form, on-screen buttons), and is the "
"subject of the shot comparable (entrance, street, person)?\n\n"
"Answer with JSON only: {\"match\": true|false, \"confidence\": 0..1, "
"\"differences\": [\"short point\", ...]}. Set match=true when the capture "
"realises the mockup's screen even if it looks much simpler."
)
def _load_env_file(path=None):
"""Populate os.environ from a .env file without overriding real variables."""
path = Path(path) if path else ROOT / ".env"
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
def _data_url(path):
import base64
payload = Path(path).read_bytes()
return "data:image/png;base64," + base64.b64encode(payload).decode("ascii")
def build_judge_request(model, screen, capture_path, design_path):
"""Build the OpenRouter chat payload for one capture/design pair."""
prompt = (f"{JUDGE_INSTRUCTIONS}\n\nScreen: {screen['title']} "
f"({screen['source']}). Design notes: {screen['description']}")
return {
"model": model,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": _data_url(design_path)}},
{"type": "image_url", "image_url": {"url": _data_url(capture_path)}},
],
}],
"temperature": 0,
"response_format": {"type": "json_object"},
}
def parse_judge_response(payload):
"""Extract a verdict dict from an OpenRouter chat response."""
try:
text = payload["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError):
return {"match": None, "confidence": 0.0,
"differences": ["unreadable model response"]}
if isinstance(text, list): # some providers return content parts
text = "".join(part.get("text", "") for part in text
if isinstance(part, dict))
text = str(text).strip()
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
text = text[start:end + 1]
import json
try:
data = json.loads(text)
except ValueError:
return {"match": None, "confidence": 0.0,
"differences": [f"non-JSON response: {text[:120]}"]}
differences = data.get("differences") or []
if not isinstance(differences, list):
differences = [str(differences)]
return {
"match": data.get("match") if isinstance(data.get("match"), bool) else None,
"confidence": float(data.get("confidence", 0.0) or 0.0),
"differences": [str(item) for item in differences],
}
def classify(verdict, threshold=0.6):
"""Turn a model verdict into a QA class.
The reference images are concept mockups, so a low-confidence disagreement is
a review note, not a failure. Only a confident mismatch fails the check.
"""
if verdict.get("match") is True:
return "match"
if verdict.get("match") is False:
return "bad" if float(verdict.get("confidence", 0.0)) >= threshold else "review"
return "unknown"
def _call_openrouter(body, timeout=180):
import urllib.error
import urllib.request
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not key:
raise ValueError("OPENROUTER_API_KEY is not set (repo .env is read "
"automatically); use --dry-run to inspect requests")
req = urllib.request.Request(
OPENROUTER_CHAT_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
"X-Title": "Step Line",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise ValueError(f"OpenRouter HTTP {exc.code}: {detail[:300]}")
def judge(args):
_load_env_file()
if not SCREENSHOTS_DIR.is_dir():
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}")
refs = {screen["name"]: screen["ref"] for screen in SCREENS if screen["ref"]}
by_name = {screen["name"]: screen for screen in SCREENS}
results = []
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
if args.only and args.only not in capture.name:
continue
design_name = next((n for n, ref in refs.items()
if ref == capture.name), None)
if design_name is None:
results.append({"capture": capture.name, "screen": None,
"match": None, "confidence": 0.0,
"differences": ["no design reference for this capture"]})
continue
reference = _design_path(design_name)
if not reference.is_file():
results.append({"capture": capture.name, "screen": design_name,
"match": None, "confidence": 0.0,
"differences": ["missing reference design; run generate"]})
continue
body = build_judge_request(args.model, by_name[design_name],
capture, reference)
if args.dry_run:
results.append({
"capture": capture.name, "screen": design_name, "match": None,
"confidence": 0.0, "model": body["model"],
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
})
continue
verdict = parse_judge_response(_call_openrouter(body))
verdict.update({"capture": capture.name, "screen": design_name,
"class": classify(verdict, args.fail_confidence)})
results.append(verdict)
if args.json:
print(json.dumps(results, indent=2))
else:
labels = {"match": "MATCH", "review": "REVIEW", "bad": "DIFF",
"unknown": "UNKNOWN"}
for result in results:
print(f"{labels[result['class']]:7} {result['capture']} "
f"(design: {result['screen']}) "
f"confidence={result['confidence']:.2f}")
for difference in result["differences"]:
print(f" - {difference}")
counts = {key: sum(1 for r in results if r["class"] == key)
for key in labels}
print(f"Summary: {counts['match']}/{len(results)} match, "
f"{counts['review']} review, {counts['bad']} diff "
f"(fail threshold confidence >= {args.fail_confidence:.2f})")
if args.dry_run:
return 0
return 1 if any(r["class"] == "bad" for r in results) else 0
def build_parser(): def build_parser():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Generate and compare Step Line screen-design references.") description="Generate and compare Step Line screen-design references.")
@@ -599,6 +797,18 @@ def build_parser():
default=generate_textures.GEN_SIZE) default=generate_textures.GEN_SIZE)
gen.set_defaults(handler=generate, draft=False) gen.set_defaults(handler=generate, draft=False)
judge_cmd = commands.add_parser(
"judge", help="ask a vision model whether captures match the designs")
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
judge_cmd.add_argument("--json", action="store_true")
judge_cmd.add_argument("--dry-run", action="store_true",
help="print requests instead of calling the API")
judge_cmd.add_argument("--only", default="",
help="only judge captures whose name contains this text")
judge_cmd.add_argument("--fail-confidence", type=float, default=0.6,
help="a mismatch at or above this confidence fails")
judge_cmd.set_defaults(handler=judge)
cmp = commands.add_parser( cmp = commands.add_parser(
"compare", help="compare docs/screenshots/*.png against the designs") "compare", help="compare docs/screenshots/*.png against the designs")
cmp.add_argument("--json", action="store_true") cmp.add_argument("--json", action="store_true")
+92
View File
@@ -86,5 +86,97 @@ class ScreenDesignsTests(unittest.TestCase):
self.assertTrue(any("aspect" in e for e in report["errors"])) self.assertTrue(any("aspect" in e for e in report["errors"]))
class VisionJudgeTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.capture = self.root / "capture.png"
self.design = self.root / "design.png"
Image.new("RGB", (64, 36), (20, 30, 40)).save(self.capture)
Image.new("RGB", (64, 36), (200, 190, 180)).save(self.design)
self.screen = screen_designs.SCREENS[0]
def tearDown(self):
self.temp_dir.cleanup()
def test_judge_request_embeds_both_images(self):
body = screen_designs.build_judge_request(
"model-x", self.screen, self.capture, self.design)
self.assertEqual(body["model"], "model-x")
self.assertEqual(body["temperature"], 0)
self.assertEqual(body["response_format"], {"type": "json_object"})
parts = body["messages"][0]["content"]
self.assertEqual(parts[0]["type"], "text")
images = [p for p in parts if p["type"] == "image_url"]
self.assertEqual(len(images), 2, "design and capture are both attached")
for image in images:
self.assertTrue(image["image_url"]["url"].startswith(
"data:image/png;base64,"))
self.assertIn(self.screen["title"], parts[0]["text"])
self.assertIn("mockup", parts[0]["text"].lower())
def test_parse_plain_json_verdict(self):
payload = {"choices": [{"message": {"content":
'{"match": true, "confidence": 0.9, "differences": ["style"]}'}}]}
verdict = screen_designs.parse_judge_response(payload)
self.assertTrue(verdict["match"])
self.assertAlmostEqual(verdict["confidence"], 0.9)
self.assertEqual(verdict["differences"], ["style"])
def test_parse_fenced_json_verdict(self):
payload = {"choices": [{"message": {"content":
'Sure:\n```json\n{"match": false, "confidence": 0.7}\n```'}}]}
verdict = screen_designs.parse_judge_response(payload)
self.assertFalse(verdict["match"])
self.assertAlmostEqual(verdict["confidence"], 0.7)
def test_parse_content_parts(self):
payload = {"choices": [{"message": {"content": [
{"type": "text", "text": '{"match": true, "confidence": 0.5}'}]}}]}
verdict = screen_designs.parse_judge_response(payload)
self.assertTrue(verdict["match"])
def test_parse_non_json_is_unknown(self):
payload = {"choices": [{"message": {"content": "no idea"}}]}
verdict = screen_designs.parse_judge_response(payload)
self.assertIsNone(verdict["match"])
self.assertTrue(verdict["differences"])
def test_parse_missing_choices_is_unknown(self):
verdict = screen_designs.parse_judge_response({})
self.assertIsNone(verdict["match"])
def test_classify_thresholds(self):
self.assertEqual(screen_designs.classify(
{"match": True, "confidence": 0.1}), "match")
self.assertEqual(screen_designs.classify(
{"match": False, "confidence": 0.9}), "bad")
self.assertEqual(screen_designs.classify(
{"match": False, "confidence": 0.3}), "review")
self.assertEqual(screen_designs.classify(
{"match": None, "confidence": 0.0}), "unknown")
self.assertEqual(screen_designs.classify(
{"match": False, "confidence": 0.55}, threshold=0.5), "bad")
def test_env_file_does_not_override_existing(self):
env_path = self.root / ".env"
env_path.write_text('OPENROUTER_API_KEY=from-file\nOTHER=1\n',
encoding="utf-8")
import os
old = os.environ.get("OPENROUTER_API_KEY")
os.environ["OPENROUTER_API_KEY"] = "from-env"
os.environ.pop("OTHER", None)
try:
screen_designs._load_env_file(env_path)
self.assertEqual(os.environ["OPENROUTER_API_KEY"], "from-env")
self.assertEqual(os.environ.get("OTHER"), "1")
finally:
if old is None:
os.environ.pop("OPENROUTER_API_KEY", None)
else:
os.environ["OPENROUTER_API_KEY"] = old
os.environ.pop("OTHER", None)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()