diff --git a/.config/hooks/pre-push b/.config/hooks/pre-push index ecfb6e18e..49b913119 100755 --- a/.config/hooks/pre-push +++ b/.config/hooks/pre-push @@ -278,6 +278,26 @@ else echo "pre-push: systems.db not in push — skipping stamp check" fi +# --- Client version mirror (T-1241) --- +# project.yaml is the version source of truth, but the client cannot read it at +# runtime (an exported build has no repo root), so it is mirrored into +# client/project.godot's `application/config/version`. The Atlas disk cache keys +# its ONLY invalidation signal on that value, so a stale mirror makes a build +# serve canvases generated by code it no longer runs — the T-1239 failure. +# +# Deliberately UNCONDITIONAL, unlike the systems.db check above. Drift is +# introduced by touching one file without the other, but it PERSISTS on main +# until someone notices, so gating on "was either file in this push" would let +# an existing drift ride along indefinitely. The check is two file reads. +if [ -f "$REPO_ROOT/tooling/check-client-version" ]; then + echo "pre-push: checking client version mirror..." + if python3 "$REPO_ROOT/tooling/check-client-version"; then + : + else + fail_check "client version mirror (drifted from project.yaml)" + fi +fi + # --- Clerk review (D-221) --- # DISABLED 2026-05-23 (#965) pending rework. Two problems made it net-negative: # 1. Non-exhaustive — a single run reports ~the first contradiction it finds diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index 7a56ecb83..749fcb019 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -169,3 +169,4 @@ FOLLOW-UPS worth their own tickets, not done here: suggests a check (e.g. a pre-push rule: canvas-generation paths touched => project.yaml version must move) rather than a fifth comment.', NULL, '2026-08-14 21:13:15', '2026-08-14 21:13:15.779', '2026-08-14 21:13:15.779', NULL, 'a7cfa7d53e62cff64cd95b5738d1ee75', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXF1VDVQDQ8EFGTXX787M90R', 'status', 'in_progress', 'done', NULL, '2026-08-14 21:20:27', '2026-08-14 21:20:27.835', '2026-08-14 21:20:27.835', NULL, '815c19499dbf40e3b7a79f44c09089c2', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0495WRHF8ADK82VR8CH1J8R', 'status', 'backlog', 'in_progress', NULL, '2026-08-14 21:21:35', '2026-08-14 21:21:35.098', '2026-08-14 21:21:35.098', NULL, 'caa82c705ee32a7582705b2dd5d2cf17', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index 6736e22dc..8f43c24ce 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -302,3 +302,4 @@ FOLLOW-UPS worth their own tickets, not done here: - Nothing enforces the generation-change/version-bump pairing. Four occurrences suggests a check (e.g. a pre-push rule: canvas-generation paths touched => project.yaml version must move) rather than a fifth comment.', 'done', 'high', NULL, 'client', 'D-261', '2026-08-06 14:43:24.765', '2026-08-14 21:20:27.835', NULL, 'fa2402988b35d9f0db6ffcf7cf7a81c2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0495WRHF8ADK82VR8CH1J8R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas disk cache never invalidates in an exported build — current_schema_version() falls back to ''?.?.?''', 'step_canvas_disk_cache.gd::current_schema_version() reads ProjectSettings.globalize_path(''res://'') + ''/../project.yaml''. That resolves to the repo-root file in a dev run (res:// = client/), but an exported build has no project.yaml one level above res://, so the function returns its ''?.?.?'' fallback. Every exported build therefore stamps and compares the SAME sentinel version, which means the schema-version invalidation path — the cache''s only invalidation signal — is inert in a shipped game: a canvas cached by one build is served forever by every later build. Found while diagnosing T-1239, where the same mechanism failed in dev for a different reason (the version simply was not bumped). Fix direction: bake the version into the client at export time (a generated const, ProjectSettings application/config/version, or an exported resource) rather than reading a repo file at runtime. Note test_current_schema_version_reads_project_yaml passes in dev and would not catch this — it asserts the non-fallback path, in the only environment where that path works.', 'in_progress', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:17.188', '2026-08-14 21:21:35.097', NULL, '0e36862b9cbd420d1f0d8114f7673cc9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; diff --git a/CLAUDE.md b/CLAUDE.md index 12f8e0377..3c07054c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ A top-down life-sim — asymmetric information, occlusion-based perception, sing **Official Title:** The Settled Reach **Repository name:** settled-reach -**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.{phase}.{n}`) +**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.{phase}.{n}`), mirrored into `client/project.godot`'s `application/config/version` — the client can't read `project.yaml` at runtime (an exported build has no repo root), and the Atlas disk cache keys its only invalidation signal on that value. Bump both; `make check-client-version` (run by the pre-push hook) fails on drift. See `client/scripts/build_version.gd` (T-1241). ## Project Structure diff --git a/Makefile b/Makefile index a30c87a8d..263a0568e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) .PHONY: help setup build client server game atlas stop test test-tooling lint lint-python setup-venv ci ci-client ci-server clean \ decisions-sync decisions-active decisions-validate \ validate-content check-fact-ids setup-hooks install-hooks \ - audit deny atlas-verify economy-db regen-db check-systems-db \ + audit deny atlas-verify economy-db regen-db check-systems-db check-client-version \ pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \ pre-pr-server pre-pr-client pre-pr-content \ fixtures-client fixtures-gauntlet golden-diff golden-update \ @@ -56,6 +56,7 @@ help: @echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)" @echo " make regen-db Regenerate systems.db from all sources + stamp meta table (#855)" @echo " make check-systems-db Verify systems.db meta stamp matches current generator sources" + @echo " make check-client-version Verify client/project.godot version mirrors project.yaml" @echo " make install-hooks Install pre-push + pre-commit git hooks (once per clone)" @echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)" @echo " make golden-diff Show diff if golden file output has changed" @@ -406,6 +407,9 @@ regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855, check-systems-db: ## Verify systems.db meta stamp matches current generator sources (#857) @python3 tooling/check-systems-db-stamp --verbose +check-client-version: ## Verify client/project.godot's baked version matches project.yaml (T-1241) + @python3 tooling/check-client-version + econ-sim: ## Build the economics simulation binary (Layer 1+2: Leontief + tâtonnement trade) @cargo build --manifest-path tooling/econ-sim/Cargo.toml --release @echo "Built: tooling/econ-sim/target/release/econ-sim" diff --git a/client/project.godot b/client/project.godot index 9a3b0dc97..80b3c377e 100644 --- a/client/project.godot +++ b/client/project.godot @@ -11,6 +11,13 @@ config_version=5 [application] config/name="The Settled Reach" +; Mirror of project.yaml's `version:` — the repo root file stays the source of +; truth (CLAUDE.md), this is the copy that survives an export. Godot bakes +; ProjectSettings into the PCK, so `application/config/version` reads the same +; in the editor and in a shipped build, where res://../project.yaml does not +; exist at all (T-1241). Kept honest by `make check-client-version`, which the +; pre-push hook runs — do not edit this by hand without moving project.yaml too. +config/version="0.4.6" run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" diff --git a/client/scripts/build_version.gd b/client/scripts/build_version.gd new file mode 100644 index 000000000..be278dbec --- /dev/null +++ b/client/scripts/build_version.gd @@ -0,0 +1,31 @@ +extends RefCounted + +## The running client's version string — the ONE place anything asks (T-1241). +## +## Reads `application/config/version` out of ProjectSettings, which Godot bakes +## into the exported PCK. That is the whole point: two call sites previously +## line-scanned `res://../project.yaml` at runtime, which resolves to the repo +## root in a dev run and TO NOTHING in an exported build — both returned their +## "?.?.?" fallback in a shipped game. +## +## For the Atlas disk cache (D-255, step_canvas_disk_cache.gd) that was not a +## cosmetic defect: the version tag is that cache's ONLY invalidation signal, so +## a constant sentinel meant every exported build stamped and compared the same +## string and a canvas cached by one build would be served by every later build, +## forever. T-1239 is what that failure looks like when it happens — eight days +## of a map drawn from a canvas whose generating code no longer existed. +## +## No file IO and no fallback branch, deliberately. A missing setting returns "" +## and callers can see that; there is no environment where this reads one thing +## in the editor and another in an export, which is exactly the property the +## project.yaml scan lacked. +## +## `project.yaml` remains the source of truth for the version (CLAUDE.md); the +## ProjectSettings entry is a mirror, and `tooling/check-client-version` fails +## the push if the two drift. + +const SETTING_KEY: String = "application/config/version" + + +static func current() -> String: + return str(ProjectSettings.get_setting(SETTING_KEY, "")) diff --git a/client/tests/test_step_canvas_disk_cache.gd b/client/tests/test_step_canvas_disk_cache.gd index b3ae515e7..0de2d2dd2 100644 --- a/client/tests/test_step_canvas_disk_cache.gd +++ b/client/tests/test_step_canvas_disk_cache.gd @@ -370,14 +370,65 @@ func test_mismatched_schema_version_has_reports_false() -> void: assert_bool(cache.has("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_false() -func test_current_schema_version_reads_project_yaml() -> void: - # project.yaml's version field is "0.4.0" per CLAUDE.md at time of - # writing — assert only that a non-placeholder value comes back (not the - # exact string, so this test doesn't need updating every version bump). +## T-1241. The predecessor of this test asserted that current_schema_version() +## did not return its "?.?.?" fallback — and passed, in the ONE environment +## where the code under test worked. It read res://../project.yaml, which exists +## in a dev run and never in an exported build, so the shipped game got the +## fallback for every build, the cache compared a constant sentinel against +## itself, and nothing was ever invalidated. A green test said otherwise. +## +## The version now comes from ProjectSettings, which Godot bakes into the PCK, so +## the dev and exported answers are the same value by construction rather than by +## luck. These tests assert that construction: a real version, sourced from the +## setting, with no file lookup left to differ between environments. +func test_current_schema_version_is_a_real_version_not_the_fallback() -> void: var v: String = StepCanvasDiskCache.current_schema_version() assert_str(v).override_failure_message( - "current_schema_version() must read a real value from project.yaml, not the '?' fallback" + "current_schema_version() must return a real version, never the '?.?.?' fallback" ).is_not_equal("?.?.?") + assert_str(v).override_failure_message( + "current_schema_version() must not be empty — the ProjectSettings key is missing" + ).is_not_empty() + + +func test_current_schema_version_comes_from_project_settings() -> void: + # The property that makes this export-safe: the value IS the baked setting, + # not something re-derived from a file that an export would not contain. + var setting: String = str(ProjectSettings.get_setting("application/config/version", "")) + assert_str(setting).override_failure_message( + "application/config/version must be set in client/project.godot — it is what " + + "survives an export, and the Atlas disk cache's only invalidation signal" + ).is_not_empty() + assert_str(StepCanvasDiskCache.current_schema_version()).is_equal(setting) + + +func test_baked_version_mirrors_project_yaml() -> void: + # Drift guard, dev-only by nature (an export has no project.yaml to compare + # against — which is the whole reason the value is baked). project.yaml stays + # the source of truth; this catches the bump-one-forget-the-other case from + # inside the suite, as well as from tooling/check-client-version in the hook. + var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml" + if not FileAccess.file_exists(yaml_path): + return # exported/packaged run — nothing to compare, and nothing to guard + var file := FileAccess.open(yaml_path, FileAccess.READ) + assert_object(file).is_not_null() + var content := file.get_as_text() + file.close() + + var yaml_version := "" + for line: String in content.split("\n"): + if line.begins_with("version:"): + var parts := line.split(":", false, 1) + if parts.size() >= 2: + yaml_version = parts[1].strip_edges() + break + assert_str(yaml_version).override_failure_message( + "project.yaml must carry a `version:` line — it is the source of truth" + ).is_not_empty() + assert_str(StepCanvasDiskCache.current_schema_version()).override_failure_message( + "client/project.godot's config/version has drifted from project.yaml. " + + "Fix with the value in project.yaml; see `make check-client-version`." + ).is_equal(yaml_version) # ============================================================================= diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd index d725e4d41..f4f9c2e8c 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_disk_cache.gd @@ -96,6 +96,10 @@ extends RefCounted ## comment. const StepCanvasCache := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd") +## preload, not a `class_name` reference: this script is reachable from the +## ImplantRegistry autoload's preload graph, and CLAUDE.md's autoload parse-order +## rule makes a bare class_name unsafe there. +const BuildVersion := preload("res://scripts/build_version.gd") ## Tier tags, mirroring stig-round2.md's IndexEntry.tier field exactly. const TIER_GEOMETRY: String = "Geometry" @@ -167,21 +171,14 @@ var _root: String = DEFAULT_ROOT var _indexes: Dictionary = {} # body_id String -> (key String -> IndexEntry Dictionary) +## T-1241: this used to line-scan `res://../project.yaml` itself. That path +## resolves to the repo root in a dev run and to nothing in an exported build, so +## a shipped game got the "?.?.?" fallback — and since this tag is the cache's +## only invalidation signal, EVERY exported build stamped and compared the same +## sentinel, making the cache permanently un-invalidatable. The version now comes +## from ProjectSettings, which Godot bakes into the PCK. See build_version.gd. static func current_schema_version() -> String: - var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml" - if not FileAccess.file_exists(yaml_path): - return "?.?.?" - var file := FileAccess.open(yaml_path, FileAccess.READ) - if file == null: - return "?.?.?" - var content := file.get_as_text() - file.close() - for line: String in content.split("\n"): - if line.begins_with("version:"): - var parts := line.split(":", false, 1) - if parts.size() >= 2: - return parts[1].strip_edges() - return "?.?.?" + return BuildVersion.current() ## `root` is injectable so tests never touch the real diff --git a/client/ui/meta/screens/loading/loading_screen.gd b/client/ui/meta/screens/loading/loading_screen.gd index a307e2518..95cb0b4c3 100644 --- a/client/ui/meta/screens/loading/loading_screen.gd +++ b/client/ui/meta/screens/loading/loading_screen.gd @@ -2,7 +2,11 @@ extends MetaScreen ## #257: Loading screen overlay — blocks input during save/load round-trip. ## Shown when LOAD_GAME fires; hidden when save_result arrives (success or failure). ## Full-screen, dark overlay with centered status text. -## #724: Shows client version (from project.yaml) and protocol version below the status text. +## #724: Shows client version and protocol version below the status text. The +## version comes from ProjectSettings (mirrored from project.yaml) so it survives +## an export — see build_version.gd (T-1241). + +const BuildVersion := preload("res://scripts/build_version.gd") const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0) const TEXT_COLOR := Color("#c8d0e0") @@ -55,21 +59,19 @@ func _build_ui() -> void: add_child(_version_label) +## T-1241: was a byte-for-byte copy of the disk cache's own project.yaml scan, +## with the same export defect — a shipped build has no res://../project.yaml, so +## the version shown to the player read "?.?.?" in exactly the builds where a +## version string is worth showing. Both call sites now share build_version.gd. +## +## The display fallback stays HERE rather than in the accessor: BuildVersion +## returns "" for a missing setting and does not invent a value, because its +## other caller keys cache invalidation on it and a plausible-looking sentinel +## there is what made this bug survive. A label, unlike a cache key, still has to +## render something, so it substitutes its own. func _read_client_version() -> String: - var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml" - if not FileAccess.file_exists(yaml_path): - return "?.?.?" - var file := FileAccess.open(yaml_path, FileAccess.READ) - if file == null: - return "?.?.?" - var content := file.get_as_text() - file.close() - for line: String in content.split("\n"): - if line.begins_with("version:"): - var parts := line.split(":", false, 1) - if parts.size() >= 2: - return parts[1].strip_edges() - return "?.?.?" + var v := BuildVersion.current() + return v if not v.is_empty() else "?.?.?" ## Update the status text shown while loading. Call before show_loading() or after. diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 15108a6b1..2ff54adbc 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -253,6 +253,26 @@ to `MIGRATION_SQL` in `import_economics.py`. See `.claude/rules/asset-pipeline.md` for the full rule set. +## Client version mirror + +`project.yaml`'s `version:` is the source of truth, but the client cannot read that +file at runtime — an exported build has no repo root above `res://`. The value is +therefore mirrored into `client/project.godot` as `application/config/version`, which +Godot bakes into the exported PCK, and read through `client/scripts/build_version.gd`. + +This is not cosmetic. The Atlas disk cache (D-255) keys its **only** invalidation +signal on that version, so a version the client cannot read means a cache that can +never be invalidated — see T-1241, and T-1239 for what a stale canvas cache actually +costs. + +```bash +make check-client-version # exit 1 = the two files disagree +``` + +The pre-push hook runs this unconditionally (drift persists on `main` once +introduced, so gating it on "were those files touched in this push" would let an +existing drift ride along). **Bump both files together.** + ## Pre-commit and Pre-push Hooks Git hooks are stored in `.config/hooks/` (version-controlled). Activate them with: diff --git a/tooling/check-client-version b/tooling/check-client-version new file mode 100755 index 000000000..4610080cb --- /dev/null +++ b/tooling/check-client-version @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Fail if client/project.godot's baked version has drifted from project.yaml. + +project.yaml is the version source of truth (CLAUDE.md). The client cannot read +it at runtime — an exported build has no repo root — so the value is mirrored +into `application/config/version` in client/project.godot, which Godot bakes +into the PCK (T-1241). + +A mirror nobody checks is worse than the bug it replaced: the old code failed +LOUDLY in an export ("?.?.?" everywhere, no cache invalidation ever), whereas a +stale mirror fails SILENTLY — the Atlas disk cache would keep serving canvases +under a version that stopped matching the build. That is precisely the T-1239 +failure, which cost eight days of a map drawn from a canvas whose generating +code no longer existed. Hence this check, wired into the pre-push hook. + +Exit: 0 = in sync, 1 = drifted or unreadable. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PROJECT_YAML = ROOT / "project.yaml" +PROJECT_GODOT = ROOT / "client" / "project.godot" + +# Anchored to line start so the commentary above `version:` (which mentions +# earlier versions by number) can never be mistaken for the field itself. +YAML_VERSION = re.compile(r"^version:\s*(\S+)\s*$", re.MULTILINE) +GODOT_VERSION = re.compile(r'^config/version\s*=\s*"([^"]*)"\s*$', re.MULTILINE) + + +def read(path: Path, pattern: re.Pattern, label: str) -> str | None: + if not path.exists(): + print(f"check-client-version: {path} not found", file=sys.stderr) + return None + match = pattern.search(path.read_text(encoding="utf-8")) + if not match: + print(f"check-client-version: no {label} in {path}", file=sys.stderr) + return None + return match.group(1) + + +def main() -> int: + yaml_version = read(PROJECT_YAML, YAML_VERSION, "`version:` line") + godot_version = read(PROJECT_GODOT, GODOT_VERSION, "`config/version=` line") + if yaml_version is None or godot_version is None: + return 1 + + if yaml_version != godot_version: + print( + "check-client-version: version drift\n" + f" project.yaml {yaml_version}\n" + f" client/project.godot {godot_version}\n" + "\n" + "project.yaml is the source of truth. Set config/version in\n" + "client/project.godot's [application] section to match it.\n" + "\n" + "This matters beyond cosmetics: the Atlas disk cache keys its\n" + "invalidation on this version, so a stale mirror makes a shipped\n" + "build serve canvases generated by code it no longer runs (T-1239).", + file=sys.stderr, + ) + return 1 + + print(f"check-client-version: OK — {yaml_version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())