Files
gen1recomp/tests/game3_link_battle_test.lua
2026-09-25 05:46:24 -04:00

1150 lines
46 KiB
Lua

#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
local COUNTER_MAP = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F"
local COLOSSEUM_MAP = "FR_BATTLE_COLOSSEUM_2P"
local UNION_MAP = "FR_UNION_ROOM"
local MAPS = {
[COUNTER_MAP] = {
warps = { { x = 9, y = 1, destMap = COLOSSEUM_MAP, destWarp = 1 } },
},
[COLOSSEUM_MAP] = {
warps = { { x = 6, y = 8, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } },
},
[UNION_MAP] = {
warps = { { x = 7, y = 11, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } },
},
}
local function legal(mon)
local okP, Pokemon = pcall(require, "src.core.game3.pokemon")
local okS, SummaryData = pcall(require, "src.core.game3.summary_data")
if mon.exp == nil and okP and okS then
local okE, exp = pcall(function()
return SummaryData.expForLevel(Pokemon.growthRate(mon.species), mon.level)
end)
if okE then mon.exp = exp end
end
mon.otName = mon.otName or "RED"
mon.otId = mon.otId or 0x1234
mon.personality = mon.personality or 0
return mon
end
local function charizard()
return legal({ species = 6, level = 60, hp = 200, maxHp = 200,
moves = { 53 }, pp = { 15 }, maxPp = { 15 } })
end
local function blastoise()
return legal({ species = 9, level = 60, hp = 190, maxHp = 190,
moves = { 57 }, pp = { 15 }, maxPp = { 15 } })
end
local function packed(list)
local Protocol = require("src.link.Protocol")
local out = {}
for i, mon in ipairs(list) do out[i] = Protocol.packMon3(legal(mon)) end
return out
end
local store = { flags = {}, vars = {} }
local session = {
store = store,
map = COLOSSEUM_MAP,
x = 6,
y = 8,
name = "RED",
gender = 0,
trainerId = 0x1234,
party = { charizard() },
bag = { pockets = { items = { [4] = 3 } } },
}
local game = { data = { maps = MAPS }, session = session }
package.loaded["src.core.game3.runtime"] = {
getSession = function() return session end,
isActive = function() return true end,
_game = game,
_mod = nil,
}
package.loaded["src.core.game3.player"] = { cellX = 6, cellY = 8, facing = "up" }
package.loaded["src.core.game3.map"] = { load = function() end, current = COLOSSEUM_MAP }
package.loaded["src.core.game3.objects"] = {
addObject = function() return true end,
removeObject = function() return true end,
refreshGraphics = function() return 0 end,
}
local ctx = { specialVars = {}, stringVars = {} }
local adapters = { log = function() end, playSe = function() end }
local romBundle = require("tests.game3_cache").bundle()
if not romBundle then
package.loaded["src.core.game3.rom_text"] = {
plain = function(key) return key end, box = function(key) return key end,
ascii = function(key) return key end, has = function() return true end,
ir = function(key) return { { t = "text", s = key } } end,
translate = function(ir) return ir end,
key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
count = function() return 0 end, list = function() return {} end,
lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end,
}
end
package.loaded["src.core.game3.scripting.space"] = {
store = store,
mapId = COLOSSEUM_MAP,
vm = { ctx = ctx, adapters = adapters },
ensureBundle = function() return romBundle end,
}
local Space = package.loaded["src.core.game3.scripting.space"]
local Natives = require("src.core.game3.scripting.natives")
local NativesLink = require("src.core.game3.scripting.natives_link")
local Link = require("src.core.game3.link")
local LB = require("src.core.game3.link.battle")
local Union = require("src.core.game3.link.union_room")
local FakeRelay = require("tests.g3link_fake_relay")
local Battle = require("src.core.game3.battle")
local Engine = require("src.core.game3.battle.engine")
local Flags = require("src.core.game3.scripting.flags")
local Std = require("src.core.game3.scripting.stdscripts")
local Ui = require("src.core.game3.battle.ui")
local function getVar(id)
return tonumber(Flags.getVar(store, ctx, id)) or 0
end
local function setVar(id, value)
Flags.setVar(store, ctx, id, value)
end
local function freshCtx()
ctx.specialVars = {}
ctx.nativePoll = nil
ctx.mode = nil
ctx.status = nil
end
local function pumpLink(host, guest)
host:update(0)
guest:update(0)
end
print("[test] 1. the colosseum specials answer to their pret def_special index")
local EXPECTED = {
TryBattleLinkup = 0x1C,
TryRecordMixLinkup = 0x1E,
EnterColosseumPlayerSpot = 0x20,
}
for name, id in pairs(EXPECTED) do
eq(NativesLink.SPECIAL[name], id, "NativesLink.SPECIAL." .. name)
check(Natives.ALLOW["special:" .. id] ~= nil, name .. " is bound in Natives.ALLOW")
eq(Std.SPECIAL_NAME_BY_ID[id], name, string.format("special 0x%X is declared by its pret name", id))
end
do
local logs = {}
local quiet = { log = function(m) logs[#logs + 1] = m end }
for _, id in ipairs({ 0x1C, 0x1E, 0x20 }) do
local _, _, known = Natives.special({ specialVars = {} }, id, quiet)
check(known, string.format("special 0x%X dispatches to a handler", id))
end
eq(#logs, 0, "nothing reached the unknown-special log")
end
Link.reset()
print("[test] 2. party validation refuses what the counter refuses")
session.party = { { species = 1, level = 5, hp = 20, maxHp = 20, isBadEgg = true } }
local ok, reason = LB.validateParty(session, Link.USING.SINGLE_BATTLE)
check(not ok, "a BAD EGG cannot enter the cable club")
eq(reason, "bad_egg", "and the reason is the bad egg")
session.party = { { species = 1, level = 5, hp = 20, maxHp = 20, isEgg = true } }
ok, reason = LB.validateParty(session, Link.USING.SINGLE_BATTLE)
check(not ok, "an egg-only party cannot battle")
eq(reason, "no_mons", "because no mon is battle eligible")
session.party = { charizard() }
ok, reason = LB.validateParty(session, Link.USING.DOUBLE_BATTLE)
check(not ok, "one mon is not enough for a double battle")
eq(reason, "need_two_mons", "HasEnoughMonsForDoubleBattle answers PLAYER_HAS_ONE_MON")
session.party = { charizard(), blastoise() }
ok = LB.validateParty(session, Link.USING.DOUBLE_BATTLE)
check(ok, "two usable mons pass the double battle check")
ok = LB.validateParty(session, Link.USING.SINGLE_BATTLE, { unionRoom = true })
check(ok, "two level 60 mons can enter a union room battle")
session.party = {
{ species = 1, level = 12, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 }, maxPp = { 35 } },
{ species = 4, level = 9, hp = 25, maxHp = 25, moves = { 10 }, pp = { 35 }, maxPp = { 35 } },
}
ok = LB.validateParty(session, Link.USING.SINGLE_BATTLE, { unionRoom = true })
check(ok, "two mons at or below Lv. 30 may enter the union room")
session.party[1].heldItem = 175
ok, reason = LB.validateParty(session, Link.USING.SINGLE_BATTLE, { unionRoom = true })
check(not ok, "an ENIGMA BERRY keeps the party out of the union room")
eq(reason, "enigma_berry", "which is what CheckPartyUnionRoomRequirements checks")
session.party[1].heldItem = nil
print("[test] 3. the script's own double battle gate, and the pret LINKUP codes")
session.party = { charizard() }
freshCtx()
Natives.special(ctx, NativesLink.SPECIAL.HasEnoughMonsForDoubleBattle, adapters)
eq(getVar(Link.VAR_RESULT), 1,
"one mon answers PLAYER_HAS_ONE_MON, which is the counter's own double battle gate")
session.party = { charizard(), blastoise() }
freshCtx()
Natives.special(ctx, NativesLink.SPECIAL.HasEnoughMonsForDoubleBattle, adapters)
eq(getVar(Link.VAR_RESULT), 0, "two usable mons answer PLAYER_HAS_TWO_USABLE_MONS")
session.party = { charizard() }
Link.reset()
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
local waited = Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
-- pokefirered/src/cable_club.c:208-222
check(waited, "with no cable yet the counter parks the script instead of answering")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING, "and reports LINKUP_ONGOING while it waits")
local spun = 0
for _ = 1, LB.LINKUP_TICKS + 1 do
if ctx.nativePoll() then break end
spun = spun + 1
end
-- pokefirered/src/cable_club.c:482 TryLinkTimeout
eq(spun, LB.LINKUP_TICKS, "it waits the full TryLinkTimeout window before giving up")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.CONNECTION_ERROR,
"and a partner that never arrives is LINKUP_CONNECTION_ERROR")
local host, guest = FakeRelay.pair({ game = game })
pumpLink(host, guest)
Link.attach(host)
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
local yielded = Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
check(yielded, "the script yields while the linkup runs")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING, "VAR_RESULT is LINKUP_ONGOING while it waits")
check(ctx.nativePoll() == false, "nothing is decided before the peer says what it picked")
guest:update(0)
local peerPick = guest:take(LB.MSG.LINKUP)
eq(peerPick and peerPick.linkType, LB.LINKTYPE.SINGLE_BATTLE,
"the peer was told this machine picked LINKTYPE_SINGLE_BATTLE")
guest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.SINGLE_BATTLE, players = 2 })
host:update(0)
check(ctx.nativePoll() == true, "the poll settles once both picks are in")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.SUCCESS, "matching picks report LINKUP_SUCCESS")
eq(LB.state, "seat", "and the session is waiting for a colosseum seat")
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
guest:update(0)
guest:take(LB.MSG.LINKUP)
guest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.MULTI_BATTLE, players = 4 })
host:update(0)
ctx.nativePoll()
eq(getVar(Link.VAR_RESULT), Link.LINKUP.DIFF_SELECTIONS,
"a peer that picked another mode reports LINKUP_DIFF_SELECTIONS")
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.MULTI_BATTLE)
Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
guest:update(0)
local multiPick = guest:take(LB.MSG.LINKUP)
eq(multiPick and multiPick.linkType, LB.LINKTYPE.MULTI_BATTLE, "MULTI BATTLE picks LINKTYPE_MULTI_BATTLE")
guest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.MULTI_BATTLE, players = 4 })
host:update(0)
ctx.nativePoll()
eq(getVar(Link.VAR_RESULT), Link.LINKUP.WRONG_NUM_PLAYERS,
"a four player mode on a two machine cable reports LINKUP_WRONG_NUM_PLAYERS")
Link.closeLink("test")
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING,
"a torn down link puts the counter back to waiting for a machine")
for _ = 1, LB.LINKUP_TICKS + 1 do
if ctx.nativePoll() then break end
end
eq(getVar(Link.VAR_RESULT), Link.LINKUP.CONNECTION_ERROR,
"and times out to LINKUP_CONNECTION_ERROR")
Link.reset()
print("[test] 4. the shared seed drives one RNG stream on both machines")
local mine = LB.makeRng(0xC0FFEE)
local theirs = LB.makeRng(0xC0FFEE)
local other = LB.makeRng(0xC0FFEF)
local same, drift = true, false
for _ = 1, 64 do
local a, b, c = mine(0, 255), theirs(0, 255), other(0, 255)
if a ~= b then same = false end
if a ~= c then drift = true end
end
check(same, "the same seed gives both machines the same 64 rolls")
check(drift, "a different seed gives a different stream")
print("[test] 5. EnterColosseumPlayerSpot exchanges the seat and the parties")
Link.reset()
session.party = { charizard() }
host, guest = FakeRelay.pair({ game = game })
pumpLink(host, guest)
Link.attach(host)
LB.headless = true
LB.fade = false
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
setVar(LB.VAR_0x8005, 0)
yielded = Natives.special(ctx, NativesLink.SPECIAL.EnterColosseumPlayerSpot, adapters)
check(yielded, "the script yields while the seat is taken")
eq(LB.seat, 0, "the seat id came from VAR_0x8005")
if not romBundle then
print("[skip] 5-32. the link battle runs on ROM species, move, trainer and battle text data: "
.. tostring(require("tests.game3_cache").reason))
os.exit(failed == 0 and 0 or 1)
end
check(ctx.nativePoll() == false, "nothing starts before the peer sits down")
guest:update(0)
local seat = guest:take(LB.MSG.SEAT)
check(seat ~= nil, "the peer saw this player take a colosseum seat")
local mySetup = guest:take(LB.MSG.SETUP)
check(mySetup ~= nil, "and received this player's party and seed")
eq(mySetup and mySetup.name, "RED", "with the player's name")
eq(#(mySetup and mySetup.party or {}), 1, "and one mon on the wire")
eq(type(mySetup and mySetup.party and mySetup.party[1] and mySetup.party[1].species), "number",
"the party goes out as packMon3 rows")
guest:send({ type = LB.MSG.SEAT, seat = 1 })
guest:send({
type = LB.MSG.SETUP, seed = mySetup and mySetup.seed, mode = Link.USING.SINGLE_BATTLE,
name = "BLUE", trainerId = 0x2222, gender = 0, party = packed({ blastoise() }),
})
host:update(0)
check(ctx.nativePoll() == true, "both seats taken starts the battle")
print("[test] 6. BATTLE_TYPE_LINK reaches the engine state")
local st = Battle.getState()
check(Battle.isActive(), "the link battle is running")
eq(st and st.link, true, "st.link is set, so the engine's link branches are live")
eq(st and st.peerName, "BLUE", "the peer's name rides along for the end-of-battle text")
eq(st and st.trainerId, nil, "TRAINER_LINK_OPPONENT is no gTrainers row, so there is no trainer id")
eq(st and st.trainerPicId, LB.TRAINER_PIC_RED, "the opponent is drawn as the peer's own player sprite")
eq(LB.peerPicId({ gender = 1 }), LB.TRAINER_PIC_LEAF, "a female peer is drawn as LEAF")
-- pokefirered/src/battle_main.c:909 the cable master also gets BATTLE_TYPE_IS_MASTER
eq(st and st.linkFlags,
LB.BATTLE_TYPE.TRAINER + LB.BATTLE_TYPE.LINK + LB.BATTLE_TYPE.IS_MASTER,
"BATTLE_TYPE_TRAINER | BATTLE_TYPE_LINK | BATTLE_TYPE_IS_MASTER on the leader")
eq(st and st.linkMaster, true, "and the leader owns battler 0 on both machines")
eq(LB.battleFlags(Link.USING.DOUBLE_BATTLE),
LB.BATTLE_TYPE.TRAINER + LB.BATTLE_TYPE.LINK + LB.BATTLE_TYPE.IS_MASTER
+ LB.BATTLE_TYPE.DOUBLE,
"a double battle adds BATTLE_TYPE_DOUBLE")
eq(LB.battleFlags(Link.USING.MULTI_BATTLE),
LB.BATTLE_TYPE.TRAINER + LB.BATTLE_TYPE.LINK + LB.BATTLE_TYPE.IS_MASTER
+ LB.BATTLE_TYPE.DOUBLE + LB.BATTLE_TYPE.MULTI,
"a multi battle adds BATTLE_TYPE_MULTI too")
print("[test] 7. neither machine resolves a turn before both actions are on the wire")
eq(Battle._phase, "linkwait", "this side is parked waiting for the peer's action")
guest:update(0)
local act = guest:take(LB.MSG.ACTION)
check(act ~= nil, "the peer received this player's chosen action")
eq(act and act.turn, 1, "tagged with turn 1")
eq(act and act.kind, "move", "as a move")
Battle.update(0, nil)
eq(Battle._phase, "linkwait", "and an update with no answer still resolves nothing")
guest:send({ type = LB.MSG.ACTION, turn = act.turn, kind = "move", slot = 1 })
host:update(0)
Battle.update(0, nil)
check(Battle._phase ~= "linkwait", "the peer's answer releases the turn")
eq(Battle.getState().turn, 1, "which is turn 1")
print("[test] 8. running is allowed in a link battle and no EXP is handed out")
check(Engine.canRun(Battle.getState(), Battle._adapter, Battle.getState().player),
"a link battle may be run from, unlike any other trainer battle")
local trainerSt = { wild = false, player = {}, enemy = {} }
check(not Engine.canRun(trainerSt, Battle._adapter, trainerSt.player),
"an ordinary trainer battle still refuses")
local guard = 0
while Battle.isActive() and guard < 600 do
guard = guard + 1
Battle.update(0, nil)
if Battle._phase == "linkwait" then
guest:update(0)
local a = guest:take(LB.MSG.ACTION)
if a then
guest:send({ type = LB.MSG.ACTION, turn = a.turn, kind = "move", slot = 1 })
host:update(0)
end
end
end
check(not Battle.isActive(), "the link battle finished")
local log = Ui.log() or {}
local sawExp, sawEnd = false, nil
for _, line in ipairs(log) do
if line:find("EXP. Points", 1, true) then sawExp = true end
if line:find("BLUE!", 1, true) then sawEnd = line end
end
check(not sawExp, "no EXP line: a link battle gives none")
if romBundle then
check(sawEnd ~= nil, "the battle ends on the link battle string, naming the peer")
else
print("[skip] ROM text: the link battle outcome string")
end
print("[test] 9. the record the battle records screen reads")
eq(LB.outcome, LB.outcomeCode(Battle.getResult()), "the outcome was recorded")
local records = session.linkBattleRecords or {}
eq(#records, 1, "one opponent is on record")
eq(records[1] and records[1].name, "BLUE", "under the peer's name")
eq(records[1] and records[1].trainerId, 0x2222, "and the peer's trainer id")
eq((tonumber(records[1] and records[1].wins) or 0)
+ (tonumber(records[1] and records[1].losses) or 0)
+ (tonumber(records[1] and records[1].draws) or 0), 1, "with exactly one battle in it")
local stats = session.gameStats or {}
eq((tonumber(stats.linkBattleWins) or 0) + (tonumber(stats.linkBattleLosses) or 0)
+ (tonumber(stats.linkBattleDraws) or 0), 1, "and one GAME_STAT_LINK_BATTLE_* bump")
local card = session.trainerCard or {}
eq((tonumber(card.linkBattleWins) or 0) + (tonumber(card.linkBattleLosses) or 0), 1,
"the trainer card counts it too")
do
local Records = require("src.ui.game3.trainer_tower_records")
Records.show({ session = session, kind = "link", onDone = function() end })
local rows = Records.rows() or {}
eq(rows[1] and rows[1].name, "BLUE", "ShowBattleRecords lists the peer in row 1")
if Records.close then Records.close() end
end
print("[test] 10. AddOpponentLinkBattleRecord keeps five entries, sorted, capped at 9999")
session.linkBattleRecords = {}
session.gameStats = {}
for i = 1, 7 do
LB.addOpponentRecord(session, "P" .. i, 0x1000 + i, LB.B_OUTCOME.WON)
end
eq(#session.linkBattleRecords, LB.RECORDS_COUNT, "LINK_B_RECORDS_COUNT entries at most")
for _ = 1, 3 do
LB.addOpponentRecord(session, "P1", 0x1001, LB.B_OUTCOME.WON)
end
eq(session.linkBattleRecords[1].name, "P1", "the busiest opponent sorts to the top")
eq(session.linkBattleRecords[1].wins, 4, "and its wins accumulate")
session.linkBattleRecords[1].wins = LB.RECORD_MAX
LB.addOpponentRecord(session, "P1", 0x1001, LB.B_OUTCOME.WON)
eq(session.linkBattleRecords[1].wins, LB.RECORD_MAX, "a win count never passes 9999")
session.gameStats.linkBattleWins = LB.RECORD_MAX
LB.addOpponentRecord(session, "P1", 0x1001, LB.B_OUTCOME.WON)
eq(session.gameStats.linkBattleWins, LB.RECORD_MAX, "nor does the game stat")
print("[test] 11. SavePlayerParty and LoadPlayerParty bracket the exchange")
Link.reset()
session.linkBattleRecords = {}
session.gameStats = {}
session.party = { charizard(), blastoise() }
local Tower = require("src.core.game3.trainer_tower")
Tower.savePlayerParty(session)
session.party = { charizard() }
session.party[1].hp = 1
Link.loadPlayerBag()
session.bag = { pockets = { items = { [4] = 0 } } }
LB.mode = Link.USING.SINGLE_BATTLE
LB.unionRoom = false
LB.peer = { name = "BLUE", trainerId = 0x2222 }
LB._started = true
LB.finish("lose")
eq(#session.party, 2, "LoadPlayerParty gave the pre-link party back")
eq(session.party[1].hp, 200, "with the damage of the link battle discarded")
eq(session.bag.pockets.items[4], 3, "SavePlayerBag gave the pre-link bag back")
print("[test] 12. ChooseHalfPartyForBattle decides what goes on the wire")
session.party = { charizard(), blastoise(), charizard(), blastoise() }
Tower.setSelectedOrder(session, { 1, 3 })
local wire = LB.battleParty(session)
eq(#wire, 2, "only the chosen mons are sent")
eq(wire[1].species, 6, "in the order the player picked them")
eq(wire[2].species, 6, "second pick is party slot 3")
Tower.clearSelectedOrder(session)
eq(#LB.battleParty(session), 4, "with no selection the whole party goes")
print("[test] 13. a peer drop mid battle ends the match without a white-out")
Link.reset()
session.party = { charizard() }
session.money = 5000
host, guest = FakeRelay.pair({ game = game })
pumpLink(host, guest)
Link.attach(host)
LB.mode = Link.USING.SINGLE_BATTLE
LB.unionRoom = false
LB.seed = 999
LB.state = "setup"
LB.headless = true
LB.fade = false
LB.beginBattle({ seed = 999, name = "BLUE", trainerId = 0x2222, gender = 0,
party = packed({ blastoise() }) })
check(Battle.isActive(), "the second link battle started")
guest:close("cable_pulled")
guest:update(0)
host:update(0)
Link.update(0)
check(not Battle.isActive(), "a yanked cable ends the battle")
eq(LB.outcome, LB.B_OUTCOME.DREW, "as a draw")
eq(session.money, 5000, "losing a link battle never costs money")
eq(Link.link, nil, "and the session is closed")
print("[test] 14. the union room routes ACTIVITY_BATTLE_SINGLE into a link battle")
Link.reset()
Union.reset()
Space.mapId = UNION_MAP
session.map = UNION_MAP
session.party = { charizard(), blastoise() }
host, guest = FakeRelay.pair({ game = game })
pumpLink(host, guest)
Link.attach(host)
LB.headless = true
LB.fade = false
session.party = {
legal({ species = 1, level = 12, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 }, maxPp = { 35 } }),
legal({ species = 4, level = 9, hp = 25, maxHp = 25, moves = { 10 }, pp = { 35 }, maxPp = { 35 } }),
}
Union.state = "start_activity"
Union.activity = Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM
Union.partnerId = 1
Union.update(0)
eq(Union.state, "in_activity", "a legal party starts the activity")
eq(LB.state, "setup", "and the link battle is waiting for the peer's party")
eq(LB.unionRoom, true, "flagged as a union room battle, which keeps it off the records")
guest:update(0)
local urSetup = guest:take(LB.MSG.SETUP)
check(urSetup ~= nil, "the peer was offered this player's party")
guest:send({ type = LB.MSG.SETUP, seed = urSetup and urSetup.seed,
name = "BLUE", trainerId = 0x2222, gender = 0, party = packed({ blastoise() }) })
host:update(0)
Union.update(0)
check(Battle.isActive(), "the union room battle started")
eq(LB.myParty()[1].level, LB.LINK_LEVEL, "our level 12 mon battles at level 50")
eq(LB.myParty()[2].level, LB.LINK_LEVEL, "and so does the level 9 one")
eq(session.party[1].level, 12, "while the party itself keeps its real level")
eq(Battle.getState().link, true, "with BATTLE_TYPE_LINK")
local urClasses = LB.unionRoomClasses()
eq(Battle.getState().trainerPicId, urClasses.trainerPic[0x2222 % 8],
"the peer is drawn with GetUnionRoomTrainerPic, not RED/LEAF")
local urFill = require("src.core.game3.battle.adapter").fill(Battle.getState())
eq(urFill.trainer1Class, urClasses.trainerClass[0x2222 % 8], "B_TRAINER1_CLASS is GetUnionRoomTrainerClass")
eq(urFill.trainer1Name, "BLUE", "B_TRAINER1_NAME is the peer's link name")
local urIntro = require("src.core.game3.battle.intro_seq").introText(Battle.getState())
local urRomText = require("src.core.game3.rom_text")
local urClassName = urRomText.plain(urRomText.key("gTrainerClassNames", urClasses.trainerClass[0x2222 % 8]))
check(urIntro:find(urClassName, 1, true) ~= nil and urIntro:find("BLUE", 1, true) ~= nil,
"the intro is sText_Trainer1WantsToBattle with the union room class and the peer's name")
local urLog = Ui.log() or {}
local urPushed = false
for _, line in ipairs(urLog) do
if line == urIntro then urPushed = true end
end
check(urPushed, "the battle printed that union room intro")
local urNone = require("src.core.game3.scripting.trainers").info(0)
local urNoneName = urNone and urNone.name
if urNoneName and urNoneName ~= "" and urNoneName ~= "BLUE" then
check(table.concat(urLog, " | "):find(urNoneName, 1, true) == nil, "trainer 0 never reaches the union room intro")
end
Battle.abort("draw")
session.linkBattleRecords = {}
LB.finish("draw")
eq(#(session.linkBattleRecords or {}), 0,
"a union room battle is not written to the battle records")
print("[test] 15. a double link battle takes both actions off the cable")
Link.reset()
local function linkMon(sp, lv, m1, m2)
return legal({ species = sp, level = lv, hp = 100, maxHp = 100,
moves = { m1, m2 }, pp = { 15, 15 }, maxPp = { 15, 15 } })
end
session.party = { linkMon(6, 30, 53), linkMon(9, 30, 57) }
host, guest = FakeRelay.pair({ game = game })
pumpLink(host, guest)
Link.attach(host)
LB.headless = true
LB.fade = false
LB.mode = Link.USING.DOUBLE_BATTLE
LB.seed = 4242
LB.state = "setup"
LB.beginBattle({ seed = 4242, name = "BLUE", trainerId = 0x2222, gender = 0,
party = packed({ linkMon(129, 30, 33, 45), linkMon(1, 30, 33, 45) }) })
local dbl = Battle.getState()
eq(dbl and dbl.double, true, "the colosseum's DOUBLE BATTLE mode is a double battle")
eq(Battle._phase, "linkwait", "and it waits for the peer before resolving turn 1")
guest:update(0)
local dblAct = guest:take(LB.MSG.ACTION)
eq(dblAct and dblAct.kind, "list", "both of this machine's actions went out in one message")
eq(#((dblAct and dblAct.actions) or {}), 2, "one per battler")
guest:send({ type = LB.MSG.ACTION, turn = dblAct.turn, kind = "list",
actions = { { kind = "move", slot = 2 }, { kind = "move", slot = 2 } } })
host:update(0)
Battle.update(0, nil)
check(Battle._phase ~= "linkwait", "the peer's pair of actions releases the turn")
local sawGrowl = false
for _ = 1, 40 do
if Battle.isActive() then Battle.update(0, nil) end
for _, line in ipairs(Ui.log() or {}) do
if line:find("GROWL", 1, true) then sawGrowl = true end
end
end
check(sawGrowl, "the enemy used the move the peer picked, not the one its AI would have")
Battle.abort("draw")
LB.reset()
print("[test] 16. the replacement after a faint crosses the cable")
Link.reset()
session.linkBattleRecords = {}
session.gameStats = {}
session.party = { charizard() }
host, guest = FakeRelay.pair({ game = game })
pumpLink(host, guest)
Link.attach(host)
LB.headless = true
LB.fade = false
LB.mode = Link.USING.SINGLE_BATTLE
LB.seed = 31337
LB.state = "setup"
LB.beginBattle({ seed = 31337, name = "BLUE", trainerId = 0x2222, gender = 0,
party = packed({
legal({ species = 129, level = 5, hp = 20, maxHp = 20, moves = { 150 }, pp = { 40 }, maxPp = { 40 } }),
legal({ species = 10, level = 5, hp = 20, maxHp = 20, moves = { 33 }, pp = { 35 }, maxPp = { 35 } }),
blastoise(),
}) })
check(Battle.isActive(), "the link battle with a three mon peer started")
guard = 0
while guard < 300 and Battle._phase ~= "linkswitch" and Battle.isActive() do
guard = guard + 1
Battle.update(0, nil)
if Battle._phase == "linkwait" then
guest:update(0)
local a = guest:take(LB.MSG.ACTION)
if a then
guest:send({ type = LB.MSG.ACTION, turn = a.turn, kind = "move", slot = 1 })
host:update(0)
end
end
end
eq(Battle._phase, "linkswitch", "the fainted peer's replacement is a lockstep wait")
for _ = 1, 30 do Battle.update(0, nil) end
eq(Battle._phase, "linkswitch", "and nothing is sent out while the peer has not said which mon")
guest:send({ type = LB.MSG.SWITCH, slot = 3 })
host:update(0)
Battle.update(0, nil)
local afterSwitch = Battle.getState()
eq(afterSwitch and afterSwitch.enemy and afterSwitch.enemy.partyIndex, 3,
"the mon the peer named is the one that came out")
local sawLinkSendOut = false
for _, line in ipairs(Ui.log() or {}) do
if line:find("BLUE sent", 1, true) then sawLinkSendOut = true end
end
check(sawLinkSendOut, "and the send-out names the link trainer, with no trainer class")
Battle.abort("draw")
LB.reset()
print("[test] 17. this machine's own replacement goes out over the cable")
Link.reset()
session.linkBattleRecords = {}
session.gameStats = {}
session.party = {
legal({ species = 129, level = 5, hp = 20, maxHp = 20, moves = { 150 }, pp = { 40 }, maxPp = { 40 } }),
charizard(),
}
host, guest = FakeRelay.pair({ game = game })
pumpLink(host, guest)
Link.attach(host)
LB.headless = true
LB.fade = false
LB.mode = Link.USING.SINGLE_BATTLE
LB.seed = 5150
LB.state = "setup"
LB.beginBattle({ seed = 5150, name = "BLUE", trainerId = 0x2222, gender = 0,
party = packed({ blastoise() }) })
guard = 0
local sentSwitch = nil
while guard < 300 and Battle.isActive() and not sentSwitch do
guard = guard + 1
Battle.update(0, nil)
guest:update(0)
local a = guest:take(LB.MSG.ACTION)
if a then
guest:send({ type = LB.MSG.ACTION, turn = a.turn, kind = "move", slot = 1 })
host:update(0)
end
sentSwitch = guest:take(LB.MSG.SWITCH)
end
check(sentSwitch ~= nil, "the peer was told which mon this machine sent out after its faint")
eq(sentSwitch and sentSwitch.slot, 2, "the party slot it chose")
Battle.abort("draw")
LB.reset()
print("[test] 18. the link battle BGM is the leader's, on both machines")
Link.reset()
host, guest = FakeRelay.pair({ game = game })
pumpLink(host, guest)
Link.attach(host)
session.trainerId = 0x1234
eq(LB.battleSong({ trainerId = 0x2223 }), LB.MUS_RS_VS_TRAINER,
"an even leader id plays MUS_RS_VS_TRAINER")
session.trainerId = 0x1235
eq(LB.battleSong({ trainerId = 0x2222 }), LB.MUS_RS_VS_GYM_LEADER,
"an odd leader id plays MUS_RS_VS_GYM_LEADER")
session.trainerId = 0x1234
Link.reset()
print("[test] 19. the imported colosseum really is where the counter warps")
local Cache = require("tests.game3_cache")
local root = Cache.mount("meta.json")
if not root then
print("[skip] colosseum map check: " .. tostring(Cache.reason))
else
local Dataset = require("src.core.game3.dataset")
local realMaps = Dataset.buildMaps()
local control = realMaps[COUNTER_MAP]
if not (control and control.warps and #control.warps > 0) then
print("[skip] colosseum map check: the mounted cache at " .. tostring(root) ..
" carries no map warps")
else
local def = realMaps[COLOSSEUM_MAP]
check(def ~= nil, "FR_BATTLE_COLOSSEUM_2P is in the imported cache")
if def then
local dynamic = false
for _, w in ipairs(def.warps or {}) do
if tonumber(w.mapNum) == 0x7F then dynamic = true end
end
check(dynamic, "and its exit is the MAP_DYNAMIC warp CleanupLinkRoomState spends")
end
end
end
print("[test] 20. the same battle run from both ends of one cable stays in step")
-- pokefirered/src/battle_controllers.c:148 InitLinkBtlControllers: the cable master owns
local TIE_SEED = 0xC0FFEE
local function tieMon(move, pp)
return legal { species = 6, level = 50, hp = 150, maxHp = 150, moves = { move }, pp = { pp },
maxPp = { pp }, personality = 0, gender = "M", nickname = "", friendship = 70,
otId = 1, otName = "X",
ivs = { hp = 10, atk = 10, def = 10, spe = 10, spa = 10, spd = 10 },
evs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 },
attack = 100, defense = 95, spAtk = 120, spDef = 100, speed = 115,
ability = 66, abilityId = 66 }
end
local function runSeat(seat)
Link.reset()
if Battle.isActive() then Battle.abort("draw") end
local mine = seat == 0 and tieMon(52, 25) or tieMon(10, 35)
local theirs = seat == 0 and tieMon(10, 35) or tieMon(52, 25)
session.party = { mine }
session.linkBattleRecords = nil
local a, b = FakeRelay.pair({ game = game })
a:update(0)
b:update(0)
local me, peer = a, b
if seat == 1 then me, peer = b, a end
Link.attach(me)
LB.dealSeed = function() return TIE_SEED end
LB.headless = true
LB.fade = false
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
setVar(LB.VAR_0x8005, seat)
Natives.special(ctx, NativesLink.SPECIAL.EnterColosseumPlayerSpot, adapters)
peer:update(0)
peer:take(LB.MSG.SEAT)
peer:take(LB.MSG.SETUP)
peer:send({ type = LB.MSG.SEAT, seat = 1 - seat })
peer:send({ type = LB.MSG.SETUP, seed = TIE_SEED, mode = Link.USING.SINGLE_BATTLE,
name = "BLUE", trainerId = 0x2222, gender = 0, party = packed({ theirs }) })
me:update(0)
ctx.nativePoll()
local trace, guard, lastTurn = {}, 0, 0
while Battle.isActive() and guard < 2000 do
guard = guard + 1
Battle.update(0, nil)
local st = Battle.getState()
if st and st.turn ~= lastTurn and Battle._phase == "linkwait" then
lastTurn = st.turn
trace[#trace + 1] = { st.player.mon.hp, st.enemy.mon.hp }
end
if Battle._phase == "linkwait" then
peer:update(0)
local act = peer:take(LB.MSG.ACTION)
if act then
peer:send({ type = LB.MSG.ACTION, turn = act.turn, kind = "move", slot = 1 })
me:update(0)
end
end
end
return trace, Battle.getResult()
end
local traceA, resultA = runSeat(0)
local traceB, resultB = runSeat(1)
eq(#traceA, #traceB, "both machines fought the same number of turns")
local agreed = #traceA > 0
for i = 1, math.max(#traceA, #traceB) do
local left, right = traceA[i] or {}, traceB[i] or {}
if left[1] ~= right[2] or left[2] ~= right[1] then agreed = false end
end
check(agreed, "every turn's HP on the leader mirrors the same turn on the follower")
local mirror = { win = "lose", lose = "win", draw = "draw" }
eq(mirror[resultA] or resultA, resultB, "one machine's win is the other machine's loss")
eq(Engine.linkSeatSwap({ link = true, linkMaster = false }), true,
"the follower reads battler1/battler2 swapped")
eq(Engine.linkSeatSwap({ link = true, linkMaster = true }), false,
"the leader reads them straight")
eq(Engine.linkSeatSwap({ link = false, linkMaster = false }), false,
"and a local battle is untouched by the seat rule")
print("[test] 21. BAG is refused in a link battle and the intro names the peer")
Link.reset()
if Battle.isActive() then Battle.abort("draw") end
session.party = { charizard() }
local bagHost, bagGuest = FakeRelay.pair({ game = game })
pumpLink(bagHost, bagGuest)
Link.attach(bagHost)
LB.mode = Link.USING.SINGLE_BATTLE
LB.unionRoom = false
LB.seed = TIE_SEED
LB.headless = true
LB.fade = false
Ui.reset({ headless = true })
LB.beginBattle({ name = "BLUE", trainerId = 0x2222, gender = 0, party = packed({ blastoise() }) })
local linkSt = Battle.getState()
eq(linkSt and linkSt.link, true, "the link battle is up")
local introLog = table.concat(Ui.log() or {}, " | "):gsub("\n", " ")
-- pokefirered/src/battle_message.c:389 sText_LinkTrainerWantsToBattle
check(introLog:find("BLUE wants to battle!", 1, true) ~= nil,
"the intro is sText_LinkTrainerWantsToBattle, not the generic trainer line")
check(introLog:find("would like to battle", 1, true) == nil,
"sText_Trainer1WantsToBattle never reaches a link battle")
local before = #(Ui.log() or {})
Battle._phase = "command"
-- pokefirered/src/battle_main.c:3182 BattleScript_ActionSelectionItemsCantBeUsed
local fakeInput = { wasPressed = function(_, key) return key == "a" end }
Ui._mode = "menu"
Ui._menuIndex = 2
check(Battle._refuseLinkItem(fakeInput), "A on BAG in a link battle is refused at selection")
local after = Ui.log() or {}
check(#after > before and tostring(after[#after]):find("can't be used", 1, true) ~= nil,
"and the refusal prints sText_ItemsCantBeUsedNow")
eq(Ui._mode, "selmsg", "shown as a selection message so it stays on screen")
eq(Ui._selReturn, "menu", "then returns to the action menu")
Ui._mode = "menu"
Ui._menuIndex = 1
eq(Battle._refuseLinkItem(fakeInput), false, "FIGHT is not touched by the rule")
local Net = require("src.link.Net")
local Guard = require("src.core.game3.battle.link_guard")
local reported = {}
package.loaded["src.online.Client"] = {
report = function(result) reported[#reported + 1] = result end,
state = function() return "online" end,
}
local lastRoom
local function relayPair(seed)
local me, peer, room = FakeRelay.pair({ game = game, seed = seed })
lastRoom = room
me:update(0)
peer:update(0)
me:update(0)
peer:update(0)
return me, peer
end
local function relayBattle(seed, mine, theirs, mode)
Link.reset()
if Battle.isActive() then Battle.abort("draw") end
reported = {}
session.party = mine
session.linkBattleRecords = {}
local me, peer = relayPair(seed)
Link.attach(me)
LB.headless = true
LB.fade = false
freshCtx()
setVar(Link.VAR_0x8004, mode or Link.USING.SINGLE_BATTLE)
setVar(LB.VAR_0x8005, 0)
Natives.special(ctx, NativesLink.SPECIAL.EnterColosseumPlayerSpot, adapters)
ctx.nativePoll()
peer:update(0)
local mySetup = peer:take(LB.MSG.SETUP)
peer:take(LB.MSG.SEAT)
peer:send({ type = LB.MSG.SEAT, seat = 1 })
peer:send({ type = LB.MSG.SETUP, seed = 12345, mode = mode or Link.USING.SINGLE_BATTLE,
name = "BLUE", trainerId = 0x2222, gender = 0, party = packed(theirs) })
me:update(0)
ctx.nativePoll()
return me, peer, mySetup
end
print("[test] 22. on the relay every seat takes the seed from match_start")
do
local _, _, mySetup = relayBattle(777, { charizard() }, { blastoise() })
eq(LB.seed, 777, "the room seed, not a dealt one, drives the battle")
eq(mySetup and mySetup.seed, nil, "and no seed rides the setup on the relay")
check(Battle.isActive() and Battle.getState().link, "the relay battle is running")
eq(LB.seedFromRelay(), 777, "LB.seedFromRelay reads the transport")
Battle.abort("draw")
end
print("[test] 23. every seat hashes each resolved turn and a mismatch ends the battle as a draw")
do
local me, peer = relayBattle(4242, { charizard(), blastoise() }, { blastoise(), charizard() })
local hashes, guard, broke = {}, 0, false
while Battle.isActive() and guard < 600 do
guard = guard + 1
Battle.update(0, nil)
peer:update(0)
local h = peer:take(LB.MSG.HASH)
while h do
hashes[#hashes + 1] = h
if h.turn == 2 then
broke = true
peer:send({ type = LB.MSG.HASH, turn = 2, value = "00000000",
parts = { actives = "00000000", volatile = h.parts.volatile, bench = h.parts.bench,
field = h.parts.field, rng = h.parts.rng } })
else
peer:send({ type = LB.MSG.HASH, turn = h.turn, value = h.value, parts = h.parts })
end
h = peer:take(LB.MSG.HASH)
end
local a = peer:take(LB.MSG.ACTION)
if a then peer:send({ type = LB.MSG.ACTION, turn = a.turn, kind = "move", slot = 1 }) end
me:update(0)
end
check(#hashes >= 2, "a game3_battle_hash went out for each resolved turn")
local first = hashes[1] or {}
eq(first.turn, 1, "the first hash covers turn 1")
check(type(first.value) == "string" and #first.value == 8, "value is 8 hex digits")
local partsOk = type(first.parts) == "table"
for _, key in ipairs({ "actives", "volatile", "bench", "field" }) do
partsOk = partsOk and type(first.parts[key]) == "string" and #first.parts[key] == 8
end
check(partsOk, "every part is an 8 hex FNV digest")
check(tonumber(first.parts and first.parts.rng) ~= nil, "rng is the decimal draw count")
check(broke, "the peer answered turn 2 with a different digest")
check(not Battle.isActive(), "the battle ended")
eq(LB.endReason, "desync", "because of the desync")
eq(Battle.getResult(), "draw", "as a draw")
peer:update(0)
local out = peer:take(LB.MSG.OUTCOME)
eq(out and out.outcome, LB.B_OUTCOME.DREW, "game3_battle_outcome DREW went to the peer")
eq(reported[1], "draw", "and Client.report(\"draw\") was sent once")
eq(#reported, 1, "exactly once")
end
print("[test] 24. the peer's game3_battle_outcome is read and checked against ours")
do
LB.log = {}
LB.outcome = LB.B_OUTCOME.WON
LB._peerOutcomes = { [1] = { outcome = LB.B_OUTCOME.LOST } }
LB.compareOutcome()
local mismatch = false
for _, line in ipairs(LB.log) do if line:find("outcome mismatch", 1, true) then mismatch = true end end
check(not mismatch, "WON against LOST is a mirror")
LB._peerOutcomes = { [1] = { outcome = LB.B_OUTCOME.WON } }
LB.compareOutcome()
for _, line in ipairs(LB.log) do if line:find("outcome mismatch", 1, true) then mismatch = true end end
check(mismatch, "WON against WON is logged")
end
print("[test] 25. a failed RNG draw inside a link battle raises instead of using the local stream")
do
relayBattle(99, { charizard() }, { blastoise() })
check(Battle.isActive(), "a link battle is up")
check(Guard.active, "the guard is armed while it runs")
local Rng = require("src.core.game3.rng")
local before = Rng.getState().value
local broken = { rng = function() return function() error("broken rng") end end }
local ok = pcall(Engine.roll, broken, 0, 255)
check(not ok, "Engine.roll raises rather than falling back to Rng.compat")
eq(Rng.getState().value, before, "and the machine's own stream was not stepped")
eq(Guard.tripped, "engine.roll", "the trip names the site")
LB.update(0)
check(not Battle.isActive(), "the link battle ends on the next link pump")
eq(LB.endReason, "desync", "as a desync")
check(not Guard.active, "and the guard is disarmed once it is over")
local okLocal = pcall(Engine.roll, broken, 0, 255)
check(okLocal, "outside a link battle the old fallback still answers")
end
print("[test] 26. a link opponent's held item is never rolled on this machine")
do
local mon = blastoise()
mon.item = 0
relayBattle(5, { charizard() }, { mon })
local st = Battle.getState()
eq(st and st.foeParty[1].item, 0, "an empty item slot stays empty")
eq(st and st.enemy and st.enemy.mon, st and st.foeParty[1], "the foe's active mon is its party row, like ours")
Battle.abort("draw")
local okStart = pcall(Battle.start, {
link = true, headless = true, autoFight = false, playerParty = { charizard() },
foe = { link = true, party = { { species = 9, level = 5, moves = { 33 }, personality = 0, ivs = {} } } },
})
check(not okStart, "a link foe with no item field refuses to start instead of rolling one")
Guard.disarm()
end
print("[test] 27. running from a link battle is LINK_BATTLE_RAN and a loss")
eq(LB.outcomeCode("run"), LB.B_OUTCOME.LINK_BATTLE_RAN, "outcomeCode(run) is 128")
eq(LB.resultWord("run"), "lose", "it reports as a loss")
eq(LB.recordOutcome(LB.B_OUTCOME.LINK_BATTLE_RAN), LB.B_OUTCOME.LOST, "and records as a loss")
do
local me, peer = relayBattle(8, { charizard() }, { blastoise() })
peer:update(0)
local a = peer:take(LB.MSG.ACTION)
peer:send({ type = LB.MSG.ACTION, turn = a and a.turn or 1, kind = "run" })
me:update(0)
for _ = 1, 20 do if Battle.isActive() then Battle.update(0, nil) end end
check(not Battle.isActive(), "the peer running ends the battle")
eq(Battle.getResult(), "win", "as this machine's win")
eq(Battle.getState().linkRan, true, "with the link-ran flag")
local log = table.concat(Ui.log() or {}, " | ")
check(log:find("BLUE fled!", 1, true) ~= nil, "and sText_WildFled names the peer")
end
print("[test] 28. a peer item action reads itemId")
do
local st = { enemy = { mon = { moves = { 33 } } } }
local act = Battle._linkEnemyAction(st, { kind = "item", itemId = 17 })
eq(act and act.item, 17, "the item comes from itemId")
end
print("[test] 29. a double battle refuses a peer action list that is not two long")
do
local me, peer = relayBattle(31, { charizard(), blastoise() }, { blastoise(), charizard() },
Link.USING.DOUBLE_BATTLE)
check(Battle.isActive() and Battle.getState().double, "a double link battle is up")
peer:update(0)
local a = peer:take(LB.MSG.ACTION)
eq(a and a.kind, "list", "this machine sent its pair")
eq(#((a and a.actions) or {}), 2, "of two")
peer:send({ type = LB.MSG.ACTION, turn = a and a.turn or 1, kind = "list",
actions = { { kind = "move", slot = 1 } } })
me:update(0)
for _ = 1, 5 do if Battle.isActive() then Battle.update(0, nil) end end
check(not Battle.isActive(), "a one-entry list ends the battle")
eq(LB.endReason, "desync", "as a protocol desync")
end
print("[test] 30. hashes are perspective-free: both seats of one battle digest the same")
do
local SEED = 0xBEEF
local function side(seat)
Link.reset()
if Battle.isActive() then Battle.abort("draw") end
local mine = seat == 0 and tieMon(52, 25) or tieMon(10, 35)
local theirs = seat == 0 and tieMon(10, 35) or tieMon(52, 25)
session.party = { mine }
local l0, l1 = FakeRelay.pair({ game = game, seed = SEED })
local me, peer = l0, l1
if seat == 1 then me, peer = l1, l0 end
me:update(0)
peer:update(0)
me:update(0)
peer:update(0)
Link.attach(me)
LB.headless = true
LB.fade = false
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
setVar(LB.VAR_0x8005, seat)
Natives.special(ctx, NativesLink.SPECIAL.EnterColosseumPlayerSpot, adapters)
peer:update(0)
peer:send({ type = LB.MSG.SEAT, seat = 1 - seat })
peer:send({ type = LB.MSG.SETUP, mode = Link.USING.SINGLE_BATTLE,
name = "BLUE", trainerId = 0x2222, gender = 0, party = packed({ theirs }) })
me:update(0)
ctx.nativePoll()
local digests, guard = {}, 0
while Battle.isActive() and guard < 2000 do
guard = guard + 1
Battle.update(0, nil)
peer:update(0)
local h = peer:take(LB.MSG.HASH)
while h do
digests[h.turn] = h.value
h = peer:take(LB.MSG.HASH)
end
local act = peer:take(LB.MSG.ACTION)
if act then
peer:send({ type = LB.MSG.ACTION, turn = act.turn, kind = "move", slot = 1 })
me:update(0)
end
end
return digests
end
local d0 = side(0)
local d1 = side(1)
local n, same = 0, true
for turn, value in pairs(d0) do
n = n + 1
if d1[turn] ~= value then same = false end
end
check(n >= 2, "both seats hashed several turns")
check(same, "and every turn's digest is identical from either seat")
end
print("[test] 31. a seat that leaves the room mid battle ends it as a draw on the relay")
do
local me, peer = relayBattle(77, { charizard() }, { blastoise() })
check(Battle.isActive(), "the relay battle is up")
lastRoom:drop(1)
for _ = 1, 5 do
Link.update(0)
if Battle.isActive() then Battle.update(0, nil) end
end
check(not Battle.isActive(), "the dropped seat ends the battle")
eq(Battle.getResult(), "draw", "as a draw")
eq(LB.endReason, "peer_dropped", "because the peer left")
eq(reported[1], "draw", "and the draw is reported once")
end
print("[test] 32. a fresh battle on the same link drops the last battle's late turn messages")
do
local me, peer = relayBattle(91, { charizard() }, { blastoise() })
Battle.abort("draw")
peer:send({ type = LB.MSG.OUTCOME, outcome = 1, turn = 3 })
peer:send({ type = LB.MSG.HASH, turn = 3, value = "x" })
peer:send({ type = LB.MSG.ACTION, turn = 1, kind = "move", slot = 1 })
me:update(0)
check(me:isOpen(), "the link outlives the battle")
LB.freshBattle()
eq(me:take(LB.MSG.OUTCOME), nil, "the late outcome is gone")
eq(me:take(LB.MSG.HASH), nil, "the late hash is gone")
eq(me:take(LB.MSG.ACTION), nil, "the late action is gone")
check(not LB.peerAhead(nil, 1), "turn 1 of the next battle does not see the peer as ahead")
end
Link.reset()
Union.reset()
LB.reset()
if failed == 0 then
print("[pass] link battle")
os.exit(0)
end
print("[fail] link battle: " .. failed)
os.exit(1)