From ac3ca6cb1e49294c28e2ae40d32036774b0a8961 Mon Sep 17 00:00:00 2001 From: Igor Chernakov Date: Wed, 30 Sep 2026 07:17:46 +0200 Subject: [PATCH] No compatibility for old saves; AGENTS.md says so for all persisted data The project is work in progress, so saves carry no format version and no per-command checkpoints: a resume replays the saved commands, and a save from an older build or story may simply play differently. Removes the replay-divergence detection, its notice, and the version check. AGENTS.md's no-backwards-compatibility rule now names persisted data: saves, settings, documents and caches get no versions, migrations or upgrade paths. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 ++ .../controllers/SessionController.lua | 4 -- apps/adventure-arena/models/SavedGames.lua | 14 ++-- apps/adventure-arena/models/Session.lua | 70 ++++--------------- tests/adventure_arena_links.test.lua | 1 - tests/adventure_arena_saves.test.lua | 47 ++----------- tests/adventure_arena_typing.test.lua | 3 +- 7 files changed, 33 insertions(+), 111 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3c412e2c..4dd2c9cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,11 @@ rg -n '^### `Widget|WidgetName' docs/PROJECT_REFERENCE.md to it completely. Delete old paths, old names, and old files — do not leave shims, aliases, or forwarding stubs behind. A clean break is always preferred over a compatibility layer. Callers update in the same commit. + The project is work in progress: this covers persisted data too. Saved + games, settings, documents and caches carry no format versions, migrations, + upgrade paths, or handling for data written by an older build or an older + story. When a format or a story changes, old data may simply stop loading + or replay differently; do not write code to detect, repair or preserve it. - **Views are etlua only, without exception.** Screens and reusable components are `.etlua` templates composed with `partial()`. Controllers must never construct view trees, create layout containers, or assemble UI in diff --git a/apps/adventure-arena/controllers/SessionController.lua b/apps/adventure-arena/controllers/SessionController.lua index 970bc3ae..53baf59f 100644 --- a/apps/adventure-arena/controllers/SessionController.lua +++ b/apps/adventure-arena/controllers/SessionController.lua @@ -63,10 +63,6 @@ function Controller:show(id, fresh) local saved = not fresh and self.savedGames and self.savedGames:find(id) or nil self:finishTyping() local ok, err = self.model:start(game, saved) - -- A save the changed story could not follow is rewritten at the kept place. - if ok and self.model.restoreNotice and self.savedGames then - if not self.savedGames:record(self.model:snapshot()) then self.savedGames:remove(game.id) end - end if not ok then self.push("SessionError", { title = game.title, message = err, actions = { back = self.back }, diff --git a/apps/adventure-arena/models/SavedGames.lua b/apps/adventure-arena/models/SavedGames.lua index 64ade792..5a87d1de 100644 --- a/apps/adventure-arena/models/SavedGames.lua +++ b/apps/adventure-arena/models/SavedGames.lua @@ -2,17 +2,13 @@ local SavedGames = {} SavedGames.__index = SavedGames -- Autosaves, one per adventure: the command history and random seed that --- replay the story, a checkpoint after each command that proves the replay --- went the same way (see Session:start), plus what the library shows about --- it (room and score) without starting an engine. The store is injected, so the model +-- replay the story, plus what the library shows about it (room and score) +-- without starting an engine. The store is injected, so the model -- never touches files; `store.load()` returns the saved table and -- `store.save(table)` persists it. -local VERSION = 2 - local function validRecord(record) return type(record) == "table" and type(record.gameId) == "string" - and type(record.commands) == "table" and type(record.checkpoints) == "table" - and #record.checkpoints == #record.commands + and type(record.commands) == "table" end function SavedGames.new(options) @@ -23,7 +19,7 @@ function SavedGames.new(options) records = {}, }, SavedGames) local loaded = self.store and self.store.load and self.store.load() - if type(loaded) == "table" and loaded.version == VERSION and type(loaded.games) == "table" then + if type(loaded) == "table" and type(loaded.games) == "table" then for _, record in ipairs(loaded.games) do if validRecord(record) then self.records[record.gameId] = record end end @@ -35,7 +31,7 @@ function SavedGames:persist() if not (self.store and self.store.save) then return end local games = {} for _, record in ipairs(self:list()) do table.insert(games, record) end - self.store.save({ version = VERSION, games = games }) + self.store.save({ games = games }) end function SavedGames:find(gameId) diff --git a/apps/adventure-arena/models/Session.lua b/apps/adventure-arena/models/Session.lua index 9e9d4d04..ed8123b8 100644 --- a/apps/adventure-arena/models/Session.lua +++ b/apps/adventure-arena/models/Session.lua @@ -35,8 +35,7 @@ function Session.new(options) end function Session:reset() - self.entries, self.history, self.checkpoints = {}, {}, {} - self.restoreNotice = nil + self.entries, self.history = {}, {} self.moves, self.score, self.maxScore, self.scoreChange = 0, 0, 0, 0 self.availableDirections, self.exitList = {}, {} self.items, self.knownItems, self.knownByNoun = {}, {}, {} @@ -282,58 +281,23 @@ function Session:appendOutput(text, openingTitle) end end --- Where a command left the story: enough to tell that a replay went the --- same way it was first played. -function Session:checkpoint() - return { room = self.roomTitle or "", score = self.score, moves = self.moves } -end - -local function sameCheckpoint(a, b) - return type(a) == "table" and type(b) == "table" - and a.room == b.room and a.score == b.score and a.moves == b.moves -end - -- `saved` is a snapshot from `Session:snapshot()`. The story is restored by -- replaying its commands against an engine seeded as before, which rebuilds --- the transcript exactly as the reader first saw it. Each command's --- checkpoint must match: when the story has changed since it was saved (a --- key now hidden until the papers are searched), the replay goes another --- way. The reader's place is then kept up to the last command that still --- matches, and `restoreNotice` says so, rather than silently playing on in --- a different world. +-- the transcript exactly as the reader first saw it. function Session:start(game, saved) if not game then return false, "Adventure not found." end if not self.engineFactory then return false, "No session engine configured." end local seed = saved and tonumber(saved.seed) or self.newSeed() - local commands = saved and saved.commands or {} - local checkpoints = saved and saved.checkpoints or {} - local function replay(limit) - local ok, engine, opening = pcall(function() - return self.engineFactory(game, seed):start() - end) - if not ok then return false, tostring(engine) end - self:reset() - self.engine, self.currentGame, self.seed = engine, game, seed - self:refreshEngineState() - self:appendOutput(opening, game.title) - for index = 1, limit do - self:submit(commands[index]) - if not sameCheckpoint(self.checkpoints[index], checkpoints[index]) then - return true, index - end - end - return true - end - local ok, diverged = replay(#commands) - if not ok then return false, diverged end - if diverged then - replay(diverged - 1) - self.restoreNotice = diverged == 1 - and "This story has changed since you last read it, so it starts again from the beginning." - or string.format("This story has changed since you last read it. Your place is kept up to \u{201C}%s\u{201D}.", - commands[diverged - 1]) - self.openEntry = nil - self:appendParagraph(self.restoreNotice) + local ok, engine, opening = pcall(function() + return self.engineFactory(game, seed):start() + end) + if not ok then return false, tostring(engine) end + self:reset() + self.engine, self.currentGame, self.seed = engine, game, seed + self:refreshEngineState() + self:appendOutput(opening, game.title) + for _, command in ipairs(saved and saved.commands or {}) do + self:submit(command) end self.scoreChange = 0 return true @@ -352,7 +316,6 @@ function Session:submit(command) self.moves = movesBefore + 1 end self.scoreChange = self.score - scoreBefore - table.insert(self.checkpoints, self:checkpoint()) self:appendOutput(response) return ok, response end @@ -361,13 +324,10 @@ end function Session:snapshot() local game = self.currentGame if not game or not self.engine then return nil end - local commands, checkpoints = {}, {} - for index, command in ipairs(self.history) do - table.insert(commands, command) - table.insert(checkpoints, self.checkpoints[index]) - end + local commands = {} + for _, command in ipairs(self.history) do table.insert(commands, command) end return { - gameId = game.id, seed = self.seed, commands = commands, checkpoints = checkpoints, + gameId = game.id, seed = self.seed, commands = commands, room = self.roomTitle or (self.scene and self.scene.title) or game.title, score = self.score, maxScore = self.maxScore, moves = self.moves, } diff --git a/tests/adventure_arena_links.test.lua b/tests/adventure_arena_links.test.lua index 742aeaad..91e6af13 100644 --- a/tests/adventure_arena_links.test.lua +++ b/tests/adventure_arena_links.test.lua @@ -150,7 +150,6 @@ local save = played:snapshot() state.room = "Gate" local resumed = Session.new { engineFactory = engine } t.expect(resumed:start({ id = "links", title = "Links" }, save), "a saved story resumes") -t.expect(resumed.restoreNotice == nil, "the replay follows the save") local last = resumed.entries[#resumed.entries] t.assertEqual(last.links[1][1].target, "staircase", "replayed prose carries its links") diff --git a/tests/adventure_arena_saves.test.lua b/tests/adventure_arena_saves.test.lua index 6ebc2ee1..aa070a08 100644 --- a/tests/adventure_arena_saves.test.lua +++ b/tests/adventure_arena_saves.test.lua @@ -17,16 +17,15 @@ t.assertEqual(#saves:list(), 0, "a new library has no saved games") t.expect(not saves:record({ gameId = "zork", commands = {} }), "opening a story without playing it is not saved") t.expect(not saves:record({ commands = { "look" } }), "a save needs a game") -t.expect(saves:record({ gameId = "zork", seed = 7, commands = { "open mailbox" }, checkpoints = { {} }, room = "West of House" }), +t.expect(saves:record({ gameId = "zork", seed = 7, commands = { "open mailbox" }, room = "West of House" }), "a played story is saved") clock = 200 -saves:record({ gameId = "planetfall", seed = 9, commands = { "up" }, checkpoints = { {} }, room = "Gangway" }) +saves:record({ gameId = "planetfall", seed = 9, commands = { "up" }, room = "Gangway" }) t.assertEqual(saves:latest().gameId, "planetfall", "the most recently played story comes first") clock = 300 -saves:record({ gameId = "zork", seed = 7, commands = { "open mailbox", "read leaflet" }, checkpoints = { {}, {} }, room = "West of House" }) +saves:record({ gameId = "zork", seed = 7, commands = { "open mailbox", "read leaflet" }, room = "West of House" }) t.assertEqual(saves:latest().gameId, "zork", "playing again moves a story to the front") t.assertEqual(#saves:find("zork").commands, 2, "a save replaces the previous one for the same story") -t.assertEqual(written.version, 2, "the store receives a versioned document") t.assertEqual(#written.games, 2, "every saved story is persisted") local reloaded = SavedGames.new { store = memory } @@ -36,19 +35,11 @@ t.expect(reloaded:remove("planetfall"), "a saved story can be removed") t.expect(not reloaded:remove("planetfall"), "removing twice reports nothing removed") t.assertEqual(#SavedGames.new({ store = memory }):list(), 1, "removal is persisted") -local corrupt = SavedGames.new { store = { load = function() return { version = 99, games = { 1, 2 } } end } } -t.assertEqual(#corrupt:list(), 0, "an unknown save format is an empty library") -local broken = SavedGames.new { store = { load = function() return { version = 2, games = { { gameId = 3 } } } end } } +local corrupt = SavedGames.new { store = { load = function() return { games = { 1, 2 } } end } } +t.assertEqual(#corrupt:list(), 0, "records that are not saves are skipped") +local broken = SavedGames.new { store = { load = function() return { games = { { gameId = 3 } } } end } } t.assertEqual(#broken:list(), 0, "malformed records are skipped") t.assertEqual(#SavedGames.new():list(), 0, "the model works without a store") -local unverifiable = SavedGames.new { store = { load = function() - return { version = 2, games = { { gameId = "zork", commands = { "look", "north" }, checkpoints = { {} } } } } -end } } -t.assertEqual(#unverifiable:list(), 0, "a save without a checkpoint for every command is skipped") -local firstFormat = SavedGames.new { store = { load = function() - return { version = 1, games = { { gameId = "zork", commands = { "look" } } } } -end } } -t.assertEqual(#firstFormat:list(), 0, "saves from before checkpoints cannot be verified and are dropped") -- ── The store is JSON in the platform document folder ───────────────── local documents = {} @@ -60,7 +51,7 @@ local fakeNs = { } local store = JsonDocument.new(fakeNs, "test/saves.json") t.expect(store.load() == nil, "a missing save file loads as nothing") -store.save({ version = 1, games = { { gameId = "zork", commands = { "look" } } } }) +store.save({ games = { { gameId = "zork", commands = { "look" } } } }) t.expect(documents["test/saves.json"]:find('"zork"', 1, true) ~= nil, "the store writes JSON") t.assertEqual(store.load().games[1].commands[1], "look", "the store reads back what it wrote") documents["test/saves.json"] = "{not json" @@ -119,28 +110,4 @@ local original = first.entries[#first.entries] t.assertEqual(table.concat(last.paragraphs or {}, "\n"), table.concat(original.paragraphs or {}, "\n"), "the last page reads the same after resuming") -t.assertEqual(#snapshot.checkpoints, 4, "every command has a checkpoint") -t.expect(resumed.restoreNotice == nil, "an unchanged story resumes without a notice") - --- ── A story changed since the save keeps the reader's place up to where it --- still agrees, and says so ────────────────────────────────────────────── -local changed = {} -for key, value in pairs(snapshot) do changed[key] = value end -changed.checkpoints = {} -for index, checkpoint in ipairs(snapshot.checkpoints) do changed.checkpoints[index] = checkpoint end -changed.checkpoints[3] = { room = "Somewhere the story no longer goes", score = 0, moves = 0 } -local diverged = play(1) -t.expect(diverged:start(planetfall, changed), "a save the story no longer follows still opens") -t.assertEqual(#diverged.history, 2, "the replay stops before the first command that went another way") -t.assertEqual(#diverged:snapshot().commands, 2, "the kept place is what is saved next") -t.expect(diverged.restoreNotice and diverged.restoreNotice:find("\u{201C}wait\u{201D}", 1, true) ~= nil, - "the reader is told the story changed and where their place is kept") -local lastEntry = diverged.entries[#diverged.entries] -t.assertEqual(lastEntry.paragraphs[#lastEntry.paragraphs], diverged.restoreNotice, "the notice ends the page") -changed.checkpoints[1] = { room = "Nowhere", score = 0, moves = 0 } -local restarted = play(1) -t.expect(restarted:start(planetfall, changed), "a save that disagrees at once still opens") -t.assertEqual(#restarted.history, 0, "it starts again from the beginning") -t.expect(restarted.restoreNotice:find("beginning", 1, true) ~= nil, "and says so") - os.exit(t.summary() and 0 or 1) diff --git a/tests/adventure_arena_typing.test.lua b/tests/adventure_arena_typing.test.lua index 95108f1f..c7abf89b 100644 --- a/tests/adventure_arena_typing.test.lua +++ b/tests/adventure_arena_typing.test.lua @@ -144,8 +144,7 @@ t.expect(not controller:isTyping(), "closing the book stops typing") finish() -- A resumed story opens at its last line without typing. -saved = { seed = 1, commands = { "open mailbox" }, - checkpoints = { { room = "West of House", score = 0, moves = 1 } } } +saved = { seed = 1, commands = { "open mailbox" } } timers, haptics = {}, {} local resumed = build() t.expect(resumed:show("zork"), "a saved story resumes")