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")