mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
832 lines
33 KiB
Lua
832 lines
33 KiB
Lua
-- The POKeGEAR's phone: the contact list, the receive-call timer, the random
|
|
-- caller gate, the special-call queue, the trainer rematch flags, and the
|
|
-- phone card that places an outgoing call.
|
|
--
|
|
-- ROM-free and draw-free. src/core/gen2/Phone.lua is love-free by design, so
|
|
-- every model assertion here runs against the module directly; the Pokegear
|
|
-- section stubs the same love pieces tests/gen2_menus_test.lua does and drives
|
|
-- the card with a fake input.
|
|
--
|
|
-- Everything asserted is transcribed behaviour, so each block names the
|
|
-- pokegold routine it is pinning down.
|
|
|
|
package.path = "./?.lua;" .. package.path
|
|
|
|
local drawn = {}
|
|
love = love or {}
|
|
love.graphics = love.graphics or {
|
|
getColor = function() return 1, 1, 1, 1 end,
|
|
setColor = function() end,
|
|
rectangle = function() end,
|
|
print = function() end,
|
|
printf = function() end,
|
|
draw = function() end,
|
|
newQuad = function() return {} end,
|
|
newImage = function() return nil end,
|
|
getShader = function() return nil end,
|
|
setShader = function() end,
|
|
newShader = function() error("no shaders in this harness") end,
|
|
getDimensions = function() return 160, 144 end,
|
|
push = function() end, pop = function() end,
|
|
translate = function() end, scale = function() end,
|
|
circle = function() end, clear = function() end,
|
|
}
|
|
love.math = love.math or {
|
|
random = function(a, b)
|
|
if b then return a end
|
|
return a and 1 or 0.5
|
|
end,
|
|
}
|
|
love.image = love.image or {}
|
|
love.filesystem = love.filesystem or {
|
|
load = function() return nil end,
|
|
getInfo = function() return nil end,
|
|
read = function() return nil end,
|
|
write = function() return true end,
|
|
remove = function() return true end,
|
|
}
|
|
love.timer = love.timer or { getTime = function() return 0 end }
|
|
|
|
require("src.core.Logger").warn = function() end
|
|
|
|
local Events = require("src.world.gen2.Events")
|
|
local Phone = require("src.core.gen2.Phone")
|
|
local Pokegear = require("src.ui.gen2.Pokegear")
|
|
local Save = require("src.core.gen2.Save")
|
|
|
|
local failures, checks = 0, 0
|
|
local function check(name, got, want)
|
|
checks = checks + 1
|
|
if got ~= want then
|
|
failures = failures + 1
|
|
print(("FAIL %s: got %s, want %s"):format(
|
|
name, tostring(got), tostring(want)))
|
|
end
|
|
end
|
|
|
|
local function newSave()
|
|
return Save.normalize({})
|
|
end
|
|
|
|
-- A deterministic Random: one byte per cart `call Random`, in the order the
|
|
-- gate consumes them. Runs off the end of the list rather than wrapping, so a
|
|
-- test that draws more rolls than it queued fails loudly instead of quietly
|
|
-- re-using one.
|
|
local function rolls(...)
|
|
local queue = { ... }
|
|
local index = 0
|
|
return function()
|
|
index = index + 1
|
|
return queue[index] or error("ran out of seeded rolls at draw " .. index, 0)
|
|
end
|
|
end
|
|
|
|
-- ------------------------------------------------------ the contact table
|
|
|
|
-- data/phone/phone_contacts.asm, `assert_table_length NUM_PHONE_CONTACTS + 1`.
|
|
check("the contact table is zero based", Phone.CONTACTS[0] ~= nil, true)
|
|
check("and runs to NUM_PHONE_CONTACTS",
|
|
Phone.CONTACTS[Phone.NUM_PHONE_CONTACTS] ~= nil, true)
|
|
check("with nothing past it",
|
|
Phone.CONTACTS[Phone.NUM_PHONE_CONTACTS + 1], nil)
|
|
|
|
-- constants/trainer_constants.asm opens the non-trainer block with
|
|
-- `const_def 1`. A zero-based reading here would shift every name by one and
|
|
-- leave PROF.ELM off the end.
|
|
check("PHONECONTACT_MOM is 1", Phone.PHONECONTACT_MOM, 1)
|
|
check("PHONECONTACT_ELM is 4", Phone.PHONECONTACT_ELM, 4)
|
|
check("and MOM's name comes off that index",
|
|
Phone.contactName(1), "MOM")
|
|
check("as does PROF.ELM's", Phone.contactName(4), "PROF.ELM")
|
|
check("contact 0 is the wrong-number filler",
|
|
Phone.contactName(0), "----------")
|
|
|
|
-- The three const_skip holes between HUEY and GAVEN, and the one before DANA,
|
|
-- are real rows in the ROM table: they must resolve, not index nil.
|
|
check("a const_skip hole is a live row", Phone.CONTACTS[9] ~= nil, true)
|
|
check("and it can never be called", Phone.CONTACTS[9].callerTime, 0)
|
|
check("nor called out to", Phone.CONTACTS[25].calleeTime, 0)
|
|
|
|
-- SCRIPT2_TIME is the mask for THEM calling YOU, and Mom, Elm, Bill and the
|
|
-- bike shop all have it at 0. This is why an unprompted Elm call can only
|
|
-- ever arrive through the special-call queue.
|
|
check("Mom never rings you at random", Phone.CONTACTS[1].callerTime, 0)
|
|
check("nor does Elm", Phone.CONTACTS[4].callerTime, 0)
|
|
check("but Joey does", Phone.CONTACTS[15].callerTime, Phone.ANYTIME)
|
|
check("and Elm can still be called", Phone.CONTACTS[4].calleeTime,
|
|
Phone.ANYTIME)
|
|
|
|
-- The scripts live in ROM bank $41, which the importer does not reach yet, so
|
|
-- a contact carries the label and the key that label will have.
|
|
check("a contact names its caller script",
|
|
Phone.CONTACTS[15].caller, "JoeyPhoneCallerScript")
|
|
check("and resolves it to a scripts.lua key",
|
|
Phone.scriptKey("JoeyPhoneCallerScript"), "41:4368")
|
|
|
|
-- ------------------------------------------------------ the contact list
|
|
|
|
-- engine/phone/phone.asm AddPhoneNumber / _CheckCellNum / DelCellNum.
|
|
do
|
|
local save = newSave()
|
|
check("a new phone book is empty", Phone.hasContact(save, 15), false)
|
|
check("adding Joey works", Phone.addContact(save, 15), true)
|
|
check("and he is in the book", Phone.hasContact(save, 15), true)
|
|
check("in the first slot", Phone.contacts(save)[1], 15)
|
|
local ok, why = Phone.addContact(save, 15)
|
|
check("adding him twice is refused", ok, false)
|
|
check("because he is already there", why, "already")
|
|
check("removing him works", Phone.removeContact(save, 15), true)
|
|
check("and he is gone", Phone.hasContact(save, 15), false)
|
|
check("removing him again does nothing", Phone.removeContact(save, 15), false)
|
|
end
|
|
|
|
-- GetRemainingSpaceInPhoneList reserves a slot for every PERMANENT number you
|
|
-- have not registered yet, so a player who has met neither MOM nor ELM can
|
|
-- only fill eight of the ten slots.
|
|
do
|
|
local save = newSave()
|
|
check("two permanent numbers are outstanding",
|
|
Phone.remainingSlots(save, 15), 8)
|
|
Phone.addContact(save, Phone.PHONECONTACT_MOM)
|
|
check("registering MOM frees her slot", Phone.remainingSlots(save, 15), 9)
|
|
Phone.addContact(save, Phone.PHONECONTACT_ELM)
|
|
check("and registering ELM frees his", Phone.remainingSlots(save, 15), 10)
|
|
end
|
|
|
|
-- `cp c / jr z, .continue`: a permanent number never reserves a slot against
|
|
-- itself, which is what lets MOM in when the book is otherwise full.
|
|
do
|
|
local save = newSave()
|
|
local trainers = { 15, 16, 17, 18, 19, 20, 21, 22 }
|
|
for _, id in ipairs(trainers) do
|
|
check("trainer " .. id .. " fits", Phone.addContact(save, id), true)
|
|
end
|
|
local ok, why = Phone.addContact(save, 23)
|
|
check("the ninth trainer does not", ok, false)
|
|
check("because the book is full", why, "full")
|
|
check("but MOM still fits",
|
|
Phone.addContact(save, Phone.PHONECONTACT_MOM), true)
|
|
check("and so does ELM",
|
|
Phone.addContact(save, Phone.PHONECONTACT_ELM), true)
|
|
check("filling all ten slots", Phone.contacts(save)[10],
|
|
Phone.PHONECONTACT_ELM)
|
|
end
|
|
|
|
-- Script_askforphonenumber's three PHONE_CONTACT_* return values. A refusal
|
|
-- never touches the book, and a full book reports FULL rather than GOT.
|
|
do
|
|
local save = newSave()
|
|
check("refusing returns PHONE_CONTACT_REFUSED",
|
|
Phone.askForNumber(save, 15, false), Phone.CONTACT_REFUSED)
|
|
check("and stores nothing", Phone.hasContact(save, 15), false)
|
|
check("accepting returns PHONE_CONTACT_GOT",
|
|
Phone.askForNumber(save, 15, true), Phone.CONTACT_GOT)
|
|
check("a second ask reports PHONE_CONTACTS_FULL",
|
|
Phone.askForNumber(save, 15, true), Phone.CONTACTS_FULL)
|
|
end
|
|
|
|
-- PokegearPhone_DeletePhoneNumber blanks the slot and then compacts, so the
|
|
-- display never shows a gap; CheckCanDeletePhoneNumber withholds DELETE from
|
|
-- MOM and ELM.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15)
|
|
Phone.addContact(save, 16)
|
|
Phone.addContact(save, 17)
|
|
Phone.deleteContactAt(save, 2)
|
|
local list = Phone.contacts(save)
|
|
check("delete compacts the list", list[1], 15)
|
|
check("pulling the tail forward", list[2], 17)
|
|
check("and leaving the end empty", list[3], 0)
|
|
check("MOM cannot be deleted", Phone.canDelete(Phone.PHONECONTACT_MOM), false)
|
|
check("nor can ELM", Phone.canDelete(Phone.PHONECONTACT_ELM), false)
|
|
check("but BILL can", Phone.canDelete(Phone.PHONECONTACT_BILL), true)
|
|
check("and so can a trainer", Phone.canDelete(15), true)
|
|
check("an empty slot cannot", Phone.canDelete(0), false)
|
|
end
|
|
|
|
-- The VM's addcellnum hook writes save.phoneContacts directly
|
|
-- (src/world/gen2/World.lua); the model adopts that set into wPhoneList and
|
|
-- mirrors the list back over it, so neither half has to move first.
|
|
do
|
|
local save = newSave()
|
|
save.phoneContacts = { [15] = true, [16] = true }
|
|
check("a legacy set is adopted into the list",
|
|
Phone.hasContact(save, 16), true)
|
|
Phone.removeContact(save, 15)
|
|
Phone.state(save)
|
|
check("and a delete propagates back out", save.phoneContacts[15], nil)
|
|
check("leaving the rest alone", save.phoneContacts[16], true)
|
|
end
|
|
|
|
-- ------------------------------------------------------ the receive timer
|
|
|
|
-- engine/overworld/time.asm InitCallReceiveDelay / NextCallReceiveDelay /
|
|
-- CheckReceiveCallTimer. The ladder is 20, 10, 5, 3 minutes, indexed by
|
|
-- wTimeCyclesSinceLastCall and capped at three.
|
|
local function at(hour, minute, day)
|
|
return { clock = { day = day or 0, hour = hour, minute = minute } }
|
|
end
|
|
|
|
do
|
|
local save = newSave()
|
|
Phone.initReceiveDelay(save, at(9, 0))
|
|
check("the first delay is twenty minutes", save.phone.delayMins, 20)
|
|
check("nineteen minutes is not enough",
|
|
Phone.checkReceiveCallTimer(save, at(9, 19)), false)
|
|
check("with a minute left on the clock", save.phone.delayMins, 1)
|
|
check("the twentieth minute expires it",
|
|
Phone.checkReceiveCallTimer(save, at(9, 20)), true)
|
|
check("and the counter winds on", save.phone.timeCycles, 1)
|
|
check("shortening the next delay to ten", save.phone.delayMins, 10)
|
|
check("which expires ten minutes later",
|
|
Phone.checkReceiveCallTimer(save, at(9, 30)), true)
|
|
check("then five", save.phone.delayMins, 5)
|
|
Phone.checkReceiveCallTimer(save, at(9, 35))
|
|
check("then three", save.phone.delayMins, 3)
|
|
Phone.checkReceiveCallTimer(save, at(9, 38))
|
|
check("and three stays three", save.phone.delayMins, 3)
|
|
check("with the counter capped", save.phone.timeCycles, 3)
|
|
end
|
|
|
|
-- GetMinutesSinceIfLessThan60 gives up past an hour and hands
|
|
-- UpdateTimeRemaining -1, which it treats as expired outright.
|
|
do
|
|
local save = newSave()
|
|
Phone.initReceiveDelay(save, at(9, 0))
|
|
check("an hour away expires the timer at once",
|
|
Phone.checkReceiveCallTimer(save, at(11, 5)), true)
|
|
end
|
|
|
|
-- StartMap runs InitCallReceiveDelay on EVERY map load, which is why warping
|
|
-- around never gets you a call.
|
|
do
|
|
local save = newSave()
|
|
Phone.initReceiveDelay(save, at(9, 0))
|
|
Phone.checkReceiveCallTimer(save, at(9, 19))
|
|
Phone.onMapLoad(save, at(9, 19))
|
|
check("a map load resets the countdown", save.phone.delayMins, 20)
|
|
check("and the cycle counter with it", save.phone.timeCycles, 0)
|
|
end
|
|
|
|
-- ------------------------------------------------------ who can call
|
|
|
|
-- GetAvailableCallers: the SCRIPT2_TIME mask has to cover the current time of
|
|
-- day, and a contact standing on your own map is skipped.
|
|
local ROUTE_30 = { id = "ROUTE_30", environment = "ROUTE", phoneService = true }
|
|
local ROUTE_31 = { id = "ROUTE_31", environment = "ROUTE", phoneService = true }
|
|
local NO_SERVICE = { id = "SPROUT_TOWER_1F", environment = "INDOOR",
|
|
phoneService = false }
|
|
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15) -- Joey, ROUTE_30
|
|
Phone.addContact(save, 16) -- Wade, ROUTE_31
|
|
local here = { map = ROUTE_30, timeOfDay = "DAY" }
|
|
local callers = Phone.availableCallers(save, here)
|
|
check("one caller is available on Route 30", #callers, 1)
|
|
check("and it is not the trainer standing there", callers[1], 16)
|
|
local elsewhere = Phone.availableCallers(save,
|
|
{ map = { id = "ROUTE_32", phoneService = true }, timeOfDay = "DAY" })
|
|
check("off both their maps, both are available", #elsewhere, 2)
|
|
end
|
|
|
|
-- CheckTime's table has no live DARKNESS row, so a dark map resolves to c = 0
|
|
-- and nobody's ANYTIME mask can match it.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15)
|
|
check("darkness leaves nobody available",
|
|
#Phone.availableCallers(save, { map = ROUTE_31, timeOfDay = "DARK" }), 0)
|
|
end
|
|
|
|
-- Mom's SCRIPT2_TIME is 0, so she is never sampled however full the book is.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, Phone.PHONECONTACT_MOM)
|
|
check("Mom is never an available caller",
|
|
#Phone.availableCallers(save, { map = ROUTE_31, timeOfDay = "DAY" }), 0)
|
|
end
|
|
|
|
-- ChooseRandomCaller: swap the nibbles of one random byte, mask to 0..31, then
|
|
-- take it modulo the number of callers (SimpleDivide returns the remainder).
|
|
do
|
|
local callers = { 15, 16, 17 }
|
|
-- $10 swaps to $01, & $1f = 1, 1 % 3 = 1 -> the second caller.
|
|
check("a seeded roll picks the second caller",
|
|
Phone.chooseRandomCaller(callers, rolls(0x10)), 16)
|
|
-- $20 swaps to $02, & $1f = 2, 2 % 3 = 2 -> the third.
|
|
check("and another picks the third",
|
|
Phone.chooseRandomCaller(callers, rolls(0x20)), 17)
|
|
-- $30 swaps to $03, & $1f = 3, 3 % 3 = 0 -> back to the first.
|
|
check("the modulo wraps", Phone.chooseRandomCaller(callers, rolls(0x30)), 15)
|
|
check("an empty list samples nothing",
|
|
Phone.chooseRandomCaller({}, rolls(0x10)), nil)
|
|
end
|
|
|
|
-- CheckTime derives wTimeOfDay from the hour, on
|
|
-- constants/misc_constants.asm's boundaries: MORN at 4, DAY at 10, NITE at 18.
|
|
check("3am is night", Phone.timeOfDay(at(3, 0)), Phone.NITE)
|
|
check("4am is morning", Phone.timeOfDay(at(4, 0)), Phone.MORN)
|
|
check("10am is day", Phone.timeOfDay(at(10, 0)), Phone.DAY)
|
|
check("6pm is night again", Phone.timeOfDay(at(18, 0)), Phone.NITE)
|
|
|
|
-- GetMapPhoneService returns the header nybble and every caller tests it
|
|
-- against zero, so ZERO means the map HAS service; maps.lua already decodes
|
|
-- that into a boolean.
|
|
check("a serviced map has a signal", Phone.mapHasService({ map = ROUTE_30 }),
|
|
true)
|
|
check("a dead zone does not", Phone.mapHasService({ map = NO_SERVICE }), false)
|
|
|
|
-- ------------------------------------------------------ the random-call gate
|
|
|
|
-- CheckPhoneCall, in order: not on an entrance tile, the timer has expired, a
|
|
-- 50% coin flip, the map has a signal, and someone is available.
|
|
local function armedSave()
|
|
local save = newSave()
|
|
Phone.addContact(save, 15) -- Joey, ROUTE_30
|
|
Phone.initReceiveDelay(save, at(9, 0))
|
|
return save
|
|
end
|
|
|
|
do
|
|
local save = armedSave()
|
|
local call = Phone.tryRandomCall(save, {
|
|
map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 20 },
|
|
rng = rolls(0x00, 0x00),
|
|
})
|
|
check("a call lands once every gate passes", call ~= nil, true)
|
|
check("from the contact in the book", call and call.contact, 15)
|
|
check("running their caller script", call and call.script,
|
|
"JoeyPhoneCallerScript")
|
|
check("keyed for scripts.lua", call and call.scriptKey, "41:4368")
|
|
check("and it is an incoming call", call and call.direction, "incoming")
|
|
end
|
|
|
|
do
|
|
local save = armedSave()
|
|
check("standing on a door refuses the call", Phone.tryRandomCall(save, {
|
|
map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 20 },
|
|
standingOnEntrance = true, rng = rolls(0x00, 0x00),
|
|
}), nil)
|
|
check("and the timer is untouched by the refusal",
|
|
save.phone.delayMins, 20)
|
|
end
|
|
|
|
do
|
|
local save = armedSave()
|
|
check("too soon is no call", Phone.tryRandomCall(save, {
|
|
map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 5 },
|
|
rng = rolls(0x00, 0x00),
|
|
}), nil)
|
|
check("but the countdown still ran down", save.phone.delayMins, 15)
|
|
end
|
|
|
|
do
|
|
local save = armedSave()
|
|
-- `and %01111111 / cp b`: a byte with its top bit set fails the flip.
|
|
check("the coin flip refuses half the time", Phone.tryRandomCall(save, {
|
|
map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 20 },
|
|
rng = rolls(0x80),
|
|
}), nil)
|
|
end
|
|
|
|
do
|
|
local save = armedSave()
|
|
check("no signal is no call", Phone.tryRandomCall(save, {
|
|
map = NO_SERVICE, timeOfDay = "DAY", clock = { hour = 9, minute = 20 },
|
|
rng = rolls(0x00, 0x00),
|
|
}), nil)
|
|
end
|
|
|
|
do
|
|
local save = armedSave()
|
|
check("nobody available is no call", Phone.tryRandomCall(save, {
|
|
map = ROUTE_30, timeOfDay = "DAY", clock = { hour = 9, minute = 20 },
|
|
rng = rolls(0x00, 0x00),
|
|
}), nil)
|
|
end
|
|
|
|
-- ------------------------------------------------------ special calls
|
|
|
|
-- Script_specialphonecall parks the id in wSpecialPhoneCallID;
|
|
-- Script_checkphonecall reports whether one is waiting; CheckSpecialPhoneCall
|
|
-- fires it the next time its condition holds.
|
|
do
|
|
local save = newSave()
|
|
check("nothing is queued to start with", Phone.hasSpecialCall(save), false)
|
|
Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_ROBBED)
|
|
check("queueing one shows up", Phone.hasSpecialCall(save), true)
|
|
check("and readvar VAR_SPECIALPHONECALL sees the id",
|
|
Phone.specialCallVar(save), 2)
|
|
-- SpecialCallOnlyWhenOutside takes TOWN and ROUTE and nothing else.
|
|
check("indoors it holds off",
|
|
Phone.checkSpecialCall(save, { map = NO_SERVICE }), nil)
|
|
local call = Phone.checkSpecialCall(save, { map = ROUTE_30 })
|
|
check("outdoors it fires", call ~= nil, true)
|
|
check("as Elm", call and call.contact, Phone.PHONECONTACT_ELM)
|
|
check("with the special call's script, not Elm's own",
|
|
call and call.script, "ElmPhoneCallerScript")
|
|
check("naming which special call it is", call and call.special, 2)
|
|
check("and pausing thirty frames first", call and call.delay, 30)
|
|
end
|
|
|
|
-- SPECIALCALL_WORRIED is Mom's lecture, and its condition is
|
|
-- SpecialCallWhereverYouAre -- it reaches you indoors too.
|
|
do
|
|
local save = newSave()
|
|
Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_WORRIED)
|
|
local call = Phone.checkSpecialCall(save, { map = NO_SERVICE })
|
|
check("Mom's lecture reaches you anywhere", call ~= nil, true)
|
|
check("from Mom", call and call.contact, Phone.PHONECONTACT_MOM)
|
|
check("running her lecture script", call and call.script,
|
|
"MomPhoneLectureScript")
|
|
end
|
|
|
|
-- A queued call fires exactly once. On the cart the called script clears the
|
|
-- queue itself (`specialphonecall SPECIALCALL_NONE`); bank $41 is not
|
|
-- extracted, so Phone.endCall clears it for a call whose script never ran.
|
|
do
|
|
local save = newSave()
|
|
Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_ASSISTANT)
|
|
local call = Phone.checkSpecialCall(save, { map = ROUTE_30 })
|
|
check("the queued call fires", call ~= nil, true)
|
|
check("and is still queued while it runs", Phone.hasSpecialCall(save), true)
|
|
Phone.endCall(save, call, at(9, 0))
|
|
check("hanging up clears it", Phone.hasSpecialCall(save), false)
|
|
check("so it does not fire twice",
|
|
Phone.checkSpecialCall(save, { map = ROUTE_30 }), nil)
|
|
check("and the receive timer restarts", save.phone.delayMins, 20)
|
|
end
|
|
|
|
-- Once the VM can run bank $41, the script's own clear is what fires and the
|
|
-- fallback must keep its hands off.
|
|
do
|
|
local save = newSave()
|
|
Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_ASSISTANT)
|
|
local call = Phone.checkSpecialCall(save, { map = ROUTE_30 })
|
|
call.ranScript = true
|
|
Phone.endCall(save, call, at(9, 0))
|
|
check("a call whose script ran clears itself",
|
|
Phone.hasSpecialCall(save), true)
|
|
end
|
|
|
|
-- ------------------------------------------------------ rematches
|
|
|
|
-- engine/phone/scripts/trainers.asm: the .WantsBattle branch of a caller
|
|
-- script sets EVENT_<NAME>_READY_FOR_REMATCH. The numbers are wEventFlags bit
|
|
-- indexes counted through constants/event_flags.asm.
|
|
do
|
|
local events = Events.new()
|
|
check("Joey has a rematch flag", Phone.rematchEvent(15), 628)
|
|
check("and Erin the last one", Phone.rematchEvent(36), 670)
|
|
check("Mom has none", Phone.rematchEvent(Phone.PHONECONTACT_MOM), nil)
|
|
check("nobody is waiting yet", Phone.isReadyForRematch(events, 15), false)
|
|
check("setting the flag works", Phone.setRematchReady(events, 15), true)
|
|
check("and Joey is waiting", Phone.isReadyForRematch(events, 15), true)
|
|
check("without dragging anyone else in",
|
|
Phone.isReadyForRematch(events, 16), false)
|
|
check("a contact with no flag cannot be set",
|
|
Phone.setRematchReady(events, Phone.PHONECONTACT_MOM), false)
|
|
end
|
|
|
|
-- ------------------------------------------------------ outgoing calls
|
|
|
|
-- MakePhoneCallFromPokegear. Three outcomes: the call, "just go talk to that
|
|
-- person" when they are on this map, and out of area.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15)
|
|
local call = Phone.call(save, 15, { map = ROUTE_31 })
|
|
check("calling Joey runs his callee script", call.script,
|
|
"JoeyPhoneCalleeScript")
|
|
check("as an outgoing call", call.direction, "outgoing")
|
|
check("standing on his map says go talk to him",
|
|
Phone.call(save, 15, { map = ROUTE_30 }).kind, "justtalk")
|
|
check("no signal is out of area",
|
|
Phone.call(save, 15, { map = NO_SERVICE }).kind, "outofarea")
|
|
check("and so is a link session",
|
|
Phone.call(save, 15, { map = ROUTE_31, linkMode = true }).kind, "outofarea")
|
|
end
|
|
|
|
-- The bike shop's SCRIPT1_TIME is 0: there is no hour at which you can call
|
|
-- them, only one at which they call you.
|
|
do
|
|
local save = newSave()
|
|
check("the bike shop cannot be called",
|
|
Phone.call(save, Phone.PHONECONTACT_BIKESHOP,
|
|
{ map = ROUTE_31, timeOfDay = "DAY" }).kind, "outofarea")
|
|
end
|
|
|
|
-- ------------------------------------------------------ the phone card
|
|
|
|
local function newInput()
|
|
local input = { pressed = {} }
|
|
function input:press(...)
|
|
for _, button in ipairs({ ... }) do self.pressed[button] = true end
|
|
end
|
|
function input:wasPressed(button)
|
|
if self.pressed[button] then
|
|
self.pressed[button] = nil
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
function input:isDown() return false end
|
|
return input
|
|
end
|
|
|
|
-- Just enough of data/generated/trainers.lua for GetCallerClassAndName: the
|
|
-- contact table stores class and member ids, and the names come off that
|
|
-- table. Stubbed rather than loaded so this suite stays ROM-free.
|
|
local TRAINERS = { classes = {
|
|
YOUNGSTER = { id = "YOUNGSTER", name = "YOUNGSTER", index = 24,
|
|
trainers = { { id = "JOEY1", name = "JOEY" } } },
|
|
BUG_CATCHER = { id = "BUG_CATCHER", name = "BUG CATCHER", index = 2,
|
|
trainers = { { id = "WADE1", name = "WADE" } } },
|
|
} }
|
|
|
|
-- A Pokegear parked on the PHONE card, inside it rather than on the strip.
|
|
local function newGear(save, opts)
|
|
opts = opts or {}
|
|
local input = newInput()
|
|
local game = {
|
|
input = input,
|
|
save = save,
|
|
data = { audio = {}, pokemon = {}, items = {} },
|
|
stack = { push = function() end, pop = function() end },
|
|
}
|
|
-- Pokegear:visibleCards keys off wPokegearFlags; without the PHONE bit the
|
|
-- card is not on the strip at all.
|
|
save.pokegearFlags = save.pokegearFlags or {}
|
|
save.pokegearFlags.phone = true
|
|
local gear = Pokegear.new(game, {
|
|
save = save,
|
|
mapDef = opts.mapDef or ROUTE_31,
|
|
clock = { hour = 9, minute = 0, weekday = 1 },
|
|
trainers = TRAINERS,
|
|
onCall = opts.onCall,
|
|
})
|
|
gear.mode = "card"
|
|
for index, card in ipairs(gear.cards) do
|
|
if card.id == "phone" then gear.cardIndex = index end
|
|
end
|
|
return gear, input
|
|
end
|
|
|
|
do
|
|
local save = newSave()
|
|
for _, id in ipairs({ 15, 16, 17, 18, 19, 20 }) do
|
|
Phone.addContact(save, id)
|
|
end
|
|
local gear, input = newGear(save)
|
|
check("the card opens on the phone", gear:card().id, "phone")
|
|
check("with the cursor at the top", gear.phoneCursor, 0)
|
|
-- PokegearPhone_GetDPad: the cursor walks the four visible rows first.
|
|
for _ = 1, 3 do
|
|
input:press("down")
|
|
gear:update(0)
|
|
end
|
|
check("down walks the cursor to the last visible row", gear.phoneCursor, 3)
|
|
check("without scrolling yet", gear.phoneScroll, 0)
|
|
input:press("down")
|
|
gear:update(0)
|
|
check("the next press scrolls instead", gear.phoneScroll, 1)
|
|
check("leaving the cursor where it is", gear.phoneCursor, 3)
|
|
check("on the fifth contact", gear:phoneSelection(), 19)
|
|
-- .scroll_page_down stops dead at CONTACT_LIST_SIZE - PHONE_DISPLAY_HEIGHT.
|
|
for _ = 1, 20 do
|
|
input:press("down")
|
|
gear:update(0)
|
|
end
|
|
check("the scroll stops at the end of the list", gear.phoneScroll, 6)
|
|
for _ = 1, 20 do
|
|
input:press("up")
|
|
gear:update(0)
|
|
end
|
|
check("and up unwinds it to the top", gear.phoneScroll, 0)
|
|
check("with the cursor back at row 0", gear.phoneCursor, 0)
|
|
end
|
|
|
|
-- `.a` returns straight back out on an empty slot: no submenu, no call.
|
|
do
|
|
local save = newSave()
|
|
local gear, input = newGear(save)
|
|
input:press("a")
|
|
gear:update(0)
|
|
check("an empty slot opens nothing", gear.phoneSubmenu, nil)
|
|
end
|
|
|
|
-- CheckCanDeletePhoneNumber picks which submenu opens.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, Phone.PHONECONTACT_MOM)
|
|
Phone.addContact(save, 15)
|
|
local gear, input = newGear(save)
|
|
input:press("a")
|
|
gear:update(0)
|
|
check("MOM gets the CALL/CANCEL menu", gear.phoneSubmenu, "callCancel")
|
|
input:press("b")
|
|
gear:update(0)
|
|
check("B closes it", gear.phoneSubmenu, nil)
|
|
check("without leaving the card", gear.mode, "card")
|
|
input:press("down")
|
|
gear:update(0)
|
|
input:press("a")
|
|
gear:update(0)
|
|
check("a trainer gets CALL/DELETE/CANCEL", gear.phoneSubmenu,
|
|
"callDeleteCancel")
|
|
end
|
|
|
|
-- The submenu's CALL entry runs MakePhoneCallFromPokegear and hands the
|
|
-- descriptor out to whoever can run its script.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15)
|
|
local seen
|
|
local gear, input = newGear(save, { onCall = function(c) seen = c end })
|
|
input:press("a")
|
|
gear:update(0)
|
|
input:press("a")
|
|
gear:update(0)
|
|
check("CALL places the call", gear.call ~= nil, true)
|
|
check("to Joey", gear.call and gear.call.contact, 15)
|
|
check("and hands the descriptor out", seen == gear.call, true)
|
|
check("naming his callee script", seen and seen.script,
|
|
"JoeyPhoneCalleeScript")
|
|
check("with his name for the textbox", seen and seen.name, "JOEY")
|
|
check("and his class under it", seen and seen.className, "YOUNGSTER")
|
|
-- Bank $41 is not extracted, so there is no line to run: the card shows what
|
|
-- the cart shows while a call connects.
|
|
check("the box shows the caller and the ellipsis",
|
|
gear.call and gear.call.text, "JOEY: ……")
|
|
input:press("b")
|
|
gear:update(0)
|
|
check("any button hangs up", gear.call, nil)
|
|
check("still inside the card", gear.mode, "card")
|
|
end
|
|
|
|
-- .no_service never reaches MakePhoneCallFromPokegear at all: it plays
|
|
-- SFX_NO_SIGNAL and prints its own out-of-service line.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15)
|
|
local gear, input = newGear(save, { mapDef = NO_SERVICE })
|
|
input:press("a")
|
|
gear:update(0)
|
|
input:press("a")
|
|
gear:update(0)
|
|
check("a dead zone refuses the call", gear.call and gear.call.kind,
|
|
"nosignal")
|
|
end
|
|
|
|
-- DELETE goes through the compacting delete, so the row below moves up.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15)
|
|
Phone.addContact(save, 16)
|
|
local gear, input = newGear(save)
|
|
input:press("a")
|
|
gear:update(0)
|
|
input:press("down")
|
|
gear:update(0)
|
|
check("the cursor is on DELETE", gear.phoneSubmenuCursor, 1)
|
|
input:press("a")
|
|
gear:update(0)
|
|
check("the submenu closes", gear.phoneSubmenu, nil)
|
|
check("Joey is gone", Phone.hasContact(save, 15), false)
|
|
check("and Wade has moved up", Phone.contacts(save)[1], 16)
|
|
end
|
|
|
|
-- The list draws every visible slot, empty ones included: GetCallerName maps
|
|
-- contact 0 to NonTrainerCallerNames' dashes.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15)
|
|
local gear = newGear(save)
|
|
local label, className = gear:contactRow(0)
|
|
check("an empty row still has a label", label, "----------:")
|
|
check("with no class under it", className, nil)
|
|
local name = gear:contactRow(Phone.PHONECONTACT_MOM)
|
|
check("MOM's row is her name and a colon", name, "MOM:")
|
|
end
|
|
|
|
-- The phone text lives in ROM bank $66, which is not extracted; the card
|
|
-- prefers the extracted string and falls back to the transcription.
|
|
do
|
|
local save = newSave()
|
|
local gear = newGear(save)
|
|
check("the fallback prompt is the cart's",
|
|
gear:phoneText("AskWhoCall"), "Whom do you want to call?")
|
|
gear.textData = { ["66:4089"] = "EXTRACTED" }
|
|
check("and extracted text wins when it arrives",
|
|
gear:phoneText("AskWhoCall"), "EXTRACTED")
|
|
end
|
|
|
|
-- ------------------------------------------------------ the save
|
|
|
|
-- Everything the phone persists rides one block on the Gold save, and
|
|
-- Save.normalize has to leave it alone.
|
|
do
|
|
local save = newSave()
|
|
Phone.addContact(save, 15)
|
|
Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_ROBBED)
|
|
Phone.initReceiveDelay(save, at(9, 0))
|
|
Save.normalize(save)
|
|
check("the phone block survives normalize", type(save.phone), "table")
|
|
check("with the contact list", save.phone.list[1], 15)
|
|
check("the queued special call", save.phone.specialCall, 2)
|
|
check("the cycle counter", save.phone.timeCycles, 0)
|
|
check("the countdown", save.phone.delayMins, 20)
|
|
check("and its start stamp", save.phone.delayStart.hour, 9)
|
|
check("and the legacy set is still mirrored", save.phoneContacts[15], true)
|
|
end
|
|
|
|
-- ------------------------------------------------------ against the cache
|
|
--
|
|
-- The tables in Phone.lua were transcribed from data/phone/*.asm and the
|
|
-- symbol file at a time when nothing pointed into ROM bank $41. The extractor
|
|
-- follows PhoneContacts and SpecialPhoneCallList now, so the two must agree --
|
|
-- and every script key they name has to be a real entry in scripts.lua, or a
|
|
-- call arrives with a body that cannot run and the step counter stalls.
|
|
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 eventsFile = loadfile(cacheDir .. "/data/generated/events.lua")
|
|
if not eventsFile then
|
|
check("cache absent or predates events.lua (SKIP)", true, true)
|
|
else
|
|
local events = eventsFile()
|
|
local scripts = assert(loadfile(
|
|
cacheDir .. "/data/generated/scripts.lua"))()
|
|
check("useExtracted takes the cache's rows", Phone.useExtracted(events), true)
|
|
for index = 0, Phone.NUM_PHONE_CONTACTS do
|
|
local row = Phone.CONTACTS[index]
|
|
local was = Phone.SCRIPT_KEYS[row.callee]
|
|
check(("contact %d callee key matches the transcription"):format(index),
|
|
row.calleeKey, was)
|
|
check(("contact %d caller key matches"):format(index),
|
|
row.callerKey, Phone.SCRIPT_KEYS[row.caller])
|
|
check(("contact %d callee script is in scripts.lua"):format(index),
|
|
scripts[row.calleeKey] ~= nil, true)
|
|
check(("contact %d caller script is in scripts.lua"):format(index),
|
|
scripts[row.callerKey] ~= nil, true)
|
|
end
|
|
-- The five Elm special calls all point at ElmPhoneCallerScript, which is
|
|
-- what lets one contact serve five different scripted beats; the bike shop
|
|
-- and Mom have their own.
|
|
for id, entry in pairs(Phone.SPECIAL_CALLS) do
|
|
check(("special call %d key matches the transcription"):format(id),
|
|
entry.scriptKey, Phone.SCRIPT_KEYS[entry.script])
|
|
check(("special call %d script is in scripts.lua"):format(id),
|
|
scripts[entry.scriptKey] ~= nil, true)
|
|
end
|
|
-- SPECIALCALL_ROBBED is Elm's "your POKeMON was stolen" beat, and its
|
|
-- script really is the shared caller script that branches on
|
|
-- VAR_SPECIALPHONECALL rather than a script of its own.
|
|
local robbed = Phone.SPECIAL_CALLS[Phone.SPECIALCALL.SPECIALCALL_ROBBED]
|
|
check("SPECIALCALL_ROBBED rings PROF.ELM",
|
|
robbed.contact, Phone.PHONECONTACT_ELM)
|
|
check("and its script reads VAR_SPECIALPHONECALL first",
|
|
scripts[robbed.scriptKey][1].op, "readvar")
|
|
-- The engine's own two scripts live in bank $24 and are reached without a
|
|
-- contact row; they are seeded by nothing but a map pointer, so this is
|
|
-- the check that says whether they came along.
|
|
check("PhoneOutOfAreaScript is reachable",
|
|
scripts[Phone.SCRIPT_KEYS.PhoneOutOfAreaScript] ~= nil, true)
|
|
-- The wiring around a landed call: the gate's descriptor drops straight
|
|
-- into Script_ReceivePhoneCall's rows (src/core/gen2/PhoneRing.lua,
|
|
-- driven end to end by tests/gen2_phone_call_test.lua) and the caller
|
|
-- script it wraps, resolved through the cache overlay just applied, is
|
|
-- live in this cache.
|
|
local PhoneRing = require("src.core.gen2.PhoneRing")
|
|
local armed = armedSave()
|
|
local landed = Phone.tryRandomCall(armed, {
|
|
map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 20 },
|
|
rng = rolls(0x00, 0x00),
|
|
})
|
|
check("the gate lands a call with the cache rows applied",
|
|
landed ~= nil, true)
|
|
local rows = PhoneRing.script(landed, "JOEY")
|
|
-- Two waitsfx/callasm ring passes now, RingTwice_StartCall's `call .Ring`
|
|
-- plus its fallthrough (engine/phone/phone.asm:458-469), so the caller
|
|
-- script sits four rows further down than it used to.
|
|
check("the wrapper rings before the caller script",
|
|
rows[3].label, "RingTwice_StartCall")
|
|
check("restarts the countdown after it",
|
|
rows[12].label, "InitCallReceiveDelay")
|
|
check("and the script it wraps is live in this cache",
|
|
scripts[rows[8].script] ~= nil, true)
|
|
end
|
|
end
|
|
|
|
print(("gen2 phone: %d checks, %d failures"):format(checks, failures))
|
|
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
|
|
-- here takes the whole tier down with it.
|
|
if failures > 0 then
|
|
error(("%d assertion(s) failed"):format(failures), 0)
|
|
end
|