Files
gen1recomp/tests/gen2_mail_test.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

904 lines
34 KiB
Lua

-- MAIL: the ten items, the `mailmsg` struct on a party slot, the PC's MAILBOX,
-- the compose keyboard's charset, and every refusal around a mon that is
-- carrying a letter.
--
-- ROM-free. The model (src/core/gen2/Mail.lua) is love-free and takes its
-- state by argument, so most of this is plain table assertions; the four
-- screens need the same love stub the rest of the Gold menu suites use, and
-- none of them draw here.
package.path = "./?.lua;" .. package.path
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,
setLineWidth = 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 }
-- No font is loaded here, so Font.encode would warn once per unknown glyph.
require("src.core.Logger").warn = function() end
local Boxes = require("src.core.gen2.Boxes")
local Breeding = require("src.core.gen2.Breeding")
local HeldItemMenu = require("src.ui.gen2.HeldItemMenu")
local Mail = require("src.core.gen2.Mail")
local MailCompose = require("src.ui.gen2.MailCompose")
local MailMenu = require("src.ui.gen2.MailMenu")
local MailRead = require("src.ui.gen2.MailRead")
local MailboxMenu = require("src.ui.gen2.MailboxMenu")
local PartyMenu = require("src.ui.gen2.PartyMenu")
local PcMenu = require("src.ui.gen2.PcMenu")
local Save = require("src.core.gen2.Save")
local Screens = require("src.ui.Screens")
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 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
local function newGame(save)
local input = newInput()
return {
input = input,
save = save,
data = { audio = {}, pokemon = {}, items = {} },
stack = { _items = {},
push = function(self, s) self._items[#self._items + 1] = s end,
pop = function(self) return table.remove(self._items) end,
top = function(self) return self._items[#self._items] end,
},
}, input
end
-- A save with a party of `n` conscious mons and nothing else going on.
local function newSave(n)
local save = Save.newGame({ playerName = "GOLD", trainerId = 1234 })
for i = 1, n or 1 do
save.party[i] = { species = "CYNDAQUIL", nickname = "MON" .. i, hp = 20,
maxHp = 20, level = 10 }
end
return save
end
-- ------------------------------------------------------------ ItemIsMail
-- data/items/mail_items.asm is a ten-entry list and ItemIsMail is a linear
-- search of it. The two that matter are LITEBLUEMAIL and PORTRAITMAIL: a name
-- test for "_MAIL" -- which is what the port used before this landed -- misses
-- both, and a pocket test misses all ten (ItemAttributes puts mail in the
-- ordinary ITEM pocket).
check("ten mail items", #Mail.ITEMS, 10)
for _, id in ipairs(Mail.ITEMS) do
check(id .. " is mail", Mail.isMail(id), true)
end
check("LITEBLUEMAIL is mail", Mail.isMail("LITEBLUEMAIL"), true)
check("PORTRAITMAIL is mail", Mail.isMail("PORTRAITMAIL"), true)
check("POTION is not", Mail.isMail("POTION"), false)
check("nil is not", Mail.isMail(nil), false)
-- A name that merely ends the right way is still not on the list.
check("a made-up _MAIL id is not", Mail.isMail("FAKE_MAIL"), false)
-- The *_MAIL_INDEX block is `const_def`, so 0-based and in the item order.
check("FLOWER_MAIL is index 0", Mail.INDEX.FLOWER_MAIL, 0)
check("MIRAGE_MAIL is index 9", Mail.INDEX.MIRAGE_MAIL, 9)
-- ------------------------------------------------------------- the struct
do
local save = newSave(2)
save.party[1].item = "FLOWER_MAIL"
Mail.compose(save, 1, "HELLO", save.party[1], "FLOWER_MAIL")
local entry = Mail.get(save, 1)
check("compose stores the message", entry.message, "HELLO")
check("with the player as author", entry.author, "GOLD")
check("and the player's ID", entry.authorId, 1234)
check("and the mon's species", entry.species, "CYNDAQUIL")
check("and the stationery", entry.type, "FLOWER_MAIL")
-- The buffer is MAIL_MSG_LENGTH bytes; anything past it never existed.
local long = ("X"):rep(64)
Mail.compose(save, 2, long, save.party[2], "SURF_MAIL")
check("a message is trimmed to the buffer",
#Mail.get(save, 2).message, Mail.MAIL_MSG_LENGTH)
-- A slot outside sPartyMail's six structs is not a slot.
check("slot 7 is refused", Mail.set(save, 7, Mail.entry("SURF_MAIL")), false)
check("slot 0 is refused", Mail.set(save, 0, Mail.entry("SURF_MAIL")), false)
end
-- Mail.lines is MailGFX_PlaceMessage's two rows: a composed message has no
-- break in it and splits by width, a script's message carries its own.
do
local wide = Mail.entry("FLOWER_MAIL", ("A"):rep(20))
local top, bottom = Mail.lines(wide)
check("a long message splits at MAIL_LINE_LENGTH", #top, 16)
check("and the rest is the second row", #bottom, 4)
local scripted = Mail.entry("FLOWER_MAIL", "DARK CAVE leads\nto another road")
local a, b = Mail.lines(scripted)
check("an explicit break wins", a, "DARK CAVE leads")
check("and gives the second row", b, "to another road")
local short = Mail.entry("FLOWER_MAIL", "HI")
local s1, s2 = Mail.lines(short)
check("a short message is one row", s1, "HI")
check("with nothing under it", s2, "")
end
-- ---------------------------------------------------------- GivePokeMail
-- `ld a, [wPartyCount] / dec a`: the letter always lands on the LAST party
-- member, which is the mon the `givepoke` before it just added.
do
local save = newSave(3)
save.party[3].otName = "RANDY"
save.party[3].otId = 518
check("give lands on the last slot",
Mail.give(save, "FLOWER_MAIL", "DARK CAVE leads\nto another road"), true)
check("and hangs the item on that mon", save.party[3].item, "FLOWER_MAIL")
check("with nothing on the first", save.party[1].item, nil)
local entry = Mail.get(save, 3)
check("the author is the mon's OT", entry.author, "RANDY")
check("and the OT's ID", entry.authorId, 518)
check("an empty party takes nothing",
Mail.give(newSave(0), "FLOWER_MAIL", "HI"), false)
check("and a non-mail item takes nothing",
Mail.give(newSave(1), "POTION", "HI"), false)
end
-- ------------------------------------------------------- the slot shifting
-- sPartyMail is keyed by SLOT, so RemoveMonFromPartyOrBox shifts every struct
-- behind the departing mon up one ("Mail time!").
do
local save = newSave(3)
Mail.set(save, 2, Mail.entry("SURF_MAIL", "TWO"))
Mail.set(save, 3, Mail.entry("EON_MAIL", "THREE"))
Mail.removeSlot(save, 1)
check("the second letter moved to slot 1", Mail.get(save, 1).message, "TWO")
check("the third to slot 2", Mail.get(save, 2).message, "THREE")
check("and the old last slot is empty", Mail.get(save, 3), nil)
-- SwitchPartyMons swaps the two structs through wSwitchMonBuffer.
Mail.swapSlots(save, 1, 2)
check("a swap carries the letters", Mail.get(save, 1).message, "THREE")
check("both ways", Mail.get(save, 2).message, "TWO")
end
-- IsAnyMonHoldingMail is a whole-party question, which is what makes MOVE
-- POKéMON W/O MAIL an all-or-nothing refusal.
do
local save = newSave(3)
check("a clean party holds no mail", Mail.anyMonHoldingMail(save), false)
save.party[3].item = "LITEBLUEMAIL"
check("one letter anywhere is enough", Mail.anyMonHoldingMail(save), true)
save.party[3].item = "POTION"
check("an ordinary held item is not", Mail.anyMonHoldingMail(save), false)
end
-- ----------------------------------------------------------- the MAILBOX
do
local save = newSave(2)
save.party[1].item = "FLOWER_MAIL"
Mail.compose(save, 1, "HELLO", save.party[1], "FLOWER_MAIL")
check("the MAILBOX starts empty", Mail.mailboxCount(save), 0)
check("SendMailToPC moves the letter", Mail.sendToPc(save, 1), true)
check("the MAILBOX has it", Mail.mailboxCount(save), 1)
check("the party slot is cleared", Mail.get(save, 1), nil)
-- SendMailToPC writes the mon's held item byte to 0 in the same routine.
check("and the mon is no longer holding it", save.party[1].item, nil)
check("a mon with no mail sends nothing", Mail.sendToPc(save, 2), false)
-- MAILBOX_CAPACITY is 10 and .full is carry, which MonMailAction prints
-- _MailboxFullText for.
for i = 2, Mail.MAILBOX_CAPACITY do
Mail.mailbox(save)[i] = Mail.entry("SURF_MAIL", "N" .. i)
end
check("the MAILBOX fills at ten", Mail.mailboxFull(save), true)
save.party[2].item = "EON_MAIL"
Mail.compose(save, 2, "LATE", save.party[2], "EON_MAIL")
check("and refuses an eleventh", Mail.sendToPc(save, 2), false)
check("leaving the letter on the mon", Mail.get(save, 2).message, "LATE")
-- DeleteMailFromPC keeps sMailboxes dense.
local removed = Mail.deleteFromPc(save, 1)
check("delete answers the letter", removed.message, "HELLO")
check("and closes the list up", Mail.mailboxCount(save), 9)
check("with the next one first", Mail.mailbox(save)[1].message, "N2")
check("deleting nothing answers nil", Mail.deleteFromPc(save, 99), nil)
end
-- MoveMailFromPCToParty: the struct moves AND the mail's type byte becomes the
-- mon's held item, which is how the letter and its stationery stay together.
do
local save = newSave(2)
Mail.mailbox(save)[1] = Mail.entry("MUSIC_MAIL", "TUNE", "AMY", 7, "NATU")
check("attach moves it", Mail.moveFromPcToParty(save, 1, 2), true)
check("the mon holds the stationery", save.party[2].item, "MUSIC_MAIL")
check("and carries the letter", Mail.get(save, 2).message, "TUNE")
check("the MAILBOX is empty again", Mail.mailboxCount(save), 0)
check("attaching from an empty box fails",
Mail.moveFromPcToParty(save, 1, 1), false)
end
-- --------------------------------------------------------- CheckPokeMail
-- The order of these five is the cart's, and it is load bearing: the one call
-- site (Route31MailRecipientScript) has an `ifequal` for every value.
do
local EXPECT = "DARK CAVE leads\nto another road"
-- REFUSED: the B press, which never looks at a mon at all.
check("no slot is REFUSED",
Mail.checkPokeMail(newSave(2), nil, EXPECT), Mail.POKEMAIL_REFUSED)
-- NO_MAIL beats WRONG_MAIL: ItemIsMail is asked before the bytes are.
local plain = newSave(2)
check("a mon with no mail is NO_MAIL",
Mail.checkPokeMail(plain, 1, EXPECT), Mail.POKEMAIL_NO_MAIL)
local wrong = newSave(2)
wrong.party[1].item = "FLOWER_MAIL"
Mail.compose(wrong, 1, "SOMETHING ELSE", wrong.party[1], "FLOWER_MAIL")
check("the wrong letter is WRONG_MAIL",
Mail.checkPokeMail(wrong, 1, EXPECT), Mail.POKEMAIL_WRONG_MAIL)
-- LAST_MON is checked AFTER the message compares equal, so the right mon
-- with the right letter still loses you the reward if it is all you have.
local last = newSave(1)
last.party[1].item = "FLOWER_MAIL"
Mail.give(last, "FLOWER_MAIL", EXPECT)
check("the last conscious mon is LAST_MON",
Mail.checkPokeMail(last, 1, EXPECT), Mail.POKEMAIL_LAST_MON)
check("and it is still in the party", #last.party, 1)
local ok = newSave(2)
Mail.give(ok, "FLOWER_MAIL", EXPECT)
check("the right letter is CORRECT",
Mail.checkPokeMail(ok, 2, EXPECT), Mail.POKEMAIL_CORRECT)
check("and the mon is gone", #ok.party, 1)
-- A stored message LONGER than the expected one still matches: the compare
-- runs until the expected string's own '@'.
local longer = newSave(2)
Mail.give(longer, "FLOWER_MAIL", EXPECT .. "!")
check("a longer stored message still matches",
Mail.checkPokeMail(longer, 2, EXPECT), Mail.POKEMAIL_CORRECT)
-- No expected message resolved (a cache built before the extractor followed
-- the operand): the answer that changes nothing.
local unresolved = newSave(2)
Mail.give(unresolved, "FLOWER_MAIL", EXPECT)
check("an unresolved expectation is WRONG_MAIL",
Mail.checkPokeMail(unresolved, 2, nil), Mail.POKEMAIL_WRONG_MAIL)
check("and keeps the mon", #unresolved.party, 2)
-- CORRECT removes the mon, so the mail shift has to ride along with it.
local shift = newSave(3)
Mail.set(shift, 3, Mail.entry("SURF_MAIL", "BEHIND"))
shift.party[1].item = "FLOWER_MAIL"
Mail.compose(shift, 1, EXPECT, shift.party[1], "FLOWER_MAIL")
check("handing over slot 1 is CORRECT",
Mail.checkPokeMail(shift, 1, EXPECT), Mail.POKEMAIL_CORRECT)
check("and the letter behind it moved up",
Mail.get(shift, 2).message, "BEHIND")
end
-- ------------------------------------------------------------- refusals
-- BillsPC_CheckMon's .HasMail arm: DEPOSIT refuses, after the last-healthy
-- rule and before anything moves.
do
local save = newSave(3)
save.party[1].item = "PORTRAITMAIL"
local ok, reason = Boxes.canDeposit(save, 1, 1)
check("a mail holder cannot be deposited", ok, false)
check("with PCString_RemoveMail", reason, "Remove MAIL.")
check("deposit really refuses", (Boxes.deposit(save, 1, 1)), false)
check("and the party is untouched", #save.party, 3)
-- Depositing anything else still shifts the letters behind it.
local shift = newSave(3)
Mail.set(shift, 3, Mail.entry("SURF_MAIL", "THIRD"))
check("an ordinary deposit works", (Boxes.deposit(shift, 2, 1)), true)
check("and the letter moved up with its mon",
Mail.get(shift, 2).message, "THIRD")
end
-- SelectTradeOrDayCareMon's ItemIsMail arm, which used to guess from the id's
-- spelling and so let LITEBLUEMAIL straight past.
do
local save = newSave(3)
save.party[1].item = "LITEBLUEMAIL"
check("the Day-Care sees the mail",
Breeding.holdsMail(nil, save.party[1]), true)
local ok, reason = Breeding.canDeposit({}, save, "man", 1)
check("and refuses the deposit", ok, false)
check("with REFUSE_MAIL", reason, Breeding.REFUSE_MAIL)
end
-- ------------------------------------------------------------ save format
-- MAIL is the 3 -> 4 step. The file has moved past 4 since (format 5 is the
-- world state), so what this suite ratchets is that the mail step is still
-- there and that a format-3 save still comes all the way up; the current
-- number itself is gen2_save_test's business.
check("mail is the 3 -> 4 step", type(Save.MIGRATIONS[3]), "function")
check("and the format has not gone backwards", Save.FORMAT >= 4, true)
for from = 1, Save.FORMAT - 1 do
check("a migration exists for format " .. from,
type(Save.MIGRATIONS[from]), "function")
end
do
local old = Save.normalize(Save.migrate({ format = 3, party = {} }))
check("a format-3 save reaches the current format", old.format, Save.FORMAT)
check("and gains the party half", type(old.mail.party), "table")
check("and the MAILBOX half", type(old.mail.box), "table")
check("both empty", next(old.mail.party), nil)
end
-- The quarantine pass. Every entry below is a region the cart could not have
-- written, so it is dropped and reported rather than handed to a screen.
do
local dirty = Save.newGame({})
dirty.mail = {
party = {
[1] = Mail.entry("FLOWER_MAIL", "FINE"),
[7] = Mail.entry("SURF_MAIL", "OUT OF RANGE"),
[2] = Mail.entry("POTION", "NOT MAIL"),
[3] = { type = "EON_MAIL", message = ("Z"):rep(40) },
[4] = "not a struct",
},
box = {
Mail.entry("MUSIC_MAIL", "KEPT"),
{ type = "MASTER_BALL", message = "NOT MAIL" },
},
}
local report = Save.validate(dirty)
check("the good letter survives", Mail.get(dirty, 1).message, "FINE")
check("the out-of-range slot is gone", Mail.get(dirty, 7), nil)
check("the non-mail stationery is gone", Mail.get(dirty, 2), nil)
check("the non-struct is gone", Mail.get(dirty, 4), nil)
check("the over-long message is trimmed",
#Mail.get(dirty, 3).message, Mail.MAIL_MSG_LENGTH)
check("the good MAILBOX entry survives", Mail.mailbox(dirty)[1].message,
"KEPT")
check("the bad one is gone", Mail.mailbox(dirty)[2], nil)
-- four drops: the slot, the item, the struct, and the MAILBOX row -- plus
-- the trim, which is reported without losing the letter.
check("five entries reported", #report.lostMail, 5)
check("and the report is not empty", Save.emptyReport(report), false)
end
check("a fresh save reports nothing",
Save.emptyReport(Save.validate(Save.newGame({}))), true)
-- A `mail` field that is not even a table is replaced wholesale.
do
local wrecked = Save.newGame({})
wrecked.mail = 7
local report = Save.validate(wrecked)
check("a non-table store is quarantined", #report.lostMail, 1)
check("and replaced", type(wrecked.mail), "table")
end
-- -------------------------------------------------------------- screen ids
-- Every mail screen is reached through src/ui/Screens.lua, so a mod can
-- replace one.
Screens.invalidate()
for _, pair in ipairs({
{ "Gen2MailCompose", MailCompose },
{ "Gen2MailRead", MailRead },
{ "Gen2MailMenu", MailMenu },
{ "Gen2MailboxMenu", MailboxMenu },
{ "Gen2HeldItemMenu", HeldItemMenu },
}) do
check(pair[1] .. " resolves to its builtin",
Screens.get({ data = {} }, pair[1]), pair[2])
end
-- ----------------------------------------------------------- the keyboard
-- data/text/mail_input_chars.asm: ten columns, six rows, and the fifth row of
-- each case is the one that is NOT a plain letter grid.
do
local save = newSave(1)
local game, input = newGame(save)
local composed = nil
local screen = MailCompose.new(game, {
onDone = function(text) composed = text end,
})
check("the upper grid has five letter rows", #MailCompose.MAIL_INPUT_UPPER, 5)
check("ten columns", #MailCompose.MAIL_INPUT_UPPER[1], 10)
check("row 1 starts at A", MailCompose.MAIL_INPUT_UPPER[1][1], "A")
check("and ends at J", MailCompose.MAIL_INPUT_UPPER[1][10], "J")
check("row 4 is the digits", MailCompose.MAIL_INPUT_UPPER[4][10], "0")
check("row 5 opens on <PK>", MailCompose.MAIL_INPUT_UPPER[5][1], "<PK>")
check("and the lower row 4 on the apostrophe pairs",
MailCompose.MAIL_INPUT_LOWER[4][1], "'d")
-- The cursor starts on A.
check("the cursor starts on A", screen:cursorCharacter(), "A")
input:press("a")
screen:update()
check("A types it", screen.text, "A")
-- .right wraps at column 9, not 8: the mail grid is one wider than the
-- naming screen's.
screen.col, screen.row = 9, 0
screen:moveHorizontal(1)
check("right wraps from column 9", screen.col, 0)
screen:moveHorizontal(-1)
check("and left wraps back to 9", screen.col, 9)
-- SELECT is the case switch anywhere.
input:press("select")
screen:update()
check("select switches case", screen.lower, true)
screen.col, screen.row = 0, 0
check("the lower grid is under it", screen:cursorCharacter(), "a")
-- START parks the cursor on END; A there finishes.
input:press("start")
screen:update()
check("start lands on the strip", screen.row, 5)
check("over END", screen:cursorCharacter(), "END")
input:press("a")
screen:update()
check("END hands the message back", composed, "A")
-- The three fat targets split at 0-2 / 3-5 / 6-9.
screen.row, screen.col = 5, 0
check("column 0 is the case switch", screen:cursorCharacter(), "CASE")
screen.col = 3
check("column 3 is DEL", screen:cursorCharacter(), "DEL")
screen.col = 6
check("column 6 is END", screen:cursorCharacter(), "END")
-- and right from END wraps to the case switch rather than stepping.
screen:moveHorizontal(1)
check("right from END wraps", screen:cursorCharacter(), "CASE")
-- B deletes rather than cancelling: there is no way out but END.
screen.text = "AB"
input:press("b")
screen:update()
check("B deletes a character", screen.text, "A")
-- The buffer is 32 characters and the last one does NOT end entry the way a
-- nickname's does.
screen.text = ("Q"):rep(Mail.MAIL_MSG_LENGTH)
screen.row, screen.col = 0, 0
composed = nil
input:press("a")
screen:update()
check("a full message takes nothing more",
#screen.text, Mail.MAIL_MSG_LENGTH)
check("and does not finish on its own", composed, nil)
end
-- ------------------------------------------------------------ MonMailAction
-- The MAIL row only appears when ItemIsMail says so.
do
local save = newSave(1)
local game = newGame(save)
local list = PartyMenu.new(game, { save = save })
local rows = list:submenuItems(save.party[1])
local labels = {}
for _, row in ipairs(rows) do labels[row.id] = true end
check("a mon with nothing shows ITEM", labels.ITEM, true)
check("and no MAIL row", labels.MAIL, nil)
save.party[1].item = "PORTRAITMAIL"
local mailRows = list:submenuItems(save.party[1])
local mailLabels = {}
for _, row in ipairs(mailRows) do mailLabels[row.id] = true end
check("a mail holder shows MAIL", mailLabels.MAIL, true)
check("and no ITEM row", mailLabels.ITEM, nil)
end
-- TAKE, both endings.
do
local save = newSave(2)
save.party[1].item = "FLOWER_MAIL"
Mail.compose(save, 1, "KEEPSAKE", save.party[1], "FLOWER_MAIL")
local game, input = newGame(save)
local closed = false
local menu = MailMenu.new(game, {
save = save, slot = 1, onClose = function() closed = true end,
})
check("the menu is READ / TAKE / QUIT", #MailMenu.ENTRIES, 3)
-- Down to TAKE, then A: the first question is the PC one.
input:press("down")
menu:update()
check("the cursor is on TAKE", MailMenu.ENTRIES[menu.index].id, "take")
input:press("a")
menu:update()
check("TAKE asks about the PC first", menu.confirm ~= nil, true)
-- YES: SendMailToPC.
input:press("a")
menu:update()
check("the letter went to the PC", Mail.mailboxCount(save), 1)
check("and off the mon", save.party[1].item, nil)
check("with a line to read", menu.message ~= nil, true)
input:press("a")
menu:update()
check("which closes the menu", closed, true)
end
do
-- Saying no to the PC drops into "the MAIL will lose its message".
local save = newSave(2)
save.party[1].item = "SURF_MAIL"
Mail.compose(save, 1, "BYE", save.party[1], "SURF_MAIL")
local game, input = newGame(save)
local menu = MailMenu.new(game, { save = save, slot = 1, onClose = function() end })
menu.index = 2
input:press("a")
menu:update()
-- NO to the PC question.
input:press("down")
menu:update()
input:press("a")
menu:update()
check("saying no asks the destructive question", menu.confirm ~= nil, true)
-- YES to losing the message.
input:press("a")
menu:update()
check("the stationery is in the bag", save.inventory.SURF_MAIL, 1)
check("the mon is empty handed", save.party[1].item, nil)
check("and the letter is gone", Mail.get(save, 1), nil)
end
do
-- A full bag is _MailNoSpaceText, and the letter survives it.
local save = newSave(2)
save.party[1].item = "EON_MAIL"
Mail.compose(save, 1, "STAYS", save.party[1], "EON_MAIL")
for i = 1, 20 do save.inventory["FILLER_" .. i] = 1 end
local game, input = newGame(save)
local menu = MailMenu.new(game, { save = save, slot = 1, onClose = function() end })
menu.index = 2
input:press("a")
menu:update()
input:press("down")
menu:update()
input:press("a")
menu:update()
input:press("a")
menu:update()
check("a full PACK keeps the letter", Mail.get(save, 1).message, "STAYS")
check("and the mon keeps holding it", save.party[1].item, "EON_MAIL")
end
-- ------------------------------------------------------------- MailboxPC
-- InitMail's z branch: an empty MAILBOX never opens a menu.
do
local save = newSave(1)
local game, input = newGame(save)
local closed = false
local box = MailboxMenu.new(game, {
save = save, onClose = function() closed = true end,
})
check("an empty MAILBOX opens on a line", box.message ~= nil, true)
input:press("a")
box:update()
check("and closes", closed, true)
end
do
local save = newSave(2)
Mail.mailbox(save)[1] = Mail.entry("FLOWER_MAIL", "ONE", "AMY", 1, "ODDISH")
Mail.mailbox(save)[2] = Mail.entry("SURF_MAIL", "TWO", "BEN", 2, "LAPRAS")
local game, input = newGame(save)
local box = MailboxMenu.new(game, { save = save, onClose = function() end })
check("the list opens on the first letter", box.index, 1)
check("with four submenu rows", #MailboxMenu.SUB_ENTRIES, 4)
input:press("a")
box:update()
check("A opens the submenu", box.submenu ~= nil, true)
check("on READ MAIL", MailboxMenu.SUB_ENTRIES[box.submenu.index].id, "read")
-- PUT IN PACK: the question, then the stationery.
box.submenu.index = 2
input:press("a")
box:update()
check("PUT IN PACK asks first", box.confirm ~= nil, true)
input:press("a")
box:update()
check("the stationery is in the bag", save.inventory.FLOWER_MAIL, 1)
check("and the letter is gone", Mail.mailboxCount(save), 1)
check("with the next one first", Mail.mailbox(save)[1].message, "TWO")
end
do
-- ATTACH MAIL refuses a mon that is already holding something, and comes
-- straight back to the party list rather than backing out.
local save = newSave(2)
save.party[1].item = "POTION"
Mail.mailbox(save)[1] = Mail.entry("LOVELY_MAIL", "XOXO", "AMY", 1)
local game, input = newGame(save)
local box = MailboxMenu.new(game, { save = save, onClose = function() end })
box.submenu = { index = 3 }
input:press("a")
box:update()
local list = game.stack:top()
check("the party list is up", list ~= nil, true)
list.onChoose(1, save.party[1])
check("a mon holding an item is refused", box.message ~= nil, true)
check("and the letter is still in the MAILBOX", Mail.mailboxCount(save), 1)
-- An empty-handed mon takes it.
input:press("a")
box:update()
local retry = game.stack:top()
retry.onChoose(2, save.party[2])
check("an empty-handed mon takes it", save.party[2].item, "LOVELY_MAIL")
check("with the letter", Mail.get(save, 2).message, "XOXO")
check("out of the MAILBOX", Mail.mailboxCount(save), 0)
end
-- ------------------------------------------------------------ the PC menu
-- MAIL BOX is on both .WhichPC lists, so it is on the storage menu this port
-- folds them into whichever PC opened it.
do
local ids = {}
for _, entry in ipairs(PcMenu.ENTRIES) do ids[entry.id] = true end
check("the PC has a MAIL BOX row", ids.mailbox, true)
end
-- BillsPC_MovePKMNMenu asks IsAnyMonHoldingMail before it opens anything.
do
local save = newSave(2)
save.party[2].item = "MORPH_MAIL"
local game, input = newGame(save)
local pc = PcMenu.new(game, { save = save, onClose = function() end })
for i, entry in ipairs(pc.entries) do
if entry.id == "move" then pc.index = i end
end
pc:choose()
check("MOVE refuses while a letter is in the party", pc.message ~= nil, true)
check("without opening a list", #game.stack._items, 0)
-- The refusal is two pages and neither of them logs off.
input:press("a")
pc:update()
check("the second page follows", pc.message ~= nil, true)
input:press("a")
pc:update()
check("and then the menu is back", pc.message, nil)
-- With the letter gone the row opens the list as usual.
save.party[2].item = nil
pc:choose()
check("MOVE opens the list once the mail is off",
#game.stack._items, 1)
end
-- ------------------------------------------------- GiveTakePartyMonItem
--
-- The ITEM row is what makes MAIL reachable at all: GivePartyItem ->
-- ComposeMailMessage is the compose keyboard's only door on the cart.
local ITEMS = {
FLOWER_MAIL = { id = "FLOWER_MAIL", name = "FLOWER MAIL", pocket = "ITEM" },
POTION = { id = "POTION", name = "POTION", pocket = "ITEM" },
BERRY = { id = "BERRY", name = "BERRY", pocket = "ITEM" },
BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM" },
}
do
-- Giving MAIL to an empty-handed mon opens the keyboard, and END writes the
-- struct on that mon's party slot.
local save = newSave(2)
save.inventory.FLOWER_MAIL = 1
local game, input = newGame(save)
game.data.items = ITEMS
local closed = false
local menu = HeldItemMenu.new(game, {
save = save, slot = 1, items = ITEMS,
onClose = function() closed = true end,
})
check("the menu is GIVE / TAKE", #HeldItemMenu.ENTRIES, 2)
input:press("a")
menu:update()
local pack = game.stack:top()
check("GIVE opens the PACK", pack ~= nil, true)
check("as a chooser", pack.give, true)
pack.onChoose("FLOWER_MAIL")
check("the mon is holding it", save.party[1].item, "FLOWER_MAIL")
check("and it left the bag", save.inventory.FLOWER_MAIL, nil)
check("with a line to read", menu.message ~= nil, true)
input:press("a")
menu:update()
local compose = game.stack:top()
check("the compose keyboard is up", compose ~= nil, true)
compose.onDone("SEE YOU SOON")
check("the letter is on the slot", Mail.get(save, 1).message, "SEE YOU SOON")
check("with the player as author", Mail.get(save, 1).author, "GOLD")
check("and the menu closed", closed, true)
end
do
-- A mon already holding MAIL is refused BEFORE the swap question, so a
-- letter cannot be knocked off by accident.
local save = newSave(1)
save.party[1].item = "FLOWER_MAIL"
Mail.compose(save, 1, "SAFE", save.party[1], "FLOWER_MAIL")
save.inventory.POTION = 1
local game = newGame(save)
game.data.items = ITEMS
local menu = HeldItemMenu.new(game, {
save = save, slot = 1, items = ITEMS, onClose = function() end,
})
menu:giveItem("POTION")
check("a mail holder refuses a new item", menu.message ~= nil, true)
check("and keeps the letter", Mail.get(save, 1).message, "SAFE")
check("and the stationery", save.party[1].item, "FLOWER_MAIL")
check("with the POTION still in the bag", save.inventory.POTION, 1)
end
do
-- .next: a KEY_ITEM cannot be held, and the PACK comes back rather than the
-- menu closing.
local save = newSave(1)
save.inventory.BICYCLE = 1
local game, input = newGame(save)
game.data.items = ITEMS
local closed = false
local menu = HeldItemMenu.new(game, {
save = save, slot = 1, items = ITEMS,
onClose = function() closed = true end,
})
menu:giveItem("BICYCLE")
check("a key item cannot be held", menu.message ~= nil, true)
check("and the mon holds nothing", save.party[1].item, nil)
input:press("a")
menu:update()
check("the PACK reopens", game.stack:top() ~= nil, true)
check("rather than the menu closing", closed, false)
end
do
-- TAKE puts the item back in the bag.
local save = newSave(1)
save.party[1].item = "BERRY"
local game, input = newGame(save)
game.data.items = ITEMS
local menu = HeldItemMenu.new(game, {
save = save, slot = 1, items = ITEMS, onClose = function() end,
})
input:press("down")
menu:update()
check("the cursor is on TAKE", HeldItemMenu.ENTRIES[menu.index].id, "take")
input:press("a")
menu:update()
check("the item is back in the bag", save.inventory.BERRY, 1)
check("and off the mon", save.party[1].item, nil)
-- An empty-handed mon is _PokemonNotHoldingText and nothing moves.
local bare = newSave(1)
local bareGame = newGame(bare)
bareGame.data.items = ITEMS
local bareMenu = HeldItemMenu.new(bareGame, {
save = bare, slot = 1, items = ITEMS, onClose = function() end,
})
bareMenu:takeItem()
check("an empty-handed mon says so", bareMenu.message ~= nil, true)
check("and gains nothing", next(bare.inventory), nil)
end
do
-- The swap question, and the bag-full arm that puts the old item straight
-- back rather than destroying either one.
local save = newSave(1)
save.party[1].item = "BERRY"
-- Two POTIONs so the slot survives GiveItemToPokemon's own remove; with one
-- the freed slot is exactly the room the BERRY needs and the swap succeeds,
-- which is the cart's behaviour too.
save.inventory.POTION = 2
for i = 1, 19 do save.inventory["FILLER_" .. i] = 1 end
local game, input = newGame(save)
game.data.items = ITEMS
local menu = HeldItemMenu.new(game, {
save = save, slot = 1, items = ITEMS, onClose = function() end,
})
menu:giveItem("POTION")
check("a held item asks about the swap", menu.confirm ~= nil, true)
input:press("a")
menu:update()
input:press("a")
menu:update()
check("a full bag keeps the old item on the mon", save.party[1].item, "BERRY")
check("and puts the new one back", save.inventory.POTION, 2)
check("with the storage-full line up", menu.message ~= nil, true)
end
do
-- ...and with room, the swap goes through both ways.
local save = newSave(1)
save.party[1].item = "BERRY"
save.inventory.POTION = 1
local game, input = newGame(save)
game.data.items = ITEMS
local menu = HeldItemMenu.new(game, {
save = save, slot = 1, items = ITEMS, onClose = function() end,
})
menu:giveItem("POTION")
-- _PokemonAskSwapItemText has a `para` in it, so the box turns a page before
-- the YES/NO comes up.
input:press("a")
menu:update()
input:press("a")
menu:update()
check("the mon holds the new item", save.party[1].item, "POTION")
check("and the old one is in the bag", save.inventory.BERRY, 1)
check("with the new one gone from it", save.inventory.POTION, nil)
end
-- -------------------------------------------------------------- MailRead
-- MailGFX_PlaceMessage's three author columns, picked by the stationery.
do
local game = newGame(newSave(1))
local read = MailRead.new(game, {
entry = Mail.entry("PORTRAITMAIL", "HI", "AMY", 1),
})
check("PORTRAITMAIL puts the author at 8", read:authorColumn(), 8)
read.entry = Mail.entry("MORPH_MAIL", "HI", "AMY", 1)
check("MORPH_MAIL at 6", read:authorColumn(), 6)
read.entry = Mail.entry("FLOWER_MAIL", "HI", "AMY", 1)
check("everything else at 5", read:authorColumn(), 5)
end
print(("gen2 mail: %d checks, %d failures"):format(checks, failures))
if failures > 0 then os.exit(1) end