diff --git a/apps/adventure-arena/Controller.lua b/apps/adventure-arena/Controller.lua index d3f6c26e..5347b1ad 100644 --- a/apps/adventure-arena/Controller.lua +++ b/apps/adventure-arena/Controller.lua @@ -1,5 +1,5 @@ local SavedGames = require("apps.adventure-arena.models.SavedGames") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local JsonDocument = require("apps.adventure-arena.services.JsonDocument") local Store = require("apps.adventure-arena.Store") local ZILRuntime = require("apps.adventure-arena.services.ZILRuntime") diff --git a/apps/adventure-arena/assets/last-broadcast.png b/apps/adventure-arena/assets/last-broadcast.png new file mode 100644 index 00000000..4769c65a Binary files /dev/null and b/apps/adventure-arena/assets/last-broadcast.png differ diff --git a/apps/adventure-arena/assets/pocket-weather.png b/apps/adventure-arena/assets/pocket-weather.png new file mode 100644 index 00000000..6025b40f Binary files /dev/null and b/apps/adventure-arena/assets/pocket-weather.png differ diff --git a/apps/adventure-arena/assets/silent-vector.png b/apps/adventure-arena/assets/silent-vector.png new file mode 100644 index 00000000..65d6f290 Binary files /dev/null and b/apps/adventure-arena/assets/silent-vector.png differ diff --git a/apps/adventure-arena/catalog/Adventures.lua b/apps/adventure-arena/catalog/Adventures.lua index 18e16f3e..9b1b30bc 100644 --- a/apps/adventure-arena/catalog/Adventures.lua +++ b/apps/adventure-arena/catalog/Adventures.lua @@ -1,4 +1,4 @@ --- Catalog content from AdventureArena e7805e87, sorted by title. +-- Editorial library order; new Arena Originals follow the established selection. -- Artwork paths are local; descriptions and reviews preserve the source text. -- `collection` groups the library into shelves, `difficulty` uses Infocom's -- published levels, `tint` is the game's jewel-tone accent taken from its cover @@ -317,4 +317,68 @@ return { genre = "Classic Adventure", cover = "apps/adventure-arena/assets/zork3.jpg", }, + + { + id = "books.pocket-weather", + statusLine = "moves", + collection = "Arena Originals", + difficulty = "Introductory", + tint = "#245E55", + tintDark = "#8ED7B7", + titleFont = "Georgia-Bold", + title = "The Pocket Weather Bureau", + shortDescription = "A tiny weather post. A runaway cloud. A better kind of forecast.", + description = "Your first morning as weather-post apprentice begins with a missing raincloud. The Bureau wants its forecast delivered. Nimbus wants to hide. Explore twelve tactile little places, trade with a rook, coax old paper open with steam, and discover what yesterday's instructions left out.\n\nAn original adventure for ages 8 and up, especially for a child and grown-up playing together. Five connected puzzles and an interactive public resolution reward observation, kindness, and careful experiments. No countdown, death, or one-way mistakes. Choose a drizzle or a puddle-sized shower, and make a promise that leaves room to pause.", + author = "Studio Null", + year = 2026, + rating = 0, + reviewCount = 0, + base = "infocom/zork1", + startFile = "pocket-weather.zil", + genre = "Children's Adventure", + cover = "apps/adventure-arena/assets/pocket-weather.png", + reviews = {}, + }, + { + id = "books.silent-vector", + statusLine = "moves", + collection = "Arena Originals", + difficulty = "Advanced", + tint = "#173E50", + tintDark = "#89C7DC", + titleFont = "Futura-CondensedExtraBold", + title = "The Silent Vector", + shortDescription = "Four bodies. Five remembered lives. Trust what you can measure.", + description = "The deep-space station Asterion has received a signal that can counterfeit a memory. Everyone remembers a fifth crew member. The station's physical records disagree.\n\nAs the engineer, restore cooling, compare independent evidence, and isolate a transmission that knows how to sound like someone you failed. Three living colleagues have different reasons to keep listening. Getting them home requires more than cutting a cable.\n\nAn original science fiction horror adventure with fourteen rooms, integrated engineering and deduction puzzles, optional progressive hints, and two deliberate endings. No timed deaths. Content includes involuntary memory, bereavement, and psychological distress.", + author = "Studio Null", + year = 2026, + rating = 0, + reviewCount = 0, + base = "infocom/zork1", + startFile = "silent-vector.zil", + genre = "Science Fiction Horror", + cover = "apps/adventure-arena/assets/silent-vector.png", + reviews = {}, + }, + { + id = "books.last-broadcast", + statusLine = "moves", + collection = "Arena Originals", + difficulty = "Standard", + tint = "#684226", + tintDark = "#E7BC88", + titleFont = "Baskerville-Bold", + title = "The Last Broadcast", + shortDescription = "A 1936 island murder. An unbroken voice. An alibi made of sound.", + description = "Saint Bride Island, 1936. A gale has isolated the radio station, and its owner is killed during a supposedly live appeal. Four survivors have secrets, but the most convincing alibi may belong to a machine. Explore thirteen rooms, operate period recording apparatus, test witness accounts, and reconstruct a murder from physical evidence before making your accusation. Wrong accusations remain recoverable; an optional casebook supplies help.", + author = "Studio Null", + year = 2026, + rating = 0, + reviewCount = 0, + base = "infocom/zork1", + startFile = "last-broadcast.zil", + genre = "Historical Detective Mystery", + cover = "apps/adventure-arena/assets/last-broadcast.png", + reviews = {}, + }, } diff --git a/apps/adventure-arena/helpers/Suggestions.lua b/apps/adventure-arena/helpers/Suggestions.lua deleted file mode 100644 index 8cd2ea26..00000000 --- a/apps/adventure-arena/helpers/Suggestions.lua +++ /dev/null @@ -1,221 +0,0 @@ --- Type-ahead for the command line. Prior art: iOS Frotz completes words from --- the story file's dictionary, and inkle's games show only verbs that make --- sense right now. This model does both: verbs come from a ranked common set --- plus the verbs the engine reports for visible objects, nouns come from the --- objects in the room (then ones seen earlier), and directions from exits. -local Suggestions = {} - -local LIMIT = 6 - --- Ordered by how often players type them, so "l" offers "look" first. -local VERBS = { - "look", "examine", "open", "take", "inventory", "read", "go", "drop", - "close", "enter", "turn on", "turn off", "push", "pull", "move", "put", - "give", "unlock", "lock", "climb", "light", "search", "listen", "smell", - "wait", "attack", "eat", "drink", "wear", "remove", "throw", "tie", - "knock", "jump", "exit", "turn", "ask", "tell", "say", "touch", -} -local VERB_RANK = {} -for index, verb in ipairs(VERBS) do VERB_RANK[verb] = index end - -local DIRECTIONS = { - "north", "south", "east", "west", "northeast", "northwest", - "southeast", "southwest", "up", "down", "in", "out", -} - --- Verbs whose object is usually followed by a second one ("put coin in slot"). -local PREPOSITIONS = { - put = { "in", "on", "under" }, give = { "to" }, unlock = { "with" }, - lock = { "with" }, attack = { "with" }, tie = { "to" }, throw = { "at", "in" }, - open = { "with" }, light = { "with" }, show = { "to" }, -} - --- Verbs whose object needs a second one make poor one-tap commands. -local NEEDS_SECOND = { put = true, give = true, tie = true, throw = true, show = true, unlock = true, lock = true } - -local function startsWith(text, prefix) - return prefix == "" or text:sub(1, #prefix) == prefix -end - --- Engine action names are ZIL identifiers such as LOOK-BEHIND. -local function playerVerb(action) - return (tostring(action):lower():gsub("-", " ")) -end - --- "small mailbox" is typed as "mailbox": ZIL parsers match the final noun. -function Suggestions.noun(name) - local words = {} - for word in tostring(name or ""):lower():gmatch("[%w']+") do table.insert(words, word) end - return words[#words] -end - --- Flatten engine room items ({ name, verbs, children }) into nouns the --- parser understands, keeping nested contents such as a leaflet in a mailbox. -function Suggestions.items(raw) - local items, seen = {}, {} - local function visit(list) - for _, entry in ipairs(type(list) == "table" and list or {}) do - local name = type(entry) == "table" and entry[1] or entry - local noun = Suggestions.noun(name) - if noun and not seen[noun] then - seen[noun] = true - local verbs = {} - for _, action in ipairs(type(entry) == "table" and entry[2] or {}) do - table.insert(verbs, playerVerb(action)) - end - table.insert(items, { name = tostring(name), noun = noun, verbs = verbs }) - end - if type(entry) == "table" then visit(entry[3]) end - end - end - visit(raw) - return items -end - -local function itemSupports(item, verb) - for _, candidate in ipairs(item.verbs or {}) do - if candidate == verb then return true end - end - return false -end - --- The verb a player most likely wants for an item: the best-ranked common --- verb the engine lists for it, else "examine". -local function primaryVerb(item) - local best, bestRank - for _, verb in ipairs(item.verbs or {}) do - local rank = VERB_RANK[verb] - if rank and verb ~= "look" and not NEEDS_SECOND[verb] and (not bestRank or rank < bestRank) then - best, bestRank = verb, rank - end - end - return best or "examine" -end - --- The verbs the story accepts for an item that make whole commands on --- their own, best known first; verbs outside the common set keep the --- story's order after them. -function Suggestions.oneTapVerbs(item) - local verbs = {} - for index, verb in ipairs(item.verbs or {}) do - if not NEEDS_SECOND[verb] then - table.insert(verbs, { verb = verb, rank = VERB_RANK[verb] or (#VERBS + index) }) - end - end - table.sort(verbs, function(a, b) return a.rank < b.rank end) - local names = {} - for _, entry in ipairs(verbs) do table.insert(names, entry.verb) end - return names -end - -local function tokenize(input) - local words = {} - for word in input:lower():gmatch("%S+") do table.insert(words, word) end - local trailing = input:match("%s$") ~= nil - local current = "" - if not trailing and #words > 0 then current = table.remove(words) end - return words, current -end - -local function collector() - local list, seen = {}, {} - return list, function(title, text, submit) - if #list >= LIMIT or seen[text] then return end - seen[text] = true - table.insert(list, { title = title, text = text, submit = submit == true }) - end -end - --- context = { items = {...}, knownItems = {...}, exits = {...} } --- Returns up to six { title, text, submit } chips, none for empty input. A chip with submit=true --- is a whole command; otherwise its text replaces the composer contents. -function Suggestions.forInput(input, context) - input = tostring(input or "") - context = context or {} - local items, exits = context.items or {}, context.exits or {} - local nouns = {} - for _, item in ipairs(items) do table.insert(nouns, item) end - local present = {} - for _, item in ipairs(items) do present[item.noun] = true end - for _, item in ipairs(context.knownItems or {}) do - if not present[item.noun] then table.insert(nouns, item) present[item.noun] = true end - end - - local list, add = collector() - local words, current = tokenize(input) - local head = table.concat(words, " ") - local prefix = head == "" and "" or head .. " " - - -- An empty composer offers nothing: chips before typing pull the eye off - -- the page, and once the reader types their attention is on the field. - if #words == 0 and current == "" then return list end - - if #words == 0 then - -- First word: the likeliest verbs, then exits and objects ("t" offers - -- "take" and also "examine table"), then the rarer verbs. - local verbs = {} - for _, verb in ipairs(VERBS) do - if startsWith(verb, current) then table.insert(verbs, verb) end - end - for index = 1, math.min(3, #verbs) do add(verbs[index], verbs[index] .. " ") end - for _, direction in ipairs(exits) do - if startsWith(direction, current) then add(direction, direction, true) end - end - for _, item in ipairs(nouns) do - if startsWith(item.noun, current) then - local command = primaryVerb(item) .. " " .. item.noun - add(command, command .. " ") - end - end - for index = 4, #verbs do add(verbs[index], verbs[index] .. " ") end - return list - end - - local verb = words[1] - if #words >= 2 and VERB_RANK[words[1] .. " " .. words[2]] then verb = words[1] .. " " .. words[2] end - if verb == "go" or verb == "walk" or verb == "run" then - for _, direction in ipairs(exits) do - if startsWith(direction, current) then add(direction, prefix .. direction, true) end - end - for _, direction in ipairs(DIRECTIONS) do - if startsWith(direction, current) then add(direction, prefix .. direction, true) end - end - return list - end - - -- "turn o" completes the particle of a two-word verb. - if #words == 1 then - for _, candidate in ipairs(VERBS) do - if candidate:sub(1, #head + 1) == head .. " " then - local particle = candidate:sub(#head + 2) - if startsWith(particle, current) then add(particle, prefix .. particle .. " ") end - end - end - end - - -- After "verb noun " offer the joining word, after a joining word a noun. - local last = words[#words] - local joins = PREPOSITIONS[verb] - local lastIsNoun = present[last] and last ~= verb - if joins and lastIsNoun then - for _, join in ipairs(joins) do - if startsWith(join, current) then add(join, prefix .. join .. " ") end - end - end - -- When the story says which objects answer this verb, offer only those; - -- otherwise ("examine", "take" of scenery) every object is fair game. - local anyFits = false - for _, item in ipairs(nouns) do - if itemSupports(item, verb) then anyFits = true break end - end - for _, item in ipairs(nouns) do - if (not anyFits or itemSupports(item, verb)) and startsWith(item.noun, current) and item.noun ~= last then - add(item.noun, prefix .. item.noun .. " ") - end - end - return list -end - -Suggestions.VERBS, Suggestions.DIRECTIONS = VERBS, DIRECTIONS - -return Suggestions diff --git a/apps/adventure-arena/models/Adventures.lua b/apps/adventure-arena/models/Adventures.lua index 6127cd04..9d41ce6f 100644 --- a/apps/adventure-arena/models/Adventures.lua +++ b/apps/adventure-arena/models/Adventures.lua @@ -10,6 +10,9 @@ local LIBRARY = { featured = 3, shelf = 10, chart = 5, related = 6 } -- Genre tiles carry an SF Symbol, as Apple Books' category tiles do. local GENRE_SYMBOLS = { + ["Children's Adventure"] = "cloud.sun.fill", + ["Science Fiction Horror"] = "waveform", + ["Historical Detective Mystery"] = "magnifyingglass", ["Sci-Fi Adventure"] = "sparkles", ["Psychological Horror"] = "eye.fill", ["Fantasy Adventure"] = "wand.and.stars", diff --git a/apps/adventure-arena/models/SavedGames.lua b/apps/adventure-arena/models/SavedGames.lua index f08f58ca..c59c96f6 100644 --- a/apps/adventure-arena/models/SavedGames.lua +++ b/apps/adventure-arena/models/SavedGames.lua @@ -1,5 +1,5 @@ local Model = require("data.model") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") -- Autosaves, one per adventure: the command history and random seed that -- replay the story, plus what the library shows about it (room and score) diff --git a/apps/adventure-arena/models/Session.lua b/apps/adventure-arena/models/Session.lua deleted file mode 100644 index 3f3aef41..00000000 --- a/apps/adventure-arena/models/Session.lua +++ /dev/null @@ -1,421 +0,0 @@ -local Suggestions = require("apps.adventure-arena.helpers.Suggestions") - -local Session = {} -Session.__index = Session - -local DIRECTIONS = { - n = "north", north = "north", - ne = "northeast", northeast = "northeast", - e = "east", east = "east", - se = "southeast", southeast = "southeast", - s = "south", south = "south", - sw = "southwest", southwest = "southwest", - w = "west", west = "west", - nw = "northwest", northwest = "northwest", - u = "up", up = "up", d = "down", down = "down", - ["in"] = "in", out = "out", -} - --- Re-rendering a transcript costs time proportional to its length, so the --- reader keeps the most recent entries; the story itself is unaffected. -local TRANSCRIPT = { limit = 120 } - -function Session.new(options) - options = options or {} - local self = setmetatable({ - engineFactory = options.engineFactory, - -- A saved game replays its commands against the same random sequence. - newSeed = options.newSeed or function() return os.time() end, - }, Session) - self:reset() - return self -end - -function Session:reset() - 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 = {}, {}, {} - self.roomTitle, self.roomIcon, self.scene, self.openEntry = nil, nil, nil, nil -end - -function Session:refreshEngineState() - if type(self.engine.progress) == "function" then - local ok, progress = pcall(self.engine.progress, self.engine) - if ok and type(progress) == "table" then - self.score = tonumber(progress.score) or 0 - self.moves = tonumber(progress.moves) or 0 - self.maxScore = tonumber(progress.maxScore) or 0 - end - end - if type(self.engine.exits) == "function" then - local ok, directions = pcall(self.engine.exits, self.engine) - if ok and type(directions) == "table" then - self.availableDirections, self.exitList = {}, {} - for _, direction in ipairs(directions) do - local normalized = DIRECTIONS[tostring(direction):lower()] - if normalized and not self.availableDirections[normalized] then - self.availableDirections[normalized] = true - table.insert(self.exitList, normalized) - end - end - -- The engine walks exits in hash order; players expect a compass. - local order = {} - for index, direction in ipairs(Suggestions.DIRECTIONS) do order[direction] = index end - table.sort(self.exitList, function(a, b) return order[a] < order[b] end) - end - end - if type(self.engine.roomName) == "function" then - local ok, name = pcall(self.engine.roomName, self.engine) - if ok and type(name) == "string" and name:match("%S") then - self.roomTitle = name - end - end - if type(self.engine.roomIcon) == "function" then - local ok, icon = pcall(self.engine.roomIcon, self.engine) - self.roomIcon = ok and type(icon) == "string" and icon or nil - end - if type(self.engine.items) == "function" then - local ok, raw = pcall(self.engine.items, self.engine) - if ok and type(raw) == "table" then - self.items = Suggestions.items(raw) - -- Objects stay suggestible after they leave the room: most of - -- them left in the player's hands. - for _, item in ipairs(self.items) do - if not self.knownByNoun[item.noun] then - self.knownByNoun[item.noun] = item - table.insert(self.knownItems, item) - end - end - end - end -end - -function Session:hasExit(direction) - local normalized = DIRECTIONS[tostring(direction or ""):lower()] - return normalized ~= nil and self.availableDirections[normalized] == true -end - --- ── Transcript structure ──────────────────────────────────────────────── --- The engine prints plain text. Books give the reader structure, so the --- transcript is parsed into a title-page banner, scenes (a new room opens --- one under the room's name), the player's own commands, and narration. --- Stories mark the words a reader can act on as "[[label]]" or --- "[[label->target]]"; paragraphs keep the label and carry the links apart. - -local function trim(text) - return (tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "")) -end - -local function paragraphsOf(text) - local paragraphs = {} - text = tostring(text or ""):gsub("\r\n", "\n") - for block in (text .. "\n\n"):gmatch("(.-)\n%s*\n") do - local paragraph = trim(block) - if paragraph ~= "" then table.insert(paragraphs, paragraph) end - end - return paragraphs -end - -local function isBanner(paragraph) - return paragraph:find("Copyright", 1, true) ~= nil - or paragraph:match("Release %d+ / Serial") ~= nil -end - -local SMALL_WORDS = { - a = true, an = true, ["and"] = true, at = true, by = true, ["for"] = true, ["in"] = true, - of = true, on = true, the = true, to = true, under = true, with = true, -} - --- Without an engine room name, a heading is a short title-cased line. -local function looksLikeHeading(line) - if #line > 48 or not line:match("%a") or line:match("[.!?,;:]") then return false end - for word in line:gmatch("%S+") do - if not SMALL_WORDS[word:lower()] and not word:match("^%u[%w'/-]*$") then return false end - end - return true -end - -local function splitFirstLine(paragraph) - local first, rest = paragraph:match("^([^\n]+)\n(.*)$") - if not first then return paragraph, nil end - rest = trim(rest) - return trim(first), rest ~= "" and rest or nil -end - --- Splits link markup from prose. Returns the text as the reader sees it and --- its links, each { location, length, label, target }: `location` counts --- characters from 0 as `utf8.len` does, and `target` is the parser's word --- for the thing (the label itself when the story names no other). -function Session.parseLinks(text) - text = tostring(text or "") - local plain, links, position = {}, {}, 1 - local length = 0 - local function keep(piece) - table.insert(plain, piece) - length = length + (utf8.len(piece) or #piece) - end - while true do - local first, last, body = text:find("%[%[(.-)%]%]", position) - if not first then break end - keep(text:sub(position, first - 1)) - local label, target = body:match("^(.-)%->(.*)$") - label, target = trim(label or body), trim(target or body) - if label ~= "" and target ~= "" then - table.insert(links, { - location = length, length = utf8.len(label) or #label, - label = label, target = target:lower(), - }) - end - keep(label) - position = last + 1 - end - keep(text:sub(position)) - return table.concat(plain), links -end - --- A scene carries its room's picture, which its first paragraph wraps. -function Session:beginScene(title) - local scene = { kind = "scene", title = title, icon = self.roomIcon, paragraphs = {}, links = {} } - table.insert(self.entries, scene) - self.scene, self.openEntry = scene, scene - return scene -end - -function Session:appendParagraph(paragraph) - if not self.openEntry then - self.openEntry = { kind = "narration", paragraphs = {}, links = {} } - table.insert(self.entries, self.openEntry) - end - -- A story's CR ends a prose paragraph, including the object descriptions - -- printed after a room. Keep each in its own reveal queue item so the - -- paragraph gap applies to single newlines too. - for line in tostring(paragraph):gmatch("[^\r\n]+") do - line = trim(line) - if line ~= "" then - local text, links = Session.parseLinks(line) - table.insert(self.openEntry.paragraphs, text) - self.openEntry.links[#self.openEntry.paragraphs] = links - end - end -end - -local function capitalized(text) - return (text:gsub("^%l", string.upper)) -end - --- What a reader can do with a linked word, as { title, command } in the --- order they are likeliest to want. A direction walks; an object offers --- "examine" and the verbs the story accepts for it, where those need no --- second object. The command uses the story's own word for the thing. -function Session:linkActions(target) - target = trim(target):lower() - if target == "" then return {} end - local direction = DIRECTIONS[target] - if direction then - return { { title = "Go " .. direction, command = direction } } - end - local words = {} - for word in target:gmatch("[%w']+") do words[word] = true end - local noun = Suggestions.noun(target) - local function find(items) - for _, item in ipairs(items) do - if item.noun == noun then return item end - end - for _, item in ipairs(items) do - for word in item.name:lower():gmatch("[%w']+") do - if words[word] then return item end - end - end - end - local item = find(self.items) or find(self.knownItems) - local verbs, seen = { "examine" }, { examine = true, look = true } - local offered = item and Suggestions.oneTapVerbs(item) or {} - -- Examining readable text prints it (Zork's V-EXAMINE shows the TEXT - -- property), so "examine" next to "read" would do the same thing twice. - for _, verb in ipairs(offered) do - if verb == "read" then verbs = {} end - end - for _, verb in ipairs(offered) do - if not seen[verb] then - seen[verb] = true - table.insert(verbs, verb) - end - end - local actions = {} - for index = 1, #verbs do - local command = verbs[index] .. " " .. target - table.insert(actions, { title = capitalized(verbs[index]), command = command }) - end - return actions -end - -function Session:appendOutput(text, openingTitle) - local paragraphs = paragraphsOf(text) - local room = self.roomTitle - local headingFound = false - for _, paragraph in ipairs(paragraphs) do - local first = splitFirstLine(paragraph) - if (room and first == room) or (not room and looksLikeHeading(first) and first ~= paragraph) then - headingFound = true - end - end - -- A move without a printed heading still enters a new room: the scene - -- opens at the first paragraph that is not the title page. - -- The opening always starts a scene, even when the engine names no room. - local pendingRoom = not headingFound and (room or openingTitle) - and (not self.scene or self.scene.title ~= (room or openingTitle)) and (room or openingTitle) or nil - - for _, paragraph in ipairs(paragraphs) do - if isBanner(paragraph) then - local title, rest = splitFirstLine(paragraph) - local lines = {} - for line in (rest or ""):gmatch("[^\n]+") do table.insert(lines, trim(line)) end - table.insert(self.entries, { kind = "banner", title = title, lines = lines }) - self.openEntry = nil - else - local first, rest = splitFirstLine(paragraph) - local heading = (room and first == room) or (not room and rest and looksLikeHeading(first)) - if heading then - if not self.scene or self.scene.title ~= first then - self:beginScene(first) - end - if rest then self:appendParagraph(rest) end - else - if pendingRoom then - self:beginScene(pendingRoom) - pendingRoom = nil - end - self:appendParagraph(paragraph) - end - end - end -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. -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) - end - self.scoreChange = 0 - return true -end - -function Session:submit(command) - command = tostring(command or ""):match("^%s*(.-)%s*$") - if command == "" or not self.engine then return false end - local ok, response = pcall(function() return self.engine:resume(command) end) - table.insert(self.history, command) - table.insert(self.entries, { kind = "command", text = command }) - self.openEntry = nil - local movesBefore, scoreBefore = self.moves, self.score - self:refreshEngineState() - if type(self.engine.progress) ~= "function" then - self.moves = movesBefore + 1 - end - self.scoreChange = self.score - scoreBefore - self:appendOutput(response) - return ok, response -end - --- Everything needed to resume, as plain values for the save store. -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 - return { - 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, - } -end - -function Session:suggestions(input) - return Suggestions.forInput(input, { - items = self.items, knownItems = self.knownItems, exits = self.exitList, - }) -end - -function Session:entryCount() - return #self.entries -end - --- The prose paragraphs of entries from `first` on, in reading order: what a --- reader has not seen yet after a command. `entry` is the absolute index. -function Session:paragraphsSince(first) - local paragraphs = {} - for index = math.max(1, first), #self.entries do - for paragraphIndex, text in ipairs(self.entries[index].paragraphs or {}) do - table.insert(paragraphs, { entry = index, paragraph = paragraphIndex, text = text }) - end - end - return paragraphs -end - -function Session:transcript(limit) - limit = limit or TRANSCRIPT.limit - local entries = {} - for index = math.max(1, #self.entries - limit + 1), #self.entries do - table.insert(entries, self.entries[index]) - end - return entries, math.max(0, #self.entries - limit) -end - --- Infocom status lines: "Score 12 of 350 · 41 moves". Planetfall's MOVES is --- the ship's chronometer, so its line reads "Time 4602" as the game's does. -function Session.statusLine(game, score, maxScore, moves) - local scoreText = maxScore > 0 - and string.format("Score %d of %d", score, maxScore) - or string.format("Score %d", score) - if game and game.statusLine == "time" then - return string.format("%s · Time %d", scoreText, moves) - end - return string.format("%s · %d %s", scoreText, moves, moves == 1 and "move" or "moves") -end - -function Session:presentation() - local game = self.currentGame or {} - local directions = {} - for direction in pairs(self.availableDirections) do table.insert(directions, direction) end - table.sort(directions) - local entries, earlier = self:transcript() - local scene = self.scene - return { - gameId = game.id, - gameTitle = game.title or "", - gameDescription = game.shortDescription or game.description or "", - gameAuthor = game.author or "", - gameYear = game.year and tostring(game.year) or "", - gameGenre = game.genre or "", - cover = game.cover, - tint = game.tint or "accent", - ink = game.ink or game.tint or "accent", - titleFont = game.titleFont, - roomTitle = self.roomTitle or (scene and scene.title) or game.title or "", - entries = entries, - earlierEntries = earlier, - score = self.score, - maxScore = self.maxScore, - moves = self.moves, - scoreChange = self.scoreChange, - progressFraction = self.maxScore > 0 and math.max(0, math.min(1, self.score / self.maxScore)) or 0, - progress = Session.statusLine(game, self.score, self.maxScore, self.moves), - availableDirections = directions, - } -end - -return Session diff --git a/apps/adventure-arena/services/ZILRuntime.lua b/apps/adventure-arena/services/ZILRuntime.lua index 4119baff..35250c5c 100644 --- a/apps/adventure-arena/services/ZILRuntime.lua +++ b/apps/adventure-arena/services/ZILRuntime.lua @@ -119,6 +119,7 @@ function ZILRuntime.new(game, readFile, seed) local engine = runtime.create_game(env, true) local opening = engine:start() return { + isRunning = function() return engine:is_running() end, resume = function(_, command) return withContext(readFile, paths, function() return engine:resume(command) end) end, @@ -126,7 +127,7 @@ function ZILRuntime.new(game, readFile, seed) return { score = tonumber(env.SCORE) or 0, moves = tonumber(env.MOVES) or 0, - maxScore = tonumber(env["SCORE-MAX"]) or 0, + maxScore = tonumber(env.SCORE_MAX) or 0, } end, roomName = function() @@ -152,7 +153,12 @@ function ZILRuntime.new(game, readFile, seed) items = function() return withContext(readFile, paths, function() local ok, items = pcall(function() return engine:resume("room-items") end) - return ok and type(items) == "table" and items or {} + if not ok or type(items) ~= "table" then return {} end + local heldOK, held = pcall(function() return engine:resume("inventory-items") end) + if heldOK and type(held) == "table" then + for _, item in ipairs(held) do table.insert(items, item) end + end + return items end) end, exits = function() diff --git a/apps/adventure-arena/zilscript b/apps/adventure-arena/zilscript index f2cc5145..0edc03ff 160000 --- a/apps/adventure-arena/zilscript +++ b/apps/adventure-arena/zilscript @@ -1 +1 @@ -Subproject commit f2cc51454a10e4279cc349aad450004e5e2abb6a +Subproject commit 0edc03ffdeba97cd90c4d7dba4c74c68059ea3f4 diff --git a/tests/adventure_arena_delight.test.lua b/tests/adventure_arena_delight.test.lua index 57204cdc..4b347c55 100644 --- a/tests/adventure_arena_delight.test.lua +++ b/tests/adventure_arena_delight.test.lua @@ -4,7 +4,7 @@ local t = require("TestKit") local ns = require("AppKit") local xml = require("ui.xml") local Adventures = require("apps.adventure-arena.models.Adventures") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local Controller = require("apps.adventure-arena.Controller") local renderFile, button = xml.renderFile, ns.Button diff --git a/tests/adventure_arena_dictation.test.lua b/tests/adventure_arena_dictation.test.lua index 890f7aa8..94485b97 100644 --- a/tests/adventure_arena_dictation.test.lua +++ b/tests/adventure_arena_dictation.test.lua @@ -3,7 +3,7 @@ _G.__headless = true local t = require("TestKit") local ns = require("AppKit") local xml = require("ui.xml") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local ReadingSettings = require("apps.adventure-arena.models.ReadingSettings") local Store = require("apps.adventure-arena.Store") local SavedGames = require("apps.adventure-arena.models.SavedGames") diff --git a/tests/adventure_arena_flow.test.lua b/tests/adventure_arena_flow.test.lua index 506e92fa..6aa298c9 100644 --- a/tests/adventure_arena_flow.test.lua +++ b/tests/adventure_arena_flow.test.lua @@ -4,7 +4,7 @@ local t = require("TestKit") local ns = require("AppKit") local xml = require("ui.xml") local Adventures = require("apps.adventure-arena.models.Adventures") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local Store = require("apps.adventure-arena.Store") local SavedGames = require("apps.adventure-arena.models.SavedGames") local catalog = Adventures diff --git a/tests/adventure_arena_library.test.lua b/tests/adventure_arena_library.test.lua index 7f563921..ab4a21ec 100644 --- a/tests/adventure_arena_library.test.lua +++ b/tests/adventure_arena_library.test.lua @@ -3,7 +3,7 @@ local t = require("TestKit") local Adventures = require("apps.adventure-arena.models.Adventures") -- The library is designed for 20-30 adventures; build a synthetic 30-game --- catalog so shelf, chart and search limits are exercised past today's nine. +-- catalog so shelf, chart and search limits are exercised past today's twelve. local collections = { "The Zork Trilogy", "Infocom Classics", "Arena Originals", "Mysteries" } local genres = { "Classic Adventure", "Sci-Fi Adventure", "Psychological Horror", "Victorian Mystery", "Fantasy Adventure" } local games = {} @@ -76,7 +76,7 @@ local realShelves = {} for _, shelf in ipairs(real:shelves()) do realShelves[shelf.title] = shelf.count end t.assertEqual(realShelves["The Zork Trilogy"], 3, "the Zork trilogy shares a shelf") t.assertEqual(realShelves["Infocom Classics"], 3, "classic Infocom titles share a shelf") -t.assertEqual(realShelves["Arena Originals"], 3, "studio titles share a shelf") +t.assertEqual(realShelves["Arena Originals"], 6, "studio titles share a shelf") t.assertEqual(real:related("infocom.zork1")[1].id, "infocom.zork2", "Zork I suggests the rest of the trilogy") t.assertEqual(#real:search("zork"), 3, "searching Zork finds the trilogy") diff --git a/tests/adventure_arena_link_actions.test.lua b/tests/adventure_arena_link_actions.test.lua new file mode 100644 index 00000000..76f9811b --- /dev/null +++ b/tests/adventure_arena_link_actions.test.lua @@ -0,0 +1,83 @@ +_G.__headless = true +local t = require("TestKit") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") +local Suggestions = require("apps.adventure-arena.zilscript.zilscript.reader.Suggestions") +local model = Session.new() +local function pattern(verb, join, reverse) + return { verb = verb, prefix = "", join = join, reverse = reverse } +end +model.items = { + { name = "tiny brass winding key", noun = "key", target = "winding key", carried = true, + aliases = { "key" }, patterns = { pattern("drop") } }, + { name = "workshop key", noun = "key", target = "workshop key", carried = true, + aliases = { "key" }, patterns = { pattern("drop") } }, + { name = "the fox", noun = "fox", target = "fox", patterns = {} }, + { name = "patchy fox toy", noun = "toy", target = "patchy fox", aliases = { "fox", "nutmeg" }, + patterns = { pattern("tell", "about"), pattern("show", "to", true) }, traits = { actor = true } }, + { name = "patient file", noun = "file", target = "patient file", carried = true, + patterns = { pattern("show", "to"), pattern("read", "with") } }, +} +model:beginScene("Workshop") +model:appendParagraph("The [[fox]] guards a [[workshop key]]. A [[wall]] is bare.") +local function has(target, command) + for _, action in ipairs(model:linkActions(target)) do + if action.command == command then return true end + end + return false +end +t.expect(has("workshop key", "drop workshop key"), "qualified key link resolves to the correct unique key") +t.expect(not has("workshop key", "drop winding key"), "same final noun cannot replace a different key") +t.expect(has("fox", "tell patchy fox about workshop key"), "vocabulary topic does not hide the actual fox's conversation") +t.expect(has("fox", "show patient file to patchy fox"), "actor menu puts evidence in the direct-object position") +t.expect(not has("fox", "show patchy fox to patient file"), "actor menu never reverses evidence and recipient") +t.expect(has("file", "show patient file to patchy fox"), "carried evidence can be shown to a present actor") +t.expect(not has("file", "show patient file to wall"), "show actions offer people as recipients") +t.expect(not has("file", "read patient file with patient file"), "instrument menus do not offer the object itself") +table.insert(model.items, { name = "wall", noun = "wall", target = "wall", patterns = { pattern("examine") } }) +model.items[5].patterns = { { action = "GIVE-TO", verb = "give", prefix = "", join = "to" } } +t.expect(has("file", "give patient file to wall"), "authored custom gift grammar can intentionally accept a non-actor recipient") +local distinct = Suggestions.items { + { "winding key", {}, {}, {}, "winding key" }, + { "workshop key", {}, {}, {}, "workshop key" }, +} +t.assertEqual(#distinct, 2, "two distinct tools with one head noun remain independently addressable") +local topicAndObject = Suggestions.items { + { "workshop key", {}, {}, {}, "workshop key", false, {}, {}, 10 }, + { "workshop key", { "EXAMINE" }, {}, { pattern("examine") }, "workshop key", true, {}, {}, 20 }, +} +t.assertEqual(#topicAndObject, 2, "a vocabulary-only topic cannot swallow a physical object's identity") +model.items = topicAndObject +t.expect(has("workshop key", "examine workshop key"), "physical key remains addressable beside its topic") +-- A link is prose history, not proof that an actor or tool is still here. +local scoped = Session.new() +scoped.items = { + { name = "cloud", noun = "cloud", target = "cloud", identity = 1, patterns = { pattern("drizzle") } }, + { name = "button", noun = "button", target = "button", identity = 2, carried = true, patterns = { pattern("drop") } }, + { name = "report", noun = "report", target = "report", identity = 3, carried = true, + patterns = { { verb = "steam", prefix = "", join = "over", partners = { [4] = true } } } }, + { name = "kettle", noun = "kettle", target = "kettle", identity = 4, patterns = { pattern("examine") } }, +} +scoped:beginScene("Kitchen") +scoped:appendParagraph("[[cloud]] [[button]] [[report]] [[kettle]] [[steam kettle->kettle]]") +local function contains(actions, command) + for _, action in ipairs(actions) do if action.command == command then return true end end + return false +end +t.expect(contains(scoped:linkActions("report"), "steam report over kettle"), "authored partner remains available") +t.expect(not contains(scoped:linkActions("report"), "steam report over cloud"), "unrelated local objects do not become authored instruments") +t.assertEqual(#scoped:linkActions("report"), 2, "partner aliases produce one action") +scoped.knownItems = { scoped.items[1], scoped.items[2] } +table.remove(scoped.items, 1) +table.remove(scoped.items, 1) +t.assertEqual(#scoped:linkActions("cloud"), 0, "an actor who has moved away has no stale local actions") +t.assertEqual(#scoped:linkActions("button"), 0, "giving away a tool removes its obsolete Drop action") +-- Conversation only offers nouns which the parser can actually resolve here. +local actor = { name = "Mica", noun = "mica", target = "mica", identity = 5, + patterns = { pattern("tell", "about") }, traits = { actor = true } } +table.insert(scoped.items, actor) +scoped:appendParagraph("[[Mica->mica]] remembers [[button]].") +t.expect(not contains(scoped:linkActions("mica"), "tell mica about button"), "remembered physical exhibits are not local parser topics") +scoped.running = false +t.assertEqual(#scoped:linkActions("north"), 0, "finished readers offer no stale movement") +t.expect(not scoped:submit("look"), "finished readers do not resume a dead coroutine") +os.exit(t.summary() and 0 or 1) diff --git a/tests/adventure_arena_link_play.test.lua b/tests/adventure_arena_link_play.test.lua new file mode 100644 index 00000000..666d28be --- /dev/null +++ b/tests/adventure_arena_link_play.test.lua @@ -0,0 +1,136 @@ +_G.__headless = true +local t = require("TestKit") +local base = "apps/adventure-arena/zilscript/" +package.path = base .. "?.lua;" .. base .. "?/init.lua;" .. package.path +local runtime = require("zilscript.runtime") +local play = require("zilscript.link_play") +local parser = require("zilscript.parser") +local compiler = require("zilscript.compiler") + +local compiled = compiler.compile(parser.parse([[ +
>> + )>> +]])) +t.expect(compiled.combined:find("FSETQ(PRSI, FLAMEBIT)", 1, true), "macro FORM keeps its quoted global flame flag") +t.expect(compiled.combined:find("FSETQ(PRSI, ONBIT)", 1, true), "macro FORM keeps its second flag") +for _, verb in ipairs({ "EXAMINE", "OPEN", "CLOSE", "THROUGH" }) do + t.expect(compiled.combined:find("'" .. verb .. "'", 1, true), "menu metadata retains action " .. verb) +end + +local save = os.tmpname() +local last, lastEnv, activeGame +local function contents(path) + local file = assert(io.open(path, "rb")) + local body = file:read("*a") + file:close() + return body +end +local function invoke(args, reuse) + local env = reuse and lastEnv or runtime.create_game_env() + if not reuse then + assert(runtime.init(env, true)) + env.require("zilscript") + package.zilpath = base .. "books/wondertown/?.zil;" .. base .. "infocom/zork1/?.zil;" .. base .. "?.zil" + env.PROSE_LINKS = true + assert(runtime.load_modules(env, { "books.wondertown.wondertown" }, { silent = true })) + end + local restored = not args.new_game + if restored and not reuse then assert(env.RESTORE(save)) end + local game = reuse and activeGame or runtime.create_game(env, true) + local function start() + if reuse then return "" end + _G._LLM_RESTORED = restored or nil + local output = game:start() + _G._LLM_RESTORED = nil + return output + end + local result = play.run { + args = args, env = env, game = game, gameName = "wondertown", restored = restored, + savefile = save, start = start, act = function(command) return game:resume(command) end, + room = function() return env.GETP(env.HERE, env.PQDESC) end, + } + last, lastEnv, activeGame = result, env, game + return result +end +local function choose(test) + local index = 0 + for _, command, target in last:gmatch('%["(.-)","(.-)","(.-)"%]') do + index = index + 1 + if test(command, target) then return invoke({ choose = tostring(index) }, true) end + end + error("Expected player action missing: " .. last) +end +local function command(verb, noun) + return function(value) return value:match("^" .. verb .. " ") and value:find(noun, 1, true) end +end + +local opening = invoke { new_game = true, seed = 97 } +t.expect(opening:find('"room":"Workshop Floor"', 1, true), "debug opens the real reader scene") +t.expect(not opening:match('"take [^"]-can"'), "menus do not reveal the hidden oil can") +local before = contents(save) +local observed = invoke {} +t.assertEqual(contents(save), before, "observing links does not change the saved VM") +t.assertEqual(opening:match('"actions":(.*)'), observed:match('"actions":(.*)'), "fresh-process restore preserves exact numbered menu order") + +local revealed = choose(function(value, target) return value:match("^examine ") and target == "workbench" end) +t.expect(revealed:match('"take [^"]-can"'), "examining the hiding place makes its actual object actionable") +choose(command("take", "string")) +local inventory = choose(function(value) return value == "inventory" end) +t.expect(inventory:find('"drop ', 1, true), "a carried object offers drop") +t.expect(contents(save .. ".transcript"):find("[[frayed string", 1, true), "native inventory names are actual prose links") +choose(function(value) return value == "north" end) +local carried = choose(function(value) return value == "inventory" end) +t.expect(carried:find("string", 1, true), "inventory links survive moving to another room and restoring") +choose(command("drop", "string")) +t.assertEqual(lastEnv.LOC(lastEnv.KEY_STRING), lastEnv.HERE, "selected menu action mutates the one actual string object") + +-- The private generator lives in scalar VM state, not a host closure. +local randomState = lastEnv.PLAY_RANDOM +lastEnv.math.random(1, 100) +local advanced = lastEnv.PLAY_RANDOM +t.expect(advanced ~= randomState, "random generation advances saved VM state") +assert(lastEnv.SAVE(save)) +invoke {} +t.assertEqual(lastEnv.PLAY_RANDOM, advanced, "restoring a snapshot does not reset its random sequence") + +local original = contents(save) +local ok = pcall(invoke, { choose = "999999" }, true) +t.expect(not ok, "debug rejects actions outside the visible menu") +t.assertEqual(contents(save), original, "an invalid choice leaves the checkpoint untouched") +local previousGame = lastEnv.PLAY_GAME +lastEnv.PLAY_GAME = "other-story" +assert(lastEnv.SAVE(save)) +t.expect(not pcall(invoke, {}, true), "debug refuses a snapshot belonging to another story") +lastEnv.PLAY_GAME = previousGame + +local adjective = lastEnv.VOC("oil", lastEnv.ADJECTIVE) +lastEnv.SYNTAX { VERB = "OIL", ACTION = "V_EXAMINE", OBJECT = {} } +lastEnv.SYNTAX { VERB = "OIL", ACTION = "V_EXAMINE", OBJECT = {} } +t.assertEqual(lastEnv.WTQ(lastEnv.WQOIL, lastEnv.PSQADJECTIVE, lastEnv.P1QADJECTIVE), adjective, + "re-registering a verb keeps the adjective meaning of the same word") +local gold = lastEnv.VOC("gold", lastEnv.ADJECTIVE) +lastEnv.VOC("gold", lastEnv.NOUN) +t.expect(lastEnv.ZIL_TRUE(lastEnv.WTQ(lastEnv.WQGOLD, lastEnv.PSQOBJECT, lastEnv.P1QOBJECT)), + "a word that is both adjective and noun retains its noun marker") +t.assertEqual(lastEnv.WTQ(lastEnv.WQGOLD, lastEnv.PSQADJECTIVE, lastEnv.P1QADJECTIVE), gold, + "adding a noun preserves the existing adjective") + +-- FINISH yields its native ending prompt. Its lifecycle marker must still +-- prevent a fresh debug invocation from re-entering ordinary MAIN-LOOP. +local ending = coroutine.create(function() + local ok, signal = pcall(lastEnv.JIGS_UP, "A test ending.") + return not ok and lastEnv.IS_ZIL_CONTROL_SIGNAL(signal, "quit") +end) +local endedOK, quit = coroutine.resume(ending) +t.expect(endedOK and quit, "the link reader finishes through the native quit signal without parser prompts") +assert(lastEnv.SAVE(save)) +local finished = invoke {} +t.expect(finished:find('"running":false', 1, true), "an ended VM remains ended after restore") +t.expect(finished:find('"actions":[]', 1, true), "ending snapshots expose no normal play actions") + +os.remove(save) +os.remove(save .. ".transcript") +os.remove(save .. ".next") +os.exit(t.summary() and 0 or 1) diff --git a/tests/adventure_arena_links.test.lua b/tests/adventure_arena_links.test.lua index 1bf23e31..7707b0c6 100644 --- a/tests/adventure_arena_links.test.lua +++ b/tests/adventure_arena_links.test.lua @@ -6,7 +6,7 @@ local bridge = require("AppKitNative") local xml = require("ui.xml") local Template = require("ui.template") local Adventures = require("apps.adventure-arena.models.Adventures") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local ReadingSettings = require("apps.adventure-arena.models.ReadingSettings") local Store = require("apps.adventure-arena.Store") local SavedGames = require("apps.adventure-arena.models.SavedGames") @@ -80,9 +80,9 @@ t.assertEqual(#scene.links[1], 2, "the first paragraph carries its links") t.assertEqual(scene.paragraphs[2], "A brass plaque hangs askew.", "a single newline starts a separate object paragraph") t.assertEqual(#scene.links[2], 1, "the object paragraph carries its own link") t.assertEqual(scene.links[2][1].location, 2, "link offsets restart at each paragraph") -t.assertEqual(commands(model:linkActions("plaque")), "take plaque,read plaque", - "readable text offers read instead of a duplicate examine, then the story's verbs by how often players use them") -t.assertEqual(model:linkActions("plaque")[1].title, "Take", "menu titles show only the capitalized verb") +t.assertEqual(commands(model:linkActions("plaque")), "examine plaque,take plaque,read plaque", + "examination remains available alongside reading and the story's other actions") +t.assertEqual(model:linkActions("plaque")[1].title, "Examine", "menu titles show only the capitalized verb") local gateActions = model:linkActions("gate") t.assertEqual(gateActions[1].title, "Examine", "the first button omits the object") t.assertEqual(gateActions[2].title, "Open", "the second button omits the object") @@ -94,14 +94,14 @@ t.assertEqual(commands(model:linkActions("door")), "examine door,open door", t.assertEqual(commands(model:linkActions("north")), "north", "a direction walks") t.assertEqual(model:linkActions("north")[1].title, "Go north", "a direction's action says where it goes") t.assertEqual(commands(model:linkActions("d")), "down", "direction abbreviations walk too") -t.assertEqual(commands(model:linkActions("ghost")), "examine ghost", "an unknown thing can still be examined") +t.assertEqual(commands(model:linkActions("ghost")), "", "a prose mention outside parser scope offers no phantom action") t.assertEqual(#model:linkActions(""), 0, "an empty target offers nothing") -- Overflow retains every supported action, with short titles and full commands. local many = Session.new { engineFactory = engine } many.items = { { name = "door", noun = "door", verbs = { "open", "close", "read", "take", "push", "pull", "touch" } } } local overflow = many:linkActions("door") -t.assertEqual(#overflow, 7, "actions beyond three and the old six-item cap stay available") +t.assertEqual(#overflow, 8, "actions beyond three and the old six-item cap stay available") for _, action in ipairs(overflow) do t.expect(not action.title:find("door", 1, true), "overflow labels omit the noun too") t.expect(action.command:find(" door$") ~= nil, "overflow commands retain their target") @@ -135,10 +135,10 @@ t.assertEqual(#live, 2, "the page marks the scene's links") t.assertEqual(live[1].text, "iron gates", "a link underlines the words of the prose") local plaque = bridge._paragraphLinks(page().paragraph_1_2)[1] t.assertEqual(plaque.text, "brass plaque", "links in later lines keep their place") -t.assertEqual(table.concat(plaque.titles, ","), "Take,Read", "a link's menu lists its actions") +t.assertEqual(table.concat(plaque.titles, ","), "Examine,Take,Read", "a link's menu lists its actions") t.assertEqual(page().paragraph_1_1.text, scene.paragraphs[1], "the page shows the prose without markup") -bridge._paragraphPerformLink(page().paragraph_1_2, 1, 2) +bridge._paragraphPerformLink(page().paragraph_1_2, 1, 3) t.assertEqual(model.history[#model.history], "read plaque", "choosing an action sends its command") t.assertEqual(page().command_2.text, "read plaque", "the command appears on the page") t.assertEqual(#bridge._paragraphLinks(page().paragraph_1_1), 2, "links stay live while the reader stays in the room") @@ -189,7 +189,7 @@ if game then end t.expect(targets.gate and targets.north and targets.plaque, "the opening links the gate, the path north and the plaque") t.expect(not table.concat(opening.paragraphs, "\n"):find("[[", 1, true), "no markup reaches the page") - t.assertEqual(commands(story:linkActions("plaque")), "take plaque,read plaque", + t.assertEqual(commands(story:linkActions("plaque")), "examine brass plaque,take brass plaque,read brass plaque,pull brass plaque", "the plaque offers the verbs the story accepts") story:submit("north") t.assertEqual(story.roomTitle, "Sanitarium Entrance Hall", "walking north enters the hall") diff --git a/tests/adventure_arena_model.test.lua b/tests/adventure_arena_model.test.lua index 75dc05a2..9cfbf71f 100644 --- a/tests/adventure_arena_model.test.lua +++ b/tests/adventure_arena_model.test.lua @@ -2,12 +2,12 @@ _G.__headless = true local t = require("TestKit") local Adventures = require("apps.adventure-arena.models.Adventures") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local Store = require("apps.adventure-arena.Store") Store.new() local catalog = Adventures -t.assertEqual(#catalog:all(), 9, "AdventureArena catalog contains the complete Lua catalog") +t.assertEqual(#catalog:all(), 12, "AdventureArena catalog contains the complete Lua catalog") t.assertEqual(catalog:find("infocom.zork1").title, "Zork I: The Great Underground Empire", "catalog resolves games by stable id") diff --git a/tests/adventure_arena_navigation.test.lua b/tests/adventure_arena_navigation.test.lua index c26ac187..f9157f47 100644 --- a/tests/adventure_arena_navigation.test.lua +++ b/tests/adventure_arena_navigation.test.lua @@ -2,7 +2,7 @@ _G.__headless = true local t = require("TestKit") local ns = require("AppKit") local Adventures = require("apps.adventure-arena.models.Adventures") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local catalog = Adventures local sessionModel = Session.new({ engineFactory = function() return { start = function() return { resume = function(_, command) return "Response to " .. command end }, "Opening" end } diff --git a/tests/adventure_arena_new_books.test.lua b/tests/adventure_arena_new_books.test.lua new file mode 100644 index 00000000..f8c85f2b --- /dev/null +++ b/tests/adventure_arena_new_books.test.lua @@ -0,0 +1,58 @@ +_G.__headless = true +local t = require("TestKit") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") +local Runtime = require("apps.adventure-arena.services.ZILRuntime") +local catalog = require("apps.adventure-arena.catalog.Adventures") +local titles = { + ["books.pocket-weather"] = "The Pocket Weather Bureau", + ["books.silent-vector"] = "The Silent Vector", + ["books.last-broadcast"] = "The Last Broadcast", +} +local function engineFactory(book, seed) return Runtime.new(book, nil, seed) end +local count = 0 +for _, book in ipairs(catalog) do + if titles[book.id] then + count = count + 1 + t.assertEqual(book.title, titles[book.id], "new story identity matches its library entry") + t.assertEqual(book.collection, "Arena Originals", "new stories belong to the originals shelf") + t.assertEqual(book.author, "Studio Null", "cover and catalog have the same credit") + t.assertEqual(book.year, 2026, "new stories carry their actual release year") + t.assertEqual(book.rating, 0, "unreviewed books have no fabricated ratings") + t.assertEqual(book.reviewCount, 0, "unreviewed books have no fabricated reader counts") + t.assertEqual(#book.reviews, 0, "no illustrative review is published as real feedback") + local cover = assert(io.open(book.cover, "rb")) + t.assertEqual(cover:read(8), "\137PNG\13\10\26\10", "new cover is a real bundled PNG") + cover:close() + local session = Session.new { engineFactory = engineFactory, newSeed = function() return 2026 end } + t.expect(session:start(book), "library entry boots its own real story through the app runtime") + t.expect(#session.items > 0 and session.scene ~= nil, "opening exposes the actual room and its objects") + t.assertEqual(Session.statusLine(book, 0, 0, 7), "7 moves", "scoreless stories show turns rather than an invented score") + local first = session.roomTitle + local command + for _, links in ipairs(session.scene.links or {}) do + for _, link in ipairs(links) do + for _, action in ipairs(session:linkActions(link.target)) do + if action.command:match("^examine ") then command = action.command; break end + end + if command then break end + end + if command then break end + end + t.expect(command ~= nil, "opening prose provides an actual examination affordance") + t.expect(session:submit(command), "a supplied reader command executes") + t.assertEqual(session.roomTitle, first, "examining does not silently change rooms") + local saved = session:snapshot() + local restored = Session.new { engineFactory = engineFactory } + t.expect(restored:start(book, saved), "app saves resume the selected new title") + t.assertEqual(restored.roomTitle, session.roomTitle, "resumed room matches") + t.assertEqual(restored.score, session.score, "resumed score matches") + t.assertEqual(#restored.items, #session.items, "resumed object scope matches") + t.assertEqual(#restored:linkActions("nonexistent gateway"), 0, "prose cannot invent a physical interaction") + end +end +t.assertEqual(count, 3, "all three new adventures are available in the real catalog") +local zork +for _, book in ipairs(catalog) do if book.id == "infocom.zork1" then zork = book end end +local classics = Runtime.new(zork, nil, 2026):start() +t.assertEqual(classics:progress().maxScore, 350, "runtime reads normalized ZIL score identifiers") +os.exit(t.summary() and 0 or 1) diff --git a/tests/adventure_arena_pages.test.lua b/tests/adventure_arena_pages.test.lua index 4a1e1a36..d05f40a1 100644 --- a/tests/adventure_arena_pages.test.lua +++ b/tests/adventure_arena_pages.test.lua @@ -5,7 +5,7 @@ local Manifest = require("data.manifest") local Routes = require("data.routes") local Adventures = require("apps.adventure-arena.models.Adventures") local Controller = require("apps.adventure-arena.Controller") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") -- Every page of the manifest has a route with a view that exists. local manifest = Manifest.load("apps/adventure-arena/app.xml") diff --git a/tests/adventure_arena_progress.test.lua b/tests/adventure_arena_progress.test.lua index 688b09ac..5f441f5a 100644 --- a/tests/adventure_arena_progress.test.lua +++ b/tests/adventure_arena_progress.test.lua @@ -4,7 +4,7 @@ local t = require("TestKit") local ns = require("AppKit") local xml = require("ui.xml") local Adventures = require("apps.adventure-arena.models.Adventures") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local Controller = require("apps.adventure-arena.Controller") -- A scripted engine: "take egg" scores, everything else echoes. diff --git a/tests/adventure_arena_reading_settings_flow.test.lua b/tests/adventure_arena_reading_settings_flow.test.lua index a84cfc93..6d511770 100644 --- a/tests/adventure_arena_reading_settings_flow.test.lua +++ b/tests/adventure_arena_reading_settings_flow.test.lua @@ -3,7 +3,7 @@ _G.__headless = true local t = require("TestKit") local ns = require("AppKit") local xml = require("ui.xml") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local ReadingSettings = require("apps.adventure-arena.models.ReadingSettings") local Store = require("apps.adventure-arena.Store") local SavedGames = require("apps.adventure-arena.models.SavedGames") diff --git a/tests/adventure_arena_saves.test.lua b/tests/adventure_arena_saves.test.lua index b9c33197..45621a2c 100644 --- a/tests/adventure_arena_saves.test.lua +++ b/tests/adventure_arena_saves.test.lua @@ -4,7 +4,7 @@ local t = require("TestKit") local ns = require("AppKit") local SavedGames = require("apps.adventure-arena.models.SavedGames") local JsonDocument = require("apps.adventure-arena.services.JsonDocument") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local Adventures = require("apps.adventure-arena.models.Adventures") local ZILRuntime = require("apps.adventure-arena.services.ZILRuntime") diff --git a/tests/adventure_arena_session.test.lua b/tests/adventure_arena_session.test.lua index 42bc63c9..4cd0c676 100644 --- a/tests/adventure_arena_session.test.lua +++ b/tests/adventure_arena_session.test.lua @@ -1,6 +1,6 @@ _G.__headless = true local t = require("TestKit") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local function kinds(session) local list = {} diff --git a/tests/adventure_arena_suggestions.test.lua b/tests/adventure_arena_suggestions.test.lua index 1f75b8b1..ce111f27 100644 --- a/tests/adventure_arena_suggestions.test.lua +++ b/tests/adventure_arena_suggestions.test.lua @@ -1,6 +1,6 @@ _G.__headless = true local t = require("TestKit") -local Suggestions = require("apps.adventure-arena.helpers.Suggestions") +local Suggestions = require("apps.adventure-arena.zilscript.zilscript.reader.Suggestions") local function titles(list) local result = {} diff --git a/tests/adventure_arena_transcript.test.lua b/tests/adventure_arena_transcript.test.lua index fc72ae7b..495f84e9 100644 --- a/tests/adventure_arena_transcript.test.lua +++ b/tests/adventure_arena_transcript.test.lua @@ -3,7 +3,7 @@ _G.__headless = true local t = require("TestKit") local ns = require("AppKit") local xml = require("ui.xml") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local ReadingSettings = require("apps.adventure-arena.models.ReadingSettings") local Store = require("apps.adventure-arena.Store") local SavedGames = require("apps.adventure-arena.models.SavedGames") diff --git a/tests/adventure_arena_typing.test.lua b/tests/adventure_arena_typing.test.lua index cf617a4b..670db9f9 100644 --- a/tests/adventure_arena_typing.test.lua +++ b/tests/adventure_arena_typing.test.lua @@ -3,7 +3,7 @@ _G.__headless = true local t = require("TestKit") local ns = require("AppKit") local xml = require("ui.xml") -local Session = require("apps.adventure-arena.models.Session") +local Session = require("apps.adventure-arena.zilscript.zilscript.reader.Session") local Store = require("apps.adventure-arena.Store") local SavedGames = require("apps.adventure-arena.models.SavedGames") local SessionController = require("apps.adventure-arena.controllers.SessionController")