Files
gen1recomp/tests/link2_lockstep.lua
T
2026-08-28 10:14:44 -04:00

512 lines
19 KiB
Lua

-- Gen 2 lockstep link battles (src/link/LinkBattle2.lua), the Gold peer of
-- the Gen 1 section in tests/run_link_tests.lua.
--
-- luajit tests/link2_lockstep.lua
--
-- ROM-free: the dataset below is a hand-written Gen 2 shaped fixture, the
-- same trick tests/gen2_battle_pack_test.lua uses, so this needs no
-- data/generated/gold. Both peers run the REAL src/ui/gen2/BattleState over
-- the REAL src/battle/gen2/Battle, exchanging actions over
-- Net.loopbackPair(), and every assertion is about the two simulations
-- agreeing rather than about either one on its own.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local failures = 0
local function check(cond, msg)
if cond then
print("ok " .. msg)
else
failures = failures + 1
print("FAIL " .. msg)
end
end
local function eq(got, want, msg)
check(got == want, ("%s (got %s, want %s)"):format(msg, tostring(got),
tostring(want)))
end
require("src.core.Logger").warn = function() end
local Battle = require("src.battle.gen2.Battle")
local LinkBattle2 = require("src.link.LinkBattle2")
local Mon = require("src.battle.gen2.Mon")
local Net = require("src.link.Net")
local Protocol = require("src.link.Protocol")
-- ---------------------------------------------------------------- fixture
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
SCRATCH = { id = "SCRATCH", name = "SCRATCH", power = 40, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
STRUGGLE = { id = "STRUGGLE", name = "STRUGGLE", power = 50, type = "NORMAL",
accuracy = 100, pp = 1, effect = "EFFECT_NORMAL_HIT" },
}
local GROWTH = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
}
local function species(id, index, stats)
return {
id = id, index = index, name = id,
baseStats = stats,
types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
}
end
local POKEMON = {
growthRates = GROWTH,
QUICKMON = species("QUICKMON", 1, { hp = 45, attack = 50, defense = 40,
speed = 90, specialAttack = 40, specialDefense = 40 }),
SLOWMON = species("SLOWMON", 2, { hp = 60, attack = 45, defense = 50,
speed = 20, specialAttack = 40, specialDefense = 45 }),
TANKMON = species("TANKMON", 3, { hp = 70, attack = 40, defense = 60,
speed = 35, specialAttack = 35, specialDefense = 50 }),
}
local ITEMS = {
-- Battle:tickHeldItem's residual arm and BattleCommand_EffectChance's
-- flinch arm, the two held effects a link battle is most likely to split on.
LEFTOVERS = { id = "LEFTOVERS", index = 1, name = "LEFTOVERS",
pocket = "ITEM", heldEffect = "HELD_LEFTOVERS" },
KINGS_ROCK = { id = "KINGS_ROCK", index = 2, name = "KING'S ROCK",
pocket = "ITEM", heldEffect = "HELD_FLINCH", heldParameter = 30 },
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
type_chart = { types = TYPES, matchups = {} },
items = ITEMS,
}
local PERFECT = { attack = 15, defense = 15, speed = 15, special = 15 }
PERFECT.hp = Mon.hpDV(PERFECT)
local function fighter(id, level, item)
local mon = Mon.new(DATA, id, level or 20, { dvs = PERFECT })
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 },
{ id = "SCRATCH", pp = 35, maxPp = 35 } }
mon.item = item
return mon
end
-- ---------------------------------------------------------------- harness
local function fakeInput()
local input = { button = nil }
function input:wasPressed(button) return self.button == button end
function input:isDown() return false end
function input:step() end
return input
end
local function makeGame(name, party)
local stack = { list = {} }
function stack:push(state, ...)
table.insert(self.list, state)
if state.enter then state:enter(...) end
end
function stack:pop() return table.remove(self.list) end
function stack:top() return self.list[#self.list] end
function stack:clear() self.list = {} end
function stack:update(dt)
local top = self:top()
if top and top.update then top:update(dt) end
end
return {
data = DATA,
input = fakeInput(),
stack = stack,
options = {},
save = { party = party, player = { name = name, id = 1234 },
inventory = { POTION = 5 }, pokedex = { seen = {}, caught = {} } },
}
end
-- The screen is on top of the stack until it opens the party list, and the
-- forced switch after a faint opens on the mon that just fainted -- so while a
-- list is up the press walks the cursor off a fainted slot before choosing,
-- exactly the way a player does.
local function buttonFor(game, screen)
local top = game.stack:top()
if top and top ~= screen then
local mon = top.party and top.index and top.party[top.index]
if not mon or (mon.hp or 0) <= 0 or mon.isEgg then return "down" end
end
return "a"
end
local function step(sides)
for _, side in ipairs(sides) do
side.game.input.button = buttonFor(side.game, side.screen)
side.game.stack:update(1 / 60)
side.game.input.button = nil
side.game.stack:update(1 / 60)
end
end
local function packedParty(party)
return Protocol.packParty2(party)
end
-- Build one loopback match. Returns the two sides, each carrying its own
-- game, screen and result.
local function newMatch(hostParty, guestParty, seed, opts)
opts = opts or {}
local netA, netB = Net.loopbackPair()
local gameA = makeGame("GOLD", hostParty)
local gameB = makeGame("SILVER", guestParty)
local packedA, packedB = packedParty(hostParty), packedParty(guestParty)
local host, hostErr = LinkBattle2.newHost(gameA, netA, {
myParty = packedA, theirParty = packedB, theirName = "SILVER",
seed = seed, verdict = "full", strict = true, keepNetOpen = true,
turnLimit = opts.turnLimit,
})
local guest, guestErr = LinkBattle2.newGuest(gameB, netB, {
myParty = packedB, theirParty = packedA, theirName = "GOLD",
seed = seed, verdict = "full", strict = true, keepNetOpen = true,
turnLimit = opts.turnLimit,
})
if not host then error("host: " .. tostring(hostErr)) end
if not guest then error("guest: " .. tostring(guestErr)) end
local sides = {
{ game = gameA, screen = host, net = netA, role = "host" },
{ game = gameB, screen = guest, net = netB, role = "guest" },
}
for _, side in ipairs(sides) do
side.screen.onFinish = function(result) side.result = result end
side.game.stack:push(side.screen)
end
return sides
end
local function drive(sides, cap)
local frame = 0
while frame < (cap or 20000) do
frame = frame + 1
step(sides)
local done = true
for _, side in ipairs(sides) do
if side.result == nil then done = false end
end
if done then return frame end
end
return nil
end
local function hashSplit(a, b)
for turn, value in pairs(a.localHashes or {}) do
local other = (b.localHashes or {})[turn]
if other and other ~= value then return turn end
end
return nil
end
-- ---------------------------------------------------------------- one duel
do
local hostParty = { fighter("QUICKMON", 20) }
local guestParty = { fighter("SLOWMON", 20) }
local sides = newMatch(hostParty, guestParty, 987654321)
local host, guest = sides[1].screen, sides[2].screen
eq(host.battle.player.species, "QUICKMON", "the host leads with its own mon")
eq(host.battle.enemy.species, "SLOWMON",
"the guest's party became the host's enemy side")
eq(guest.battle.enemy.species, "QUICKMON",
"the host's party became the guest's enemy side")
eq(host.battle.save, nil,
"the lockstep battle carries no save, so no badge boost applies")
eq(host.battle:hasBadge("badges", "ZEPHYR"), false,
"...and every badge reads false on both sides")
eq(host.battle:badgeTypeBoost(host.battle.player, "NORMAL"), false,
"...including the type boost DoBadgeTypeBoosts would apply")
local frames = drive(sides)
check(frames ~= nil,
("a Gen 2 lockstep battle finishes on both sides (%s / %s)")
:format(tostring(sides[1].result), tostring(sides[2].result)))
local a, b = sides[1].result, sides[2].result
check((a == "win" and b == "lose") or (a == "lose" and b == "win")
or (a == "draw" and b == "draw"),
("the two simulations agree on the outcome (%s / %s)")
:format(tostring(a), tostring(b)))
eq(host.battle.player.hp, guest.battle.enemy.hp,
"the host's mon has identical HP on both machines")
eq(host.battle.enemy.hp, guest.battle.player.hp,
"the guest's mon has identical HP on both machines")
eq(hashSplit(host, guest), nil, "no hash split across the whole battle")
check(next(host.localHashes) ~= nil, "hashes were actually exchanged")
eq(host.battle.rngDraws, guest.battle.rngDraws,
"both machines drew the same number of random numbers")
eq(hostParty[1].hp, hostParty[1].stats.hp,
"the real party is untouched (the battle used clamped copies)")
eq(hostParty[1].experience, guest.battle.enemy.experience,
"no experience is paid on the cable")
end
-- ---------------------------------------------------------------- faints
-- Three mons a side, so both sides face a forced replacement and the wire
-- carries `replace` in both directions.
do
local hostParty = { fighter("QUICKMON", 15), fighter("SLOWMON", 18),
fighter("TANKMON", 20) }
local guestParty = { fighter("SLOWMON", 15), fighter("QUICKMON", 18),
fighter("TANKMON", 20) }
local sides = newMatch(hostParty, guestParty, 24680)
local host, guest = sides[1].screen, sides[2].screen
local frames = drive(sides, 60000)
check(frames ~= nil,
("a three-a-side battle finishes on both sides (%s / %s)")
:format(tostring(sides[1].result), tostring(sides[2].result)))
local a, b = sides[1].result, sides[2].result
check((a == "win" and b == "lose") or (a == "lose" and b == "win")
or (a == "draw" and b == "draw"),
"the two simulations agree after forced replacements")
eq(hashSplit(host, guest), nil, "no hash split across the replacements")
eq(host.battle.rngDraws, guest.battle.rngDraws,
"the RNG streams stayed in step through the faints")
for i = 1, 3 do
eq(host.battle.party[i].hp, guest.battle.enemyParty[i].hp,
"host bench slot " .. i .. " has identical HP on both machines")
eq(host.battle.enemyParty[i].hp, guest.battle.party[i].hp,
"guest bench slot " .. i .. " has identical HP on both machines")
end
local faints = 0
for _, mon in ipairs(host.battle.party) do
if (mon.hp or 0) <= 0 then faints = faints + 1 end
end
for _, mon in ipairs(host.battle.enemyParty) do
if (mon.hp or 0) <= 0 then faints = faints + 1 end
end
check(faints >= 3, "the match actually ran through faints (" .. faints .. ")")
end
-- ---------------------------------------------------------------- held items
do
local hostParty = { fighter("TANKMON", 25, "LEFTOVERS"),
fighter("SLOWMON", 25, "LEFTOVERS") }
local guestParty = { fighter("QUICKMON", 25, "KINGS_ROCK"),
fighter("TANKMON", 25, "KINGS_ROCK") }
local sides = newMatch(hostParty, guestParty, 1357911)
local host, guest = sides[1].screen, sides[2].screen
eq(host.battle.player.item, "LEFTOVERS", "the held item survives packMon2")
eq(guest.battle.enemy.item, "LEFTOVERS", "...and reaches the peer's copy")
eq(host.battle.enemy.item, "KINGS_ROCK", "the foe's item comes over too")
local frames = drive(sides, 60000)
check(frames ~= nil,
("a held-item battle finishes on both sides (%s / %s)")
:format(tostring(sides[1].result), tostring(sides[2].result)))
eq(hashSplit(host, guest), nil,
"Leftovers and King's Rock stay in sync across the wire")
eq(host.battle.rngDraws, guest.battle.rngDraws,
"...including the flinch rolls King's Rock adds")
eq(host.battle.player.hp, guest.battle.enemy.hp,
"the Leftovers holder's HP matches on both machines")
end
-- ---------------------------------------------------------------- cable rules
do
local sides = newMatch({ fighter("QUICKMON", 20) },
{ fighter("SLOWMON", 20) }, 4242)
local host = sides[1].screen
for _ = 1, 400 do
step(sides)
if host.phase == "menu" then break end
end
eq(host.phase, "menu", "the host reaches its own battle menu")
host:chooseMenu("item")
eq(host.phase, "refuse-menu", "the PACK is refused in a link battle")
check((host.message or ""):find("Items") ~= nil,
"...with the cable's own line: " .. tostring(host.message))
host.phase = "menu"
host:chooseMenu("run")
eq(host.phase, "refuse-menu", "RUN is refused in a link battle")
host.phase = "menu"
eq(host:chooseMenu("fight"), true, "FIGHT still opens the move list")
eq(host.phase, "moves", "...and reaches the move menu")
end
-- ---------------------------------------------------------------- no love.math
-- Rule 7 of .bazinga/online-handoff-rules.md: nothing on the link path may
-- roll love.math.random or read game.options. Both peers run a whole battle
-- with love.math.random replaced by a throw.
do
local saved = love.math.random
love.math.random = function()
error("love.math.random reached the link path", 2)
end
local ok, err = pcall(function()
local sides = newMatch({ fighter("QUICKMON", 20), fighter("TANKMON", 22) },
{ fighter("SLOWMON", 20), fighter("QUICKMON", 22) },
555777)
local frames = drive(sides, 60000)
if not frames then error("the battle did not finish") end
if hashSplit(sides[1].screen, sides[2].screen) then
error("hash split under the throwing rng")
end
end)
love.math.random = saved
check(ok, "a whole Gen 2 link battle rolls no love.math.random: "
.. tostring(err))
end
-- ---------------------------------------------------------------- signature
do
local hostParty = { fighter("QUICKMON", 20, "LEFTOVERS") }
local guestParty = { fighter("SLOWMON", 20) }
local sides = newMatch(hostParty, guestParty, 31337)
local host, guest = sides[1].screen, sides[2].screen
local hSig = host.battle:linkSignature("host")
local gSig = guest.battle:linkSignature("guest")
eq(hSig.actives, gSig.actives,
"the two machines sign the same actives string at turn 0")
eq(hSig.bench, gSig.bench, "...and the same bench string")
eq(hSig.volatile, gSig.volatile, "...and the same volatile string")
check(hSig.actives:find("LEFTOVERS", 1, true) ~= nil,
"the held item is inside the actives component")
check(hSig.actives:find("|r0", 1, true) ~= nil,
"the rng draw counter rides the actives component")
-- the components are what a mismatch NAMES: move one and only that one moves
host.battle.weather = "rain"
host.battle.weatherTurns = 5
local moved = host.battle:linkSignature("host")
eq(moved.actives, hSig.actives, "weather does not disturb the actives")
check(moved.volatile ~= hSig.volatile, "weather lands in the volatile part")
host.battle.weather, host.battle.weatherTurns = nil, 0
host.battle.party[1].hp = host.battle.party[1].hp - 1
local hurt = host.battle:linkSignature("host")
check(hurt.actives ~= hSig.actives, "a HP change lands in the actives")
check(hurt.bench ~= hSig.bench, "...and in the bench, which is fatal too")
end
-- ---------------------------------------------------------------- spectator
do
local hostParty = { fighter("QUICKMON", 18), fighter("TANKMON", 20) }
local guestParty = { fighter("SLOWMON", 18), fighter("QUICKMON", 20) }
local seed = 8080808
local sides = newMatch(hostParty, guestParty, seed)
-- The relay's spectate envelope: every action and replace the two real
-- players send, tagged with the side that sent it.
local specNet = Net.loopbackPair()
local feed = {}
for _, side in ipairs(sides) do
local realSend = side.net.send
local tag = side.role
side.net.send = function(self, msg)
if msg.type == "action" or msg.type == "replace" or msg.type == "bye" then
feed[#feed + 1] = { type = "spectate", side = tag, msg = msg }
end
return realSend(self, msg)
end
end
local specGame = makeGame("WATCHER", {})
local inbox = {}
local specNetStub = {
closed = false,
update = function() end,
poll = function()
local out = inbox
inbox = {}
return out
end,
send = function() end,
close = function(self) self.closed = true end,
}
local spectator, specErr = LinkBattle2.newSpectator(specGame, specNetStub, {
hostParty = packedParty(hostParty), guestParty = packedParty(guestParty),
hostName = "GOLD", guestName = "SILVER", seed = seed,
verdict = "full", strict = true, keepNetOpen = true,
})
check(spectator ~= nil, "the spectator builds: " .. tostring(specErr))
local specResult
spectator.onFinish = function(r) specResult = r end
specGame.stack:push(spectator)
local frame = 0
while frame < 60000 do
frame = frame + 1
step(sides)
for _, msg in ipairs(feed) do table.insert(inbox, msg) end
feed = {}
specGame.input.button = "a"
specGame.stack:update(1 / 60)
specGame.input.button = nil
specGame.stack:update(1 / 60)
if sides[1].result and sides[2].result and specResult then break end
end
check(sides[1].result ~= nil and sides[2].result ~= nil,
"the spectated match finished for both players")
check(specResult ~= nil, "the spectator finished too: " .. tostring(specResult))
local host = sides[1].screen
eq(spectator.battle.player.species, host.battle.player.species,
"the spectator's host side is the host's own mon")
eq(spectator.battle.player.hp, host.battle.player.hp,
"the spectator converged on the host's HP")
eq(spectator.battle.enemy.hp, host.battle.enemy.hp,
"...and on the guest's")
for i = 1, 2 do
eq(spectator.battle.party[i].hp, host.battle.party[i].hp,
"spectator host bench slot " .. i .. " converged")
eq(spectator.battle.enemyParty[i].hp, host.battle.enemyParty[i].hp,
"spectator guest bench slot " .. i .. " converged")
end
end
-- ---------------------------------------------------------------- forfeit
do
local sides = newMatch({ fighter("QUICKMON", 20) },
{ fighter("SLOWMON", 20) }, 606060)
local host, guest = sides[1].screen, sides[2].screen
for _ = 1, 2000 do
step(sides)
if host.phase == "menu" or host.phase == "moves" then break end
end
guest.update(guest, 1 / 60)
-- The peer's own shot clock ran out: unlike a mutual draw this has a winner.
sides[1].net:send({ type = "forfeit" })
for _ = 1, 400 do
step(sides)
if guest.result then break end
end
eq(guest.result, "win", "a forfeit from the peer is a win for the other side")
end
print(("\n%s"):format(failures == 0 and "ALL GEN 2 LOCKSTEP TESTS PASSED"
or failures .. " FAILURES"))
if failures > 0 then error("link2 lockstep failures: " .. failures, 0) end