diff --git a/MEMORY.md b/MEMORY.md index 6b189bd..8124cf5 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -88,7 +88,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o `Materials.NPC_FABRIC_UV_SCALE = Vector3(4,4,1)`, and `NpcAvatar` skips the baked per-character AO on those two slots. The 256² AO was magnifying into large blotches; the fabric now reads as a fine weave. Skin/hair/gear keep baked AO. Test `test_fabric_uv_scale_avoids_the_baked_ao_pattern` - guards this. Current suite is 263/263. + guards this. Current suite is 272/272. - **NPC visual baseline (0.9.4, 2026-11-02):** `src/test/capture_npc_baseline.gd` is the neutral-studio 8-variant × 6-view baseline (front, three-quarter, profile, back, full body, crowd). It uses a fixed orbit camera, mid-grey studio backdrop, key/fill lights, no HUD/crowd/animation; @@ -183,6 +183,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o 0.9.3 UI polish, 0.9.4 NPC visual optimization, 0.9.5 texture/material pass and 0.9.6 integration are complete and in `DONE.md`. 0.10.0 release-candidate work is next. The source version is 0.9.6. +- **Save migration (0.10.0 work, 2026-11-03):** fixtures in `src/test/fixtures/saves/` + cover 0.8 settings/campaign/history and a 0.9.0 checkpoint. `_load_json` uses + `JSON.new().parse()` so corrupt player files fall back silently instead of + logging a parse error; atomic writes leave the old file intact when the temp + path cannot be written. The migration suite is 9 tests; total 272. - **0.9.6 integration (2026-11-03):** no gameplay/content changes. A clean detached worktree at `f5b8ef2` rebuilt Linux/Windows/macOS/Web/HQ without the dirty tree's ignored `tmp/`/`build/` payload; the in-repo build was 1.6–3.1 GB @@ -841,7 +846,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - Venue `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI. - Access loadout is implemented; do not re-fix `override_access` as if it were still unread. - `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`. -- Headless suite is **263/263**; Python tooling is **101/101**; screenshot compare is **21 good / 0 bad** and the automatic judge is **21/21 match, 0 diff**. Remaining visual follow-ups are in `TODO.md`. +- Headless suite is **272/272**; Python tooling is **101/101**; screenshot compare is **21 good / 0 bad** and the automatic judge is **21/21 match, 0 diff**. Remaining visual follow-ups are in `TODO.md`. - Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args). - GDScript inference trap that broke the main scene: a `var` typed as a base class (`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a diff --git a/TODO.md b/TODO.md index 097114d..0c19895 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these Baseline: **v0.9.6** (2026-11-03). Three playable venues, tutorial/campaign/checkpoint resume, difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete -escalation/access/radio loop, shift review, the Blender asset library, 263 headless + 101 Python +escalation/access/radio loop, shift review, the Blender asset library, 272 headless + 101 Python tests, a 21/21 clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong release-candidate foundation, not yet a 0.10.0 release candidate. @@ -58,8 +58,10 @@ verify every distribution path. This is the next minor release after the 0.9.x p ### Content lock and external playtest -- [ ] Declare a content/feature freeze for 0.10.0; only release-blocking fixes and balance changes - may land after the freeze. +- [x] Declare a content/feature freeze for 0.10.0; only release-blocking fixes and balance changes + may land after the freeze. (Frozen 2026-11-03 after the 0.9.6 integration + checkpoint; this pass is limited to release reliability, accessibility, + balance evidence and packaging.) - [ ] Produce a release-candidate build with the tutorial, campaign, all three venues, Help, accessibility settings and checkpoint/resume enabled. - [ ] Run at least five first-time-player sessions covering the tutorial and one full venue shift; @@ -84,16 +86,31 @@ verify every distribution path. This is the next minor release after the 0.9.x p ### Save migration and session reliability -- [ ] Add fixtures for a 0.8-era settings/campaign/history save and a 0.9.0 checkpoint; verify - migration/default-filling and a clean first launch with no files. -- [ ] Test fresh install → tutorial → campaign → pause → quit → resume → review → next venue on - an isolated user-data directory. -- [ ] Test corrupt JSON, partial temp files, missing optional fields and a failed atomic rename; - confirm fallback behavior never destroys a valid save. -- [ ] Verify checkpoint resume after all three venues, after an incident, with NPCs at the door and - with flashlight/body-camera/door state set. -- [ ] Document save locations, schema versions, reset behavior and rollback/export instructions in - `docs/getting-started.md` and the release notes. +- [x] Add fixtures for a 0.8-era settings/campaign/history save and a 0.9.0 checkpoint; verify + migration/default-filling and a clean first launch with no files. (Committed + fixtures under `src/test/fixtures/saves/`; 9 migration/failure tests cover + old settings/campaign/history, the 0.9.0 checkpoint, all three venue + checkpoint round-trips, clean install, corrupt JSON and a blocked atomic + replace. Suite is now 272 tests.) +- [x] Test fresh install → tutorial → campaign → pause → quit → resume → review → next venue on + an isolated user-data directory. (Tutorial and campaign smokes shared one + isolated `XDG_DATA_HOME`; tutorial completion/replay/skip then campaign + resume/review-continue/help both passed. Pause/menu behavior is covered by + `smoke_session_shell.gd`.) +- [x] Test corrupt JSON, partial temp files, missing optional fields and a failed atomic rename; + confirm fallback behavior never destroys a valid save. (Corrupt/partial files + fall back to defaults; a blocked temp path reports an error and leaves the + prior valid settings intact. `_load_json` now uses the non-logging JSON + instance parser.) +- [x] Verify checkpoint resume after all three venues, after an incident, with NPCs at the door and + with flashlight/body-camera/door state set. (The migration suite round-trips a + checkpoint for Hollywood, the Joke Shop and the TV Studio with an open + incident, one NPC row, flashlight/body-camera/door flags; the campaign smoke + resumes a live checkpoint through the real `GameManager`.) +- [x] Document save locations, schema versions, reset behavior and rollback/export instructions in + `docs/getting-started.md` and the release notes. (`getting-started.md` now + documents the four files, version-1 checkpoint, atomic write/fallback, + clean install, reset and backup/rollback.) ### Release engineering and distribution diff --git a/docs/development-status.md b/docs/development-status.md index a2a6b7e..2069908 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -5,7 +5,7 @@ Implemented so far: - Godot 4.7.2 project scaffold, input map, procedural world builder (CI/local/release unified) - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Three authored scenarios (El Capitan Entertainment Centre / Hollywood Boulevard, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools -- Headless unit tests: **263 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, input remaps, 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, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) +- Headless unit tests: **272 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, input remaps, 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, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) - 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`) @@ -43,6 +43,7 @@ Implemented so far: - NPC face-depth pass (0.9.4 work): sclera are recessed and capped by a thin upper lid, and the separate jaw/cheek spheres are replaced by a chin plate and cheekbones sculpted into the head mesh. The full library was rebuilt; the comparison is `sheet_npc_baseline_compare-face.jpg`. - NPC torso/hip transition (0.9.4 work): the pelvis and chest are slightly wider than the waist, giving shirts and jackets a readable taper into the hips without changing the build multipliers or the skeleton. The comparison is `sheet_npc_baseline_compare-torso.jpg`. - Release v0.9.6 (2026-11-03): the tagged commit `4bb2896` passed Gitea Actions run 1296 (checks + full build) and published seven artifacts (Linux, Windows, macOS, NSIS setup, AppImage, Web and Desktop HQ). Release page: . +- Save migration and reliability (0.10.0 work): committed 0.8-era settings/campaign/history fixtures and a 0.9.0 checkpoint. The loader default-fills old settings, keeps campaign progress and validates checkpoints for all three venues with an open incident, NPC rows, door, flashlight and body-camera state. Corrupt and partial files fall back to defaults; a blocked atomic replace leaves the prior valid file intact. `_load_json` uses the non-logging JSON parser; save locations and rollback are documented in `getting-started.md`. Suite is 272/272. - Integration checkpoint (0.9.6 work): the clean worktree at `f5b8ef2` rebuilt all five release packages and the Desktop HQ zip without ignored `tmp/`/`build/` content (Linux 161.5 MB, Windows 197.1 MB, macOS 148.0 MB, Web 97.8 MB, HQ 241.5 MB). The Web zip contains `index.html`, `index.wasm` and `index.pck`; the HQ zip contains `override.cfg` and launches headless with the Forward+ override. Current verification: Godot 263/263, Python 101/101, 14 smokes green, 24-NPC budget 6.90 ms / 168 meshes / 131,616 triangles, gallery 21 good / 0 bad, screen judge 21/21 match with 0 review/diff, and a pinned realism judge (`z-ai/glm-5.3-flash`, one pass per shot) at mean overall 2.1 and Pillow gap 0.291. No new gameplay or content changes. - Release v0.9.5 (2026-11-02): the tagged commit `4bc6390` passed Gitea Actions run 922 (checks + full build) and published seven artifacts (Linux, Windows, macOS, NSIS setup, AppImage, Web and Desktop HQ). Release page: . - Release v0.9.4 (2026-11-02): the tagged commit `a57b86c` passed the Gitea checks and full build and published seven artifacts (Linux, Windows, macOS, NSIS setup, AppImage, Web and Desktop HQ). Release page: . diff --git a/docs/getting-started.md b/docs/getting-started.md index 2ff7122..c81e865 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -74,6 +74,32 @@ XDG_DATA_HOME=/tmp/gigline-campaign godot --path . --fixed-fps 60 --script res:/ The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. +## Save Data and Migration + +The game keeps four small JSON files in Godot's `user://` directory, which is +`~/.local/share/godot/app_userdata/Gig Line/` on Linux, +`%APPDATA%\Godot\app_userdata\Gig Line\` on Windows and +`~/Library/Application Support/Godot/app_userdata/Gig Line/` on macOS: + +| File | Contents | +| --- | --- | +| `settings.json` | Camera, HUD, binding, difficulty, audio, accessibility and tutorial flags. Missing keys are filled from the current defaults; there is no schema version because the field set grew compatibly. | +| `campaign.json` | Campaign index, completed venues, difficulty, shift number and last grade. Older files with missing fields open at the safe defaults. | +| `shift_results.json` | Completed-shift review history, appended as an array. | +| `shift_state.json` | Resumable mid-shift checkpoint, version `1`. It stores the venue, guard/door/flashlight/body-camera state, coordinator snapshot and NPC rows. | + +Writes go to a sibling `.tmp` file and are renamed only after the complete JSON +is written. A corrupt or partial file is ignored on load, so the player gets +defaults rather than a crash and a valid old file is not overwritten by a failed +replace. The migration fixtures used by the tests live in +`src/test/fixtures/saves/`. + +For a clean first launch, delete the four files (or the whole `Gig Line` user +directory). Settings' **Reset campaign** clears campaign progress separately. +To back up or move a profile, copy those files while the game is closed; to roll +back, restore the previous copies. Shift history is diagnostic and is not +required to resume a checkpoint. + ## Controls Keyboard and mouse are the default; a gamepad works without configuration (left stick moves, diff --git a/src/autoload/save.gd b/src/autoload/save.gd index 9ed5f5c..6747b3b 100644 --- a/src/autoload/save.gd +++ b/src/autoload/save.gd @@ -14,8 +14,13 @@ func _load_json(file_name: String) -> Variant: var text := FileAccess.get_file_as_string(path) if text.is_empty(): return null - var parsed = JSON.parse_string(text) - return parsed + # JSON.parse_string logs an engine error on malformed player files. Use the + # instance parser so a corrupt save silently falls back to defaults instead + # of looking like a crash in the console. + var json := JSON.new() + if json.parse(text) != OK: + return null + return json.data func _save_json(file_name: String, data: Variant) -> Error: var path := "user://" + file_name diff --git a/src/test/fixtures/README.md b/src/test/fixtures/README.md new file mode 100644 index 0000000..36bcf78 --- /dev/null +++ b/src/test/fixtures/README.md @@ -0,0 +1,12 @@ +# Test Fixtures + +Committed JSON fixtures used by persistence and migration tests. + +- [`saves/settings_0_8.json`](saves/settings_0_8.json) — a 0.8-era settings file missing the later volume, motion and caption keys; loading it must keep the old values and fill the new defaults. +- [`saves/campaign_0_8.json`](saves/campaign_0_8.json) — a 0.8-era campaign after the Hollywood shift; loading it must resolve the Joke Shop as the next venue. +- [`saves/shift_results_0_8.json`](saves/shift_results_0_8.json) — a 0.8-era shift history with two completed venues. +- [`saves/shift_state_0_9_0.json`](saves/shift_state_0_9_0.json) — a version-1 0.9.0 checkpoint at the Joke Shop with door, flashlight and body-camera state set. + +These are deliberately schema-less where the old game was schema-less; the +loader fills missing fields from the current defaults rather than rewriting the +player's file on load. diff --git a/src/test/fixtures/saves/campaign_0_8.json b/src/test/fixtures/saves/campaign_0_8.json new file mode 100644 index 0000000..573f9fb --- /dev/null +++ b/src/test/fixtures/saves/campaign_0_8.json @@ -0,0 +1,11 @@ +{ + "index": 1, + "next_venue": "joke_shop", + "shift_number": 2, + "difficulty": "hard", + "complete": false, + "completed": [ + "hollywood_building" + ], + "last_grade": "B" +} diff --git a/src/test/fixtures/saves/settings_0_8.json b/src/test/fixtures/saves/settings_0_8.json new file mode 100644 index 0000000..84889e2 --- /dev/null +++ b/src/test/fixtures/saves/settings_0_8.json @@ -0,0 +1,15 @@ +{ + "look_sensitivity": 0.0031, + "hud_font_scale": 1.15, + "invert_y": true, + "head_bob": false, + "input_bindings": { + "inspect": { + "type": "key", + "code": 90 + } + }, + "ai_difficulty": "hard", + "tutorial_completed": true, + "tutorial_skipped": false +} diff --git a/src/test/fixtures/saves/shift_results_0_8.json b/src/test/fixtures/saves/shift_results_0_8.json new file mode 100644 index 0000000..3c46ad0 --- /dev/null +++ b/src/test/fixtures/saves/shift_results_0_8.json @@ -0,0 +1,20 @@ +[ + { + "scenario_id": "hollywood_building", + "reputation": { + "rating": 81.0, + "grade": "B" + }, + "spawned_count": 6, + "completed_at_ms": 1000 + }, + { + "scenario_id": "joke_shop", + "reputation": { + "rating": 74.5, + "grade": "C" + }, + "spawned_count": 5, + "completed_at_ms": 2000 + } +] diff --git a/src/test/fixtures/saves/shift_state_0_9_0.json b/src/test/fixtures/saves/shift_state_0_9_0.json new file mode 100644 index 0000000..d60ef4a --- /dev/null +++ b/src/test/fixtures/saves/shift_state_0_9_0.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "scenario_id": "joke_shop", + "guard_position": [0.0, 0.1, 10.5], + "guard_yaw": 1.25, + "head_pitch": 0.1, + "door_locked": true, + "flashlight_on": true, + "body_camera_on": true, + "npc_counter": 7, + "coordinator": { + "duration_s": 900.0, + "elapsed_s": 540.0, + "current_phase": 3, + "phase_offset_s": 700.0, + "spawned_count": 7, + "responder_outcomes": [], + "investigations": [], + "recorder": {}, + "reputation": {}, + "crowd": {}, + "radio": {}, + "scheduler": {} + }, + "npcs": [] +} diff --git a/src/test/tests/test_save_migration.gd b/src/test/tests/test_save_migration.gd new file mode 100644 index 0000000..0c20bf6 --- /dev/null +++ b/src/test/tests/test_save_migration.gd @@ -0,0 +1,248 @@ +extends RefCounted +## Persistence migration and failure-mode tests. The fixtures represent a +## 0.8-era player profile and a 0.9.0 checkpoint; loading them must fill current +## defaults without rewriting or losing the old fields. + +const Campaign := preload("res://src/core/campaign.gd") +const ScenarioFactory := preload("res://src/content/scenario_factory.gd") +const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd") +const FixtureDir := "res://src/test/fixtures/saves/" +const UserFiles := ["settings.json", "campaign.json", "shift_results.json", "shift_state.json"] + +func _save_instance() -> Object: + var save_script: GDScript = load("res://src/autoload/save.gd") + return save_script.new() + +func _fixture(name: String) -> String: + return FileAccess.get_file_as_string(FixtureDir + name) + +func _user_path(name: String) -> String: + return ProjectSettings.globalize_path("user://" + name) + +func _write_user(name: String, text: String) -> void: + var file := FileAccess.open("user://" + name, FileAccess.WRITE) + if file == null: + return + file.store_string(text) + file.close() + +func _read_user(name: String) -> String: + var path := "user://" + name + if not FileAccess.file_exists(path): + return "" + return FileAccess.get_file_as_string(path) + +func _remove_user(name: String) -> void: + var path := _user_path(name) + if FileAccess.file_exists("user://" + name) or DirAccess.dir_exists_absolute(path): + DirAccess.remove_absolute(path) + +func _snapshot_files() -> Dictionary: + var snapshot := {} + for name in UserFiles: + snapshot[name] = _read_user(name) + return snapshot + +func _restore_files(snapshot: Dictionary) -> void: + for name in UserFiles: + _remove_user(name) + _remove_user(name + ".tmp") + var text := str(snapshot.get(name, "")) + if not text.is_empty(): + _write_user(name, text) + +func test_settings_0_8_fixture_fills_new_defaults() -> bool: + var backup := _snapshot_files() + _write_user("settings.json", _fixture("settings_0_8.json")) + var save := _save_instance() + var loaded: Dictionary = save.load_settings() + var a := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0031), + "old sensitivity survives") + var b := TestUtils.check(is_equal_approx(float(loaded.get("hud_font_scale", 0.0)), 1.15), + "old font scale survives") + var c := TestUtils.check_eq(bool(loaded.get("invert_y", false)), true, "old invert setting survives") + var d := TestUtils.check_eq(bool(loaded.get("head_bob", true)), false, "old head-bob setting survives") + var e := TestUtils.check_eq(str(loaded.get("ai_difficulty", "")), "hard", "old difficulty survives") + var f := TestUtils.check_eq(bool(loaded.get("tutorial_completed", false)), true, + "old tutorial completion survives") + var g := TestUtils.check(is_equal_approx(float(loaded.get("master_volume", 0.0)), 1.0), + "new master-volume default is filled") + var h := TestUtils.check_eq(bool(loaded.get("captions_enabled", false)), true, + "new caption default is filled") + var i := TestUtils.check_eq(bool(loaded.get("reduce_motion", true)), false, + "new reduced-motion default is filled") + save.free() + _restore_files(backup) + return a and b and c and d and e and f and g and h and i + +func test_campaign_0_8_fixture_keeps_progress() -> bool: + var backup := _snapshot_files() + _write_user("campaign.json", _fixture("campaign_0_8.json")) + var save := _save_instance() + var campaign := Campaign.new() + campaign.setup(save.load_campaign()) + var a := TestUtils.check_eq(campaign.index, 1, "campaign index survives") + var b := TestUtils.check_eq(campaign.next_venue(), "joke_shop", "next venue survives") + var c := TestUtils.check_eq(campaign.shift_number, 2, "shift number survives") + var d := TestUtils.check_eq(campaign.difficulty, "hard", "difficulty survives") + var e := TestUtils.check_eq(campaign.completed, ["hollywood_building"], "history survives") + var f := TestUtils.check_eq(campaign.last_grade, "B", "last grade survives") + save.free() + _restore_files(backup) + return a and b and c and d and e and f + +func test_history_0_8_fixture_loads() -> bool: + var backup := _snapshot_files() + _write_user("shift_results.json", _fixture("shift_results_0_8.json")) + var save := _save_instance() + var results: Array = save.load_shift_results() + var a := TestUtils.check_eq(results.size(), 2, "both old results load") + var b := TestUtils.check_eq(str(results[0].get("scenario_id", "")), "hollywood_building", + "first result keeps its venue") + var c := TestUtils.check_eq(str(results[1].get("scenario_id", "")), "joke_shop", + "second result keeps its venue") + save.free() + _restore_files(backup) + return a and b and c + +func test_checkpoint_0_9_0_fixture_restores() -> bool: + var backup := _snapshot_files() + _write_user("shift_state.json", _fixture("shift_state_0_9_0.json")) + var save := _save_instance() + var state: Dictionary = save.load_shift_state() + var a := TestUtils.check_eq(int(state.get("version", 0)), 1, "checkpoint version survives") + var b := TestUtils.check_eq(str(state.get("scenario_id", "")), "joke_shop", + "checkpoint venue survives") + var c := TestUtils.check_eq(bool(state.get("door_locked", false)), true, "door state survives") + var d := TestUtils.check_eq(bool(state.get("flashlight_on", false)), true, + "flashlight state survives") + var e := TestUtils.check_eq(bool(state.get("body_camera_on", false)), true, + "body-camera state survives") + var coordinator := ShiftCoordinator.new() + coordinator.setup(ScenarioFactory.joke_shop(), 900.0, 7) + coordinator.restore(state.get("coordinator", {})) + var f := TestUtils.check(is_equal_approx(coordinator.elapsed_s, 540.0), "elapsed time restores") + var g := TestUtils.check_eq(coordinator.current_phase, 3, "late phase restores") + var h := TestUtils.check(is_equal_approx(coordinator.phase_offset_s, 700.0), + "phase offset restores") + save.free() + _restore_files(backup) + return a and b and c and d and e and f and g and h + +func test_checkpoint_round_trip_all_venues() -> bool: + var backup := _snapshot_files() + var venues := [ + ["hollywood_building", ScenarioFactory.hollywood_building()], + ["joke_shop", ScenarioFactory.joke_shop()], + ["tv_studio", ScenarioFactory.tv_studio()], + ] + var save := _save_instance() + var ok := true + for index in venues.size(): + var venue_id: String = venues[index][0] + var scenario: VenueScenario = venues[index][1] + var coordinator := ShiftCoordinator.new() + coordinator.setup(scenario, 900.0, index + 3) + coordinator.elapsed_s = 300.0 + index * 60.0 + coordinator.recorder.start_incident("guest", "focus") + var state := { + "version": 1, + "scenario_id": venue_id, + "guard_position": [1.0, 0.1, 8.0 + index], + "guard_yaw": 0.5, + "head_pitch": 0.1, + "door_locked": index % 2 == 0, + "flashlight_on": true, + "body_camera_on": true, + "npc_counter": index + 1, + "coordinator": coordinator.snapshot(), + "npcs": [{"npc_id": "guest_%d" % index, "npc_type": 0}], + } + ok = TestUtils.check_eq(int(save.save_shift_state(state)), OK, + "%s checkpoint writes" % venue_id) and ok + var loaded: Dictionary = save.load_shift_state() + ok = TestUtils.check_eq(str(loaded.get("scenario_id", "")), venue_id, + "%s checkpoint keeps its venue" % venue_id) and ok + ok = TestUtils.check_eq(bool(loaded.get("flashlight_on", false)), true, + "%s checkpoint keeps flashlight state" % venue_id) and ok + ok = TestUtils.check_eq(bool(loaded.get("body_camera_on", false)), true, + "%s checkpoint keeps body-camera state" % venue_id) and ok + ok = TestUtils.check_eq(bool(loaded.get("door_locked", false)), index % 2 == 0, + "%s checkpoint keeps door state" % venue_id) and ok + ok = TestUtils.check_eq((loaded.get("npcs", []) as Array).size(), 1, + "%s checkpoint keeps NPC rows" % venue_id) and ok + var restored := ShiftCoordinator.new() + restored.setup(scenario, 900.0, index + 3) + restored.restore(loaded.get("coordinator", {})) + ok = TestUtils.check(is_equal_approx(restored.elapsed_s, 300.0 + index * 60.0), + "%s checkpoint restores elapsed time" % venue_id) and ok + ok = TestUtils.check(restored.recorder.has_open_incident("guest"), + "%s checkpoint restores an open incident" % venue_id) and ok + save.free() + _restore_files(backup) + return ok + +func test_clean_install_has_safe_defaults() -> bool: + var backup := _snapshot_files() + for name in UserFiles: + _remove_user(name) + var save := _save_instance() + var settings: Dictionary = save.load_settings() + var a := TestUtils.check(is_equal_approx(float(settings.get("look_sensitivity", 0.0)), 0.0022), + "clean install gets default sensitivity") + var b := TestUtils.check_eq(str(settings.get("ai_difficulty", "")), "normal", + "clean install gets normal difficulty") + var c := TestUtils.check(save.load_campaign().is_empty(), "clean install has no campaign") + var d := TestUtils.check_eq(save.load_shift_results().size(), 0, "clean install has no history") + var e := TestUtils.check(save.load_shift_state().is_empty(), "clean install has no checkpoint") + save.free() + _restore_files(backup) + return a and b and c and d and e + +func test_corrupt_json_falls_back_without_crashing() -> bool: + var backup := _snapshot_files() + _write_user("settings.json", "{not valid json") + _write_user("campaign.json", "{not valid json") + _write_user("shift_results.json", "{not valid json") + _write_user("shift_state.json", "{not valid json") + var save := _save_instance() + var a := TestUtils.check(is_equal_approx(float(save.load_settings().get("look_sensitivity", 0.0)), + 0.0022), "corrupt settings fall back to defaults") + var b := TestUtils.check(save.load_campaign().is_empty(), "corrupt campaign returns an empty dictionary") + var c := TestUtils.check_eq(save.load_shift_results().size(), 0, "corrupt history returns an empty array") + var d := TestUtils.check(save.load_shift_state().is_empty(), "corrupt checkpoint returns an empty dictionary") + save.free() + _restore_files(backup) + return a and b and c and d + +func test_partial_temp_file_is_ignored() -> bool: + var backup := _snapshot_files() + _remove_user("settings.json") + _write_user("settings.json.tmp", "{\"look_sensitivity\": 0.009, ") + var save := _save_instance() + var loaded: Dictionary = save.load_settings() + var ok := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0022), + "a leftover partial temp file is not treated as settings") + save.free() + _restore_files(backup) + return ok + +func test_failed_atomic_write_keeps_existing_save() -> bool: + var backup := _snapshot_files() + _remove_user("settings.json") + _remove_user("settings.json.tmp") + var save := _save_instance() + var first := TestUtils.check_eq(int(save.save_settings({"look_sensitivity": 0.0022})), OK, + "baseline settings write succeeds") + var temp_path := _user_path("settings.json.tmp") + var made := DirAccess.make_dir_absolute(temp_path) + var err: int = save.save_settings({"look_sensitivity": 0.009}) + var a := TestUtils.check(made == OK, "test can block the temp path") + var b := TestUtils.check(err != OK, "blocked temp path reports a write error") + var loaded: Dictionary = save.load_settings() + var c := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0022), + "a failed replace leaves the valid settings file intact") + DirAccess.remove_absolute(temp_path) + save.free() + _restore_files(backup) + return first and a and b and c diff --git a/tools/publish_release.sh b/tools/publish_release.sh index cabdd69..44f0078 100755 --- a/tools/publish_release.sh +++ b/tools/publish_release.sh @@ -78,12 +78,60 @@ for f in "${extras[@]}"; do fi done +# Preflight before creating anything on the host: no empty files, and enough +# local disk to assemble/checksum the release. Upload failures are rolled back +# below so a full or interrupted host never keeps a partial release. +total_bytes=0 +for f in "${all_artifacts[@]}"; do + if [[ ! -s "$f" ]]; then + echo "error: missing or empty artifact $f — run ./tools/build_releases.sh first" >&2 + exit 1 + fi + total_bytes=$((total_bytes + $(stat -c '%s' "$f"))) +done +free_kb="$(df -Pk build | awk 'NR==2 {print $4}')" +need_kb=$(((total_bytes + 1023) / 1024)) +reserve_kb=262144 +if [[ -z "${free_kb:-}" || "$free_kb" -lt $((need_kb + reserve_kb)) ]]; then + echo "error: not enough free disk in build/ for ${need_kb} KiB of artifacts plus a 256 MiB reserve (free: ${free_kb:-unknown} KiB)" >&2 + exit 1 +fi + +# Local checksums are useful evidence even though the release host does not need +# a duplicate checksum asset; CI keeps the manifest with the build workspace. +checksum_file="build/SHA256SUMS-$VERSION.txt" +: > "$checksum_file" +for f in "${all_artifacts[@]}"; do + sha256sum "$f" >> "$checksum_file" +done +echo "==> Checksums written to $checksum_file" +cat "$checksum_file" + API="$HOST/api/v1/repos/$REPO/releases" +created_release=0 +uploaded_ids=() + +rollback_release() { + echo "error: rolling back the partial release upload" >&2 + for id in ${uploaded_ids[@]+"${uploaded_ids[@]}"}; do + curl -sS -X DELETE "${AUTH[@]}" "$API/$RELEASE_ID/assets/$id" >/dev/null 2>&1 || true + done + if [[ "$created_release" == "1" ]]; then + curl -sS -X DELETE "${AUTH[@]}" "$API/$RELEASE_ID" >/dev/null 2>&1 || true + echo "removed the new release $TAG" >&2 + else + echo "removed only the ${#uploaded_ids[@]} newly uploaded asset(s) from existing release $TAG" >&2 + fi +} + echo "==> Creating release $TAG on $HOST/$REPO" RELEASE_ID="$(curl -s -X POST "$API" "${AUTH[@]}" \ -H "Content-Type: application/json" \ -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Gig Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}" \ | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)" +if [[ -n "$RELEASE_ID" ]]; then + created_release=1 +fi # If the release already exists (tag already pushed), reuse it. if [[ -z "$RELEASE_ID" ]]; then @@ -96,10 +144,6 @@ fi echo "Release id: $RELEASE_ID" for f in "${all_artifacts[@]}"; do - if [[ ! -f "$f" ]]; then - echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2 - exit 1 - fi echo "==> Uploading $(basename "$f")" # Fail loudly: a silent 4xx here used to leave a release with no artifacts while # the job still reported success. @@ -109,8 +153,16 @@ for f in "${all_artifacts[@]}"; do echo "error: upload of $(basename "$f") failed with HTTP $code" >&2 cat /tmp/release-upload.json >&2 || true echo "" >&2 + rollback_release exit 1 fi + asset_id="$(python3 -c "import sys,json;print(json.load(open('/tmp/release-upload.json')).get('id',''))" 2>/dev/null || true)" + if [[ -z "$asset_id" ]]; then + echo "error: upload of $(basename "$f") returned no asset id" >&2 + rollback_release + exit 1 + fi + uploaded_ids+=("$asset_id") done # Verify the release actually carries the artifacts we just sent. @@ -123,9 +175,33 @@ except Exception: 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 + rollback_release exit 1 fi echo "Verified $have artifacts on the release" +# Optional retention: set RETAIN_RELEASES=7 to keep the newest seven releases. +# The default 0 disables pruning; never delete old releases implicitly. +RETAIN_RELEASES="${RETAIN_RELEASES:-0}" +if [[ "$RETAIN_RELEASES" =~ ^[0-9]+$ ]] && (( RETAIN_RELEASES > 0 )); then + echo "==> Pruning releases older than the newest $RETAIN_RELEASES" + releases_json="$(curl -sS "${AUTH[@]}" "$API?limit=50")" + while IFS= read -r old_id; do + [[ -z "$old_id" ]] && continue + echo " removing release id $old_id" + curl -sS -X DELETE "${AUTH[@]}" "$API/$old_id" >/dev/null 2>&1 || true + done < <(RELEASES_JSON="$releases_json" RETAIN="$RETAIN_RELEASES" python3 - <<'PYPRUNE' +import json, os +try: + data = json.loads(os.environ["RELEASES_JSON"]) +except Exception: + raise SystemExit +keep = int(os.environ["RETAIN"]) +for release in sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)[keep:]: + print(release.get("id", "")) +PYPRUNE + ) +fi + echo "" echo "Release live: $HOST/$REPO/releases/tag/$TAG"