Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/adventure-arena/controllers/SessionController.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
10 changes: 6 additions & 4 deletions apps/adventure-arena/models/SavedGames.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 55 additions & 15 deletions apps/adventure-arena/models/Session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}, {}, {}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
}
Expand Down
44 changes: 22 additions & 22 deletions lua/embedded/AppKit.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -1466,28 +1488,6 @@ end
--- @prop width number optional. Component-specific setting passed to the native control.
--- @example <List />
--- @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
Expand Down
8 changes: 7 additions & 1 deletion tests/adventure_arena_links.test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
42 changes: 37 additions & 5 deletions tests/adventure_arena_saves.test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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)
3 changes: 2 additions & 1 deletion tests/adventure_arena_typing.test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down