Add save migration fixtures and failure-mode coverage

This commit is contained in:
zwitschi committed 2026-11-03 07:29:42 +01:00
1 parent b51bdc8efe
commit 60bf9a8ef6
12 files changed
+484 -22

No files matched your search

+7 -2
View File
@@ -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
+30 -13
View File
@@ -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
+2 -1
View File
@@ -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: <https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.9.6>.
- 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: <https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.9.5>.
- 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: <https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.9.4>.
+26
View File
@@ -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,
+7 -2
View File
@@ -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
+12
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
{
"index": 1,
"next_venue": "joke_shop",
"shift_number": 2,
"difficulty": "hard",
"complete": false,
"completed": [
"hollywood_building"
],
"last_grade": "B"
}
+15
View File
@@ -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
}
+20
View File
@@ -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
}
]
+26
View File
@@ -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": []
}
+248
View File
@@ -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
+80 -4
View File
@@ -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"