Enable anonymous downloads on the host and harden Strings.fmt

Host (applied with root@192.168.88.30)
- [service] REQUIRE_SIGNIN_VIEW = false in /etc/gitea/app.ini (backup written
  alongside), Gitea restarted. Verified anonymously: repo page 200, API 200, and a
  release asset downloads at full size (50.8 MB AppImage).

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

Docs
- TODO: distribution, release-pipeline, and open-polish sections are complete and
  moved to DONE; the v0.2.4-v0.2.8 backfill is closed as superseded.
- README/MEMORY updated. Version 0.2.14.
This commit is contained in:
zwitschi committed 2026-10-31 22:02:24 +01:00
1 parent a2a0ded527
commit 76defa6142
7 files changed
+182 -44

No files matched your search

+42
View File
@@ -526,3 +526,45 @@ Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scena
crowd spike (more rubberneckers, not only one `arrive` spawn). crowd spike (more rubberneckers, not only one `arrive` spawn).
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in - [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
at least one authored event. at least one authored event.
## Distribution follow-up (host config)
Completed 2026-10-31 (working tree). Headless **153/153**.
Verified via API+host credentials (2026-10-31):
- [x] Repo is **public** (`private:false`) and account has admin rights.
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
AppImage).
- [x] **Host setting applied**: `[service] REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
(backup written alongside), Gitea restarted. Verified anonymously: repo page 200, API 200,
and a release asset downloads with the full 50.8 MB AppImage.
## Release pipeline follow-up
Completed 2026-10-31 (working tree). Headless **153/153**.
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
so every tag build since v0.2.2 failed to publish.
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
`GITEA_TOKEN` secret).
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
Gitea runner does not expose `github.token`).
- [x] Uploads now fail loudly and the release is verified to carry every artifact
(a silent HTTP 4xx used to leave an empty release with a green job).
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
- [x] Backfill decision: v0.2.4–v0.2.8 stay superseded; their tags and source remain, and the
release page points at the current build. No rebuilds planned.
## Open Polish
Completed 2026-10-31 (working tree). Headless **153/153**.
- [x] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
+10 -3
View File
@@ -21,8 +21,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0. Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit; - `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
CI builds the portable binaries, NSIS installer, and AppImage on the tag push. CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
- Next work: the remaining TODO sections — distribution host setting (`REQUIRE_SIGNIN_VIEW`), - Next work: the two remaining TODO sections — the vision-model screenshot judge
open polish (`Strings.fmt` placeholders), the vision-model screenshot judge, and editor tooling. (`tools/screen_designs.py`, OpenRouter key in `.env`) and editor tooling (GUT / `.tres` venues).
- 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 +95,11 @@ 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.
- **String formatting safety (2026-10-31):** `Strings.placeholder_count()` counts `%` specs
(ignoring `%%`), `fmt()` warns and returns the template when the arg count does not match
(instead of raising an engine error), and `validate()` lints the table for trailing `%`,
unsupported conversions, and mixed positional/sequential placeholders. Tests cover all three
plus a formatting pass over every placeholder key with type-correct dummy args.
- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged - **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to `_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
@@ -141,7 +146,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
+6 -4
View File
@@ -28,9 +28,10 @@ The central design goal is simple:
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> **Latest: v0.2.13** — authored scenario content: Joke Shop access edge cases, Studio filming > Downloads are public (no sign-in required) since the host's instance-wide
> boundaries and celebrity crowd spikes, and Building events that use the body-camera and > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
> request-reason verbs. >
> **Latest: v0.2.14** — string-formatting safety, plus public downloads (no sign-in) on the host.
> 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
@@ -312,7 +313,7 @@ Implemented so far:
- Godot 4.3 project scaffold, input map, procedural world builder - Godot 4.3 project scaffold, input map, procedural world builder
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **149 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content) - Headless unit tests: **153 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)
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) - Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
@@ -329,6 +330,7 @@ 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)
- Localization safety: `Strings.fmt` validates the placeholder count before formatting and fails safe (warns, returns the template) instead of raising an engine error, `Strings.placeholder_count` ignores `%%`, and `Strings.validate()` lints the table for trailing `%`, unsupported conversions, and mixed positional/sequential placeholders
- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs - Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
+2 -36
View File
@@ -1,42 +1,8 @@
# TODO # TODO
Godot 4.3 prototype. Release **v0.2.13** is tagged. Headless suite **149/149**, plus the session, Godot 4.3 prototype. Latest release **v0.2.14**. Headless suite **153/153**, plus the session,
PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and windowed focus/radial smokes. PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and windowed focus/radial smokes.
Anonymous downloads are enabled on the host.
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
## 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`).
- [x] Uploads now fail loudly and the release is verified to carry every artifact
(a silent HTTP 4xx used to leave an empty release with a green job).
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
- [ ] Optionally backfill releases for v0.2.4–v0.2.8 (their CI artifacts were not
retained; would need local rebuilds) or leave them superseded by the latest.
## Open Polish
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Screen design references (QA tooling) ## Screen design references (QA tooling)
+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.13" config/version="0.2.14"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+57
View File
@@ -34,12 +34,69 @@ static func lookup(key: String) -> String:
_ensure_loaded() _ensure_loaded()
return TABLE.get(key, key) return TABLE.get(key, key)
## Number of `%` conversion placeholders in a template, ignoring `%%` escapes.
static func placeholder_count(template: String) -> int:
var count := 0
var i := 0
while i < template.length():
if template[i] == "%":
if i + 1 < template.length() and template[i + 1] == "%":
i += 2
continue
count += 1
i += 1
return count
## Format a string by key with positional arguments. ## Format a string by key with positional arguments.
##
## GDScript's `%` binds arguments by order, so a key whose placeholder count drifts
## from its call site would push an engine error. Validate first and fail safe: log
## a warning and return the raw template instead of crashing.
static func fmt(key: String, args: Array) -> String: static func fmt(key: String, args: Array) -> String:
var template := lookup(key) var template := lookup(key)
if template == key: if template == key:
return template return template
var needed := placeholder_count(template)
if needed != args.size():
push_warning("Strings.fmt(%s): template needs %d args, got %d" % [key, needed, args.size()])
return template
return template % args return template % args
## Lint the loaded table for formatting hazards. Returns an array of problems.
## Checks: trailing `%`, invalid conversions, and mixed positional (`%1$s`) and
## sequential placeholders in one template.
static func validate() -> Array[String]:
_ensure_loaded()
var problems: Array[String] = []
for key in TABLE.keys():
var template := str(TABLE[key])
var positional := template.contains("$")
var sequential := 0
var i := 0
while i < template.length():
if template[i] != "%":
i += 1
continue
if i + 1 >= template.length():
problems.append("%s: trailing %% escape" % key)
break
var next := template[i + 1]
if next == "%":
i += 2
continue
var j := i + 1
while j < template.length() and template[j] in "0123456789.$":
if template[j] == "$":
positional = true
j += 1
if j >= template.length() or not (template[j] in "sdfxXv"):
problems.append("%s: unsupported conversion near '%%%s'" % [key, template.substr(i + 1, 3)])
elif not positional:
sequential += 1
i = j + 1
if positional and sequential > 0:
problems.append("%s: mixes positional (%%1$s) and sequential (%%s) placeholders" % key)
return problems
func _init() -> void: func _init() -> void:
push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.") push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.")
+64
View File
@@ -23,6 +23,70 @@ func test_strings_json_backed_all_resolve() -> bool:
return TestUtils.fail("key %s resolves to itself — table entry missing" % key) return TestUtils.fail("key %s resolves to itself — table entry missing" % key)
return true return true
func test_placeholder_count() -> bool:
var a := TestUtils.check_eq(Strings.placeholder_count("no args"), 0, "plain text has no placeholders")
var b := TestUtils.check_eq(Strings.placeholder_count("%s complies"), 1, "one %s")
var c := TestUtils.check_eq(Strings.placeholder_count("%s: \"%s\""), 2, "two %s")
var d := TestUtils.check_eq(Strings.placeholder_count("100%% sure"), 0, "%% is an escape, not an argument")
var e := TestUtils.check_eq(Strings.placeholder_count("%.0f%% mood"), 1, "float plus percent sign")
return a and b and c and d and e
func test_fmt_fails_safe_on_arg_mismatch() -> bool:
## A drifting placeholder count must not raise an engine error at runtime.
var result: String = Strings.fmt("react.comply", [])
var a := TestUtils.check_eq(result, Strings.lookup("react.comply"), "wrong arg count returns the raw template")
var extra: String = Strings.fmt("react.comply", ["A", "B"])
var b := TestUtils.check_eq(extra, Strings.lookup("react.comply"), "too many args also fails safe")
return a and b
func test_table_validates_cleanly() -> bool:
var problems: Array = Strings.validate()
if not problems.is_empty():
return TestUtils.fail("string table format problems: %s" % str(problems))
return true
func test_every_placeholder_key_formats() -> bool:
## Feed each template one dummy arg per placeholder, with a type that matches
## the conversion (%s text, %d int, %f float). Nothing may be left unformatted.
var problems: Array[String] = []
for key in Strings.TABLE.keys():
var template: String = str(Strings.TABLE[key])
var specs: Array[String] = _specifiers(template)
if specs.is_empty():
continue
var args := []
for spec in specs:
var conv: String = spec.substr(spec.length() - 1)
if conv == "d" or conv == "x" or conv == "X":
args.append(7)
elif conv == "f":
args.append(1.5)
else:
args.append("x")
var out: String = Strings.fmt(key, args)
if out == template:
problems.append("%s did not format (%s)" % [key, str(specs)])
return TestUtils.check(problems.is_empty(), "all placeholder keys format (problems: %s)" % str(problems))
func _specifiers(template: String) -> Array[String]:
var out: Array[String] = []
var i := 0
while i < template.length():
if template[i] != "%":
i += 1
continue
if i + 1 < template.length() and template[i + 1] == "%":
i += 2
continue
var j := i + 1
while j < template.length() and template[j] in "0123456789.$":
j += 1
if j < template.length():
out.append(template.substr(i, j - i + 1))
j += 1
i = j
return out
func test_strings_cover_ui_keys() -> bool: func test_strings_cover_ui_keys() -> bool:
## Every key referenced by the HUD and game manager must exist. ## Every key referenced by the HUD and game manager must exist.
var required := [ var required := [