diff --git a/DONE.md b/DONE.md index bb95ac0..4817483 100644 --- a/DONE.md +++ b/DONE.md @@ -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). - [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). diff --git a/MEMORY.md b/MEMORY.md index 0078c73..4d5a293 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -21,8 +21,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0. - `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit; CI builds the portable binaries, NSIS installer, and AppImage on the tag push. -- Next work: the remaining TODO sections — distribution host setting (`REQUIRE_SIGNIN_VIEW`), - open polish (`Strings.fmt` placeholders), the vision-model screenshot judge, and editor tooling. +- Next work: the two remaining TODO sections — the vision-model screenshot judge + (`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. ## 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 `_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved` 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 when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via `_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`. - **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. -- 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` 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 diff --git a/README.md b/README.md index ad17a67..4f8e55e 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,10 @@ The central design goal is simple: **All releases:** -> **Latest: v0.2.13** — authored scenario content: Joke Shop access edge cases, Studio filming -> boundaries and celebrity crowd spikes, and Building events that use the body-camera and -> request-reason verbs. +> Downloads are public (no sign-in required) since the host's instance-wide +> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. +> +> **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 > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > `./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 - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools -- Headless unit tests: **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 - 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`) @@ -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 - 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) +- 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 - 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) diff --git a/TODO.md b/TODO.md index 0c852bb..9e4cad8 100644 --- a/TODO.md +++ b/TODO.md @@ -1,42 +1,8 @@ # 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. - -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). +Anonymous downloads are enabled on the host. ## Screen design references (QA tooling) diff --git a/project.godot b/project.godot index 620930a..1a5bf34 100644 --- a/project.godot +++ b/project.godot @@ -8,7 +8,7 @@ config_version=5 config/name="Step Line" config/description="Workplace security simulation about observation, access verification, and proportionate escalation." -config/version="0.2.13" +config/version="0.2.14" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") diff --git a/src/core/strings.gd b/src/core/strings.gd index f53936e..a421ede 100644 --- a/src/core/strings.gd +++ b/src/core/strings.gd @@ -34,12 +34,69 @@ static func lookup(key: String) -> String: _ensure_loaded() 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. +## +## 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: var template := lookup(key) if template == key: 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 +## 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: push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.") diff --git a/src/test/tests/test_strings.gd b/src/test/tests/test_strings.gd index 09f6dbe..4fb1211 100644 --- a/src/test/tests/test_strings.gd +++ b/src/test/tests/test_strings.gd @@ -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 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: ## Every key referenced by the HUD and game manager must exist. var required := [