mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
434 lines
16 KiB
Lua
434 lines
16 KiB
Lua
-- Script text flow around the intro stretch: Mom's clock ladder confirms in
|
|
-- text (InitialSetDSTFlag / InitialClearDSTFlag, engine/rtc/timeset.asm), the
|
|
-- NameRival special parks the officer until the keyboard closes
|
|
-- (engine/events/specials.asm NameRival), <RIVAL> resolves off the Gold save,
|
|
-- Mom's leaving speech keeps the cart's page waits (data/text/common_1.asm
|
|
-- _MomLeavingText1), and a stale wStringBuffer2 never leaks into a complete
|
|
-- text (the "Obtained the POKeDEX!PSNCUREBERRY!" splice). The catching
|
|
-- tutorial's map-music restore and the Sprout Tower scene's sfx ids ride
|
|
-- along: same lane, same seams.
|
|
--
|
|
-- GOLD_CACHE=".../gold" luajit tests/gen2_text_flow_test.lua
|
|
--
|
|
-- Everything is ROM-free except the Sprout Tower row pins, which SKIP.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 text flow")
|
|
local check, eq = S.check, S.eq
|
|
|
|
love = require("tests.love_stub")
|
|
|
|
local Vm = require("src.script.gen2.Vm")
|
|
local Events = require("src.world.gen2.Events")
|
|
local Specials = require("src.script.gen2.Specials")
|
|
local TextBox = require("src.render.TextBox")
|
|
|
|
-- ---- the DST ladder confirms in text ---------------------------------------
|
|
-- PlayersHouse1F's MeetMomScript: writetext IsItDSTText / yesorno / special /
|
|
-- yesorno / iffalse back to the top. The special prints "<time> DST, is that
|
|
-- OK?" (or the plain time for the clear arm) into the open box, so the second
|
|
-- yesorno has its own question; silent handlers left it hanging on IsItDST,
|
|
-- which read as Mom asking about daylight saving over and over.
|
|
local function dstVm(record)
|
|
local shown = {}
|
|
local answers, ai = {}, 0
|
|
local vm = Vm.new({
|
|
["s:ladder"] = {
|
|
{ op = "writetext", text = "t:isdst" },
|
|
{ op = "yesorno" },
|
|
{ op = "iffalse", script = "s:wrong" },
|
|
{ op = "special", id = 0 }, -- InitialSetDSTFlag
|
|
{ op = "yesorno" },
|
|
{ op = "iffalse", script = "s:ladder" },
|
|
{ op = "end" },
|
|
},
|
|
["s:wrong"] = {
|
|
{ op = "special", id = 1 }, -- InitialClearDSTFlag
|
|
{ op = "yesorno" },
|
|
{ op = "iffalse", script = "s:ladder" },
|
|
{ op = "end" },
|
|
},
|
|
}, { ["t:isdst"] = "Is it Daylight\nSaving Time now?" }, Events.new(), {
|
|
specialOrder = { "InitialSetDSTFlag", "InitialClearDSTFlag" },
|
|
specials = {
|
|
save = function() return record end,
|
|
world = { hour = function() return 10 end },
|
|
},
|
|
showText = function(body, onDone)
|
|
shown[#shown + 1] = body
|
|
onDone()
|
|
end,
|
|
yesorno = function(onDone)
|
|
ai = ai + 1
|
|
onDone(answers[ai])
|
|
end,
|
|
})
|
|
return vm, shown, answers
|
|
end
|
|
|
|
do
|
|
local record = {}
|
|
local vm, shown, answers = dstVm(record)
|
|
answers[1], answers[2] = true, true -- yes it is DST, yes that is OK
|
|
check(vm:start("s:ladder"), "the ladder runs")
|
|
for _ = 1, 10 do vm:update() end
|
|
check(not vm:running(), "and settles on a double yes")
|
|
eq(#shown, 2, "two pages: the question and the confirmation")
|
|
-- The minutes are the host clock's, so the pin is a pattern.
|
|
check(shown[2]:match("^10:%d%d DST,\nis that OK%?$") ~= nil,
|
|
"the set arm prints the time and .DSTIsThatOKText")
|
|
eq(record.rtc and record.rtc.dst, true, "and DST_F is set")
|
|
end
|
|
|
|
do
|
|
local record = {}
|
|
local vm, shown, answers = dstVm(record)
|
|
-- no it is not DST, no that is not OK (loop), no again, yes that is OK
|
|
answers[1], answers[2] = false, false
|
|
answers[3], answers[4] = false, true
|
|
check(vm:start("s:ladder"), "the ladder runs again")
|
|
for _ = 1, 20 do vm:update() end
|
|
check(not vm:running(), "a refused confirmation loops and then settles")
|
|
eq(#shown, 4, "question, confirm, question again, confirm again")
|
|
check(shown[2]:match("^10:%d%d,\nis that OK%?$") ~= nil,
|
|
"the clear arm prints the time and .TimeAskOkayText")
|
|
eq(shown[3], shown[1], "the loop re-asks the cart's own question")
|
|
eq(record.rtc and record.rtc.dst, false, "and DST_F ends clear")
|
|
end
|
|
|
|
-- ---- NameRival parks the officer -------------------------------------------
|
|
do
|
|
local shown = {}
|
|
local nameDone = nil
|
|
local vm = Vm.new({
|
|
["s:cop"] = {
|
|
{ op = "opentext" },
|
|
{ op = "writetext", text = "t:one" },
|
|
{ op = "promptbutton" },
|
|
{ op = "special", id = 0 }, -- NameRival
|
|
{ op = "writetext", text = "t:two" },
|
|
{ op = "closetext" },
|
|
{ op = "end" },
|
|
},
|
|
}, {
|
|
["t:one"] = "His POKeMON was one of ours.",
|
|
["t:two"] = "OK! So {RIVAL}\nwas his name.",
|
|
}, Events.new(), {
|
|
specialOrder = { "NameRival" },
|
|
nameRival = function(done) nameDone = done end,
|
|
showText = function(body, onDone)
|
|
shown[#shown + 1] = body
|
|
onDone()
|
|
end,
|
|
})
|
|
check(vm:start("s:cop"), "the officer's script starts")
|
|
for _ = 1, 6 do vm:update() end
|
|
check(vm:running(), "the script is PARKED on the naming screen")
|
|
eq(#shown, 1, "the follow-up line has not printed under the keyboard")
|
|
check(nameDone ~= nil, "the naming screen was opened")
|
|
nameDone("KAMON")
|
|
for _ = 1, 6 do vm:update() end
|
|
check(not vm:running(), "the close of the keyboard resumes the officer")
|
|
eq(shown[2], "OK! So {RIVAL}\nwas his name.",
|
|
"and only then does his line print")
|
|
end
|
|
|
|
-- ---- World:nameRival stores the typed name, and empty keeps the default ----
|
|
do
|
|
local World = require("src.world.gen2.World")
|
|
local Screens = require("src.ui.Screens")
|
|
local function rig()
|
|
local pushed = {}
|
|
local game = {
|
|
data = { screens = {
|
|
Gen2NamingScreen = function(_, opts)
|
|
pushed.opts = opts
|
|
return { opts = opts }
|
|
end,
|
|
} },
|
|
save = { player = { name = "GOLD" }, rival = { name = "SILVER" } },
|
|
stack = { push = function() end, pop = function() end },
|
|
}
|
|
return World.new(game), game, pushed
|
|
end
|
|
|
|
local world, game, pushed = rig()
|
|
local got = nil
|
|
world:nameRival(function(name) got = name end)
|
|
check(pushed.opts ~= nil, "the keyboard screen went up")
|
|
pushed.opts.onDone("KAMON")
|
|
eq(game.save.rival.name, "KAMON", "the typed name lands in save.rival.name")
|
|
eq(got, "KAMON", "and the script's resume sees it")
|
|
|
|
Screens.invalidate()
|
|
local world2, game2, pushed2 = rig()
|
|
world2:nameRival(function() end)
|
|
pushed2.opts.onDone("")
|
|
eq(game2.save.rival.name, "SILVER",
|
|
"an empty entry keeps InitName's version default")
|
|
Screens.invalidate()
|
|
|
|
-- The rig above hand-seeds "SILVER", which the real New Game never does:
|
|
-- InitializeNPCNames seeds "???" and NameRival's InitName is what puts
|
|
-- SILVER there. Both flavours of blank -- empty and all spaces, which
|
|
-- _InitString treats identically (home/string.asm:6-30) -- have to write
|
|
-- the default themselves.
|
|
for _, entry in ipairs({ "", " " }) do
|
|
local world3, game3, pushed3 = rig()
|
|
game3.save.rival = { name = "???" }
|
|
world3:nameRival(function() end)
|
|
pushed3.opts.onDone(entry)
|
|
eq(game3.save.rival.name, "SILVER",
|
|
("a blank entry (%q) over the \"???\" seed still lands on SILVER")
|
|
:format(entry))
|
|
Screens.invalidate()
|
|
end
|
|
end
|
|
|
|
-- ---- <RIVAL> resolves off the Gold save ------------------------------------
|
|
do
|
|
local gold = { data = {},
|
|
save = { player = { name = "GOLD" }, rival = { name = "KAMON" } } }
|
|
eq(TextBox.substitute(gold, "So {RIVAL} it is."), "So KAMON it is.",
|
|
"a Gold save's rival name feeds the token")
|
|
local red = { data = {}, save = { player = { name = "RED", rival = "GARY" } } }
|
|
eq(TextBox.substitute(red, "{RIVAL}!"), "GARY!",
|
|
"a Gen 1 save still reads player.rival")
|
|
local bare = { data = {}, save = { player = {} } }
|
|
eq(TextBox.substitute(bare, "{RIVAL}"), "BLUE",
|
|
"only a save with neither falls back to BLUE")
|
|
local goldBare = { data = {}, save = { generation = 2, player = {} } }
|
|
eq(TextBox.substitute(goldBare, "{RIVAL}"), "???",
|
|
"a Gold save with no rival record reads InitializeNPCNames' \"???\", not BLUE")
|
|
end
|
|
|
|
-- ---- the yes/no lookahead reaches the text hook -----------------------------
|
|
-- Script_yesorno is `call YesNoBox` with nothing between it and the page it
|
|
-- prompts over (engine/overworld/scripting.asm:366), and a `writetext` whose
|
|
-- next row is `yesorno` ends in `done` -- DoneText returns with no
|
|
-- PromptButton (home/text.asm:484). So the VM hands the one-command lookahead
|
|
-- to the text hook as its THIRD argument, and World's hook has to take it:
|
|
-- declared with two parameters it was silently dropped, which cost a button
|
|
-- press the cart never asks for and re-printed the question under the prompt.
|
|
do
|
|
local function stayFor(after)
|
|
local seen
|
|
local vm = Vm.new({
|
|
["s:ask"] = {
|
|
{ op = "writetext", text = "t:q" },
|
|
{ op = after },
|
|
{ op = "end" },
|
|
},
|
|
}, { ["t:q"] = "Would you like me\nto show you how?" }, Events.new(), {
|
|
showText = function(_body, onDone, stay)
|
|
seen = stay
|
|
onDone()
|
|
end,
|
|
yesorno = function(onChoose) onChoose(true) end,
|
|
})
|
|
vm:start("s:ask")
|
|
for _ = 1, 200 do
|
|
if not vm:running() then break end
|
|
vm:update()
|
|
end
|
|
return seen
|
|
end
|
|
eq(stayFor("yesorno"), true, "a writetext in front of yesorno holds its box")
|
|
eq(stayFor("waitbutton"), false, "one in front of waitbutton does not")
|
|
end
|
|
|
|
-- ---- Mom's leaving speech keeps its page waits ------------------------------
|
|
-- _MomLeavingText1 is nine `para` pages on the cart; transcribed as one page
|
|
-- of stacked lines it typed itself out to the money prompt with no button in
|
|
-- between. The handler is driven for real and the FIRST page it shows is
|
|
-- paginated with the shipped TextBox rules.
|
|
do
|
|
local rec = { player = { money = 3000 }, mom = { savedMoney = 0 } }
|
|
local texts, stays = {}, {}
|
|
local vm = Vm.new({}, {}, Events.new(), {
|
|
specials = {
|
|
save = function() return rec end,
|
|
money = function() return 0 end,
|
|
setMoney = function() end,
|
|
},
|
|
})
|
|
vm.showTextFn = function() end
|
|
vm.co = coroutine.create(function() Specials.HANDLERS.BankOfMom(vm) end)
|
|
local ok, req = coroutine.resume(vm.co)
|
|
while true do
|
|
if not ok then error(req) end
|
|
if req and req.kind == "text" then
|
|
texts[#texts + 1] = req.text
|
|
stays[#texts] = req.stay
|
|
end
|
|
if coroutine.status(vm.co) == "dead" then break end
|
|
ok, req = coroutine.resume(vm.co, req and req.kind == "yesorno" or nil)
|
|
end
|
|
check(#texts >= 3, "the first-visit conversation ran")
|
|
-- .InitializeBank is `PrintText MomLeavingText1 / call YesNoBox`
|
|
-- (engine/events/mom.asm:50-53) with nothing between the two, and the text
|
|
-- ends `done` -- so the box stays up and the prompt goes over it. Vm's
|
|
-- one-command lookahead cannot see this: the script row being run is the
|
|
-- `special` itself, so the handler says it (Specials showRawHeld).
|
|
check(stays[1] == true, "the page in front of Mom's prompt holds its box")
|
|
check(not stays[2], "the page behind it, with no prompt after, does not")
|
|
local pages = TextBox.paginate(texts[1], 18)
|
|
eq(#pages, 9, "MomLeavingText1 is nine pages, one per cart `para`")
|
|
for index, page in ipairs(pages) do
|
|
check(#page <= 2,
|
|
("page %d holds at most the box's two lines"):format(index))
|
|
end
|
|
eq(pages[1][1], "Wow, that's a cute", "page one is the cart's opener")
|
|
eq(pages[9][2], "save your money?",
|
|
"and the money question is the last page, behind eight button waits")
|
|
end
|
|
|
|
-- ---- a stale string buffer stays out of complete texts ---------------------
|
|
-- CopyName1 never clears wStringBuffer2, so the berry picked outside is still
|
|
-- in it when Oak hands over the POKeDEX; only a {STRBUF} marker may read it.
|
|
do
|
|
local shown = {}
|
|
local vm = Vm.new({
|
|
["s:oak"] = {
|
|
{ op = "getitemname", item = 77 },
|
|
{ op = "writetext", text = "t:dex" },
|
|
{ op = "writetext", text = "t:buf" },
|
|
{ op = "end" },
|
|
},
|
|
}, {
|
|
["t:dex"] = "{PLAYER} received\nPOKeDEX!",
|
|
["t:buf"] = "{PLAYER} received\n{STRBUF}.",
|
|
}, Events.new(), {
|
|
showText = function(body, onDone)
|
|
shown[#shown + 1] = body
|
|
onDone()
|
|
end,
|
|
getItemName = function() return "PSNCUREBERRY" end,
|
|
})
|
|
check(vm:start("s:oak"), "Oak's handover runs")
|
|
for _ = 1, 6 do vm:update() end
|
|
eq(shown[1], "{PLAYER} received\nPOKeDEX!",
|
|
"a complete text prints untouched, stale buffer and all")
|
|
check(shown[1]:find("PSNCUREBERRY", 1, true) == nil,
|
|
"the berry name cannot splice itself in")
|
|
eq(shown[2], "{PLAYER} received\nPSNCUREBERRY.",
|
|
"while a real {STRBUF} marker still reads the buffer")
|
|
end
|
|
|
|
-- ---- the catching tutorial gives the route its music back -------------------
|
|
-- CatchTutorial wraps an ordinary battle, and startBattle's onDone runs
|
|
-- RestartMapMusic (Script_reloadmapafterbattle) -- so when the DUDE's demo
|
|
-- ends, wMapMusic (Route 29's theme) comes back without a map change. Real
|
|
-- Music module, real startBattle; only the two battle screens are registry
|
|
-- fakes and love.audio is a stub source factory.
|
|
do
|
|
local World = require("src.world.gen2.World")
|
|
local Screens = require("src.ui.Screens")
|
|
local Music = require("src.core.Music")
|
|
|
|
local function fakeSource()
|
|
return {
|
|
setLooping = function() end, setVolume = function() end,
|
|
setFilter = function() end, play = function() end,
|
|
stop = function() end, pause = function() end,
|
|
isPlaying = function() return false end,
|
|
}
|
|
end
|
|
love.audio = { newSource = function() return fakeSource() end }
|
|
|
|
local battleDone = nil
|
|
local game
|
|
game = {
|
|
data = {
|
|
pokemon = { RATTATA = { name = "RATTATA", index = 21,
|
|
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
|
|
specialAttack = 25, specialDefense = 35 },
|
|
types = { "NORMAL", "NORMAL" },
|
|
levelMoves = { { level = 1, move = "TACKLE" } } } },
|
|
moves = { TACKLE = { name = "TACKLE", pp = 35, power = 40,
|
|
type = "NORMAL", accuracy = 255 } },
|
|
items = {},
|
|
audio = { runtime = true,
|
|
songs = { Music_Route29 = { file = "route29.ogg" },
|
|
Music_JohtoWildBattle = { file = "wild.ogg" } },
|
|
mapSongs = { ROUTE_29 = "Music_Route29" } },
|
|
screens = {
|
|
Gen2BattleTransition = function(_, opts)
|
|
-- The wipe finishes instantly here; onDone pushes the battle.
|
|
opts.onDone()
|
|
return {}
|
|
end,
|
|
Gen2BattleState = function(_, opts)
|
|
battleDone = opts.onDone
|
|
return {}
|
|
end,
|
|
},
|
|
},
|
|
save = { player = { name = "GOLD" }, mom = { name = "MOM" },
|
|
party = {}, inventory = {} },
|
|
options = {},
|
|
stack = { push = function() end, pop = function() end },
|
|
}
|
|
local world = World.new(game)
|
|
game.world = world
|
|
world.map = { id = "ROUTE_29", def = { environment = "ROUTE" } }
|
|
world.daytime = "DAY"
|
|
|
|
Music.playMap(game.data, "ROUTE_29")
|
|
eq(Music.current(), "Music_Route29", "Route 29's theme is playing")
|
|
|
|
local finished = false
|
|
check(world:startCatchTutorial({ species = 21, level = 5 }, 3, function()
|
|
finished = true
|
|
end), "the DUDE's demo battle opens")
|
|
eq(Music.current(), "Music_JohtoWildBattle",
|
|
"the battle theme took the channels")
|
|
check(battleDone ~= nil, "the battle screen is up")
|
|
battleDone("win")
|
|
check(finished, "the tutorial handed the script back")
|
|
eq(Music.current(), "Music_Route29",
|
|
"and Route 29's theme resumed in place, no map change needed")
|
|
|
|
Music.stop()
|
|
love.audio = nil
|
|
Screens.invalidate()
|
|
end
|
|
|
|
-- ---- the Sprout Tower scene's sfx are the cart's ---------------------------
|
|
-- maps/SproutTower3F.asm really does play SFX_TACKLE + SFX_ELEVATOR twice
|
|
-- while the great pillar sways (that IS the elevator rumble), and Silver's
|
|
-- Escape Rope exit is SFX_WARP_TO. Pin the extracted rows to the labels so a
|
|
-- repointed sfx table cannot quietly swap the cues.
|
|
do
|
|
local cache = os.getenv("GOLD_CACHE")
|
|
if not cache then
|
|
local home = os.getenv("HOME") or ""
|
|
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
|
|
end
|
|
local scripts = (function()
|
|
local chunk = loadfile(cache .. "/data/generated/scripts.lua")
|
|
return chunk and chunk()
|
|
end)()
|
|
local audio = (function()
|
|
local chunk = loadfile(cache .. "/data/generated/audio.lua")
|
|
return chunk and chunk()
|
|
end)()
|
|
if not (scripts and audio and audio.sfxOrder) then
|
|
check(true, "gold cache absent (SKIP Sprout Tower sfx pins)")
|
|
else
|
|
local rows = scripts["42:444b"]
|
|
check(type(rows) == "table", "the rival scene extracted")
|
|
local sounds = {}
|
|
for _, row in ipairs(rows or {}) do
|
|
if row.op == "playsound" then
|
|
sounds[#sounds + 1] = audio.sfxOrder[(row.id or 0) + 1]
|
|
end
|
|
end
|
|
eq(table.concat(sounds, ","),
|
|
"Sfx_Tackle,Sfx_Elevator,Sfx_Tackle,Sfx_Elevator,Sfx_WarpTo",
|
|
"tackle + elevator sway twice, then the Escape Rope warp")
|
|
end
|
|
end
|
|
|
|
S.finish()
|