mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
305 lines
11 KiB
Lua
305 lines
11 KiB
Lua
-- A full 16-player tournament, played out.
|
|
--
|
|
-- Sixteen distinct identities, six random Pokemon each, everyone mashing A,
|
|
-- through every match of every round until one champion is left: 8 + 4 + 2 +
|
|
-- 1 = 15 real lockstep battles, each with the tournament match shape
|
|
-- (turnLimit shot clock, keepNetOpen, forceLevel) plus a live spectator
|
|
-- rebuilding the same match from the relay's fan-out.
|
|
--
|
|
-- What this is actually checking, beyond "it finishes":
|
|
-- * 15 consecutive lockstep matches agree turn by turn, so a desync is
|
|
-- not something that only shows up deep into a bracket
|
|
-- * every match produces one winner and one loser -- never a double-win,
|
|
-- a double-loss, or a draw the server has to coin-flip (relay.js
|
|
-- resolves conflicting results with crypto.randomInt, so a draw here is
|
|
-- a real player's tournament decided by a coin toss)
|
|
-- * the spectator's reconstruction matches the two players' own views,
|
|
-- which is the tournament-only client path
|
|
-- * the shot clock does not fire on its own during ordinary play
|
|
--
|
|
-- Bracket pairing/advancement itself is the relay's job and is covered
|
|
-- server-side (pokeserver test/tournament16.js); this owns the client half.
|
|
--
|
|
-- luajit tests/link_tournament16.lua [seed]
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
love = love or require("tests.love_stub")
|
|
|
|
local Data = require("src.core.Data")
|
|
if not Data.pokemon then Data:load() end
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local Protocol = require("src.link.Protocol")
|
|
local Net = require("src.link.Net")
|
|
local Input = require("src.core.Input")
|
|
local LinkBattle = require("src.link.LinkBattle")
|
|
Input:init()
|
|
require("src.render.Font").load(Data)
|
|
|
|
local PLAYERS = 16
|
|
local PARTY_SIZE = 6
|
|
local TURN_LIMIT = 6 -- one of relay.js's VALID_TURN_LIMITS
|
|
|
|
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 makeRandom(seed)
|
|
local s = seed % 2147483647
|
|
if s <= 0 then s = s + 2147483646 end
|
|
return function(a, b)
|
|
s = (s * 16807) % 2147483647
|
|
if a == nil then return s / 2147483647 end
|
|
if b == nil then a, b = 1, a end
|
|
return a + (s % (b - a + 1))
|
|
end
|
|
end
|
|
|
|
local SPECIES = {}
|
|
for id in pairs(Data.pokemon) do SPECIES[#SPECIES + 1] = id end
|
|
table.sort(SPECIES)
|
|
local MOVES = {}
|
|
for id, m in pairs(Data.moves) do
|
|
if id ~= "STRUGGLE" and m.pp and m.pp > 0 then MOVES[#MOVES + 1] = id end
|
|
end
|
|
table.sort(MOVES)
|
|
|
|
local function makeFakeGame(name)
|
|
local save = require("src.core.SaveData").newGame()
|
|
save.player.name = name
|
|
local stack = { list = {} }
|
|
function stack:push(s, ...)
|
|
table.insert(self.list, s)
|
|
if s.enter then s:enter(...) end
|
|
end
|
|
function stack:pop() table.remove(self.list) end
|
|
function stack:top() return self.list[#self.list] end
|
|
function stack:update(dt)
|
|
local t = self:top()
|
|
if t and t.update then t:update(dt) end
|
|
end
|
|
return { data = Data, input = Input, stack = stack, save = save }
|
|
end
|
|
|
|
-- six random species with random movesets, the tournament's required size
|
|
local function randomParty(rnd)
|
|
local party = {}
|
|
for _ = 1, PARTY_SIZE do
|
|
local mon = Pokemon.new(Data, SPECIES[rnd(1, #SPECIES)], rnd(20, 60))
|
|
mon.moves = {}
|
|
for _ = 1, rnd(1, 4) do
|
|
local id = MOVES[rnd(1, #MOVES)]
|
|
table.insert(mon.moves, { id = id, pp = Data.moves[id].pp })
|
|
end
|
|
party[#party + 1] = mon
|
|
end
|
|
return party
|
|
end
|
|
|
|
-- a three-way loopback: the two players relay to each other, and every line
|
|
-- is also fanned out to the spectator tagged with the side that sent it,
|
|
-- which is the `spectate` envelope relay.js wraps tournament traffic in
|
|
local function matchNets()
|
|
local a, b = Net.loopbackPair()
|
|
local spec = Net.new()
|
|
spec.paired, spec.mode = true, "loopback"
|
|
local function send(self, msg)
|
|
if self.closed then return end
|
|
local Json = require("src.link.Json")
|
|
local decoded = Json.decode(Json.encode(msg))
|
|
if not decoded then return end
|
|
if not self.peerEnd.closed then table.insert(self.peerEnd.inbox, decoded) end
|
|
if not spec.closed then
|
|
table.insert(spec.inbox, { type = "spectate", side = self.matchSide,
|
|
msg = Json.decode(Json.encode(msg)) })
|
|
end
|
|
end
|
|
a.matchSide, b.matchSide = "host", "guest"
|
|
a.send, b.send = send, send
|
|
return a, b, spec
|
|
end
|
|
|
|
local PARTS = { "actives", "volatile", "bench" }
|
|
local function splitTurn(a, b)
|
|
for turn, mine in pairs(a.localParts) do
|
|
local theirs = b.localParts[turn]
|
|
if theirs then
|
|
for _, part in ipairs(PARTS) do
|
|
if mine[part] ~= theirs[part] then return turn, part end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Plays one bracket match to a finish. Returns the winner/loser entries, or
|
|
-- nil plus why it could not be decided.
|
|
local function playMatch(hostEntry, guestEntry, rnd, label)
|
|
local netH, netG, netS = matchNets()
|
|
local packedH = Protocol.packParty(hostEntry.game.save.party)
|
|
local packedG = Protocol.packParty(guestEntry.game.save.party)
|
|
local seed = rnd(1, 2 ^ 30)
|
|
local opts = { turnLimit = TURN_LIMIT, keepNetOpen = true,
|
|
verdict = "full", strict = true }
|
|
|
|
local bH = LinkBattle.newHost(hostEntry.game, netH, {
|
|
myParty = packedH, theirParty = packedG, theirName = guestEntry.name,
|
|
seed = seed, turnLimit = opts.turnLimit, keepNetOpen = true,
|
|
verdict = opts.verdict, strict = opts.strict })
|
|
local bG = LinkBattle.newGuest(guestEntry.game, netG, {
|
|
myParty = packedG, theirParty = packedH, theirName = hostEntry.name,
|
|
seed = seed, turnLimit = opts.turnLimit, keepNetOpen = true,
|
|
verdict = opts.verdict, strict = opts.strict })
|
|
-- one of the fourteen players not in this match, watching it live (it has
|
|
-- a party of its own like any eliminated entrant would -- the spectator
|
|
-- never battles with it, but newWild builds its scaffold from one)
|
|
local specEntry = makeFakeGame("WATCHER")
|
|
specEntry.save.party = randomParty(rnd)
|
|
local bS = LinkBattle.newSpectator(specEntry, netS, {
|
|
hostParty = packedH, guestParty = packedG,
|
|
hostName = hostEntry.name, guestName = guestEntry.name,
|
|
seed = seed, verdict = opts.verdict, strict = opts.strict })
|
|
if not (bH and bG and bS) then return nil, label .. ": a side refused to build" end
|
|
|
|
local resH, resG
|
|
bH.onFinish = function(r) resH = r end
|
|
bG.onFinish = function(r) resG = r end
|
|
hostEntry.game.stack:push(bH)
|
|
guestEntry.game.stack:push(bG)
|
|
specEntry.stack:push(bS)
|
|
|
|
local sides = {
|
|
{ bt = bH, game = hostEntry.game, party = bH.playerParty },
|
|
{ bt = bG, game = guestEntry.game, party = bG.playerParty },
|
|
}
|
|
-- everyone mashes A: the cursor is only steered onto a legal move row,
|
|
-- and occasionally onto a switch, so all six mons get used
|
|
local function drive(side)
|
|
local bt = side.bt
|
|
if bt.result then return end
|
|
if bt.phase ~= "menu" then side.menuFrames = 0 end
|
|
if bt.phase == "moveSelect" then
|
|
local usable = {}
|
|
for i, mv in ipairs(bt.player.curMoves) do
|
|
if (mv.pp or 0) > 0 and bt.player.disabledSlot ~= i then usable[#usable + 1] = i end
|
|
end
|
|
if #usable > 0 then bt.moveIndex = usable[rnd(1, #usable)] end
|
|
bt.menuIndex = 1
|
|
elseif bt.phase == "menu" then
|
|
bt.menuIndex = 1
|
|
side.menuFrames = (side.menuFrames or 0) + 1
|
|
if side.menuFrames >= 2 and not bt:menuLockedAction(bt.player)
|
|
and rnd(1, 100) <= 8 then
|
|
for _ = 1, 4 do
|
|
local mon = side.party[rnd(1, #side.party)]
|
|
if mon.hp > 0 and mon ~= bt.player.mon then
|
|
bt:resolveSwitch(mon)
|
|
side.menuFrames = 0
|
|
return
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local guard = 0
|
|
while (resH == nil or resG == nil) and guard < 200000 do
|
|
guard = guard + 1
|
|
Input.pressed = { a = true }
|
|
for _, side in ipairs(sides) do
|
|
drive(side)
|
|
side.game.stack:update(1 / 60)
|
|
end
|
|
specEntry.stack:update(1 / 60)
|
|
local turn, part = splitTurn(bH, bG)
|
|
if turn then
|
|
return nil, ("%s: turn %d %s split"):format(label, turn, part)
|
|
end
|
|
end
|
|
if resH == nil or resG == nil then
|
|
return nil, ("%s: unfinished after %d frames (turn %s)"):format(
|
|
label, guard, tostring(bH.turnCount))
|
|
end
|
|
-- the two views of the same match have to be opposite verdicts; a draw
|
|
-- means the server picks the winner with a coin flip
|
|
if not ((resH == "win" and resG == "lose") or (resH == "lose" and resG == "win")) then
|
|
return nil, ("%s: not a decisive result (%s / %s)"):format(label, resH, resG)
|
|
end
|
|
-- The spectator is a replay, so it is legitimately behind the two players
|
|
-- when they finish -- it still has their last turns queued. Let it drain
|
|
-- before comparing, or the check reads a mid-match frame.
|
|
local settle = 0
|
|
while settle < 20000 and not bS.finished do
|
|
settle = settle + 1
|
|
local before = bS.turnCount
|
|
specEntry.stack:update(1 / 60)
|
|
if bS.result and bS.turnCount == before and #bS.queue == 0 then break end
|
|
end
|
|
-- the spectator rebuilt the same battle from the relay copy
|
|
local specOk = bS.player.mon.hp == bH.player.mon.hp
|
|
and bS.enemy.mon.hp == bG.player.mon.hp
|
|
if resH == "win" then
|
|
return hostEntry, guestEntry, specOk
|
|
end
|
|
return guestEntry, hostEntry, specOk
|
|
end
|
|
|
|
local seed = tonumber(arg and arg[1]) or 20260728
|
|
local rnd = makeRandom(seed)
|
|
|
|
local entrants = {}
|
|
for i = 1, PLAYERS do
|
|
local name = ("PLAYER%02d"):format(i)
|
|
local game = makeFakeGame(name)
|
|
game.save.party = randomParty(rnd)
|
|
game.save.player.id = rnd(0, 65535) -- distinct trainer identities
|
|
entrants[i] = { name = name, game = game }
|
|
end
|
|
check(#entrants == PLAYERS, "16 distinct entrants registered")
|
|
local sizesOk = true
|
|
for _, e in ipairs(entrants) do
|
|
if #e.game.save.party ~= PARTY_SIZE then sizesOk = false end
|
|
end
|
|
check(sizesOk, "every entrant brings a full party of " .. PARTY_SIZE)
|
|
|
|
-- 16 is a power of two, so no byes: 8 + 4 + 2 + 1 real matches
|
|
local alive = entrants
|
|
local round, matchesPlayed, specOkCount = 0, 0, 0
|
|
while #alive > 1 and failures == 0 do
|
|
round = round + 1
|
|
local survivors = {}
|
|
for i = 1, #alive, 2 do
|
|
local label = ("round %d match %d"):format(round, (i + 1) / 2)
|
|
local winner, loser, specOk = playMatch(alive[i], alive[i + 1], rnd, label)
|
|
if not winner then
|
|
check(false, tostring(loser))
|
|
break
|
|
end
|
|
matchesPlayed = matchesPlayed + 1
|
|
if specOk then specOkCount = specOkCount + 1 end
|
|
survivors[#survivors + 1] = winner
|
|
end
|
|
if failures > 0 then break end
|
|
check(#survivors == #alive / 2,
|
|
("round %d halved the field (%d -> %d)"):format(round, #alive, #survivors))
|
|
alive = survivors
|
|
end
|
|
|
|
check(matchesPlayed == PLAYERS - 1,
|
|
("every bracket match was played (%d of %d)"):format(matchesPlayed, PLAYERS - 1))
|
|
check(round == 4, ("the bracket ran 4 rounds (ran %d)"):format(round))
|
|
check(#alive == 1, "exactly one champion is left")
|
|
if alive[1] then print(" champion: " .. alive[1].name) end
|
|
check(specOkCount == matchesPlayed,
|
|
("every match's spectator view matched the players' (%d of %d)"):format(
|
|
specOkCount, matchesPlayed))
|
|
|
|
print(("\ntournament16: %s"):format(
|
|
failures == 0 and "PASSED" or (failures .. " FAILURES")))
|
|
assert(failures == 0, failures .. " tournament failure(s)")
|
|
return true
|