-- Automated run of the whole thing. -- -- Enabled with BELL_SMOKE=1. Written with the lessons from the previous three -- projects baked in: -- -- * Never set the savegame value a trigger sets. That hides triggers too -- small to stand in, which is a dead end for the player and a green tick -- for the test. -- * Never move the hero with hero:walk(). The engine does not activate -- sensors while the hero is in the "forced walking" state, so a scripted -- walk passes straight through a trigger without firing it. -- * Drive with simulated command holds, which is what a player does. -- * Check the copy fits the frame. Narration that runs off the side of a -- 320-pixel screen is the one bug that no amount of playing finds, because -- the person playing wrote the line and knows what it says. require("scripts/multi_events") local smoke = {} local DIRECTIONS = { "right", "left", "up", "down" } local LINE_WIDTH = 292 -- what cinema/stage.lua wraps narration to local DIALOG_WIDTH = 205 -- the writable width inside the dialog box, in pixels local DIALOG_LINES = 3 -- how many the box shows at once local SCREEN_WIDTH = 308 -- a 320 frame with six pixels of air either side -- The SNES style in ../../VOICES.md commits to a ten-year-old following every -- sentence. That is a judgement, but two thirds of it are arithmetic, so the -- arithmetic gets checked: Flesch-Kincaid grade level over everything the -- player reads, and a cap on the longest sentence in the game. local MAX_GRADE = 6.0 local MAX_SENTENCE_WORDS = 20 -- The perf pass, which is the only one that runs at normal speed. It compares -- the frame rate with text on screen against the same machine with none, so a -- busy computer cancels out; see perf_case for why an absolute floor does not -- work. Before the fix this ratio was about 0.3. local HINT_SHOW_MS = 7000 -- must match data/maps/callow.lua local PERF_SECONDS = 6 -- Measured: 27% before the fix, 78-100% after it on a machine pegged at a -- hundred per cent CPU. The floor sits between the two with room on both -- sides, because the job here is to catch "something is rebuilt every frame", -- which costs two thirds of the frame rate - not to police the last ten. local MIN_TEXT_RATIO = 0.55 -- Below this with nothing on screen, the machine is the bottleneck and the -- comparison means nothing. 60 is the engine's cap. local QUIET_ENOUGH_FPS = 52 -- A clean machine gives 60 mean and 58-60 worst, so these floors are wide on -- purpose: this pass measures the computer as well as the game, and a failure -- here means "look at what else is running" at least as often as it means a -- regression. It is set to catch the thing it was written for - narration that -- held the game between thirteen and twenty-six - and not to police the last -- ten per cent. local function log(message) print("[smoke] " .. message) end local function run(context, steps, index) index = index or 1 local step = steps[index] if step == nil then log("done") sol.timer.start(context, 500, function() sol.main.exit() end) return end local timer = sol.timer.start(context, step.delay, function() log(step.name) local ok, err = pcall(step.action, context) if not ok then print("[smoke] FAILED at " .. step.name .. ": " .. tostring(err)) sol.main.exit() return end run(context, steps, index + 1) end) timer:set_suspended_with_map(false) end --- Lets go of every direction, so a hold still pending at a map change does --- not walk the hero into the next scene. local function release_all(game) for _, command in ipairs(DIRECTIONS) do game:simulate_command_released(command) end end local function hold_command(game, command, duration) release_all(game) game:simulate_command_pressed(command) local timer = sol.timer.start(game, duration, function() game:simulate_command_released(command) end) timer:set_suspended_with_map(false) end --- Holds a direction until `done` says so, or until it gives up. --- --- Walking for a fixed number of milliseconds and hoping is how a test ends up --- asserting about wherever the hero happened to stop, which is a different --- test every time somebody moves a fence. local function walk_until(game, command, done, timeout) release_all(game) game:simulate_command_pressed(command) local elapsed = 0 local timer = sol.timer.start(game, 60, function() elapsed = elapsed + 60 if done(game) or elapsed >= timeout then game:simulate_command_released(command) return false end return true end) timer:set_suspended_with_map(false) end local function hero_x(game) local x = game:get_hero():get_position() return x end local function hero_y(game) local _, y = game:get_hero():get_position() return y end --- Presses "advance" on every beat, so the run takes a minute rather than the --- eight the intro is actually paced at. local function hurry(game) local pusher = sol.timer.start(game, 90, function() local map = game:get_map() local stage = map ~= nil and map.smoke_stage or nil if stage ~= nil and stage.on_skip ~= nil then stage.on_skip() end return true end) pusher:set_suspended_with_map(false) end --- Grabs one frame. --- --- Through the stage while a cutscene is running, so the letterbox and the --- narration are in the picture; through map:on_draw once the scene is over, --- because a finished stage is a stopped menu and its on_draw never runs --- again. The first version of this quietly captured nothing for three of the --- four shots and said so in a log line nobody was reading. local function shot(game, name) local map = game:get_map() local stage = map ~= nil and map.smoke_stage or nil local function grab(destination) destination:save("shot_" .. name .. ".png") log("captured " .. name) end if stage ~= nil and sol.menu.is_started(stage) then local draw = stage.on_draw stage.on_draw = function(self, destination) draw(self, destination) stage.on_draw = draw grab(destination) end return end local draw = map.on_draw map.on_draw = function(self, destination) if draw ~= nil then draw(self, destination) end map.on_draw = draw grab(destination) end end -- Dialogs are normally force-closed so the run does not take eight minutes. -- One test turns that off, because "the player can get through a conversation" -- is not something stop_dialog() can tell you anything about. local auto_close = true local function auto_close_dialogs(game) local captured = false game:register_event("on_dialog_started", function(game) -- Let go of whatever was being held. A player stops walking to read, and a -- test that keeps a direction held through a four-page conversation walks -- out of the map the moment the person in the way steps aside. release_all(game) if not captured then captured = true sol.timer.start(game, 1400, function() local box = game.dialog_box_menu if box == nil then return end local draw = box.on_draw box.on_draw = function(self, destination) draw(self, destination) box.on_draw = draw destination:save("shot_dialog.png") log("captured dialog") end end) end sol.timer.start(game, 1600, function() if auto_close and game:is_dialog_enabled() then game:stop_dialog() end end) end) end --- Every narration line the game ships, measured in the font it is drawn in. local function check_narration_fits() local measure = sol.text_surface.create{ font = "8_bit", font_size = 11 } local worst, worst_line = 0, nil local function check(text, where) for line in (text .. "|"):gmatch("([^|]*)|") do if line ~= "" then measure:set_text(line) local width = measure:get_size() if width > worst then worst, worst_line = width, where .. ": " .. line end assert(width <= LINE_WIDTH, "narration runs off the screen (" .. width .. "px): " .. line) end end end for _, beat in ipairs(require("scripts/menus/prologue").beats) do if type(beat.narrate) == "string" then check(beat.narrate, "prologue") end end local map = sol.main.get_game():get_map() if map ~= nil and map.hint_lines ~= nil then for _, lines in ipairs(map.hint_lines) do for _, line in ipairs(lines) do check(line, "hint") end end end for _, id in ipairs({ "callow", "bell_tower" }) do local ok, beats = pcall(function() return sol.main.get_game():get_map().beats end) if ok and beats ~= nil and sol.main.get_game():get_map():get_id() == id then for _, beat in ipairs(beats) do if type(beat.narrate) == "string" then check(beat.narrate, id) end end end end log(("widest narration line: %dpx of %d (%s)"):format(worst, LINE_WIDTH, worst_line or "-")) end --- Advances the pre-game menus. --- --- The epigraph and the title both wait for a key, deliberately, so that --- launching the game and walking away is safe - which also means an automated --- run of the intro sits on the epigraph until the harness gives up and calls --- it a timeout. There is no way to fake a keystroke at the SDL level from --- Lua, so the menus get poked directly: the cutscene ones through the stage --- the director is holding, the rest through their own key handler. --- --- The previous project claimed `npm run smoke -- --intro` worked. It did not. --- It hung on the first screen for five minutes and reported INCOMPLETE. function smoke.drive_menus(menus) local timer = sol.timer.start(sol.main, 300, function() if sol.main.get_game() ~= nil then return false end for _, menu in ipairs(menus) do if sol.menu.is_started(menu) then local stage = menu.scene ~= nil and menu.scene.stage or nil if stage ~= nil and stage.on_skip ~= nil then stage.on_skip() elseif menu.on_key_pressed ~= nil then menu:on_key_pressed("space") end end end return true end) timer:set_suspended_with_map(false) end --- Nobody touches the controls, and she has to come and get him. --- --- The path this covers is the one that cannot be reached by playing well: a --- player who does not find the woman standing in a gap in a fence at the top --- of a map four screens wide. Everything else in the map is a trigger the --- player has to walk into. This is the one that walks into the player. local function fetch_case(game) run(game, { { delay = 2500, name = "Callow loaded", action = function(game) assert(game:get_map():get_id() == "callow", "wrong starting map") end }, { delay = 3000, name = "control is back, nobody is going anywhere", action = function(game) local hero = game:get_hero() assert(hero:get_state() == "free", "control was never handed back (state: " .. hero:get_state() .. ")") assert(not game:get_value("sela_stood_aside"), "the conversation happened by itself") local x, y = hero:get_position() log((" standing at %d,%d and not moving"):format(x, y)) end }, { delay = 14000, name = "she came and got him", action = function(game) local map = game:get_map() assert(game:get_value("sela_stood_aside"), "nobody went to Sela and Sela never came - the player is stuck for good") assert(map:get_entity("to_ridge"):is_enabled(), "the way up is still shut") end }, }) end --- Every line of every dialog in the game, measured in the font it is drawn --- in and against the width it is drawn into. --- --- The lines in dialogs.dat are wrapped by hand, which means they are wrapped --- by eye, which means they are wrapped against a mental picture of the box --- rather than the box. Nothing checked them until a line ran off the side of --- one and somebody had to notice by looking at it. --- --- Dialog ids are read out of the file, so a dialog cannot be added without --- being measured. local function check_dialogs_fit() local file = sol.file.open("languages/en/text/dialogs.dat") assert(file ~= nil, "cannot open dialogs.dat") local source = file:read("*a") file:close() local measure = sol.text_surface.create{ font = "8_bit", font_size = 11 } local worst, worst_line, count = 0, nil, 0 -- Collected rather than asserted one at a time: a writer fixing copy wants -- the whole list, not the first line of it. local problems = {} for id in source:gmatch('id%s*=%s*"([^"]+)"') do count = count + 1 local dialog = sol.language.get_dialog(id) assert(dialog ~= nil, "dialogs.dat lists " .. id .. " but the engine has no such dialog") local run = 0 for line in (dialog.text .. "\n"):gmatch("([^\n]*)\n") do measure:set_text(line) local width = measure:get_size() if width > worst then worst, worst_line = width, id .. ": " .. line end if width > DIALOG_WIDTH then problems[#problems + 1] = (" %s %dpx %s"):format(id, width, line) end -- Blank lines are how the writing is paragraphed, and the box shows -- three lines at a time, so a run of four without a break puts one -- somewhere nobody looked. -- A paragraph longer than the box simply pages, which is normal, but a -- lonely last line reads badly - so it is reported and not failed on. if line:match("^%s*$") then run = 0 else run = run + 1 end if run == DIALOG_LINES + 1 then log((" note: %s pages mid-paragraph"):format(id)) end end end if #problems > 0 then for _, problem in ipairs(problems) do log(problem) end error(("%d dialog lines do not fit the box (%dpx)"):format(#problems, DIALOG_WIDTH)) end log(("%d dialogs checked, widest line %dpx of %d (%s)"):format( count, worst, DIALOG_WIDTH, worst_line or "-")) end --- Reads a whole conversation the way a player does, and watches every page. --- --- Paragraphs in dialogs.dat are separated by blank lines and the box takes --- exactly three lines a page, blank ones included - so a paragraph break --- landing on a page boundary used to open the next page with an empty line. --- This drives the real widget through the longest dialog in the game and --- fails if any page starts blank. local pager = { pages = 0, blank = {}, done = false } local function read_a_whole_conversation(game, id) pager = { pages = 0, blank = {}, done = false } auto_close = false game:start_dialog(id) local timer = sol.timer.start(game, 200, function() if not game:is_dialog_enabled() then pager.done = true auto_close = true return false end local box = game.dialog_box_menu if box ~= nil and box.full then pager.pages = pager.pages + 1 local first = box.lines[1] or "" if first:match("^%s*$") then pager.blank[#pager.blank + 1] = pager.pages end game:simulate_command_pressed("action") sol.timer.start(game, 60, function() game:simulate_command_released("action") end) end return true end) timer:set_suspended_with_map(false) end --- Every string the pre-game menus draw, measured in its own font. --- --- The menus each hand over a `copy` table for this. Until they did, nothing --- had ever measured them. --- --- Every menu string is centred on the frame, so a line fits if it is narrower --- than the frame. That is only true because the credits were changed to be --- centred: they used to anchor the role and the name either side of a point --- eight pixels left of centre, which meant a long name ran off the right-hand --- edge no matter how wide the screen was. "Solarus Free Resource Pack" ended --- forty pixels past it, and had done since the project before this one. local MENUS = { "scripts/menus/epigraph", "scripts/menus/march", "scripts/menus/flyover", "scripts/menus/title", "scripts/menus/chapter_card", } local function check_menu_copy() local problems, checked = {}, 0 for _, name in ipairs(MENUS) do local menu = require(name) assert(menu.copy ~= nil, name .. " does not list the text it draws") for _, entry in ipairs(menu.copy) do checked = checked + 1 local measure = sol.text_surface.create{ font = entry.font, font_size = entry.size } measure:set_text(entry.text) local width = measure:get_size() -- Centred on a 320-pixel frame, with a little air either side. if width > SCREEN_WIDTH then problems[#problems + 1] = (" %s %dpx of %d %s"):format( name:match("[^/]+$"), width, SCREEN_WIDTH, entry.text) end end end if #problems > 0 then for _, problem in ipairs(problems) do log(problem) end error(("%d menu lines are wider than the screen"):format(#problems)) end log(("%d menu lines checked, all inside %dpx"):format(checked, SCREEN_WIDTH)) end --- Syllables, by the usual heuristic: count runs of vowels, drop a silent --- trailing e, never go below one. It is wrong about "fire" and "poem" and --- right about almost everything else, which is all a readability score needs. local function syllables(word) word = word:lower():gsub("[^a-z]", "") if #word == 0 then return 0 end local count = 0 local previous_was_vowel = false for i = 1, #word do local is_vowel = word:sub(i, i):match("[aeiouy]") ~= nil if is_vowel and not previous_was_vowel then count = count + 1 end previous_was_vowel = is_vowel end if #word > 2 and word:sub(-1) == "e" and not word:sub(-2, -2):match("[aeiouy]") then count = count - 1 end return math.max(1, count) end --- Flesch-Kincaid over everything the player reads. --- --- Crude, and still the difference between "this feels simple" and "this is --- grade 4.1 and the longest sentence in the game is seventeen words". A --- ceiling on the longest sentence is in here too, because an average hides --- the one paragraph nobody can follow. local function check_reading_level() local words, sentences, syls = 0, 0, 0 local longest, longest_text = 0, nil local function measure(text, where) -- Line breaks inside a speech are not sentence ends; full stops are. text = text:gsub("|", " "):gsub("\n", " ") for sentence in (text .. " ."):gmatch("([^%.%?!]+)[%.%?!]") do local n = 0 for word in sentence:gmatch("[%a'-]+") do n = n + 1 syls = syls + syllables(word) end if n > 0 then words = words + n sentences = sentences + 1 if n > longest then longest, longest_text = n, where .. ": " .. sentence:gsub("^%s+", "") end end end end for _, beat in ipairs(require("scripts/menus/prologue").beats) do if type(beat.narrate) == "string" then measure(beat.narrate, "prologue") end end local file = sol.file.open("languages/en/text/dialogs.dat") local source = file:read("*a") file:close() for id in source:gmatch('id%s*=%s*"([^"]+)"') do local dialog = sol.language.get_dialog(id) -- Speaker names are not sentences and would drag the average around. local text = dialog.text:gsub("\n[A-Z][A-Z ']*\n", "\n") measure(text, id) end local grade = 0.39 * (words / sentences) + 11.8 * (syls / words) - 15.59 log(("reading level: grade %.1f over %d sentences, %.1f words each") :format(grade, sentences, words / sentences)) log((" longest sentence, %d words - %s"):format(longest, longest_text or "-")) assert(grade <= MAX_GRADE, ("the writing reads at grade %.1f; the ceiling is %.1f"):format(grade, MAX_GRADE)) assert(longest <= MAX_SENTENCE_WORDS, ("longest sentence is %d words, the ceiling is %d - %s") :format(longest, MAX_SENTENCE_WORDS, longest_text)) end --- What drawing text costs, measured against the same machine drawing none. --- --- The intro shipped at thirteen to twenty-six frames a second whenever a line --- was on screen, because the stage rebuilt every line's font texture twice a --- frame. It read as text crawling and controls that would not answer. --- --- The obvious test - "assert the frame rate is at least fifty" - is a bad --- test, and I wrote it first: it failed at 22 in the suite and passed at 60 --- run by hand a minute earlier, because it was measuring what else the --- computer was doing. Two leftover headless Chrome processes were enough to --- fail it. --- --- So it measures the *ratio* instead. Five seconds with no narration, five --- seconds with the worst case the stage ever draws, on the same machine --- moments apart. Load affects both halves equally and divides out. Drawing --- three lines of cached text should cost almost nothing; before the fix it --- cost two thirds of the frame rate. local function perf_case(game) local frames = 0 sol.main.get_metatable("map"):register_event("on_draw", function() frames = frames + 1 end) local quiet, loud = {}, {} local LINES = { "This is the worst case the", "narration ever draws, which is", "three lines of it at once." } local function mean(t) local total = 0 for _, n in ipairs(t) do total = total + n end return total / math.max(1, #t) end run(game, { -- After the opening cutscene has handed control back: a camera pan and a -- plate fade in the sample make the numbers about them instead. { delay = 7000, name = "control is back", action = function(game) assert(game:get_hero():get_state() == "free", "the cutscene had not finished; the sample would be about the pan") local stage = game:get_map().smoke_stage assert(stage ~= nil, "no stage on the map") -- Interleaved a second at a time rather than five seconds of one and -- then five of the other. The machine this runs on drifts - it was at a -- hundred per cent while this was being written - and sequential phases -- turn that drift into the result. Alternating divides it out. local showing = false local ticks = 0 local function set(on) stage:say(on and LINES or nil) stage.line_alpha = on and 255 or 0 stage.line_target = stage.line_alpha end set(false) frames = 0 local timer = sol.timer.start(game, 1000, function() ticks = ticks + 1 if showing then loud[#loud + 1] = frames else quiet[#quiet + 1] = frames end frames = 0 showing = not showing set(showing) return ticks < PERF_SECONDS * 2 end) timer:set_suspended_with_map(false) end }, { delay = (PERF_SECONDS * 2 + 2) * 1000, name = "text costs almost nothing to draw", action = function() local without, with = mean(quiet), mean(loud) local ratio = with / math.max(1, without) log(("%.0f fps with no text, %.0f with three lines - %.0f%% of it, over %d samples each") :format(without, with, ratio * 100, #quiet)) assert(#quiet >= PERF_SECONDS - 1 and #loud >= PERF_SECONDS - 1, "the frame rate was never sampled") -- This only works on a quiet machine, and it says so rather than -- pretending. When something else is eating the processor the engine -- spends most of each frame waiting, so its own extra work becomes a -- smaller share of it: with the bug deliberately put back, an idle -- machine measured 27% and one pegged at a hundred per cent measured -- 73%, which is indistinguishable from healthy. A check that quietly -- stops checking is worse than no check, so it reports that instead. if without < QUIET_ENOUGH_FPS then log((" only %.0f fps with nothing on screen - this machine is too busy to"):format(without)) log(" measure text cost on. Not asserting. Close things and run it again.") return end assert(ratio >= MIN_TEXT_RATIO, ("drawing text costs %.0f%% of the frame rate (%.0f fps down to %.0f); " .. "the floor is %.0f%%. Something is being rebuilt per frame.") :format((1 - ratio) * 100, without, with, MIN_TEXT_RATIO * 100)) end }, }) end function smoke.start(game) auto_close_dialogs(game) hurry(game) local case = os.getenv ~= nil and os.getenv("BELL_CASE") or nil if case == "fetch" then return fetch_case(game) end if case == "perf" then return perf_case(game) end run(game, { { delay = 2500, name = "Callow loaded", action = function(game) assert(game:get_map():get_id() == "callow", "wrong starting map") shot(game, "callow") end }, { delay = 400, name = "narration fits the frame", action = function() check_narration_fits() end }, { delay = 300, name = "every dialog fits the box", action = function() check_dialogs_fit() end }, { delay = 300, name = "every menu line fits the screen", action = function() check_menu_copy() end }, { delay = 300, name = "a ten-year-old could read it", action = function() check_reading_level() end }, { delay = 300, name = "read a whole conversation", action = function(game) read_a_whole_conversation(game, "callow.sela.ready") end }, { delay = 22000, name = "no page started with a blank line", action = function() assert(pager.done, "the conversation never ended - " .. pager.pages .. " pages in") assert(pager.pages >= 4, "only " .. pager.pages .. " pages - that dialog should be longer than that") assert(#pager.blank == 0, ("%d of %d pages opened with an empty line (pages %s)"):format( #pager.blank, pager.pages, table.concat(pager.blank, ", "))) log((" %d pages, none of them blank at the top"):format(pager.pages)) end }, -- The stage sits on top of the dialog box; if it reports keys it has no -- use for as handled, no cutscene can be advanced past its first line. { delay = 400, name = "stage hands unused keys back", action = function(game) local stage = game:get_map().smoke_stage assert(stage ~= nil, "no stage on the map") stage.on_skip = nil assert(stage:on_key_pressed("space") == false, "the stage swallowed a key it had no use for - dialogs will not advance") stage.skippable = false assert(stage:on_key_pressed("space") == false, "the stage swallowed a key while control was handed back") stage.skippable = true end }, -- The one path nothing else covers: getting through a conversation by -- pressing the button, rather than by calling stop_dialog() from a test. -- The stage menu sits on top of the dialog box, and if it ever reports a -- key it has no use for as handled, every conversation in the game is a -- wall. { delay = 400, name = "a dialog advances on the action command", action = function(game) auto_close = false game:start_dialog("callow.notice") local presses = 0 local timer = sol.timer.start(game, 300, function() presses = presses + 1 game:simulate_command_pressed("action") sol.timer.start(game, 60, function() game:simulate_command_released("action") end) return presses < 14 and game:is_dialog_enabled() end) timer:set_suspended_with_map(false) end }, { delay = 5000, name = "the conversation ended", action = function(game) auto_close = true assert(not game:is_dialog_enabled(), "a dialog could not be advanced by pressing action - the player is stuck in it") end }, -- The screen has to stop looking like a cutscene when it stops being one. -- This was reported three separate times as the game being stuck, and it -- was never stuck: the letterbox stayed up and the caption never left, so -- there was nothing on screen saying the player could move. { delay = 9000, name = "the screen hands itself back too", action = function(game) local stage = game:get_map().smoke_stage assert(game:get_hero():get_state() == "free", "control was not handed back") assert(stage.bars_target == 0, "the letterbox is still up while the player has control") assert(stage.line_target == 0, "the hint is still on screen " .. HINT_SHOW_MS .. "ms after it appeared") -- And the player can see themselves. -- -- This is the assertion that was missing for the whole of today. Every -- test here checked a mechanism - is the hero free, did the trigger -- fire, is the gate open - and every one of them passed while the camera -- sat in manual mode at the top of the map and the hero walked around -- invisibly a hundred and forty pixels below the bottom of the screen. -- "Nothing happens, no inputs, just the scene." Everything happened. local camera = game:get_map():get_camera() assert(camera:get_state() == "tracking", "the camera was left in " .. camera:get_state() .. " mode with the player in control") local cx, cy = camera:get_position() local cw, ch = camera:get_size() local hx, hy = game:get_hero():get_position() assert(hx >= cx and hx <= cx + cw and hy >= cy and hy <= cy + ch, ("the hero is off screen: hero at %d,%d, camera showing %d,%d to %d,%d") :format(hx, hy, cx, cy, cx + cw, cy + ch)) log((" bars down, caption gone, hero free and on screen at %d,%d"):format(hx, hy)) shot(game, "fair") end }, { delay = 400, name = "the town answers", action = function(game) local map = game:get_map() for _, name in ipairs({ "bryn", "hallam", "nib", "rider", "dess", "orin", "sela" }) do local npc = map:get_entity(name) assert(npc ~= nil, "no entity named " .. name) assert(npc.on_interaction ~= nil, name .. " does not answer when talked to") end end }, -- The way up is shut until she moves. Both halves of that are worth a -- test: a teletransporter left on is a player who walks past the whole -- scene, and a teletransporter left off is a player who cannot finish. { delay = 4000, name = "control is back, and the way up is shut", action = function(game) local hero = game:get_hero() assert(hero:get_state() == "free", "control was never handed back (state: " .. hero:get_state() .. ")") local gate = game:get_map():get_entity("to_ridge") assert(gate ~= nil and not gate:is_enabled(), "the way up was open before Sela moved") -- Off to the west end of the line, well away from the gap. walk_until(game, "left", function(g) return hero_x(g) <= 172 end, 5000) end }, -- The bug this exists for: the first build fenced only the eight pixels -- either side of the track, so the barrier could be walked round in about -- a second, and walking round it led to a patch of grass with a -- switched-off teletransporter in it. A gate you can walk round is not a -- gate, it is a player wandering in circles. { delay = 5200, name = "go north where there is no gap", action = function(game) log((" hero at x=%d"):format(hero_x(game))) hold_command(game, "up", 4000) end }, { delay = 4400, name = "the line held away from the gap", action = function(game) local x, y = game:get_hero():get_position() assert(game:get_map():get_id() == "callow", "the barrier was walked round - ended up on " .. game:get_map():get_id()) assert(y > 130, "the barrier was walked round at x=" .. x .. " - got to y=" .. y) log((" stopped at %d,%d"):format(x, y)) end }, -- Back down into the square first: the row he is standing in has a -- memorial stone in it, and a test that walks into scenery and calls it a -- failed gate is worse than no test. { delay = 200, name = "back down into the square", action = function(game) hold_command(game, "down", 1700) end }, { delay = 1900, name = "back along the square to the gap", action = function(game) walk_until(game, "right", function(g) return hero_x(g) >= 306 end, 8000) end }, { delay = 8200, name = "up to the rope", action = function(game) local x = hero_x(game) assert(x >= 296 and x <= 328, "never got back to the gap - x=" .. x) hold_command(game, "up", 3400) end }, -- Walking up to her is the whole interaction: she speaks first. Nothing -- here presses the action command, because requiring it is what broke - -- a hero held against her is in the "pushing" state, and the action -- command does nothing in it. { delay = 3600, name = "she stopped him and stood aside", action = function(game) local map = game:get_map() assert(game:get_value("sela_stood_aside"), "talking to Sela did nothing") assert(map:get_entity("to_ridge"):is_enabled(), "the way up is still shut") local x = map:get_entity("sela"):get_position() assert(x < 300, "Sela is still standing in the gap, at x=" .. x) shot(game, "square") end }, -- And she still answers if you go back and talk to her. { delay = 400, name = "she answers afterwards", action = function(game) local map = game:get_map() local sela = map:get_entity("sela") local hx, hy = game:get_hero():get_position() local sx, sy = sela:get_position() log((" on map %s, hero at %d,%d, sela at %d,%d"):format(map:get_id(), hx, hy, sx, sy)) assert(sela.on_interaction ~= nil, "Sela stopped answering once she had moved") sela:on_interaction() end }, { delay = 600, name = "walk up to the ridge", action = function(game) hold_command(game, "up", 5000) end }, { delay = 7000, name = "the ridge loaded", action = function(game) assert(game:get_map():get_id() == "bell_tower", "never reached the ridge - still on " .. game:get_map():get_id()) shot(game, "ridge") end }, { delay = 9000, name = "the bell rang", action = function(game) local bell = game:get_map():get_entity("bell") assert(bell ~= nil, "there is no bell on the ridge") shot(game, "ringing") end }, { delay = 22000, name = "reached the chapter card", action = function(game) assert(game:get_value("chapter_one_done"), "the ending never ran") end }, }) end return smoke