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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions apps/adventure-arena/controllers/SessionController.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
14 changes: 5 additions & 9 deletions apps/adventure-arena/models/SavedGames.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down
70 changes: 15 additions & 55 deletions apps/adventure-arena/models/Session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}, {}, {}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
}
Expand Down
1 change: 0 additions & 1 deletion tests/adventure_arena_links.test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")

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