Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f763cff744 | ||
|
|
72b9378e69 | ||
|
|
74f14014b2 | ||
|
|
a2a0ded527 | ||
|
|
922a990c7a | ||
|
|
f0cec192a7 | ||
|
|
2dba562c52 | ||
|
|
afd086ba11 |
No files matched your search
@@ -514,3 +514,84 @@ Completed 2026-10-31 (working tree). Headless **139/139**; verified visually at
|
|||||||
|
|
||||||
- [x] Escalation colors that still read in grayscale (shape or label, not hue alone).
|
- [x] Escalation colors that still read in grayscale (shape or label, not hue alone).
|
||||||
- [x] HUD font scale in settings.
|
- [x] HUD font scale in settings.
|
||||||
|
|
||||||
|
## Scenario content still missing vs PLAN lists
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scenario_content.gd` spawns the refusal event and the celebrity arrival through the real signal path.
|
||||||
|
|
||||||
|
|
||||||
|
- [x] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
|
||||||
|
refuses wand screening; late arrival during a performance.
|
||||||
|
- [x] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
|
||||||
|
crowd spike (more rubberneckers, not only one `arrive` spawn).
|
||||||
|
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
||||||
|
at least one authored event.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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: `TODO.md` scenario content gaps (Joke Shop / Studio / Building events), then the
|
- **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
|
||||||
remaining polish and tooling items.
|
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,30 @@ 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
|
||||||
|
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
|
||||||
|
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
|
||||||
|
`NPCController.refuses_screening`; `_toggle_wand_screening` logs `wand.refused`, records a
|
||||||
|
`wand_refused` event, applies a small prop hit, and does not open the panel). `film_boundary`
|
||||||
|
is a new intent that maps to `steps_end` so the influencer stands past the posted line.
|
||||||
- **Accessibility (2026-10-31):** escalation text is `"<name> [###--] L3/5"` via
|
- **Accessibility (2026-10-31):** escalation text is `"<name> [###--] L3/5"` via
|
||||||
`Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses
|
`Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses
|
||||||
`add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`,
|
`add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`,
|
||||||
@@ -135,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
|
||||||
@@ -148,7 +175,29 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
`actions/checkout` writes (`git config --local --get-regexp '^http\..*\.extraheader$'`),
|
`actions/checkout` writes (`git config --local --get-regexp '^http\..*\.extraheader$'`),
|
||||||
which carries the runner's basic-auth credentials for the same server. Verified locally by
|
which carries the runner's basic-auth credentials for the same server. Verified locally by
|
||||||
adding that extraheader and publishing v0.2.10 (all five artifacts). **v0.2.9 and v0.2.10
|
adding that extraheader and publishing v0.2.10 (all five artifacts). **v0.2.9 and v0.2.10
|
||||||
are published**; v0.2.4–v0.2.8 tags have no artifacts. Verify the CI path on the next tag. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
are published**; v0.2.4–v0.2.8 tags have no artifacts. Verify the CI path on the next tag.
|
||||||
|
- **CI publishing verified (2026-10-31):** tagging v0.2.12 produced a release with all five
|
||||||
|
artifacts automatically (no repo secret), confirming the checkout-extraheader fallback.
|
||||||
|
- **Host access (2026-10-31):** `root@192.168.88.30` now works from this workstation (key auth),
|
||||||
|
host name `gitea`, Gitea 28.2.0. Maintenance applied: `apt-get clean`, journald capped to
|
||||||
|
`SystemMaxUse=300M` (`/etc/systemd/journald.conf.d/99-size.conf`, journal ~133M), disk at
|
||||||
|
~7.6G free of 16G.
|
||||||
|
- **Host disk resolved (2026-10-31):** the Gitea VM root filesystem was expanded from 7.8G to
|
||||||
|
16G (`/dev/mapper/pve-vm--500--disk--0`, now ~7.8G free). The pruned **v0.2.0** and **v0.2.9**
|
||||||
|
releases were restored from the local `build/` artifacts and verified (4 and 5 assets).
|
||||||
|
`git.allucanget.biz` runs on this host as user `zwitschi`; root@192.168.88.30 is **not**
|
||||||
|
reachable from this workstation (publickey denied; password auth closes the banner), so host
|
||||||
|
maintenance must go through a `zwitschi` login with sudo.
|
||||||
|
- **Host disk full (2026-10-31, historical):** the v0.2.11 tag build created release id 27 and logged the
|
||||||
|
uploads, but they failed with `HTTP 500 Attachments.Save: write … no space left on device`
|
||||||
|
(`/dev/mapper/pve-vm--500--disk--0`, 7.8G, 100%, 0 free). `git push` then failed too
|
||||||
|
("unpacker error"). Unblocked by deleting the superseded **v0.2.0** and **v0.2.9** releases
|
||||||
|
via the API (~565 MB of assets), then publishing v0.2.11 locally with the fixed script
|
||||||
|
(verified 5 assets). Remaining ~150 MB is tight — the next release needs host cleanup on
|
||||||
|
192.168.88.30: `sudo journalctl --vacuum-size=100M` (journal 837 MB), `sudo apt-get clean`
|
||||||
|
(91 MB), clear `/var/lib/gitea/data/attachments/tmp`. SSH works as `zwitschi`; `sudo -n`
|
||||||
|
needs a password. **Releases now:** v0.2.11, v0.2.10, v0.2.1 (5 assets each); v0.2.0 and
|
||||||
|
v0.2.9 entries were pruned. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
||||||
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
|
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
|
||||||
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
|
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
|
||||||
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
|
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ The central design goal is simple:
|
|||||||
|
|
||||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||||
|
|
||||||
> **Latest: v0.2.11** — release pipeline fix: tag builds publish without a repo secret by reusing
|
> Downloads are public (no sign-in required) since the host's instance-wide
|
||||||
> the runner's checkout credentials.
|
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
||||||
|
>
|
||||||
|
> **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
|
||||||
@@ -193,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
|
||||||
|
|
||||||
@@ -203,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`.
|
||||||
|
|
||||||
@@ -256,6 +274,9 @@ godot --headless --path . --script res://src/test/smoke_timeline.gd
|
|||||||
|
|
||||||
# Audio smoke: crowd bed follows mood, radio open/close crackle
|
# Audio smoke: crowd bed follows mood, radio open/close crackle
|
||||||
godot --headless --path . --script res://src/test/smoke_audio.gd
|
godot --headless --path . --script res://src/test/smoke_audio.gd
|
||||||
|
|
||||||
|
# Scenario-content smoke: wand refusal, celebrity crowd spike
|
||||||
|
godot --headless --path . --script res://src/test/smoke_scenario_content.gd
|
||||||
```
|
```
|
||||||
|
|
||||||
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
||||||
@@ -308,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: **139 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)
|
- 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`)
|
||||||
@@ -325,6 +346,9 @@ 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
|
||||||
- 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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +1,15 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Godot 4.3 prototype. Release **v0.2.9** is tagged. Headless suite **139/139**, 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, 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.
|
||||||
|
|
||||||
## Scenario content still missing vs PLAN lists
|
## Future ideas (optional, not scheduled)
|
||||||
|
|
||||||
- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
|
- [ ] Live 3D body-camera replay (the timeline is text-only today).
|
||||||
refuses wand screening; late arrival during a performance.
|
- [ ] LOD/culling for NPCs if `MAX_LIVE_NPCS` grows past the measured 16-avatar budget.
|
||||||
- [ ] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
|
- [ ] Additional venues or seasonal events authored from the new `.tres` files.
|
||||||
crowd spike (more rubberneckers, not only one `arrive` spawn).
|
- [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
|
||||||
- [ ] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
- [ ] Web export build target.
|
||||||
at least one authored event.
|
|
||||||
|
|
||||||
## Distribution follow-up (host config, deferred)
|
|
||||||
|
|
||||||
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).
|
|
||||||
- [ ] **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`).
|
|
||||||
- [ ] Verify the fallback on the next tag push produces a release automatically.
|
|
||||||
- [ ] 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.
|
|
||||||
@@ -56,6 +56,15 @@
|
|||||||
"reason.ask": "You ask %s why they are here.",
|
"reason.ask": "You ask %s why they are here.",
|
||||||
"reason.reply": "%s: \"%s\"",
|
"reason.reply": "%s: \"%s\"",
|
||||||
"reason.silent": "%s says nothing and looks away.",
|
"reason.silent": "%s says nothing and looks away.",
|
||||||
|
"hint.reservation_no_id": "On the reservation list but no ID — ask for ID, then check the list.",
|
||||||
|
"hint.borrowed_id": "The ID photo does not match the person. Verify before admitting.",
|
||||||
|
"hint.refuses_wand": "This guest refuses the wand screen. No screen, no entry.",
|
||||||
|
"hint.late_arrival": "Late arrival during the performance — check ID and ticket before opening the door.",
|
||||||
|
"hint.film_boundary": "Filming past the posted line. Ask their reason and point them back to the sidewalk.",
|
||||||
|
"hint.film_boundary_repeat": "Same crew again, still past the line. A formal warning is now reasonable.",
|
||||||
|
"hint.celebrity_arrival": "Celebrity arrival: the crowd is swelling. Keep the entrance clear.",
|
||||||
|
"hint.body_camera_reason": "They are filming the entrance. Ask their reason (Reason) and consider the body camera (B).",
|
||||||
|
"hint.ask_reason": "Unclear intent. Ask why they are here (Reason) before deciding anything else.",
|
||||||
"station.blocking": "You step between %s and the door.",
|
"station.blocking": "You step between %s and the door.",
|
||||||
"perception.near": "%s notices you standing close.",
|
"perception.near": "%s notices you standing close.",
|
||||||
"perception.aware": "%s notices you watching.",
|
"perception.aware": "%s notices you watching.",
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
@@ -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."]
|
||||||
|
}
|
||||||
@@ -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 = {}
|
||||||
@@ -49,6 +49,11 @@ already injects basic-auth credentials for the checkout, and the script reuses t
|
|||||||
header for the API (same server). `github.token` is *not* available in this Gitea
|
header for the API (same server). `github.token` is *not* available in this Gitea
|
||||||
setup, so do not rely on it.
|
setup, so do not rely on it.
|
||||||
|
|
||||||
|
> **Disk space:** Gitea stores release assets under `/var/lib/gitea/data/attachments` on
|
||||||
|
> the host root filesystem (7.8 GB here). When it is full, asset uploads fail with
|
||||||
|
> `HTTP 500 … no space left on device` and even `git push` can fail with "unpacker error".
|
||||||
|
> `publish_release.sh` now verifies the asset count and exits non-zero, so this is visible.
|
||||||
|
>
|
||||||
> **Incident (2026-10-31):** `tools/publish_release.sh` never set `AUTH` in its
|
> **Incident (2026-10-31):** `tools/publish_release.sh` never set `AUTH` in its
|
||||||
> `~/.git-credentials` branch, so every API call failed silently (`AUTH[@]` was
|
> `~/.git-credentials` branch, so every API call failed silently (`AUTH[@]` was
|
||||||
> unbound under `set -u`). Combined with no `GITEA_TOKEN` secret on the repo, tag
|
> unbound under `set -u`). Combined with no `GITEA_TOKEN` secret on the repo, tag
|
||||||
|
|||||||
+1
-1
@@ -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.11"
|
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")
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,28 @@ static func hollywood_building() -> VenueScenario:
|
|||||||
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
|
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
|
||||||
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
|
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
|
||||||
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
|
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
|
||||||
|
{
|
||||||
|
"id": "building_filming_boundary",
|
||||||
|
"phase": Rules.Phase.MIDDAY,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.INFLUENCER,
|
||||||
|
"intent": "film",
|
||||||
|
"time_range_s": Vector2(300, 600),
|
||||||
|
"probability": 0.4,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.body_camera_reason",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "building_unclear_intent",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.CONFUSED_VISITOR,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(600, 780),
|
||||||
|
"probability": 0.4,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.ask_reason",
|
||||||
|
},
|
||||||
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
|
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
|
||||||
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
|
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
|
||||||
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
|
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
|
||||||
@@ -437,6 +459,67 @@ static func joke_shop() -> VenueScenario:
|
|||||||
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
|
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
|
||||||
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}),
|
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}),
|
||||||
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
|
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
|
||||||
|
{
|
||||||
|
"id": "joke_guest_reservation_no_id",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(620, 860),
|
||||||
|
"probability": 0.45,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"override_access": {
|
||||||
|
"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID,
|
||||||
|
"has_id": false, "has_ticket": false,
|
||||||
|
},
|
||||||
|
"hint": "hint.reservation_no_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "joke_borrowed_id",
|
||||||
|
"phase": Rules.Phase.MIDDAY,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(320, 700),
|
||||||
|
"probability": 0.4,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true, "id_status": Rules.ArtifactStatus.MISMATCHED,
|
||||||
|
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
|
||||||
|
},
|
||||||
|
"hint": "hint.borrowed_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "joke_refuses_wand",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(650, 880),
|
||||||
|
"probability": 0.4,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"refuses_screening": true,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
|
||||||
|
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
|
||||||
|
},
|
||||||
|
"hint": "hint.refuses_wand",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "joke_late_arrival",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "arrive",
|
||||||
|
"time_range_s": Vector2(700, 860),
|
||||||
|
"probability": 0.35,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
|
||||||
|
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
|
||||||
|
},
|
||||||
|
"hint": "hint.late_arrival",
|
||||||
|
},
|
||||||
_joke_staff_entry(Vector2(0, 900), 0.25),
|
_joke_staff_entry(Vector2(0, 900), 0.25),
|
||||||
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
|
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
|
||||||
_joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15),
|
_joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15),
|
||||||
@@ -515,6 +598,45 @@ static func tv_studio() -> VenueScenario:
|
|||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
|
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "studio_film_boundary",
|
||||||
|
"phase": Rules.Phase.MIDDAY,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.INFLUENCER,
|
||||||
|
"intent": "film_boundary",
|
||||||
|
"time_range_s": Vector2(330, 560),
|
||||||
|
"probability": 0.5,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.film_boundary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "studio_film_boundary_repeat",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.INFLUENCER,
|
||||||
|
"intent": "film_boundary",
|
||||||
|
"time_range_s": Vector2(640, 830),
|
||||||
|
"probability": 0.45,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.film_boundary_repeat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "studio_celebrity_arrival",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "arrive",
|
||||||
|
"time_range_s": Vector2(620, 760),
|
||||||
|
"probability": 0.5,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"override_access": {"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID},
|
||||||
|
"extra_spawns": [
|
||||||
|
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
|
||||||
|
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
|
||||||
|
{"npc_type": Rules.NpcType.INFLUENCER, "intent": "film"},
|
||||||
|
],
|
||||||
|
"hint": "hint.celebrity_arrival",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "studio_follow_through",
|
"id": "studio_follow_through",
|
||||||
"phase": Rules.Phase.EVENING,
|
"phase": Rules.Phase.EVENING,
|
||||||
@@ -587,6 +709,10 @@ static func _joke_routine(id: String, npc_type: int, intent: String, time_range:
|
|||||||
static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
|
static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
|
||||||
return _building_routine(id, npc_type, intent, time_range, prob)
|
return _building_routine(id, npc_type, intent, time_range, prob)
|
||||||
|
|
||||||
|
## Scripted film-boundary intent: the influencer stands past the posted line.
|
||||||
|
static func _film_boundary_spot() -> String:
|
||||||
|
return "steps_end"
|
||||||
|
|
||||||
static func _phase_for_range(time_range: Vector2, duration: float) -> int:
|
static func _phase_for_range(time_range: Vector2, duration: float) -> int:
|
||||||
var start := time_range.x / duration
|
var start := time_range.x / duration
|
||||||
if start < 0.34:
|
if start < 0.34:
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ var guard_noticed := false
|
|||||||
|
|
||||||
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
|
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
|
||||||
var screened := false
|
var screened := false
|
||||||
|
## Authored event flag: this person refuses the wand screen (Joke Shop).
|
||||||
|
var refuses_screening := false
|
||||||
## Whether the guard has declared they recognize this person (a player decision
|
## Whether the guard has declared they recognize this person (a player decision
|
||||||
## recorded as evidence, PLAN.md: Authorized Access).
|
## recorded as evidence, PLAN.md: Authorized Access).
|
||||||
var player_recognized := false
|
var player_recognized := false
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -63,7 +63,9 @@ func _spot_for_intent(intent: String) -> String:
|
|||||||
return "entrance"
|
return "entrance"
|
||||||
"photo", "film", "perform", "wander", "roam":
|
"photo", "film", "perform", "wander", "roam":
|
||||||
return "sidewalk"
|
return "sidewalk"
|
||||||
"sit":
|
"sit", "film_boundary":
|
||||||
|
# film_boundary: an influencer ignoring the posted filming line, so they
|
||||||
|
# stand at the steps instead of the permitted sidewalk spot.
|
||||||
return "steps_end"
|
return "steps_end"
|
||||||
_:
|
_:
|
||||||
return "sidewalk"
|
return "sidewalk"
|
||||||
|
|||||||
@@ -479,8 +479,19 @@ func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dict
|
|||||||
# Authored events stay for the whole shift; routine traffic cycles out.
|
# Authored events stay for the whole shift; routine traffic cycles out.
|
||||||
avatar.ephemeral = event.get("kind", "routine") == "routine"
|
avatar.ephemeral = event.get("kind", "routine") == "routine"
|
||||||
avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60)
|
avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60)
|
||||||
|
avatar.get_npc_controller().refuses_screening = bool(event.get("refuses_screening", false))
|
||||||
scene_root.add_child(avatar)
|
scene_root.add_child(avatar)
|
||||||
npcs.append(avatar)
|
npcs.append(avatar)
|
||||||
|
# Authored events can point the player at the right verb (PLAN.md verbs).
|
||||||
|
if event.has("hint") and hud:
|
||||||
|
hud.log(Strings.lookup(str(event["hint"])))
|
||||||
|
# Celebrity arrivals draw extra bystanders, not just one avatar.
|
||||||
|
for extra in event.get("extra_spawns", []):
|
||||||
|
var extra_profile := selected_scenario.profile_for(extra.get("npc_type", Rules.NpcType.TOURIST))
|
||||||
|
if extra_profile == null or npcs.size() >= MAX_LIVE_NPCS:
|
||||||
|
continue
|
||||||
|
_spawn_npc(extra_profile, str(extra.get("intent", "photo")),
|
||||||
|
"%s_w%d" % [npc_id, npcs.size()], {"kind": "routine"})
|
||||||
|
|
||||||
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
|
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
|
||||||
func _purge_npcs() -> void:
|
func _purge_npcs() -> void:
|
||||||
@@ -1238,6 +1249,16 @@ func _toggle_wand_screening() -> void:
|
|||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
if controller == null:
|
if controller == null:
|
||||||
return
|
return
|
||||||
|
if controller.refuses_screening:
|
||||||
|
# Authored event: this guest refuses the screen (Joke Shop content).
|
||||||
|
hud.set_wand_icon(false)
|
||||||
|
hud.log(Strings.fmt("wand.refused", [controller.profile.display_name if controller.profile else "The guest"]))
|
||||||
|
coordinator.recorder.add_event(controller.npc_id, {
|
||||||
|
"type": "wand_refused",
|
||||||
|
"outcome": "refused",
|
||||||
|
})
|
||||||
|
coordinator.reputation.apply("prop", -1.0, "guest refused screening")
|
||||||
|
return
|
||||||
var profile: Object = controller.profile
|
var profile: Object = controller.profile
|
||||||
var items: Array = profile.carried_items if profile else []
|
var items: Array = profile.carried_items if profile else []
|
||||||
var result: Dictionary = wand_screener.screen(items)
|
var result: Dictionary = wand_screener.screen(items)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Scenario-content smoke: forces the authored Joke Shop refusal event and the
|
||||||
|
## Studio celebrity arrival through the real spawn path, then checks the extra
|
||||||
|
## bystanders, the hint log, and that a screening refusal is handled.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --path . --script res://src/test/smoke_scenario_content.gd
|
||||||
|
|
||||||
|
const MAX_FRAMES := 900
|
||||||
|
|
||||||
|
var frame := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var started := false
|
||||||
|
var step := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frame += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
return false
|
||||||
|
if not started:
|
||||||
|
gm.call("_on_venue_selected", "joke_shop")
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
if frame > MAX_FRAMES:
|
||||||
|
_fail("timeout")
|
||||||
|
return true
|
||||||
|
if frame < 120:
|
||||||
|
return false
|
||||||
|
match step:
|
||||||
|
0:
|
||||||
|
# Force the refusal event through the normal spawn signal.
|
||||||
|
var event := _find_event(gm.selected_scenario.event_pool, "joke_refuses_wand")
|
||||||
|
_check(not event.is_empty(), "the refusal event exists in the Joke Shop pool")
|
||||||
|
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
|
||||||
|
gm.coordinator.npc_spawn_requested.emit(guest, "enter", "smoke_refuser", event)
|
||||||
|
_check(gm.npcs.size() > 0, "the refusing guest spawned")
|
||||||
|
gm.call("_on_guard_target", gm.npcs[gm.npcs.size() - 1])
|
||||||
|
var controller: Object = gm.npcs[gm.npcs.size() - 1].get_npc_controller()
|
||||||
|
_check(controller.refuses_screening, "the refusal flag reached the controller")
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
_check(not gm.wand_open, "screening does not open for someone who refuses")
|
||||||
|
step = 1
|
||||||
|
1:
|
||||||
|
# Studio: celebrity arrival should bring extra bystanders.
|
||||||
|
gm.call("_on_venue_selected", "tv_studio")
|
||||||
|
step = 2
|
||||||
|
2:
|
||||||
|
if frame % 20 != 0:
|
||||||
|
return false
|
||||||
|
var event := _find_event(gm.selected_scenario.event_pool, "studio_celebrity_arrival")
|
||||||
|
var before: int = gm.npcs.size()
|
||||||
|
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
|
||||||
|
gm.coordinator.npc_spawn_requested.emit(guest, "arrive", "smoke_celeb", event)
|
||||||
|
var added: int = gm.npcs.size() - before
|
||||||
|
_check(added >= 4, "celebrity arrival spawns the guest plus bystanders (%d)" % added)
|
||||||
|
step = 3
|
||||||
|
3:
|
||||||
|
_finish()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _find_event(pool: Array, id: String) -> Dictionary:
|
||||||
|
for event in pool:
|
||||||
|
if str((event as Dictionary).get("id", "")) == id:
|
||||||
|
return event
|
||||||
|
return {}
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("ok: " + message)
|
||||||
|
else:
|
||||||
|
_fail(message)
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: authored scenario content")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Authored scenario content added to match the PLAN lists: Joke Shop access
|
||||||
|
## edge cases, Studio filming boundaries and celebrity crowd spikes, and Building
|
||||||
|
## events that point at the body-camera / request-reason verbs.
|
||||||
|
|
||||||
|
const Factory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||||
|
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||||
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
|
|
||||||
|
func _events(scenario_id: String) -> Array:
|
||||||
|
var scenario: Resource
|
||||||
|
match scenario_id:
|
||||||
|
"joke_shop":
|
||||||
|
scenario = Factory.joke_shop()
|
||||||
|
"tv_studio":
|
||||||
|
scenario = Factory.tv_studio()
|
||||||
|
_:
|
||||||
|
scenario = Factory.hollywood_building()
|
||||||
|
return scenario.event_pool
|
||||||
|
|
||||||
|
func _event(scenario_id: String, id: String) -> Dictionary:
|
||||||
|
for event in _events(scenario_id):
|
||||||
|
if str(event.get("id", "")) == id:
|
||||||
|
return event
|
||||||
|
return {}
|
||||||
|
|
||||||
|
func test_joke_shop_has_the_missing_edge_cases() -> bool:
|
||||||
|
var a := TestUtils.check(not _event("joke_shop", "joke_guest_reservation_no_id").is_empty(),
|
||||||
|
"reservation list without ID exists")
|
||||||
|
var b := TestUtils.check(not _event("joke_shop", "joke_borrowed_id").is_empty(),
|
||||||
|
"borrowed/mismatched ID exists")
|
||||||
|
var c := TestUtils.check(not _event("joke_shop", "joke_refuses_wand").is_empty(),
|
||||||
|
"guest who refuses the wand exists")
|
||||||
|
var d := TestUtils.check(not _event("joke_shop", "joke_late_arrival").is_empty(),
|
||||||
|
"late arrival during the performance exists")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_reservation_without_id_is_denied() -> bool:
|
||||||
|
var event := _event("joke_shop", "joke_guest_reservation_no_id")
|
||||||
|
var profile := _guest_profile()
|
||||||
|
var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {}))
|
||||||
|
var verifier := AccessVerifier.new()
|
||||||
|
var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false)
|
||||||
|
var a := TestUtils.check(not result.get("granted", true), "a reservation without ID is not admitted")
|
||||||
|
var b := TestUtils.check(not (result.get("issues", []) as Array).is_empty(), "the denial names the missing artifact")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_borrowed_id_is_mismatched() -> bool:
|
||||||
|
var event := _event("joke_shop", "joke_borrowed_id")
|
||||||
|
var profile := _guest_profile()
|
||||||
|
var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {}))
|
||||||
|
var found_mismatch := false
|
||||||
|
for artifact in artifacts:
|
||||||
|
if artifact.kind == Rules.AccessKind.ID and artifact.status == Rules.ArtifactStatus.MISMATCHED:
|
||||||
|
found_mismatch = true
|
||||||
|
var verifier := AccessVerifier.new()
|
||||||
|
var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false)
|
||||||
|
var a := TestUtils.check(found_mismatch, "the ID is flagged mismatched")
|
||||||
|
var b := TestUtils.check(not result.get("granted", true), "a mismatched ID is not admitted")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_refuses_wand_event_sets_the_flag() -> bool:
|
||||||
|
var event := _event("joke_shop", "joke_refuses_wand")
|
||||||
|
var ok := TestUtils.check(bool(event.get("refuses_screening", false)), "the event marks the refusal")
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var wired := TestUtils.check(src.contains("refuses_screening = bool(event.get("),
|
||||||
|
"the spawn copies the flag onto the controller")
|
||||||
|
var handled := TestUtils.check(src.contains("if controller.refuses_screening:"),
|
||||||
|
"the wand screen handles a refusal")
|
||||||
|
return ok and wired and handled
|
||||||
|
|
||||||
|
func test_studio_filming_boundary_repeats() -> bool:
|
||||||
|
var first := _event("tv_studio", "studio_film_boundary")
|
||||||
|
var repeat := _event("tv_studio", "studio_film_boundary_repeat")
|
||||||
|
var a := TestUtils.check(not first.is_empty(), "first boundary event exists")
|
||||||
|
var b := TestUtils.check(not repeat.is_empty(), "repeat boundary event exists")
|
||||||
|
var c := TestUtils.check_eq(str(first.get("intent", "")), "film_boundary", "first uses the boundary intent")
|
||||||
|
var d := TestUtils.check_eq(str(repeat.get("intent", "")), "film_boundary", "repeat uses the boundary intent")
|
||||||
|
var e := TestUtils.check(int(repeat.get("time_range_s", Vector2.ZERO).x) > int(first.get("time_range_s", Vector2.ZERO).x),
|
||||||
|
"the repeat happens later in the shift")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_film_boundary_stands_at_the_steps() -> bool:
|
||||||
|
var scenario := Factory.tv_studio()
|
||||||
|
var spot: Vector3 = scenario.waypoint_for_intent("film_boundary")
|
||||||
|
var sidewalk: Vector3 = scenario.waypoint_for_intent("film")
|
||||||
|
var a := TestUtils.check(spot != sidewalk, "the boundary spot differs from the permitted sidewalk spot")
|
||||||
|
var b := TestUtils.check_eq(spot, scenario._location_or_default("steps_end", Vector3.ZERO),
|
||||||
|
"the influencer stands at the steps, past the line")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_celebrity_arrival_spawns_a_crowd() -> bool:
|
||||||
|
var event := _event("tv_studio", "studio_celebrity_arrival")
|
||||||
|
var extras: Array = event.get("extra_spawns", [])
|
||||||
|
var a := TestUtils.check(extras.size() >= 3, "celebrity arrival adds several bystanders")
|
||||||
|
var types: Array = []
|
||||||
|
for extra in extras:
|
||||||
|
types.append(int((extra as Dictionary).get("npc_type", -1)))
|
||||||
|
var b := TestUtils.check(types.has(Rules.NpcType.TOURIST), "tourists gather")
|
||||||
|
var c := TestUtils.check(types.has(Rules.NpcType.INFLUENCER), "an influencer films it")
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var d := TestUtils.check(src.contains('event.get("extra_spawns", [])'), "the spawner honours extra_spawns")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_building_events_point_at_the_new_verbs() -> bool:
|
||||||
|
var camera_event := _event("hollywood_building", "building_filming_boundary")
|
||||||
|
var reason_event := _event("hollywood_building", "building_unclear_intent")
|
||||||
|
var a := TestUtils.check(str(camera_event.get("hint", "")).contains("body"), "an event hints at the body camera")
|
||||||
|
var b := TestUtils.check(str(reason_event.get("hint", "")).contains("reason"), "an event hints at requesting a reason")
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var c := TestUtils.check(src.contains('if event.has("hint")'), "spawning logs the authored hint")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_every_authored_hint_resolves() -> bool:
|
||||||
|
var missing: Array[String] = []
|
||||||
|
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
|
||||||
|
for event in scenario.event_pool:
|
||||||
|
var key := str((event as Dictionary).get("hint", ""))
|
||||||
|
if key != "" and Strings.lookup(key) == key:
|
||||||
|
missing.append(key)
|
||||||
|
return TestUtils.check(missing.is_empty(), "all authored hints have strings (missing: %s)" % str(missing))
|
||||||
|
|
||||||
|
func test_new_events_have_unique_ids() -> bool:
|
||||||
|
# Event ids only need to be unique within a venue.
|
||||||
|
var problems: Array[String] = []
|
||||||
|
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
|
||||||
|
var seen := {}
|
||||||
|
for event in scenario.event_pool:
|
||||||
|
var id := str((event as Dictionary).get("id", ""))
|
||||||
|
if seen.has(id):
|
||||||
|
problems.append("%s:%s" % [scenario.id, id])
|
||||||
|
seen[id] = true
|
||||||
|
return TestUtils.check(problems.is_empty(), "event ids are unique per venue (dupes: %s)" % str(problems))
|
||||||
|
|
||||||
|
func _guest_profile() -> NpcProfile:
|
||||||
|
var p := NpcProfile.new()
|
||||||
|
p.id = "guest"
|
||||||
|
p.display_name = "Guest"
|
||||||
|
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
||||||
|
return p
|
||||||
@@ -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 := [
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -99,8 +99,31 @@ for f in "${all_artifacts[@]}"; do
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "==> Uploading $(basename "$f")"
|
echo "==> Uploading $(basename "$f")"
|
||||||
curl -s -X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" >/dev/null
|
# Fail loudly: a silent 4xx here used to leave a release with no artifacts while
|
||||||
|
# the job still reported success.
|
||||||
|
code="$(curl -sS -o /tmp/release-upload.json -w '%{http_code}' --retry 2 \
|
||||||
|
-X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" || true)"
|
||||||
|
if [[ "$code" != "201" && "$code" != "200" ]]; then
|
||||||
|
echo "error: upload of $(basename "$f") failed with HTTP $code" >&2
|
||||||
|
cat /tmp/release-upload.json >&2 || true
|
||||||
|
echo "" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Verify the release actually carries the artifacts we just sent.
|
||||||
|
have="$(curl -sS "${AUTH[@]}" "$API/$RELEASE_ID" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
print(0); raise SystemExit
|
||||||
|
print(len(d.get('assets', [])))" 2>/dev/null || echo 0)"
|
||||||
|
if [[ "${have:-0}" -lt "${#all_artifacts[@]}" ]]; then
|
||||||
|
echo "error: release has $have assets, expected ${#all_artifacts[@]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Verified $have artifacts on the release"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Release live: $HOST/$REPO/releases/tag/$TAG"
|
echo "Release live: $HOST/$REPO/releases/tag/$TAG"
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user