mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
383 lines
16 KiB
Lua
383 lines
16 KiB
Lua
-- The random incoming call's overworld wiring, and the ring chrome around
|
|
-- every call that lands: CheckTimeEvents' `farcall CheckPhoneCall` arm,
|
|
-- Script_ReceivePhoneCall as PhoneRing.script's rows, GetCallerLocation's two
|
|
-- chatter specials (RandomPhoneMon / RandomPhoneWildMon), Mom's shopping call
|
|
-- riding the same chrome, and the Pokegear's outgoing callee script.
|
|
--
|
|
-- tests/gen2_phone_test.lua owns the MODEL (the gate, the timer, the contact
|
|
-- list); this suite owns the CALL SITES, driven through the real World
|
|
-- methods and a real Vm over the extracted cache. Cache blocks SKIP without
|
|
-- one, the same bargain that suite strikes.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 phone call")
|
|
local check, eq = S.check, S.eq
|
|
|
|
require("src.core.Logger").warn = function() end
|
|
|
|
local CallAsm = require("src.script.gen2.CallAsm")
|
|
local Events = require("src.world.gen2.Events")
|
|
local Phone = require("src.core.gen2.Phone")
|
|
local PhoneRing = require("src.core.gen2.PhoneRing")
|
|
local Save = require("src.core.gen2.Save")
|
|
local Specials = require("src.script.gen2.Specials")
|
|
local Vm = require("src.script.gen2.Vm")
|
|
local World = require("src.world.gen2.World")
|
|
|
|
local function newSave()
|
|
return Save.normalize({})
|
|
end
|
|
|
|
-- The ring's `waitsfx` rows and the Phone_Wait20Frames `pause` between its
|
|
-- two passes park the coroutine on Vm:update, the way DelayFrames parks the
|
|
-- cart's, so a call only runs to completion when something drives the frames
|
|
-- the overworld would drive.
|
|
local function pump(vm, frames)
|
|
for _ = 1, frames or 600 do
|
|
if not vm:running() then return end
|
|
vm:update()
|
|
end
|
|
end
|
|
|
|
-- ------------------------------------------------- Script_ReceivePhoneCall
|
|
--
|
|
-- engine/phone/phone.asm: reanchormap, RingTwice_StartCall, the caller's own
|
|
-- script, waitbutton, HangUp, closetext, InitCallReceiveDelay, end. The row
|
|
-- list has to keep that order or the countdown restarts before the call has
|
|
-- even been answered.
|
|
--
|
|
-- RingTwice_StartCall (:458-469) is `call .Ring` falling through into .Ring,
|
|
-- and each pass opens on Phone_StartRinging's `call WaitSFX` (:564-567), so
|
|
-- the ring is two waitsfx/callasm pairs spaced by the three
|
|
-- Phone_Wait20Frames between the passes (:576-580). This assertion used to
|
|
-- pin one ungated ring, which is the shape that let the $6a SFX_CALL be
|
|
-- dropped by the PlaySFX priority gate; it now pins the cart's.
|
|
do
|
|
local call = Phone.loadCallerScript(15, "incoming", "caller")
|
|
local rows = PhoneRing.script(call, "JOEY", "YOUNGSTER")
|
|
local ops = {}
|
|
for _, row in ipairs(rows) do ops[#ops + 1] = row.op end
|
|
eq(table.concat(ops, " "),
|
|
"reanchormap waitsfx callasm pause waitsfx callasm rawtext farscall "
|
|
.. "waitbutton hangup closetext callasm end",
|
|
"Script_ReceivePhoneCall's rows in the cart's order")
|
|
eq(rows[3].label, "RingTwice_StartCall", "the ring rings first")
|
|
eq(rows[6].label, "RingTwice_StartCall", "and rings a second time")
|
|
eq(rows[4].frames, 60, "three Phone_Wait20Frames apart")
|
|
eq(rows[12].label, "InitCallReceiveDelay", "and the countdown restarts last")
|
|
eq(rows[8].script, "41:4368", "around the caller's own bank $41 script")
|
|
check(rows[7].text:find("RING!", 1, true) ~= nil, "the ring page rings")
|
|
check(rows[7].text:find("JOEY: YOUNGSTER", 1, true) ~= nil,
|
|
"and carries the caller-ID line")
|
|
eq(PhoneRing.callerId("MOM"), "MOM:",
|
|
"a non-trainer caller is the name and the colon alone")
|
|
|
|
-- Script_SpecialElmCall's siblings `pause 30` before the ring; the
|
|
-- descriptor carries that as `delay` and the wrapper honours it.
|
|
local special = { delay = 30, scriptKey = "41:41e1" }
|
|
local wrapped = PhoneRing.script(special, "PROF.ELM")
|
|
eq(wrapped[1].op, "pause", "a special call pauses before ringing")
|
|
eq(wrapped[1].frames, 30, "for the cart's thirty frames")
|
|
end
|
|
|
|
-- ------------------------------------------------- the two chrome callasms
|
|
do
|
|
check(CallAsm.STUBS.RingTwice_StartCall == nil,
|
|
"RingTwice_StartCall is ported, not stubbed")
|
|
check(CallAsm.STUBS.InitCallReceiveDelay == nil,
|
|
"and so is InitCallReceiveDelay")
|
|
eq(CallAsm.nameFor(nil, 0x24, 0x4277), "RingTwice_StartCall",
|
|
"the cart address for the ring still dispatches")
|
|
eq(CallAsm.nameFor(nil, 0x04, 0x5800), "InitCallReceiveDelay",
|
|
"and the cart address for the countdown restart")
|
|
|
|
local rang = {}
|
|
CallAsm.run({ playSfxNamed = function(_, name) rang[#rang + 1] = name end },
|
|
"RingTwice_StartCall")
|
|
eq(rang[1], "Sfx_Call", "the ring is SFX_CALL")
|
|
|
|
-- InitCallReceiveDelay: zero the cycle counter, park the countdown back on
|
|
-- twenty minutes. Wound on first so the reset is visible.
|
|
local save = newSave()
|
|
Phone.initReceiveDelay(save, { clock = { day = 0, hour = 9, minute = 0 } })
|
|
Phone.checkReceiveCallTimer(save,
|
|
{ clock = { day = 0, hour = 9, minute = 20 } })
|
|
eq(save.phone.timeCycles, 1, "the timer was wound before the reset")
|
|
local ctx = {
|
|
game = { save = save },
|
|
stepContext = function()
|
|
return { phone = { clock = { day = 0, hour = 9, minute = 20 } } }
|
|
end,
|
|
}
|
|
CallAsm.run(ctx, "InitCallReceiveDelay")
|
|
eq(save.phone.timeCycles, 0, "InitCallReceiveDelay zeroes the cycles")
|
|
eq(save.phone.delayMins, 20, "and restarts the countdown at twenty")
|
|
end
|
|
|
|
-- ------------------------------------------------- CheckTimeEvents' arm
|
|
--
|
|
-- The call site itself, driven through the real World:checkTimeEvents and
|
|
-- World:stepContext on a stub self: the `.do_daily` arm has to consult the
|
|
-- gate every overworld frame, hand it the tile underfoot, and route a landed
|
|
-- call into receivePhoneCall.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15) -- Joey, ROUTE_30
|
|
local received = {}
|
|
local coll = 0x00
|
|
local fake = {
|
|
game = { save = save, clock = { day = 0, hour = 9, minute = 0 } },
|
|
maps = {},
|
|
daytime = "DAY",
|
|
playerState = "walk",
|
|
map = {
|
|
def = { id = "ROUTE_31", environment = "ROUTE", phoneService = true },
|
|
cellCollision = function() return coll end,
|
|
},
|
|
player = { cellX = 4, cellY = 4 },
|
|
stepContext = World.stepContext,
|
|
checkTimeEvents = World.checkTimeEvents,
|
|
receivePhoneCall = function(_, call)
|
|
received[#received + 1] = call
|
|
return true
|
|
end,
|
|
}
|
|
-- Pin the gate's two Random draws (the coin flip and ChooseRandomCaller)
|
|
-- to the passing arm; everything else stays on the ambient stream.
|
|
local ambient = math.random
|
|
math.random = function(a, b)
|
|
if a == 0 and b == 255 then return 0 end
|
|
return ambient(a, b)
|
|
end
|
|
|
|
eq(fake:checkTimeEvents(), false, "minute zero stamps the countdown")
|
|
fake.game.clock.minute = 19
|
|
eq(fake:checkTimeEvents(), false, "nineteen minutes is not enough")
|
|
eq(#received, 0, "so nobody has rung")
|
|
fake.game.clock.minute = 20
|
|
eq(fake:checkTimeEvents(), true, "the twentieth minute lands the call")
|
|
eq(#received, 1, "through receivePhoneCall")
|
|
eq(received[1] and received[1].contact, 15, "from the contact in the book")
|
|
eq(received[1] and received[1].direction, "incoming", "as an incoming call")
|
|
eq(save.phone.delayMins, 10, "and the consumed timer wound on to ten")
|
|
|
|
-- CheckStandingOnEntrance: a door tile refuses the call BEFORE the timer
|
|
-- check, so the countdown is untouched while the player stands on it.
|
|
coll = 0x71 -- COLL_DOOR
|
|
fake.game.clock.minute = 40
|
|
eq(fake:checkTimeEvents(), false, "standing on a door never rings")
|
|
eq(#received, 1, "no second call landed")
|
|
eq(save.phone.delayMins, 10, "and the countdown was not consumed")
|
|
coll = 0x00
|
|
eq(fake:checkTimeEvents(), true, "stepping off the door frees the line")
|
|
eq(#received, 2, "and the held call lands")
|
|
|
|
math.random = ambient
|
|
end
|
|
|
|
-- ------------------------------------------------- Mom's ring
|
|
--
|
|
-- MomTriesToBuySomething's .Script is `callasm .ASMFunction / farsjump
|
|
-- Script_ReceivePhoneCall`, so the queued shopping pages have to arrive
|
|
-- wrapped in the same chrome with PHONE_MOM on the line.
|
|
do
|
|
local save = Save.normalize({ mom = { savedMoney = 10000, active = true,
|
|
savingMoney = true }, party = {} })
|
|
local vm = {}
|
|
local fake = {
|
|
game = { save = save },
|
|
events = Events.new(),
|
|
vm = vm,
|
|
momTriesToBuy = World.momTriesToBuy,
|
|
}
|
|
local purchase = fake:momTriesToBuy()
|
|
check(purchase ~= nil, "a full ladder rung buys something")
|
|
local rows = fake.queuedScript
|
|
check(type(rows) == "table", "and queues the call for the overworld")
|
|
eq(rows[1] and rows[1].op, "reanchormap", "wrapped in the ring chrome")
|
|
eq(rows[3] and rows[3].label, "RingTwice_StartCall", "which rings")
|
|
check(rows[7].text:find("MOM:", 1, true) ~= nil, "as MOM")
|
|
local inner = rows[8] and rows[8].script
|
|
check(type(inner) == "table", "around her queued pages")
|
|
eq(inner[1] and inner[1].op, "rawtext", "which are the shopping lines")
|
|
eq(inner[#inner].op, "end", "and end like any caller script")
|
|
eq(vm.curPhoneCaller, Phone.PHONECONTACT_MOM,
|
|
"with wCurCaller parked on PHONE_MOM")
|
|
|
|
-- The farscall's operand is the inline row list, the shape runList takes
|
|
-- for a script wCallerContact would have pointed at: the whole queued call
|
|
-- has to run to completion through a real Vm.
|
|
local pages = {}
|
|
local runner = Vm.new({}, {}, Events.new(), {
|
|
showText = function(body, onDone)
|
|
pages[#pages + 1] = body
|
|
onDone()
|
|
end,
|
|
})
|
|
check(runner:start(rows), "the queued call runs whole")
|
|
pump(runner)
|
|
check(not runner:running(), "to completion")
|
|
check(pages[1]:find("MOM:", 1, true) ~= nil, "ringing as MOM")
|
|
check(table.concat(pages, "|"):find("Click!", 1, true) ~= nil,
|
|
"and hanging up on the Click!")
|
|
end
|
|
|
|
-- ------------------------------------------------- against the cache
|
|
--
|
|
-- A REAL caller script through a real Vm inside the wrapper: Joey's
|
|
-- (41:4368), whose .WantsBattle arm is the whole rematch mechanic.
|
|
do
|
|
local cacheDir = os.getenv("GOLD_CACHE")
|
|
if not cacheDir then
|
|
local home = os.getenv("HOME") or ""
|
|
cacheDir = home .. "/Library/Application Support/LOVE/gold-dev/gold"
|
|
end
|
|
local generated = cacheDir .. "/data/generated/"
|
|
local scriptsFile = loadfile(generated .. "scripts.lua")
|
|
if not scriptsFile then
|
|
check(true, "cache absent; VM-side blocks skipped (SKIP)")
|
|
else
|
|
local scripts = scriptsFile()
|
|
local text = assert(loadfile(generated .. "text.lua"))()
|
|
local constants = assert(loadfile(generated .. "constants.lua"))()
|
|
local trainers = assert(loadfile(generated .. "trainers.lua"))()
|
|
local encounters = assert(loadfile(generated .. "encounters.lua"))()
|
|
local pokemon = assert(loadfile(generated .. "pokemon.lua"))()
|
|
local TrainerHouse = require("src.world.gen2.TrainerHouse")
|
|
|
|
local function newCallVm(save, world)
|
|
local pages = {}
|
|
local vm
|
|
vm = Vm.new(scripts, text, Events.new(), {
|
|
showText = function(body, onDone)
|
|
pages[#pages + 1] = body
|
|
onDone()
|
|
end,
|
|
specialOrder = constants.specialOrder,
|
|
getTrainerName = function(group, index)
|
|
return TrainerHouse.name(trainers, save, group, index)
|
|
end,
|
|
playSound = function(id) world.sfx[#world.sfx + 1] = id end,
|
|
callAsm = function(label, bank, addr)
|
|
return CallAsm.dispatch(world, label, bank, addr)
|
|
end,
|
|
specials = {
|
|
world = world,
|
|
save = function() return save end,
|
|
data = function() return { pokemon = pokemon, trainers = trainers } end,
|
|
},
|
|
})
|
|
return vm, pages
|
|
end
|
|
|
|
local function newWorld(save)
|
|
return {
|
|
game = { save = save },
|
|
encounters = encounters,
|
|
daytime = "DAY",
|
|
sfx = {},
|
|
playSfxNamed = function(self, name) self.sfx[#self.sfx + 1] = name end,
|
|
stepContext = function()
|
|
return { phone = { clock = { day = 0, hour = 9, minute = 40 } } }
|
|
end,
|
|
}
|
|
end
|
|
|
|
-- The rematch arm: ENGINE_FLYPOINT_GOLDENROD set (checkflag 69 in the
|
|
-- extracted body) and PhoneScript_Random2 landing 0 takes .WantsBattle,
|
|
-- whose `setevent EVENT_JOEY_READY_FOR_REMATCH` (628) is the only writer
|
|
-- of that flag in the whole game.
|
|
local save = newSave()
|
|
Phone.initReceiveDelay(save, { clock = { day = 0, hour = 9, minute = 0 } })
|
|
local world = newWorld(save)
|
|
local vm, pages = newCallVm(save, world)
|
|
vm.engineFlags[69] = true
|
|
local ambient = math.random
|
|
math.random = function(a) return a == 0 and 0 or 0 end
|
|
vm.curPhoneCaller = 15
|
|
local call = Phone.loadCallerScript(15, "incoming", "caller")
|
|
check(vm:start(PhoneRing.script(call, "JOEY", "YOUNGSTER")),
|
|
"the wrapped caller script runs")
|
|
pump(vm)
|
|
math.random = ambient
|
|
check(not vm:running(), "to completion")
|
|
check(pages[1]:find("RING!", 1, true) ~= nil, "opening on the ring page")
|
|
local said = table.concat(pages, "|")
|
|
check(said:find("It's me, JOEY", 1, true) ~= nil,
|
|
"the greeting names the caller off gettrainername")
|
|
check(vm.events:get(628),
|
|
"and .WantsBattle armed EVENT_JOEY_READY_FOR_REMATCH")
|
|
check(said:find("Click!", 1, true) ~= nil, "the hang-up clicks")
|
|
eq(world.sfx[1], "Sfx_Call", "the ring SFX played")
|
|
-- RingTwice_StartCall rings twice (engine/phone/phone.asm:458-469).
|
|
eq(world.sfx[2], "Sfx_Call", "and rang a second time")
|
|
eq(save.phone.delayMins, 20,
|
|
"and InitCallReceiveDelay restarted the countdown")
|
|
local unknown = {}
|
|
for op in pairs(vm.unknownOps) do unknown[#unknown + 1] = op end
|
|
eq(table.concat(unknown, ","), "", "no opcode fell through")
|
|
|
|
-- The chatter: the shared PhoneScript_Generic body (41:48f0) opens on
|
|
-- `special RandomPhoneMon` and its "My {STRBUF}'s really energetic" line
|
|
-- reads the buffer that special filled from the CALLER'S party; the
|
|
-- `special RandomPhoneWildMon` body it chains into (41:4920) then names
|
|
-- a wild mon off the caller's own route.
|
|
local save2 = newSave()
|
|
local world2 = newWorld(save2)
|
|
local vm2, pages2 = newCallVm(save2, world2)
|
|
vm2.curPhoneCaller = 15
|
|
-- Every Random2 lands 1 (the chat arms); Specials.random holds its own
|
|
-- reference to the roll, so it gets its own stub: the LAST slot of both
|
|
-- the party and the four commonest wilds.
|
|
math.random = function(a, b) return b or a end
|
|
local oldSpecialsRoll = Specials.random
|
|
Specials.random = function(n) return n end
|
|
check(vm2:start("41:48f0"), "the generic chat body runs")
|
|
Specials.random = oldSpecialsRoll
|
|
math.random = ambient
|
|
check(table.concat(pages2, "|"):find("My RATTATA", 1, true) ~= nil,
|
|
"RandomPhoneMon named a mon out of JOEY's own party")
|
|
local day4 = encounters.grass.ROUTE_30.slots.DAY[4]
|
|
eq(vm2.stringBuffer, pokemon[day4.species].name,
|
|
"and RandomPhoneWildMon followed with a wild one off ROUTE 30")
|
|
|
|
-- RandomPhoneWildMon: one of the four commonest DAY slots on the
|
|
-- caller's map (Joey: ROUTE_30), named into the buffer.
|
|
local save3 = newSave()
|
|
local world3 = newWorld(save3)
|
|
local vm3 = newCallVm(save3, world3)
|
|
vm3.curPhoneCaller = 15
|
|
local oldRoll = Specials.random
|
|
Specials.random = function() return 1 end
|
|
check(vm3:start({ { op = "special", id = 91 }, { op = "end" } }),
|
|
"RandomPhoneWildMon dispatches by its cache id")
|
|
Specials.random = oldRoll
|
|
local day = encounters.grass.ROUTE_30.slots.DAY
|
|
eq(vm3.stringBuffer, pokemon[day[1].species].name,
|
|
"and names the commonest wild mon on the caller's own route")
|
|
|
|
-- The Pokegear's outgoing half: Game2:runPokegearCall runs the contact's
|
|
-- SCRIPT1 through the same VM, with wCurCaller parked first.
|
|
local Game2 = require("src.core.Game2")
|
|
local save4 = newSave()
|
|
Phone.addContact(save4, 15)
|
|
local world4 = newWorld(save4)
|
|
local vm4, pages4 = newCallVm(save4, world4)
|
|
world4.vm = vm4
|
|
local gold = setmetatable({ world = world4 }, Game2)
|
|
local out = Phone.call(save4, 15,
|
|
{ map = { id = "ROUTE_31", phoneService = true }, timeOfDay = "DAY" })
|
|
eq(out.script, "JoeyPhoneCalleeScript", "the descriptor names SCRIPT1")
|
|
check(gold:runPokegearCall(out), "and runPokegearCall runs it")
|
|
check(#pages4 > 0, "so the callee actually talks")
|
|
check(table.concat(pages4, "|"):find("JOEY", 1, true) ~= nil,
|
|
"as himself")
|
|
eq(vm4.curPhoneCaller, 15, "with wCurCaller parked for the specials")
|
|
eq(out.ranScript, true, "and the descriptor records the run")
|
|
check(not gold:runPokegearCall({ kind = "outofarea" }),
|
|
"an out-of-area answer keeps the card's own line instead")
|
|
end
|
|
end
|
|
|
|
S.finish()
|