diff --git a/Makefile b/Makefile index 6b133a1..9cae1cf 100644 --- a/Makefile +++ b/Makefile @@ -163,6 +163,7 @@ test-limehouse-walkthrough: @lua5.4 run-zil-test.lua books/limehouse-killings/test/test-report-regressions @lua5.4 run-zil-test.lua books/limehouse-killings/test/test-prose-reveal @lua5.4 tests/test_limehouse_walkthrough.lua + @lua5.4 tests/test_limehouse_companion.lua test-wondertown-descriptions: @echo "Running Wondertown description ownership regressions..." diff --git a/infocom/zork1/test/test-assertions.zil b/infocom/zork1/test/test-assertions.zil index b37b328..ffe74bb 100644 --- a/infocom/zork1/test/test-assertions.zil +++ b/infocom/zork1/test/test-assertions.zil @@ -22,6 +22,8 @@ <==? ,HERE ,RECEPTION-ROOM>> ;"Take the key" + ;"The key lies under the reception papers until they are searched." + > <==? ,ADVENTURER>> ;"Check drawer is NOT open yet (should pass)" diff --git a/infocom/zork1/test/test-check-commands.zil b/infocom/zork1/test/test-check-commands.zil index c25344a..65759cc 100644 --- a/infocom/zork1/test/test-check-commands.zil +++ b/infocom/zork1/test/test-check-commands.zil @@ -25,6 +25,8 @@ >> ;"Take the key" + ;"The key lies under the reception papers until they are searched." + > <==? ,ADVENTURER>> ;"Unlock and open drawer - the unlock command both unlocks AND opens it" diff --git a/infocom/zork1/test/test-walk-around-house.zil b/infocom/zork1/test/test-walk-around-house.zil index f97ca79..74ce378 100644 --- a/infocom/zork1/test/test-walk-around-house.zil +++ b/infocom/zork1/test/test-walk-around-house.zil @@ -3,5 +3,5 @@ > > + <==? ,HERE ,NORTH-OF-HOUSE>> > diff --git a/infocom/zork1/test/zork1-walkthrough.zil b/infocom/zork1/test/zork1-walkthrough.zil index 07c6b64..3972af0 100644 --- a/infocom/zork1/test/zork1-walkthrough.zil +++ b/infocom/zork1/test/zork1-walkthrough.zil @@ -14,8 +14,8 @@ > > > - > - > + <==? ,HERE ,SOUTH-OF-HOUSE>> + <==? ,HERE ,EAST-OF-HOUSE>> > > > @@ -24,7 +24,7 @@ > > > - > + <==? ,HERE ,EAST-OF-CHASM>> > > > @@ -40,7 +40,7 @@ > > > - > + <==? ,HERE ,TROLL-ROOM>> > > > @@ -55,13 +55,13 @@ ;> ;> > - > - > - > + <==? ,HERE ,ROUND-ROOM>> + <==? ,HERE ,ENGRAVINGS-CAVE>> + <==? ,HERE ,DOME-ROOM>> > - > + <==? ,HERE ,TORCH-ROOM>> > - > + <==? ,HERE ,EGYPT-ROOM>> > > > @@ -69,10 +69,10 @@ > > > - > - > - > - > + <==? ,HERE ,CANYON-VIEW>> + <==? ,HERE ,CLIFF-MIDDLE>> + <==? ,HERE ,CANYON-BOTTOM>> + <==? ,HERE ,END-OF-RAINBOW>> > > > @@ -97,9 +97,9 @@ > > > - > + <==? ,HERE ,RESERVOIR-SOUTH>> > - > + <==? ,HERE ,DAM-LOBBY>> > > > @@ -110,7 +110,7 @@ > > > - > + > ;"The roar drives the player back out" > @@ -124,37 +124,37 @@ > > > - > + <==? ,HERE ,ENTRANCE-TO-HADES>> > > > > > > - > + <==? ,HERE ,LAND-OF-LIVING-DEAD>> > > > - > + > > - > - > - > - > + <==? ,HERE ,COLD-PASSAGE>> + <==? ,HERE ,SLIDE-ROOM>> + <==? ,HERE ,MINE-ENTRANCE>> + <==? ,HERE ,SQUEEKY-ROOM>> > - > - > + <==? ,HERE ,BAT-ROOM>> + <==? ,HERE ,SHAFT-ROOM>> > > > - > - > + <==? ,HERE ,SMELLY-ROOM>> + <==? ,HERE ,GAS-ROOM>> > > > > - > - > + <==? ,HERE ,LADDER-TOP>> + <==? ,HERE ,LADDER-BOTTOM>> > > > @@ -176,13 +176,13 @@ > > > - > + <==? ,HERE ,TIMBER-ROOM>> > > > > > - > + <==? ,HERE ,MACHINE-ROOM>> > > > @@ -227,12 +227,12 @@ > > > - > - > + <==? ,HERE ,RESERVOIR-SOUTH>> + <==? ,HERE ,RESERVOIR>> > - > + <==? ,HERE ,RESERVOIR-NORTH>> > - > + <==? ,HERE ,ATLANTIS-ROOM>> > > > @@ -241,7 +241,7 @@ > > > - > + <==? ,HERE ,DAM-BASE>> ;"replaced command to use new INFLAT syntax" > > @@ -259,7 +259,7 @@ > > > - > + <==? ,HERE ,SANDY-CAVE>> > > > @@ -289,9 +289,9 @@ > > > - > - > - > + <==? ,HERE ,NORTH-OF-HOUSE>> + <==? ,HERE ,PATH>> + <==? ,HERE ,UP-A-TREE>> > > > @@ -310,11 +310,11 @@ > > > - > + <==? ,HERE ,CYCLOPS-ROOM>> > > > - > + <==? ,HERE ,STRANGE-PASSAGE>> > > > @@ -337,7 +337,7 @@ > > > - > + <==? ,HERE ,GRATING-ROOM>> ;> > diff --git a/main.lua b/main.lua index 2ff7ebe..86b74a6 100644 --- a/main.lua +++ b/main.lua @@ -131,13 +131,50 @@ local function companion_selector(query) end end +-- The arrow-key widget needs a terminal. Piped or scripted input gets a +-- numbered menu read a line at a time: a number picks a choice, anything +-- else is typed as a command. +local interactive = os.execute("test -t 0") == true + +local function numbered_selector(query) + local displayed = {} + local function print_group(title, group) + local shown = false + for _, choice in ipairs(query.choices) do + if (choice.group or "scene") == group then + if not shown then io.write("\n" .. title .. "\n") shown = true end + table.insert(displayed, choice) + io.write(string.format(" %d. %s\n", #displayed, choice.label)) + end + end + end + if query.scene and query.scene.alt then + io.write("\n" .. query.scene.alt .. "\n") + end + io.write("\nWhat will you do?\n") + print_group("In this scene", "scene") + print_group("Go somewhere", "move") + io.write("\nChoose a number, or type any command: ") + local input = io.read() + if not input then os.exit(0) end + local trimmed = input:match("^%s*(.-)%s*$") + local index = tonumber(trimmed) + local choice = index and displayed[index] + if choice then + io.write("\n> " .. choice.command .. "\n\n") + return {kind = "choice", id = choice.id, command = choice.command} + end + io.write("\n") + return {kind = "typed", command = trimmed} +end + while game:is_running() do local command if options.interface == "companion" then local query = env.COMPANION_QUERY() if query.ok and #query.choices > 0 then - local result = companion_selector(query) + local result = (interactive and companion_selector or numbered_selector)(query) if result.kind == "choice" then local selected = env.COMPANION_SELECT(result.id) if selected.ok then diff --git a/tests/test_limehouse_companion.lua b/tests/test_limehouse_companion.lua index 19505cb..5fa4279 100644 --- a/tests/test_limehouse_companion.lua +++ b/tests/test_limehouse_companion.lua @@ -41,10 +41,14 @@ local function run_companion(choices) return output end +-- Piped input gets the numbered menu (main.lua). A first visit prints a +-- room's prose without its name, which hosts set as a heading, so rooms are +-- recognized by their own words. + -- Test 1: Companion loads without errors test("companion loads", function() local output = run_companion({}) - assert_contains(output, "Ashworth Manor Gate") + assert_contains(output, "Wet iron bars") assert_contains(output, "Choose a number") end) @@ -69,14 +73,14 @@ end) -- Test 5: Entering manor from gate test("enter manor works", function() local output = run_companion({"1", "3"}) - assert_contains(output, "Entrance Hall") + assert_contains(output, "measured hush") end) -- Test 6: Library accessible from hall test("library accessible", function() -- Gate: read telegram(1), enter(3), Hall: try study(1), go library(4) local output = run_companion({"1", "3", "1", "4"}) - assert_contains(output, "Library") + assert_contains(output, "Floor-to-ceiling bookshelves") end) -- Test 7: Library shows torn page choice diff --git a/tests/test_limehouse_walkthrough.lua b/tests/test_limehouse_walkthrough.lua index 07bc7a1..e3b6d59 100644 --- a/tests/test_limehouse_walkthrough.lua +++ b/tests/test_limehouse_walkthrough.lua @@ -108,6 +108,9 @@ test.describe("Limehouse Killings walkthrough", function(t) local suffix = " --save " .. shell_quote(savefile) .. " --game limehouse-killings" assert.assert_equal(run_command("lua5.4 llm.lua --new-game" .. suffix), 0) + -- A first visit prints the room's description without its name, which + -- hosts set as a heading (infocom/zork1/verbs.zil DESCRIBE-ROOM); each + -- move is recognized by its own prose. local actions = { {"examine me", "eyes are prehensile"}, {"examine myself", "eyes are prehensile"}, @@ -116,16 +119,16 @@ test.describe("Limehouse Killings walkthrough", function(t) {"examine fog", "fog swirls"}, {"examine gates", "iron gates"}, {"examine path", "gravel path"}, - {"go north", "Entrance Hall"}, + {"go north", "Doorways lead south to the gate"}, {"examine chandelier", "chandelier hangs"}, {"examine portraits", "Portraits of the Ashworth family"}, {"examine rug", "Persian rug"}, - {"go down", "Kitchen"}, + {"go down", "seen better days"}, {"pull servant bell", "distant bell rings"}, {"use servant bell", "distant bell rings"}, - {"go west", "Garden"}, + {"go west", "overgrown hedges"}, {"take footprint cast", "take the footprint cast"}, - {"go south", "Servants' Quarters"}, + {"go south", "narrow beds"}, {"examine mister hudson", "Mr. Hudson"}, {"ask hudson", "What do you want to ask Mr. Hudson about"}, {"tell hudson about footprint-cast", "don't know anything about that"}, @@ -165,10 +168,10 @@ test.describe("Limehouse Killings walkthrough", function(t) assert.assert_equal(run_command("lua5.4 llm.lua --new-game" .. suffix), 0) local actions = { - {"go north", "Entrance Hall"}, - {"go down", "Kitchen"}, - {"go west", "Garden"}, - {"go south", "Servants' Quarters"}, + {"go north", "Doorways lead south to the gate"}, + {"go down", "seen better days"}, + {"go west", "overgrown hedges"}, + {"go south", "narrow beds"}, {"ask hudson about key", "hands you the keyring"}, {"go north", "Garden"}, {"go east", "Kitchen"}, @@ -178,7 +181,7 @@ test.describe("Limehouse Killings walkthrough", function(t) {"look", "closed but unlocked"}, {"open study door", "You open the study door"}, {"look", "stands open, revealing the study beyond"}, - {"go north", "Study"}, + {"go north", "chalk outline"}, } for _, entry in ipairs(actions) do diff --git a/zilscript/bootstrap.lua b/zilscript/bootstrap.lua index 9d87fff..709e338 100644 --- a/zilscript/bootstrap.lua +++ b/zilscript/bootstrap.lua @@ -957,6 +957,12 @@ local function io_flush() return text end +-- What the story printed since the last prompt. A host takes it when the +-- game ends, since no READ yields that last text. +function TAKE_OUTPUT() + return io_flush() +end + local function is_control_signal(signal, kind) if kind then return signal == ZIL_CONTROL_SIGNALS[kind] diff --git a/zilscript/choice_widget.lua b/zilscript/choice_widget.lua index ed6620a..2400322 100644 --- a/zilscript/choice_widget.lua +++ b/zilscript/choice_widget.lua @@ -88,7 +88,7 @@ local function run_loop(config, terminal) selected = 0 dirty = true end - elseif key.kind == "interrupt" then + elseif key.kind == "interrupt" or key.kind == "eof" then return {kind = "interrupt"} elseif key.kind == "text" and allow_text then typed = typed .. key.text diff --git a/zilscript/runtime.lua b/zilscript/runtime.lua index 853a5a7..70d330f 100644 --- a/zilscript/runtime.lua +++ b/zilscript/runtime.lua @@ -300,41 +300,46 @@ end -- Create a coroutine for the game that yields on input -- Returns a coroutine object function M.create_game(env, silent) + -- The ending's text: printed after the last READ, so no prompt yields it. + local function final_output() + if type(env.TAKE_OUTPUT) == "function" then return env.TAKE_OUTPUT() end + end local co co = coroutine.create(function() -- Check if we're restoring from a save (LLM mode) - local restored = rawget(_G, "_LLM_RESTORED") - if restored then - -- Skip GO() initialization, go directly to MAIN_LOOP - local success = M.execute("MAIN_LOOP()", 'main', env, silent) - if not success then - error("Failed to start game: MAIN_LOOP() not defined or failed") - end - else - -- Normal game start - if type(env.GO) ~= "function" then - error("Failed to start game: GO() not defined or failed") + -- A restored save skips GO()'s initialization and re-enters the main + -- loop. Both entries run under the same handler, so an ending's QUIT + -- or RESTART after a restore ends or restarts the game rather than + -- escaping as a raw control signal. + local entry = env.GO + if rawget(_G, "_LLM_RESTORED") then + entry = env.MAIN_LOOP + if type(entry) ~= "function" then + error("Failed to start game: MAIN_LOOP() not defined") end + elseif type(entry) ~= "function" then + error("Failed to start game: GO() not defined or failed") end while true do - local ok, result = pcall(env.GO) + local ok, result = pcall(entry) if ok then if not silent then print("\n*** Game has ended ***\n") end - return result + return final_output() end if type(env.IS_ZIL_CONTROL_SIGNAL) == "function" and env.IS_ZIL_CONTROL_SIGNAL(result, "restart") then -- RESTART restores captured state and re-enters GO(). + entry = env.GO elseif type(env.IS_ZIL_CONTROL_SIGNAL) == "function" and env.IS_ZIL_CONTROL_SIGNAL(result, "quit") then if not silent then print("\n*** Game has ended ***\n") end - return + return final_output() else error(tostring(result), 0) end diff --git a/zilscript/terminal.lua b/zilscript/terminal.lua index 9f9c738..9cebbdb 100644 --- a/zilscript/terminal.lua +++ b/zilscript/terminal.lua @@ -61,7 +61,8 @@ end function Terminal:read_key() local char = self.stdin:read(1) if not char then - return nil + -- The input ended (a closed pipe): there is nothing left to choose. + return {kind = "eof"} end if char == "\27" then