-- Gold's intro seams: the Oak speech under Gen 1's names, and the GS boot -- cinema under its own. -- -- Two different claims are being made here, and they are deliberately not the -- same claim: -- -- * src/ui/gen2/OakSpeech.lua reuses `intro.oak_speech.build` and the four -- `intro.oak_speech.*` events VERBATIM -- same names, same payload keys, -- same moments -- because Gold has a real Oak speech and a mod written for -- Red's must land on Gold's without a second listener. -- * the copyright card, the GAME FREAK splash, the attract movie and the -- Ho-Oh title have NO Gen 1 counterpart, so they take new `intro.boot.*` -- names rather than borrowing one that would then mean two things. -- -- Both halves are also the parity case the no-mod gate wants: with nobody -- subscribed every one of these sites must be inert. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local CopyrightSplash = require("src.ui.gen2.CopyrightSplash") local GameFreakPresents = require("src.ui.gen2.GameFreakPresents") local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro") local OakSpeech = require("src.ui.gen2.OakSpeech") local TitleState = require("src.ui.gen2.TitleState") -- The shared names, spelled out so a rename has to come through this file. local SPEECH_EVENTS = { "intro.oak_speech.started", "intro.oak_speech.step", "intro.oak_speech.answered", "intro.oak_speech.finished", } -- ...and the Gen 2-only ones. local BOOT_EVENTS = { "intro.boot.copyright", "intro.boot.gamefreak", "intro.boot.movie", "intro.boot.movie_ended", "intro.boot.title", } local savedEvents, savedHooks = Runtime.events, Runtime.hooks local function logged(fragment) for _, line in ipairs(Logger.history or {}) do if line:find(fragment, 1, true) then return true end end return false end local function fakeGame() return { data = {}, save = { player = {} } } end -- ------- 1. no-mod parity: every site cold do Runtime.events, Runtime.hooks = savedEvents, savedHooks for _, name in ipairs(SPEECH_EVENTS) do T.eq(Runtime.wants(name), false, "no subscriber leaves " .. name .. " cold") end for _, name in ipairs(BOOT_EVENTS) do T.eq(Runtime.wants(name), false, "no subscriber leaves " .. name .. " cold") end T.eq(Runtime.wantsHook("intro.oak_speech.build"), false, "no wrapper leaves intro.oak_speech.build cold") -- the four boot cards still run their moment with nobody listening CopyrightSplash.new(fakeGame(), {}):enter() GameFreakPresents.new(fakeGame(), {}):enter() local movie = GoldSilverIntro.new(fakeGame(), {}) movie:enter() movie:finish() TitleState.new(fakeGame(), {}):enter() T.check(true, "the boot cinema runs unsubscribed") end -- ------- 2. the Oak speech step list do local speech = OakSpeech.new(fakeGame(), {}) local steps = OakSpeech.defaultSteps(speech) T.eq(#steps, 9, "Gold's vanilla speech has nine beats") T.eq(steps[1].id, "init_clock", "the speech opens on the farcall InitClock beat") T.eq(steps[#steps].id, "shrink", "and ends on ShrinkPlayer") -- the anchors a Gen 1 mod already knows how to aim at local byId = {} for index, step in ipairs(steps) do byId[step.id] = index end for _, id in ipairs({ "oak_welcome", "demo_mon", "world_spiel", "ask_player_name", "name_player", "legend", "shrink" }) do T.check(byId[id] ~= nil, "Gold keeps Gen 1's step anchor `" .. id .. "`") end T.check(byId.oak_welcome < byId.demo_mon and byId.demo_mon < byId.name_player and byId.name_player < byId.shrink, "and keeps them in Gen 1's order") end -- ------- 3. intro.oak_speech.build, the hook Gen 1 already ships do Runtime.events, Runtime.hooks = Events.new(), Hooks.new() local speech = OakSpeech.new(fakeGame(), {}) Runtime.hooks:wrap("intro.oak_speech.build", function(nextFn, steps, sp) steps = nextFn(steps, sp) table.insert(steps, 3, { id = "extra_q", kind = "choice", saveKey = "mood", choices = { "FINE", "TIRED" } }) return steps end, 0, "fixture") local built = speech:buildSteps() T.eq(built[3].id, "extra_q", "intro.oak_speech.build can insert a beat into Gold's speech") T.eq(built[4].id, "demo_mon", "later vanilla beats shift down") Runtime.hooks:removeOwner("fixture") Runtime.hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad") built = speech:buildSteps() T.eq(built[1].id, "init_clock", "a non-table intro.oak_speech.build result degrades to vanilla") T.check(logged("intro.oak_speech.build returned"), "and the degrade is logged, as it is under Gen 1") Runtime.hooks:removeOwner("bad") end -- ------- 4. the four lifecycle events, with the Gen 1 payload keys do Runtime.events, Runtime.hooks = Events.new(), Hooks.new() local speech = OakSpeech.new(fakeGame(), {}) -- Replace the beats with two that need no stack, so the lifecycle can be -- driven headlessly: `fn` is the escape hatch a build wrapper gets. Runtime.hooks:wrap("intro.oak_speech.build", function() return { { id = "probe_one", kind = "fn", run = function(sp, done) sp:recordAnswer({ id = "probe_one", saveKey = "mood" }, 2, "TIRED", "TIRED") done() end }, { id = "probe_two", kind = "fn", run = function(_, done) done() end }, } end, 0, "fixture") local started, stepped, answered, finished = nil, {}, nil, nil Runtime.events:on("intro.oak_speech.started", function(ev) started = ev end, 0, "fixture") Runtime.events:on("intro.oak_speech.step", function(ev) stepped[#stepped + 1] = ev end, 0, "fixture") Runtime.events:on("intro.oak_speech.answered", function(ev) answered = ev end, 0, "fixture") Runtime.events:on("intro.oak_speech.finished", function(ev) finished = ev end, 0, "fixture") speech:enter() T.check(started ~= nil and started.speech == speech and type(started.steps) == "table", "intro.oak_speech.started carries { speech, steps }") T.eq(#stepped, 2, "intro.oak_speech.step fires once per beat") T.eq(stepped[1].index, 1, "and carries the 1-based index") T.eq(stepped[1].step.id, "probe_one", "and the step itself") T.check(answered ~= nil and answered.saveKey == "mood" and answered.value == "TIRED" and answered.index == 2 and answered.label == "TIRED" and answered.speech == speech, "intro.oak_speech.answered carries Gen 1's six keys") T.eq(speech.answers.mood, "TIRED", "and the answer is stored on the speech") T.check(finished ~= nil and finished.answers == speech.answers, "intro.oak_speech.finished carries the answer table") -- #308's guard, ported: a second finish is not a second speech. local firstFinish = finished finished = nil speech:finish() T.eq(finished, nil, "finished fires exactly once per speech") T.check(firstFinish ~= nil, "and it did fire the first time") Runtime.events:removeOwner("fixture") Runtime.hooks:removeOwner("fixture") end -- ------- 5. the GS boot cinema's own names do Runtime.events, Runtime.hooks = Events.new(), Hooks.new() local seen = {} for _, name in ipairs(BOOT_EVENTS) do Runtime.events:on(name, function(ev) seen[name] = ev end, 0, "fixture") end local game = fakeGame() CopyrightSplash.new(game, {}):enter() T.check(seen["intro.boot.copyright"] ~= nil, "the copyright card announces itself") T.check(seen["intro.boot.copyright"].screen ~= nil, "with the screen in the payload") GameFreakPresents.new(game, {}):enter() T.check(seen["intro.boot.gamefreak"] ~= nil, "the GAME FREAK splash announces itself") local movie = GoldSilverIntro.new(game, {}) movie:enter() T.check(seen["intro.boot.movie"] ~= nil, "the attract movie announces itself") movie:skip() T.check(seen["intro.boot.movie_ended"] ~= nil, "and announces its end") T.eq(seen["intro.boot.movie_ended"].skipped, true, "intro.boot.movie_ended reports a button skip") -- a movie that plays out reports the other answer seen["intro.boot.movie_ended"] = nil local watched = GoldSilverIntro.new(game, {}) watched:enter() watched:finish() T.eq(seen["intro.boot.movie_ended"].skipped, false, "and reports a movie that was watched to the end") TitleState.new(game, {}):enter() T.check(seen["intro.boot.title"] ~= nil, "the title screen announces itself") Runtime.events:removeOwner("fixture") end Runtime.events, Runtime.hooks = savedEvents, savedHooks T.finish("gen2_intro_seams")