mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 05:32:07 -04:00
1150 lines
46 KiB
Lua
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)
|