From d39e69fea98120f52c6b6d597671fd8022c4480c Mon Sep 17 00:00:00 2001 From: Igor Chernakov Date: Tue, 29 Sep 2026 22:11:34 +0200 Subject: [PATCH] Saves prove their replay; AppKit.List keeps its docblock A save replays its commands, so after a story changes (the Sanitarium's key now hides under the papers) a replay could silently play on in a different world. Each saved command now records a checkpoint (room, score, moves); a resume stops at the first command whose checkpoint no longer matches, keeps the reader's place up to there, says so on the page, and rewrites the save. Saves from before checkpoints cannot be verified and are dropped with the format bump. AppKit.List's docblock had been separated from it by a helper, failing docs-check. Pins zilscript to main with restore-safe endings, which also brings back the final words of a finished story. Co-Authored-By: Claude Opus 5.5 --- .../controllers/SessionController.lua | 4 ++ apps/adventure-arena/models/SavedGames.lua | 10 +-- apps/adventure-arena/models/Session.lua | 70 +++++++++++++++---- apps/adventure-arena/zilscript | 2 +- lua/embedded/AppKit.lua | 44 ++++++------ tests/adventure_arena_links.test.lua | 8 ++- tests/adventure_arena_saves.test.lua | 42 +++++++++-- tests/adventure_arena_typing.test.lua | 3 +- 8 files changed, 134 insertions(+), 49 deletions(-) diff --git a/apps/adventure-arena/controllers/SessionController.lua b/apps/adventure-arena/controllers/SessionController.lua index 53baf59f..970bc3ae 100644 --- a/apps/adventure-arena/controllers/SessionController.lua +++ b/apps/adventure-arena/controllers/SessionController.lua @@ -63,6 +63,10 @@ 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 5050d6c5..64ade792 100644 --- a/apps/adventure-arena/models/SavedGames.lua +++ b/apps/adventure-arena/models/SavedGames.lua @@ -2,15 +2,17 @@ local SavedGames = {} SavedGames.__index = SavedGames -- Autosaves, one per adventure: the command history and random seed that --- replay the story, plus what the library shows about it (room and score) --- without starting an engine. The store is injected, so the model +-- 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 -- never touches files; `store.load()` returns the saved table and -- `store.save(table)` persists it. -local VERSION = 1 +local VERSION = 2 local function validRecord(record) return type(record) == "table" and type(record.gameId) == "string" - and type(record.commands) == "table" + and type(record.commands) == "table" and type(record.checkpoints) == "table" + and #record.checkpoints == #record.commands end function SavedGames.new(options) diff --git a/apps/adventure-arena/models/Session.lua b/apps/adventure-arena/models/Session.lua index ed8123b8..9e9d4d04 100644 --- a/apps/adventure-arena/models/Session.lua +++ b/apps/adventure-arena/models/Session.lua @@ -35,7 +35,8 @@ function Session.new(options) end function Session:reset() - self.entries, self.history = {}, {} + self.entries, self.history, self.checkpoints = {}, {}, {} + self.restoreNotice = nil self.moves, self.score, self.maxScore, self.scoreChange = 0, 0, 0, 0 self.availableDirections, self.exitList = {}, {} self.items, self.knownItems, self.knownByNoun = {}, {}, {} @@ -281,23 +282,58 @@ 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. +-- 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. 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 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) + 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) end self.scoreChange = 0 return true @@ -316,6 +352,7 @@ 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 @@ -324,10 +361,13 @@ end function Session:snapshot() local game = self.currentGame if not game or not self.engine then return nil end - local commands = {} - for _, command in ipairs(self.history) do table.insert(commands, command) end + local commands, checkpoints = {}, {} + for index, command in ipairs(self.history) do + table.insert(commands, command) + table.insert(checkpoints, self.checkpoints[index]) + end return { - gameId = game.id, seed = self.seed, commands = commands, + gameId = game.id, seed = self.seed, commands = commands, checkpoints = checkpoints, 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/apps/adventure-arena/zilscript b/apps/adventure-arena/zilscript index cf2f8420..da39120d 160000 --- a/apps/adventure-arena/zilscript +++ b/apps/adventure-arena/zilscript @@ -1 +1 @@ -Subproject commit cf2f8420b5c04e6ce4eca20c736ea20d73c73558 +Subproject commit da39120dd3b823fe9416594fbe2479419bb88b3e diff --git a/lua/embedded/AppKit.lua b/lua/embedded/AppKit.lua index df7978c0..ad6d56a4 100644 --- a/lua/embedded/AppKit.lua +++ b/lua/embedded/AppKit.lua @@ -1443,6 +1443,28 @@ function AppKit.TimelineView(props) return applyLayout(content, props) end +-- A column's `template` is a Lua factory (see "Column content templates" in +-- docs/tableview_swiftui.md); the native column spec is plain data. Returns +-- the specs without their templates, and a function that installs them. +local function columnTemplates(columns) + local specs, templates = {}, {} + for _, column in ipairs(columns) do + local spec = {} + for key, value in pairs(column) do + if key ~= "template" then spec[key] = value end + end + if column.template ~= nil then + assert(type(column.template) == "function", "Column template must be a function") + assert(column.id, "a Column with a template requires an id") + templates[column.id] = column.template + end + table.insert(specs, spec) + end + return specs, function(view) + for id, template in pairs(templates) do bridge._tableColumnTemplate(view, id, template) end + end +end + --- Displays rows of data in a native table or list control. --- --- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers. @@ -1466,28 +1488,6 @@ end --- @prop width number optional. Component-specific setting passed to the native control. --- @example --- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation. --- A column's `template` is a Lua factory (see "Column content templates" in --- docs/tableview_swiftui.md); the native column spec is plain data. Returns --- the specs without their templates, and a function that installs them. -local function columnTemplates(columns) - local specs, templates = {}, {} - for _, column in ipairs(columns) do - local spec = {} - for key, value in pairs(column) do - if key ~= "template" then spec[key] = value end - end - if column.template ~= nil then - assert(type(column.template) == "function", "Column template must be a function") - assert(column.id, "a Column with a template requires an id") - templates[column.id] = column.template - end - table.insert(specs, spec) - end - return specs, function(view) - for id, template in pairs(templates) do bridge._tableColumnTemplate(view, id, template) end - end -end - function AppKit.List(props) local columns = props.columns if not columns or type(columns) ~= "table" then diff --git a/tests/adventure_arena_links.test.lua b/tests/adventure_arena_links.test.lua index f0c7e836..742aeaad 100644 --- a/tests/adventure_arena_links.test.lua +++ b/tests/adventure_arena_links.test.lua @@ -143,8 +143,14 @@ t.assertEqual(hall[1].text, "grand staircase", "re-set links stay on their words -- A resumed story rebuilds its links from the replayed commands. state.room = "Gate" +local played = Session.new { engineFactory = engine, newSeed = function() return 1 end } +played:start({ id = "links", title = "Links" }) +played:submit("north") +local save = played:snapshot() +state.room = "Gate" local resumed = Session.new { engineFactory = engine } -t.expect(resumed:start({ id = "links", title = "Links" }, { seed = 1, commands = { "north" } }), "a saved story resumes") +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 e22f4620..6ebc2ee1 100644 --- a/tests/adventure_arena_saves.test.lua +++ b/tests/adventure_arena_saves.test.lua @@ -17,16 +17,16 @@ 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" }, room = "West of House" }), +t.expect(saves:record({ gameId = "zork", seed = 7, commands = { "open mailbox" }, checkpoints = { {} }, room = "West of House" }), "a played story is saved") clock = 200 -saves:record({ gameId = "planetfall", seed = 9, commands = { "up" }, room = "Gangway" }) +saves:record({ gameId = "planetfall", seed = 9, commands = { "up" }, checkpoints = { {} }, 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" }, room = "West of House" }) +saves:record({ gameId = "zork", seed = 7, commands = { "open mailbox", "read leaflet" }, checkpoints = { {}, {} }, 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, 1, "the store receives a versioned document") +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 } @@ -38,9 +38,17 @@ t.assertEqual(#SavedGames.new({ store = memory }):list(), 1, "removal is persist 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 = 1, games = { { gameId = 3 } } } end } } +local broken = SavedGames.new { store = { load = function() return { version = 2, 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 = {} @@ -111,4 +119,28 @@ 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 c7abf89b..95108f1f 100644 --- a/tests/adventure_arena_typing.test.lua +++ b/tests/adventure_arena_typing.test.lua @@ -144,7 +144,8 @@ 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" } } +saved = { seed = 1, commands = { "open mailbox" }, + checkpoints = { { room = "West of House", score = 0, moves = 1 } } } timers, haptics = {}, {} local resumed = build() t.expect(resumed:show("zork"), "a saved story resumes")