Files
2026-09-24 05:17:32 -04:00

2284 lines
90 KiB
Lua

-- luajit tests/online_client.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local Protocol2 = require("src.online.Protocol2")
local Wire = require("src.link.Wire")
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
local function copy(v)
if type(v) ~= "table" then return v end
local out = {}
for k, val in pairs(v) do out[k] = copy(val) end
return out
end
local savedGetTime = love.timer.getTime
local CLOCK = 0
love.timer.getTime = function() return CLOCK end
-- ---------------------------------------------------------------- harness
local function newTransport()
local t = { paired = true, closed = false, error = nil,
inbox = {}, outbox = {} }
function t:update() end
function t:poll()
local messages = self.inbox
self.inbox = {}
return messages
end
function t:send(msg) table.insert(self.outbox, copy(msg)) end
function t:close() self.closed = true end
return t
end
local function newClientModule()
package.loaded["src.online.Client"] = nil
local Client = require("src.online.Client")
Client.reset()
return Client
end
local PROFILE = {
engine = 1, version = "red", engineVersion = "0.0.0-dev",
apiVersion = "2", fingerprint = "abc123", rulesetId = "gen1_faithful",
kind = "vanilla", rule = { partySize = 1 },
}
local function rid(n) return ("r%016x"):format(n) end
local function tid(n) return ("t%016x"):format(n) end
local ROOM1 = rid(0xabc234)
local TOUR1 = tid(0x234)
local Relay = {}
Relay.__index = Relay
local function newRelay()
return setmetatable({ seats = {}, room = nil }, Relay)
end
function Relay:seat(id, name)
local seat = { id = id, name = name, transport = newTransport(), ack = 0 }
self.seats[id] = seat
return seat
end
function Relay:to(seat, msg)
if not seat then return end
table.insert(seat.transport.inbox, copy(msg))
end
function Relay:welcome(seat, resumed)
self:to(seat, { type = "lobby_welcome", session = "S-" .. seat.id,
you = { id = seat.id, name = seat.name, verified = true },
serverTime = 1000, heartbeatMs = 10000,
resumed = resumed == true })
end
function Relay:sideOf(id)
local room = self.room
if not room then return nil end
if room.players[1] and room.players[1].id == id then return "host" end
if room.players[2] and room.players[2].id == id then return "guest" end
return nil
end
function Relay:roomStateMsg()
local room = self.room
local players = {}
for i, p in ipairs(room.players) do
table.insert(players, { id = p.id, name = p.name, verified = true,
ready = p.ready or false, online = true,
seat = i - 1 })
end
local spectators = {}
for _, sp in ipairs(room.spectators) do
table.insert(spectators, { id = sp.id, name = sp.name, verified = false })
end
return { type = "room_state", room = room.room, players = players,
spectators = spectators, stage = room.stage,
profile = room.profile, host = room.host, seed = room.seed,
rule = room.profile and room.profile.rule or nil,
intent = "battle", maxSpectators = 8, match = room.match,
engine = 1, seats = 2, locked = false, listed = true,
origin = room.origin or "create", deadlines = {} }
end
function Relay:broadcast(msg)
local room = self.room
for _, p in ipairs(room.players) do self:to(self.seats[p.id], msg) end
for _, sp in ipairs(room.spectators) do self:to(self.seats[sp.id], msg) end
end
function Relay:roomState(stage)
local room = self.room
if not room then return end
room.stage = stage or room.stage
self:broadcast(self:roomStateMsg())
end
function Relay:startMatch(token)
local room = self.room
room.matchNo = (room.matchNo or 0) + 1
room.match = token or (room.room .. "-m" .. room.matchNo)
room.seed = 4242
room.log = {}
room.seq = 0
room.reports = {}
self:roomState("battling")
local host, guest = room.players[1], room.players[2]
local base = { seed = room.seed, ruleset = room.profile.rulesetId,
rule = room.profile.rule, hostName = host.name,
guestName = guest.name, hostParty = host.party,
guestParty = guest.party, match = room.match,
room = room.room, seats = 2, engine = 1,
players = { { id = host.id, name = host.name, seat = 0 },
{ id = guest.id, name = guest.name, seat = 1 } } }
local function start(seat, extra)
local msg = {}
for k, v in pairs(base) do msg[k] = v end
for k, v in pairs(extra) do msg[k] = v end
self:to(seat, msg)
end
start(self.seats[host.id], { type = "match_start", role = "host", seat = 0,
peerName = guest.name, theirParty = guest.party })
start(self.seats[guest.id], { type = "match_start", role = "guest", seat = 1,
peerName = host.name, theirParty = host.party })
for _, sp in ipairs(room.spectators) do
start(self.seats[sp.id], { type = "match_start_spectate",
role = "spectator" })
end
end
function Relay:resolve(how, winnerSide)
local room = self.room
local winner = winnerSide and room.players[winnerSide == "host" and 1 or 2]
self:broadcast({ type = "room_result", match = room.match, room = room.room,
winner = winner and winner.name or nil,
winnerId = winner and winner.id or nil, how = how,
winnerSide = winnerSide and (winnerSide == "host" and 0 or 1) or nil,
winners = winner and { winner.id } or {} })
for _, p in ipairs(room.players) do p.ready = false end
room.seed = nil
room.reports = {}
self:roomState("waiting")
local entry = self.tour and self.tour.liveEntry
if entry and entry.match == room.match then
entry.state = "done"
entry.how = how
entry.winner = winner and winner.id or entry.a
local loser = entry.winner == entry.a and entry.b or entry.a
for _, p in ipairs(self.tour.players) do
if p.id == loser then p.eliminated = true end
end
self.tour.liveEntry = nil
self.tour.live = nil
self:tourState()
self:tourAdvance()
end
end
function Relay:fanout(fromId, clientSeq, inner)
local room = self.room
if not room then return end
local side = self:sideOf(fromId)
if not side then return end
room.clientSeq = room.clientSeq or {}
if clientSeq and room.clientSeq[fromId] and clientSeq <= room.clientSeq[fromId] then
room.duplicates = (room.duplicates or 0) + 1
return
end
if clientSeq then room.clientSeq[fromId] = clientSeq end
room.seq = room.seq + 1
local seat = side == "host" and 0 or 1
local entry = { seq = room.seq, clientSeq = clientSeq, side = side,
seat = seat, msg = copy(inner) }
table.insert(room.log, entry)
for _, p in ipairs(room.players) do
if p.id ~= fromId then
self:to(self.seats[p.id], { type = "room_msg", seq = entry.seq,
clientSeq = clientSeq, seat = seat,
msg = copy(inner) })
end
end
for _, sp in ipairs(room.spectators) do
self:to(self.seats[sp.id], { type = "room_msg", seq = entry.seq,
clientSeq = clientSeq, side = side,
seat = seat, msg = copy(inner) })
end
if inner.type == "forfeit" then
room.reports[side] = "lose"
self:resolve("forfeit", side == "host" and "guest" or "host")
end
end
function Relay:replay(seat, from)
local room = self.room
local side = self:sideOf(seat.id)
local msgs = {}
for _, e in ipairs(room.log or {}) do
if e.seq > from and not (side and e.side == side) then
table.insert(msgs, side
and { seq = e.seq, clientSeq = e.clientSeq, seat = e.seat,
msg = copy(e.msg) }
or { seq = e.seq, clientSeq = e.clientSeq, side = e.side,
seat = e.seat, msg = copy(e.msg) })
end
end
self:to(seat, { type = "room_replay", from = from, msgs = msgs,
yourSeq = (room.clientSeq or {})[seat.id] or 0 })
end
-- ------------------------------------------------------------- tournaments
local CHILD_ROOMS = { rid(0xc1), rid(0xc2), rid(0xc3), rid(0xc4), rid(0xc5),
rid(0xc6), rid(0xc7), rid(0xc8) }
function Relay:tourSeats()
local out = {}
for _, p in ipairs(self.tour.players) do out[#out + 1] = self.seats[p.id] end
for _, sp in ipairs(self.tour.spectators) do
out[#out + 1] = self.seats[sp.id]
end
return out
end
function Relay:tourBroadcast(msg)
for _, seat in ipairs(self:tourSeats()) do self:to(seat, msg) end
end
function Relay:tourNameOf(id)
for _, p in ipairs(self.tour.players) do
if p.id == id then return p.name end
end
return nil
end
function Relay:tourStateMsg()
local t = self.tour
local players, spectators, bracket = {}, {}, {}
for _, p in ipairs(t.players) do
players[#players + 1] = { id = p.id, name = p.name, verified = true,
online = true, eliminated = p.eliminated == true }
end
for _, sp in ipairs(t.spectators) do
spectators[#spectators + 1] = { id = sp.id, name = sp.name,
verified = false }
end
for _, round in ipairs(t.bracket) do
local matches = {}
for _, e in ipairs(round.matches) do
matches[#matches + 1] = { match = e.match, a = e.a, b = e.b,
winner = e.winner, how = e.how,
state = e.state }
end
bracket[#bracket + 1] = { round = round.round, matches = matches }
end
return { type = "tour_state", tour = t.tour, code = t.code, creator = t.creator,
stage = t.stage, players = players, spectators = spectators,
profile = t.profile, rule = t.profile and t.profile.rule or nil,
shotClock = t.shotClock, round = t.round, bracket = bracket,
live = t.live, champion = t.champion,
maxSpectators = 16 }
end
function Relay:tourState()
if not self.tour then return end
self:tourBroadcast(self:tourStateMsg())
end
function Relay:tourStartMatch(entry, roundNo)
local t = self.tour
entry.state = "live"
t.live = entry.match
t.liveEntry = entry
t.childNo = (t.childNo or 0) + 1
local child = CHILD_ROOMS[t.childNo]
local function playerRec(id)
for _, p in ipairs(t.players) do
if p.id == id then
return { id = p.id, name = p.name, party = p.party }
end
end
end
local a, b = playerRec(entry.a), playerRec(entry.b)
local spectators = {}
for _, p in ipairs(t.players) do
if p.id ~= entry.a and p.id ~= entry.b then
spectators[#spectators + 1] = { id = p.id, name = p.name }
end
end
for _, sp in ipairs(t.spectators) do
spectators[#spectators + 1] = { id = sp.id, name = sp.name }
end
self.room = { room = child, players = { a, b }, spectators = spectators,
stage = "waiting", profile = t.profile, host = t.creator,
seed = nil, log = {}, seq = 0, reports = {}, matchNo = 0,
match = nil, origin = "tour" }
self:to(self.seats[entry.a], { type = "tour_match", match = entry.match,
round = roundNo, room = child })
self:to(self.seats[entry.b], { type = "tour_match", match = entry.match,
round = roundNo, room = child })
for _, sp in ipairs(spectators) do
self:to(self.seats[sp.id], { type = "tour_match_spectate",
match = entry.match, round = roundNo,
room = child })
end
self:tourState()
self:to(self.seats[entry.a], { type = "tour_deadline", kind = "shot",
at = 1000 + (t.shotClock or 6) * 1000,
match = entry.match })
self:startMatch(entry.match)
end
function Relay:tourAdvance()
local t = self.tour
if not t or t.stage ~= "running" then return end
local round = t.bracket[#t.bracket]
for _, e in ipairs(round.matches) do
if e.state == "bye" and not e.winner then
e.winner = e.a
self:to(self.seats[e.a], { type = "tour_bye", match = e.match,
round = round.round })
end
end
for _, e in ipairs(round.matches) do
if e.state == "pending" then
self:tourStartMatch(e, round.round)
return
end
end
local winners = {}
for _, e in ipairs(round.matches) do
if e.winner then winners[#winners + 1] = e.winner end
end
if #winners <= 1 then
t.stage = "finished"
t.champion = winners[1]
self:tourState()
self:tourBroadcast({ type = "tour_over", tour = t.tour,
championId = winners[1],
champion = self:tourNameOf(winners[1]) })
return
end
t.round = t.round + 1
local matches = {}
for i = 1, #winners, 2 do
local a, b = winners[i], winners[i + 1]
matches[#matches + 1] = {
match = ("%s-r%d-m%d"):format(t.tour, t.round, math.ceil(i / 2)),
a = a, b = b, state = b and "pending" or "bye" }
end
t.bracket[#t.bracket + 1] = { round = t.round, matches = matches }
self:tourState()
self:tourAdvance()
end
function Relay:tourHandle(seat, msg)
local kind = msg.type
local t = self.tour
if kind == "tour_create" then
self.tour = { tour = TOUR1, code = msg.public == false and "TRN234" or nil,
creator = seat.id, stage = "registering",
players = {}, spectators = {}, profile = msg.profile,
shotClock = msg.shotClock, round = 0, bracket = {},
live = nil, champion = nil, childNo = 0 }
if msg.playing == false then
table.insert(self.tour.spectators, { id = seat.id, name = seat.name })
elseif not msg.party or #msg.party == 0 then
self.tour = nil
self:to(seat, { type = "join_error", reason = "party_ineligible" })
return true
else
table.insert(self.tour.players, { id = seat.id, name = seat.name,
party = msg.party,
digest = msg.partyDigest })
end
self:tourState()
return true
end
if kind ~= "tour_join" and kind ~= "tour_leave" and kind ~= "tour_start"
and kind ~= "tour_kick" and kind ~= "tour_close" then
return false
end
if not t then
self:to(seat, { type = "join_error", reason = "tour_not_found" })
return true
end
if kind == "tour_join" then
local byId = msg.tour ~= nil and msg.tour == t.tour
local byCode = msg.code ~= nil and t.code ~= nil and msg.code == t.code
if byId and t.code ~= nil then
self:to(seat, { type = "join_error", reason = "tour_private" })
return true
end
if not byId and not byCode then
self:to(seat, { type = "join_error", reason = "tour_not_found" })
return true
end
if msg.as == "spectator" then
table.insert(t.spectators, { id = seat.id, name = seat.name })
else
if t.stage ~= "registering" then
self:to(seat, { type = "join_error", reason = "tour_started" })
return true
end
if not msg.party or #msg.party == 0 then
self:to(seat, { type = "join_error", reason = "party_ineligible" })
return true
end
table.insert(t.players, { id = seat.id, name = seat.name,
party = msg.party, digest = msg.partyDigest })
end
self:tourState()
elseif kind == "tour_start" then
if seat.id ~= t.creator then
self:to(seat, { type = "join_error", reason = "not_creator" })
return true
end
if #t.players < 2 then return true end
t.stage = "running"
t.round = 1
local matches = {}
for i = 1, #t.players, 2 do
local a, b = t.players[i], t.players[i + 1]
matches[#matches + 1] = {
match = ("%s-r1-m%d"):format(t.tour, math.ceil(i / 2)),
a = a.id, b = b and b.id or nil,
state = b and "pending" or "bye" }
end
t.bracket[1] = { round = 1, matches = matches }
self:tourState()
self:tourAdvance()
elseif kind == "tour_kick" then
if seat.id ~= t.creator then
self:to(seat, { type = "join_error", reason = "not_creator" })
return true
end
self.tourKicked = msg.id
self:to(self.seats[msg.id], { type = "tour_closed", tour = t.tour,
reason = "kicked" })
for i = #t.players, 1, -1 do
if t.players[i].id == msg.id then table.remove(t.players, i) end
end
self:tourState()
elseif kind == "tour_close" then
if seat.id ~= t.creator then
self:to(seat, { type = "join_error", reason = "not_creator" })
return true
end
self.tourClosedBy = seat.id
self:tourBroadcast({ type = "tour_closed", tour = t.tour,
reason = "closed" })
self.tour = nil
elseif kind == "tour_leave" then
self.tourLeft = seat.id
for i = #t.players, 1, -1 do
if t.players[i].id == seat.id then table.remove(t.players, i) end
end
for i = #t.spectators, 1, -1 do
if t.spectators[i].id == seat.id then table.remove(t.spectators, i) end
end
self:tourState()
end
return true
end
function Relay:handle(seat, msg)
local kind = msg.type
local room = self.room
if kind:sub(1, 5) == "tour_" then
if self:tourHandle(seat, msg) then return end
end
if kind == "lobby_hello" then
seat.name = msg.name or seat.name
self:welcome(seat, false)
elseif kind == "resume" then
seat.resumedWith = msg.ack
self:welcome(seat, true)
if room then
self:to(seat, self:roomStateMsg())
self:replay(seat, math.max(0, msg.ack or 0))
end
elseif kind == "room_kick" then
self.kicked = msg.id
elseif kind == "room_close" then
self.closedBy = seat.id
if room then self:broadcast({ type = "room_closed", reason = "closed",
room = room.room }) end
self.room = nil
elseif kind == "room_create" then
self.room = { room = ROOM1, players = {}, spectators = {},
stage = "waiting", profile = msg.profile,
host = seat.id, seed = nil, log = {}, seq = 0,
reports = {}, matchNo = 0, match = nil }
table.insert(self.room.players, { id = seat.id, name = seat.name })
self:roomState("waiting")
elseif kind == "room_join" then
if not room or msg.room ~= room.room then
self:to(seat, { type = "join_error", reason = "not_found" })
return
end
if not msg.profile then
self:to(seat, { type = "join_error", reason = "bad_profile" })
return
end
if msg.as == "spectator" then
table.insert(room.spectators, { id = seat.id, name = seat.name })
else
table.insert(room.players, { id = seat.id, name = seat.name })
end
self:roomState(nil)
if msg.as == "spectator" and room.stage == "battling" then
self:replay(seat, 0)
end
elseif kind == "room_ready" then
local ready = 0
for _, p in ipairs(room.players) do
if p.id == seat.id then
p.ready = true
p.party = msg.party
p.digest = msg.partyDigest
end
if p.ready then ready = ready + 1 end
end
if ready >= 2 then self:startMatch() else self:roomState("ready") end
elseif kind == "room_msg" then
self:fanout(seat.id, msg.seq, msg.msg)
elseif kind == "room_ack" then
seat.ack = math.max(seat.ack, msg.seq or 0)
elseif kind == "room_report" or kind == "forfeit" then
if not room or not room.match then return end
if msg.match and msg.match ~= room.match then
self.staleReports = (self.staleReports or 0) + 1
return
end
local side = self:sideOf(seat.id)
if not side then return end
room.reports[side] = kind == "forfeit" and "lose" or msg.result
local other = side == "host" and "guest" or "host"
if kind == "forfeit" then
self:resolve("forfeit", other)
elseif room.reports[other] then
local winner = room.reports.host == "win" and "host" or "guest"
self:resolve("agreed", winner)
end
elseif kind == "room_leave" then
if room then
for i = #room.players, 1, -1 do
if room.players[i].id == seat.id then table.remove(room.players, i) end
end
for i = #room.spectators, 1, -1 do
if room.spectators[i].id == seat.id then
table.remove(room.spectators, i)
end
end
end
end
end
function Relay:pump()
local ids = {}
for id in pairs(self.seats) do ids[#ids + 1] = id end
table.sort(ids)
for _, id in ipairs(ids) do
local seat = self.seats[id]
local outbox = seat.transport.outbox
seat.transport.outbox = {}
for _, msg in ipairs(outbox) do
if type(msg) == "table" and type(msg.type) == "string" then
self:handle(seat, msg)
end
end
end
end
local function connectTo(Client, seat, name)
Client.configure({ relayAddress = "fake:1", connect = function()
return seat.transport
end })
return Client.connect({ name = name, profiles = { PROFILE } })
end
-- ---------------------------------------------------------------- protocol
do
local hello = Protocol2.lobbyHello({ name = "REDDISHLONGNAMEHERE",
profiles = { PROFILE },
engineVersion = "0.0.0-dev" })
eq(hello.type, "lobby_hello", "lobbyHello builds its type")
eq(#hello.name, 16, "a display name is bounded to 16 characters")
eq(hello.profiles[1].rulesetId, "gen1_faithful", "the profile rides along")
eq(hello.profiles[1].rule.partySize, 1, "the party rule rides along")
eq(hello.protocol, 3, "lobby_hello announces protocol 3")
local join = Protocol2.roomJoin(ROOM1:upper(), "spectator")
eq(join.room, ROOM1, "a room id is lower-cased")
eq(join.as, "spectator", "join carries the seat kind")
eq(join.code, nil, "room_join never carries a code")
eq(Protocol2.roomJoin("ABC234", "player").room, nil,
"a code-shaped room reference is refused")
eq(Protocol2.roomJoin("r123", "player").room, nil,
"a short room id is refused")
eq(Protocol2.roomJoin(ROOM1, "player", PROFILE, "0042").pin, "0042",
"a four-digit PIN rides along")
eq(Protocol2.roomJoin(ROOM1, "player", PROFILE, "42").pin, nil,
"a short PIN is refused")
eq(Protocol2.roomJoin(ROOM1, "player", PROFILE, 1234).pin, nil,
"a numeric PIN is refused (it must be a string)")
local wrapped = Protocol2.roomMsg(3, { type = "action", kind = "move",
slot = 2 })
eq(wrapped.seq, 3, "room_msg keeps its seq")
eq(wrapped.msg.kind, "move", "room_msg sanitizes the inner message")
eq(Protocol2.roomMsg(1, nil), nil, "room_msg without an inner message drops")
local list = Wire.sanitize({ type = "lobby_list", entries = (function()
local out = {}
for i = 1, 400 do out[i] = { id = "p" .. i, name = "N", since = i } end
return out
end)() })
eq(#list.entries, 200, "a lobby list is bounded to 200 entries")
local replay = Wire.sanitize({ type = "room_replay", from = 0,
msgs = (function()
local out = {}
for i = 1, 900 do out[i] = { seq = i, msg = { type = "bye" } } end
return out
end)() })
eq(#replay.msgs, 512, "a replay is bounded to 512 messages")
local state = Wire.sanitize({ type = "room_state", room = ROOM1,
players = {}, spectators = (function()
local out = {}
for i = 1, 200 do out[i] = { id = "s" .. i, name = "S" } end
return out
end)() })
eq(#state.spectators, 64, "spectators are bounded to 64")
eq(select(2, Protocol2.validate({ type = "room_state", players = {} })),
"room_state without a room id", "an id-less room_state fails validation")
eq(select(2, Protocol2.validate({ type = "room_state", code = "ABC234",
players = {} })),
"room_state without a room id", "a room code is no room id")
eq(select(2, Protocol2.validate({ type = "join_error" })),
"join_error without a reason", "a reasonless join_error fails validation")
eq(Protocol2.validate({ type = "action", kind = "move", slot = 1 }).kind,
"move", "an unvalidated type still passes through sanitize")
local created = Protocol2.roomCreate({ intent = "battle", profile = PROFILE,
playing = true, maxSpectators = 4,
private = true, pin = "0000",
note = string.rep("z", 90) })
eq(created.private, true, "roomCreate carries a private flag")
eq(created.pin, "0000", "and the host's PIN")
eq(created.public, nil, "the legacy public flag is gone")
eq(created.seats, 2, "a room is two seats unless the caller says otherwise")
eq(#created.note, 40, "a room note is bounded to 40 characters")
eq(Protocol2.roomCreate({ profile = PROFILE }).private, false,
"a room is open unless the caller says otherwise")
eq(Protocol2.roomCreate({ profile = PROFILE, pin = "1234" }).pin, nil,
"an open room never carries a PIN")
eq(Protocol2.roomCreate({ profile = PROFILE }).note, nil,
"a room without a note sends none")
local shaped = Wire.sanitize({ type = "lobby_delta", added = {
{ id = "p9", name = "N", since = 1, code = "abc234", open = "yes",
stage = "battling", players = 900, spectators = 200,
maxSpectators = 200 } } }).added[1]
eq(shaped.code, nil, "a lobby entry never carries a code")
eq(shaped.open, nil, "a non-boolean open is dropped from a lobby entry")
eq(shaped.stage, "battling", "a lobby entry carries the room stage")
eq(shaped.players, 64, "the entry player count is bounded")
eq(shaped.spectators, 64, "the entry spectator count is bounded")
eq(shaped.maxSpectators, 64, "the entry spectator ceiling is bounded")
local openEntry = Wire.sanitize({ type = "lobby_delta", added = {
{ id = "p8", name = "N", since = 1, code = "abc234", open = true,
stage = "waiting", players = 1, spectators = 0,
maxSpectators = 4 } } }).added[1]
eq(openEntry.open, true, "an open flag survives the schema")
eq(Wire.sanitize({ type = "lobby_list", entries = {}, online = 12 }).online,
12, "lobby_list carries the relay's online count")
local create = Protocol2.tourCreate({ profile = PROFILE, playing = false,
shotClock = 7, maxSpectators = 999 })
eq(create.type, "tour_create", "tourCreate builds its type")
eq(create.playing, false, "a spectating creator says so")
eq(create.shotClock, 6, "an off-list shot clock snaps to 3/6/9")
eq(Protocol2.tourCreate({ shotClock = 9 }).shotClock, 9,
"an allowed shot clock is kept")
eq(create.maxSpectators, 64, "maxSpectators is bounded to 64")
eq(create.public, true, "a tournament is public unless it says otherwise")
eq(Protocol2.tourCreate({ public = false }).public, false,
"tourCreate carries a private flag")
eq(Protocol2.tourCreate({ note = ("z"):rep(80) }).note:len(), 40,
"the tournament note is clipped to 40 characters")
local tj = Protocol2.tourJoin("trn234", "player", PROFILE,
{ { species = "PIKACHU", level = 5 } }, "dg")
eq(tj.code, "TRN234", "tour_join upper-cases a private tournament code")
eq(tj.tour, nil, "and sends no id with it")
eq(tj.party[1].species, "PIKACHU", "tour_join carries the team")
eq(tj.partyDigest, "dg", "tour_join carries the digest")
local tjId = Protocol2.tourJoin({ tour = TOUR1, as = "spectator",
profile = PROFILE })
eq(tjId.tour, TOUR1, "the table form joins a public tournament by id")
eq(tjId.code, nil, "without a code")
eq(tjId.as, "spectator", "and keeps the seat kind")
eq(Protocol2.tourKick("p9").id, "p9", "tour_kick names its target")
eq(Protocol2.tourStart().type, "tour_start", "tour_start builds its type")
eq(Protocol2.tourClose().type, "tour_close", "tour_close builds its type")
eq(Protocol2.tourLeave().type, "tour_leave", "tour_leave builds its type")
local function fill(n, fn)
local out = {}
for i = 1, n do out[i] = fn(i) end
return out
end
local big = Wire.sanitize({
type = "tour_state", tour = TOUR1, creator = "p1", stage = "running",
players = fill(200, function(i)
return { id = "p" .. i, name = "N" .. i, eliminated = false }
end),
spectators = fill(200, function(i) return { id = "s" .. i, name = "S" } end),
bracket = fill(20, function(r)
return { round = r, matches = fill(80, function(k)
return { match = "m" .. r .. "-" .. k, a = "p1", b = "p2",
state = "pending" }
end) }
end),
})
eq(#big.players, 64, "a tournament is bounded to 64 players")
eq(#big.spectators, 64, "and to 64 spectators")
eq(#big.bracket, 7, "a bracket is bounded to 7 rounds")
eq(#big.bracket[1].matches, 32, "a round is bounded to 32 matches")
eq(big.players[1].eliminated, false, "an elimination mark rides along")
eq(Wire.sanitize({ type = "tour_state", code = "TRN234", stage = "nonsense",
players = {} }).stage, "registering",
"an unknown stage falls back to registering")
eq(Wire.sanitize({ type = "tour_state", code = "TRN234", players = {},
bracket = { { round = 1, matches = {
{ match = "m1", a = "p1", state = "sideways" } } } }
}).bracket[1].matches[1].state, nil,
"an unknown match state is dropped")
eq(select(2, Protocol2.validate({ type = "tour_match", round = 1 })),
"tour_match without a match token",
"a tokenless tour_match fails validation")
eq(select(2, Protocol2.validate({ type = "tour_match", match = "m1" })),
"tour_match without a child room id",
"an id-less tour_match fails validation")
eq(select(2, Protocol2.validate({ type = "tour_match", match = "m1",
code = "CHA234" })),
"tour_match without a child room id",
"a child room code is no child room id")
eq(select(2, Protocol2.validate({ type = "tour_deadline", kind = "shot" })),
"tour_deadline without a time", "a timeless tour_deadline fails")
eq(select(2, Protocol2.validate({ type = "tour_over" })),
"tour_over without a tournament id", "an id-less tour_over fails")
eq(Protocol2.validate({ type = "tour_match", match = "m1",
room = rid(0xc1):upper() }).room, rid(0xc1),
"a child room id is lower-cased on the way in")
eq(select(2, Protocol2.validate({ type = "tour_closed", tour = TOUR1 })),
"tour_closed without a reason", "a reasonless tour_closed fails")
eq(Protocol2.validate({ type = "tour_closed", tour = TOUR1,
reason = "idle" }).tour, TOUR1,
"tour_closed carries its tournament id")
eq(Protocol2.tourCreate({ profile = PROFILE, party = {
{ species = "PIKACHU", level = 5 } }, partyDigest = "dd" }).party[1]
.species, "PIKACHU", "tour_create carries the creator's team")
end
-- ---------------------------------------------------------------- handshake
local relay = newRelay()
local seatA = relay:seat("a1", "RED")
local ClientA = newClientModule()
local states = {}
ClientA.on("state", function(s) table.insert(states, s) end)
eq(ClientA.state(), "offline", "a fresh client is offline")
check(connectTo(ClientA, seatA, "RED"), "connect opens the transport")
eq(ClientA.state(), "connecting", "connect moves to connecting")
eq(seatA.transport.outbox[1].type, "lobby_hello", "connect sends lobby_hello")
relay:pump()
ClientA.update(1 / 60)
eq(ClientA.state(), "online", "lobby_welcome moves to online")
eq(ClientA.you().name, "RED", "the welcome names us")
eq(ClientA.you().verified, true, "the welcome carries verification")
check(ClientA.serverTime() ~= nil and ClientA.serverTime() >= 1000,
"the client estimates the relay's clock")
eq(ClientA.sessionId(), "S-a1", "the session id is held for resume")
eq(table.concat(states, ","), "connecting,online",
"state transitions are offline -> connecting -> online")
-- ---------------------------------------------------------------- lobby
local lobbyEvents = 0
ClientA.on("lobby", function() lobbyEvents = lobbyEvents + 1 end)
relay:to(seatA, { type = "lobby_list", entries = {
{ id = "p3", name = "GREEN", since = 30, intent = "battle", profile = PROFILE },
{ id = "p1", name = "BLUE", since = 10, intent = "battle", profile = PROFILE },
{ id = "p2", name = "LEAF", since = 20, intent = "trade", profile = PROFILE },
} })
ClientA.update(0)
eq(#ClientA.lobby(), 3, "the lobby list lands")
eq(ClientA.lobby()[1].name, "BLUE", "entries order by since (first)")
eq(ClientA.lobby()[3].name, "GREEN", "entries order by since (last)")
eq(lobbyEvents, 1, "a lobby list emits one lobby event")
relay:to(seatA, { type = "lobby_delta",
added = { { id = "p0", name = "GOLD", since = 5,
room = ROOM1:upper(), locked = true } },
removed = { "p2" }, changed = {} })
ClientA.update(0)
eq(#ClientA.lobby(), 3, "a delta adds and removes")
eq(ClientA.lobby()[1].name, "GOLD", "a delta keeps the ordering stable")
eq(ClientA.lobby()[1].room, ROOM1,
"a lobby entry carries the advertiser's room id")
eq(ClientA.lobby()[1].locked, true, "and whether it is PIN-locked")
local names = {}
for _, e in ipairs(ClientA.lobby()) do table.insert(names, e.name) end
eq(table.concat(names, ","), "GOLD,BLUE,GREEN", "the whole order is stable")
relay:to(seatA, { type = "lobby_delta", added = {}, removed = {},
changed = { { id = "p1", name = "BLUE", since = 10,
note = "wants a battle" } } })
ClientA.update(0)
eq(ClientA.lobby()[2].note, "wants a battle", "a changed entry updates in place")
eq(#ClientA.lobby(), 3, "a change does not duplicate an entry")
relay:to(seatA, { type = "lobby_delta", op = "update",
entry = { id = "p1", name = "BLUE", since = 10,
note = "still here" } })
ClientA.update(0)
eq(ClientA.lobby()[2].note, "still here",
"the older single-entry delta shape still applies")
-- ------------------------------------------------------- open rooms, watch
relay:to(seatA, { type = "lobby_list", online = 9, entries = {
{ id = "a1", name = "RED", since = 5, intent = "battle", profile = PROFILE,
room = rid(0xa), open = true, stage = "waiting", players = 1,
spectators = 0, maxSpectators = 4 },
{ id = "p1", name = "BLUE", since = 10, intent = "battle", profile = PROFILE,
room = rid(0xb), open = true, stage = "waiting", players = 1,
spectators = 0, maxSpectators = 4 },
{ id = "p2", name = "LEAF", since = 20, intent = "battle", profile = PROFILE,
room = rid(0xc), open = false, stage = "battling", players = 2,
spectators = 1, maxSpectators = 4 },
{ id = "p3", name = "GREEN", since = 30, intent = "battle", profile = PROFILE,
room = rid(0xd), open = false, stage = "battling", players = 2,
spectators = 4, maxSpectators = 4 },
{ id = "p4", name = "GOLD", since = 40, intent = "battle", profile = PROFILE,
note = "looking for a battle" },
{ id = "p5", name = "SILVER", since = 50, intent = "tournament",
profile = PROFILE, tour = tid(0xe), open = false, stage = "registering",
players = 3, spectators = 0, maxSpectators = 8 },
{ id = "p6", name = "CRYS", since = 60, intent = "battle", profile = PROFILE,
room = rid(0xf), open = true, stage = "waiting", players = 1,
spectators = 0, maxSpectators = 0, locked = true },
} })
ClientA.update(0)
local openRooms = ClientA.openRooms()
eq(#openRooms, 2, "openRooms keeps only the open entries with a room")
eq(openRooms[1].room, rid(0xb), "openRooms sorts by since")
eq(openRooms[2].room, rid(0xf), "...and the later room comes second")
eq(openRooms[2].locked, true, "a locked room is still listed, with its lock")
local sawSelf = false
for _, e in ipairs(openRooms) do
if e.id == ClientA.you().id then sawSelf = true end
end
check(not sawSelf, "openRooms never lists your own room")
eq(openRooms[1].players, 1, "an open room entry carries its player count")
eq(openRooms[1].maxSpectators, 4, "...and its spectator ceiling")
local watchable = ClientA.watchable()
eq(#watchable, 2, "watchable keeps live rooms with a free spectator seat")
eq(watchable[1].room, rid(0xc), "a battling room with room to spectate is watchable")
eq(watchable[2].tour, tid(0xe), "a tournament is watchable too")
local sawFull = false
for _, e in ipairs(watchable) do
if e.room == rid(0xd) then sawFull = true end
end
check(not sawFull, "a room with every spectator seat taken is not watchable")
local counts = ClientA.counts()
eq(counts.players, 9, "counts uses the relay's online count when it is sent")
eq(counts.openRooms, 2, "counts reports the open room total")
relay:to(seatA, { type = "lobby_list", entries = {
{ id = "p1", name = "BLUE", since = 10, intent = "battle", profile = PROFILE,
room = rid(0xb), open = true, stage = "waiting", players = 1,
spectators = 0, maxSpectators = 4 },
{ id = "p4", name = "GOLD", since = 40, intent = "battle", profile = PROFILE },
} })
ClientA.update(0)
local fallback = ClientA.counts()
eq(fallback.players, 2, "without an online count, counts falls back to entries")
eq(fallback.openRooms, 1, "and still counts the open rooms")
eq(#ClientA.watchable(), 0, "nothing is watchable in a quiet lobby")
relay:to(seatA, { type = "lobby_list", entries = {
{ id = "p1", name = "BLUE", since = 10, intent = "battle", profile = PROFILE,
room = rid(0xb), open = true, stage = "waiting", players = 1,
spectators = 0, maxSpectators = 4, online = false },
{ id = "p2", name = "LEAF", since = 20, intent = "battle", profile = PROFILE,
room = rid(0xc), open = true, stage = "waiting", players = 2,
spectators = 0, maxSpectators = 4, online = true },
{ id = "p3", name = "GREEN", since = 30, intent = "battle", profile = PROFILE,
room = rid(0xc), open = false, stage = "waiting", online = false },
{ id = "p6", name = "CRYS", since = 60, intent = "battle", profile = PROFILE,
room = rid(0xf), open = true, stage = "waiting", players = 1,
spectators = 0, maxSpectators = 0, online = true },
} })
ClientA.update(0)
local liveRooms = ClientA.openRooms()
eq(#liveRooms, 1, "openRooms hides rooms with an offline host or seated player")
eq(liveRooms[1] and liveRooms[1].room, rid(0xf), "only the live room stays listed")
eq(ClientA.counts().openRooms, 1, "counts skips the offline rooms too")
relay:to(seatA, { type = "lobby_delta", changed = {
{ id = "p1", name = "BLUE", since = 10, intent = "battle", profile = PROFILE,
room = rid(0xb), open = true, stage = "waiting", players = 1,
spectators = 0, maxSpectators = 4, online = true },
} })
ClientA.update(0)
eq(#ClientA.openRooms(), 2, "a host that resumes lists its room again")
relay:to(seatA, { type = "lobby_list", entries = {
{ id = "a1", name = "RED", since = 5, intent = "tournament",
profile = PROFILE, tour = tid(0x1), open = true, stage = "registering",
players = 8, spectators = 0, maxSpectators = 8 },
{ id = "p7", name = "IVY", since = 6, intent = "tournament",
profile = PROFILE, tour = tid(0x2), open = true, stage = "registering",
players = 5, spectators = 0, maxSpectators = 8 },
} })
ClientA.update(0)
local watchMine = ClientA.watchable()
eq(#watchMine, 1, "watchable drops the caller's own entry")
eq(watchMine[1].tour, tid(0x2), "only another trainer's tournament is watchable")
eq(watchMine[1].players, 5,
"a tournament entry keeps a player count above the room ceiling")
-- ---------------------------------------------------------------- advertise
ClientA.advertise("battle", PROFILE, "come at me")
relay:pump()
eq(seatA.transport.outbox[1], nil, "advertise leaves nothing queued after a pump")
ClientA.unadvertise()
local sawUnadvertise = false
for _, m in ipairs(seatA.transport.outbox) do
if m.type == "unadvertise" then sawUnadvertise = true end
end
check(sawUnadvertise, "unadvertise is sent")
relay:pump()
-- ---------------------------------------------------------------- rooms
local roomEvents = 0
ClientA.on("room", function() roomEvents = roomEvents + 1 end)
local pending = ClientA.createRoom({ intent = "battle", profile = PROFILE,
playing = true, maxSpectators = 8,
private = false, note = "come and get it" })
local createSent
for _, m in ipairs(seatA.transport.outbox) do
if m.type == "room_create" then createSent = m end
end
check(createSent ~= nil, "createRoom sends a room_create")
eq(createSent and createSent.private, false, "createRoom sends the private flag")
eq(createSent and createSent.note, "come and get it",
"createRoom sends the note")
relay:pump()
ClientA.update(0)
check(pending.done, "createRoom's promise completes")
eq(pending.id, ROOM1, "createRoom yields a room id")
eq(pending.code, nil, "and no code")
eq(ClientA.room().room, ROOM1, "the room model holds the id")
eq(ClientA.room().seats, 2, "the room model holds its seat count")
eq(ClientA.room().engine, 1, "and its engine")
eq(ClientA.seat(), 0, "the creator sits in seat 0")
eq(ClientA.room().stage, "waiting", "a fresh room is waiting")
eq(#ClientA.room().players, 1, "the creator is the only player")
eq(roomEvents, 1, "room_state emits a room event")
local seatB = relay:seat("b1", "BLUE")
local ClientB = newClientModule()
connectTo(ClientB, seatB, "BLUE")
relay:pump()
ClientB.update(0)
eq(ClientB.state(), "online", "the second client comes online")
local joinPending = ClientB.joinRoom(ROOM1, "player")
local joinMsg
for _, m in ipairs(seatB.transport.outbox) do
if m.type == "room_join" then joinMsg = m end
end
eq(joinMsg and joinMsg.profile and joinMsg.profile.fingerprint, "abc123",
"room_join carries the joiner's profile")
eq(joinMsg and joinMsg.room, ROOM1, "room_join names the room by id")
relay:pump()
ClientA.update(0)
ClientB.update(0)
check(joinPending.done, "joinRoom's promise completes")
eq(joinPending.id, ROOM1, "the join promise names the room")
eq(ClientB.room().room, ROOM1, "the guest sees the room")
eq(ClientB.seat(), 1, "the joiner sits in seat 1")
eq(#ClientA.room().players, 2, "the host sees both players")
local seatC = relay:seat("c1", "GREEN")
local ClientC = newClientModule()
connectTo(ClientC, seatC, "GREEN")
relay:pump()
ClientC.update(0)
ClientC.joinRoom(ROOM1, "spectator")
local specJoin
for _, m in ipairs(seatC.transport.outbox) do
if m.type == "room_join" then specJoin = m end
end
eq(specJoin and specJoin.as, "spectator", "a spectator join names its seat")
eq(specJoin and specJoin.profile and specJoin.profile.rulesetId, "gen1_faithful",
"a spectator sends its profile too")
relay:pump()
ClientA.update(0); ClientB.update(0); ClientC.update(0)
eq(#ClientC.room().spectators, 1, "a spectator lands in the room")
eq(#ClientC.room().players, 2, "a spectator sees both players")
local sentBefore = #seatC.transport.outbox
local codeJoin = ClientC.joinRoom("ZZZZZZ", "player")
check(codeJoin.done and codeJoin.reason == "bad_room",
"a code-shaped room reference fails locally as bad_room")
eq(#seatC.transport.outbox, sentBefore, "and nothing reaches the relay")
local badJoin = ClientC.joinRoom(rid(0xdead), "player")
relay:pump()
ClientC.update(0)
check(badJoin.done and badJoin.error ~= nil, "an unknown room answers join_error")
eq(badJoin.reason, "not_found", "the join error keeps its reason")
-- ---------------------------------------------------------------- match_start
local startA, startB, startC
ClientA.on("match_start", function(p) startA = p end)
ClientB.on("match_start", function(p) startB = p end)
ClientC.on("match_start", function(p) startC = p end)
local partyRed = { { species = "CHARIZARD", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
local partyBlue = { { species = "BLASTOISE", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
ClientA.ready(partyRed, "digestA")
ClientB.ready(partyBlue, "digestB")
relay:pump()
ClientA.update(0); ClientB.update(0); ClientC.update(0)
check(startA ~= nil and startB ~= nil, "both players get match_start")
eq(ClientA.room().stage, "battling", "a started room reads as battling")
eq(startA.match, ROOM1 .. "-m1", "match_start carries the match token")
eq(ClientA.match(), ROOM1 .. "-m1", "the client holds the match token")
eq(startA.room, ROOM1, "match_start names the room")
eq(startA.seat, 0, "match_start carries the host's seat")
eq(startB.seat, 1, "and the guest's seat")
eq(startA.seats, 2, "and the seat count")
eq(startC and startC.seat, nil, "a spectator has no seat")
eq(startA.players and #startA.players, 2, "match_start lists the seated players")
eq(ClientA.role(), "host", "the client knows its role")
eq(ClientC.role(), "spectator", "a spectator knows its role")
eq(startA.role, "host", "the creator is the host")
eq(startB.role, "guest", "the joiner is the guest")
eq(startC and startC.role, "spectator", "the spectator gets a spectator role")
eq(startA.seed, 4242, "match_start carries the room seed")
eq(startA.ruleset, "gen1_faithful", "match_start carries the ruleset")
eq(startA.rule.partySize, 1, "match_start carries the party rule")
eq(startA.peerName, "BLUE", "the host's peer is the guest")
eq(startB.peerName, "RED", "the guest's peer is the host")
eq(startA.hostName, "RED", "match_start names the host")
eq(startA.guestName, "BLUE", "match_start names the guest")
eq(startA.myParty, nil, "match_start leaves myParty for ArenaBoot")
eq(startA.theirParty[1].species, "BLASTOISE", "the host's theirParty is the guest's")
eq(startB.theirParty[1].species, "CHARIZARD", "the guest's theirParty is the host's")
eq(startC.hostParty[1].species, "CHARIZARD", "a spectator gets the host party")
eq(startC.guestParty[1].species, "BLASTOISE", "a spectator gets the guest party")
eq(startC.theirParty, nil, "a spectator has no theirParty")
-- ---------------------------------------------------------------- room session
local sessA = ClientA.roomSession()
local sessB = ClientB.roomSession()
check(sessA ~= nil and sessB ~= nil, "roomSession exists once a room does")
eq(sessA.paired, true, "a two-player room reads as paired")
eq(sessA.closed, false, "a live room session is not closed")
sessA:send({ type = "action", kind = "move", slot = 1 })
sessA:send({ type = "hash", turn = 1, value = "x" })
local sentSeqs = {}
for _, m in ipairs(seatA.transport.outbox) do
if m.type == "room_msg" then table.insert(sentSeqs, m.seq) end
end
eq(table.concat(sentSeqs, ","), "1,2", "send wraps into room_msg with a rising seq")
relay:pump()
ClientB.update(0)
local got = sessB:poll()
eq(#got, 2, "poll unwraps both inner messages")
eq(got[1].type, "action", "poll keeps seq order (first)")
eq(got[2].type, "hash", "poll keeps seq order (second)")
eq(got[1].seat, nil, "an engine 1 seat gets the bare inner message, untagged")
eq(sessB:seat(), 1, "the room session knows its seat")
eq(sessB:seats(), 2, "and the seat count")
eq(sessB:role(), "guest", "and its role")
eq(sessB:engine(), 1, "and the engine")
eq(sessB:peerOnline(0), true, "the host seat reads as online")
eq(sessB.target, ROOM1, "the session targets the room id")
eq(sessB.code, nil, "and carries no code")
local ackSeq
for _, m in ipairs(seatB.transport.outbox) do
if m.type == "room_ack" then ackSeq = m.seq end
end
eq(ackSeq, 2, "poll acks the highest delivered seq")
relay:pump()
sessA:send({ type = "replace", index = 2 })
sessA:send({ type = "bye" })
relay:pump()
ClientB.update(0)
local taken = sessB:take("bye")
check(taken ~= nil, "take pulls a message by type")
eq(sessB:pollOne().type, "replace", "the untaken message stays in order")
sessB:unread({ { type = "forfeit" } })
eq(sessB:take("forfeit") ~= nil, true, "unread puts a message back")
-- ---------------------------------------------------------------- reconnect
relay:pump()
sessA:send({ type = "hash", turn = 2, value = "y" })
sessA:send({ type = "hash", turn = 3, value = "z" })
relay:pump()
ClientB.update(0)
eq(#sessB:poll(), 2, "two more messages reach the guest before the drop")
sessA:send({ type = "hash", turn = 4, value = "w" })
relay:pump()
local oldTransport = seatB.transport
oldTransport.closed = true
ClientB.update(0)
eq(ClientB.state(), "reconnecting", "a dropped transport goes to reconnecting")
seatB.transport = newTransport()
ClientB.configure({ connect = function() return seatB.transport end })
CLOCK = CLOCK + 2
ClientB.update(0)
local resumeMsg
for _, m in ipairs(seatB.transport.outbox) do
if m.type == "resume" then resumeMsg = m end
end
check(resumeMsg ~= nil, "the reconnect sends resume")
eq(resumeMsg.session, "S-b1", "resume carries the session id")
eq(resumeMsg.ack, 6, "resume carries the last acked seq")
relay:pump()
ClientB.update(0)
eq(ClientB.state(), "online", "the resumed client is online again")
local replayed = ClientB.roomSession():poll()
eq(#replayed, 1, "the replay delivers only the unacked message")
eq(replayed[1].value, "w", "the replayed message is the missing one")
-- ------------------------------------------------------------- spectating
local sessC = ClientC.roomSession()
check(sessC ~= nil, "a spectator holds a room session")
relay:pump()
ClientC.update(0)
sessC:poll()
sessA:send({ type = "action", kind = "move", slot = 3 })
relay:pump()
ClientC.update(0)
local watched = sessC:poll()
eq(#watched, 1, "a spectator's poll delivers the fanned-out message")
eq(watched[1].type, "spectate",
"a side-tagged room_msg reaches a spectator as a spectate envelope")
eq(watched[1].side, "host", "the envelope keeps the side that sent it")
eq(watched[1].msg.kind, "move", "and carries the inner message")
sessA:send({ type = "hash", turn = 9, value = "s" })
relay:pump()
ClientC.update(0)
local takenSpec = sessC:take("spectate", function(m) return m.side == "host" end)
check(takenSpec ~= nil and takenSpec.msg.type == "hash",
"take matches a spectate envelope by type and side")
ClientB.update(0)
local peerGot = sessB:poll()
check(#peerGot > 0 and peerGot[#peerGot].type == "hash",
"the seated peer still gets bare inner messages")
-- ------------------------------------------------- resume replays its tail
relay:pump()
sessB:send({ type = "hash", turn = 5, value = "b5" })
relay:pump()
ClientA.update(0)
sessA:poll()
sessB:send({ type = "hash", turn = 6, value = "b6" })
seatB.transport.closed = true
ClientB.update(0)
eq(ClientB.state(), "reconnecting", "the second drop goes to reconnecting")
seatB.transport = newTransport()
ClientB.configure({ connect = function() return seatB.transport end })
CLOCK = CLOCK + 2
ClientB.update(0)
relay:pump()
local yourSeqSeen
for _, m in ipairs(seatB.transport.inbox) do
if m.type == "room_replay" then yourSeqSeen = m.yourSeq end
end
ClientB.update(0)
eq(ClientB.state(), "online", "the second resume lands")
eq(yourSeqSeen, 1, "room_replay tells the resumed client its last logged seq")
local extraResume = false
for _, m in ipairs(seatB.transport.outbox) do
if m.type == "resume" then extraResume = true end
end
check(not extraResume, "a landed resume clears the retry timer instead of re-resuming")
check(ClientB.roomSession() == sessB,
"the room session object survives a resume, so a live battle keeps its net")
relay:pump()
ClientA.update(0)
local resent = sessA:poll()
eq(#resent, 1, "the resumed client replays only the message the relay missed")
eq(resent[1].value, "b6", "and it is the one that never arrived")
eq((relay.room.duplicates or 0), 0,
"nothing the relay already had was sent a second time")
ClientB.sendRaw(Protocol2.roomMsg(2, { type = "hash", turn = 6, value = "b6" }))
relay:pump()
ClientA.update(0)
eq(#sessA:poll(), 0, "a re-sent clientSeq is dropped by the relay")
eq(relay.room.duplicates, 1, "and counted as a duplicate")
-- ---------------------------------------------------------------- results
local endA, endB, endC
ClientA.on("match_end", function(p) endA = p end)
ClientB.on("match_end", function(p) endB = p end)
ClientC.on("match_end", function(p) endC = p end)
ClientA.report("win")
local reportMsg
for _, m in ipairs(seatA.transport.outbox) do
if m.type == "room_report" then reportMsg = m end
end
check(reportMsg ~= nil, "report sends room_report")
eq(reportMsg.match, ROOM1 .. "-m1", "room_report carries the live match token")
eq(reportMsg.result, "win", "room_report carries the result")
ClientB.report("lose")
relay:pump()
ClientA.update(0); ClientB.update(0); ClientC.update(0)
check(endA ~= nil and endB ~= nil, "both players get match_end")
eq(endA.how, "agreed", "match_end carries how the relay resolved it")
eq(endA.match, ROOM1 .. "-m1", "match_end carries the match token")
eq(endA.winnerId, "a1", "match_end carries the winner's seat id")
eq(endA.youWon, true, "the winner is told it won")
eq(endB.youWon, false, "the loser is told it did not")
eq(endC and endC.youWon, false, "a spectator never wins")
eq(ClientA.room().stage, "waiting", "the room returns to waiting after a result")
ClientA.ready(partyRed, "digestA")
ClientB.ready(partyBlue, "digestB")
relay:pump()
ClientA.update(0); ClientB.update(0)
eq(ClientA.match(), ROOM1 .. "-m2", "a rematch takes a fresh match token")
endA, endB = nil, nil
ClientB.forfeit()
local forfeitMsg
for _, m in ipairs(seatB.transport.outbox) do
if m.type == "forfeit" then forfeitMsg = m end
end
check(forfeitMsg ~= nil, "forfeit sends a top-level forfeit")
eq(forfeitMsg.match, ROOM1 .. "-m2", "forfeit carries the live match token")
relay:pump()
ClientA.update(0); ClientB.update(0)
eq(endA and endA.how, "forfeit", "a forfeit resolves as a forfeit")
eq(endA and endA.youWon, true, "the forfeiter's opponent wins")
ClientA.ready(partyRed, "digestA")
ClientB.ready(partyBlue, "digestB")
relay:pump()
ClientA.update(0); ClientB.update(0)
local thirdMatch = ClientA.match()
eq(thirdMatch, ROOM1 .. "-m3", "a third match takes another token")
endA, endB = nil, nil
ClientA.report("error")
local errForfeit
for _, m in ipairs(seatA.transport.outbox) do
if m.type == "forfeit" then errForfeit = m end
end
check(errForfeit ~= nil, "report with a non-result forfeits instead")
eq(errForfeit and errForfeit.match, thirdMatch,
"the forfeit names the live match")
relay:pump()
ClientA.update(0); ClientB.update(0)
eq(endA and endA.how, "forfeit", "the relay resolves a reported non-result")
eq(endB and endB.youWon, true, "the other seat wins it")
local staleBefore = relay.staleReports or 0
ClientA.report("win")
relay:pump()
eq(relay.staleReports or 0, staleBefore,
"a report after a result still names the match the relay knows")
ClientA.update(0)
ClientA.kick("b1")
relay:pump()
eq(relay.kicked, "b1", "kick sends room_kick with the target id")
relay:to(seatA, { type = "room_closed", reason = "closed", room = ROOM1 })
local closedErr
ClientA.on("error", function(e) closedErr = e end)
ClientA.update(0)
eq(ClientA.room(), nil, "room_closed clears the room model")
eq(ClientA.match(), nil, "room_closed clears the match token")
eq(ClientA.roomSession(), nil, "room_closed drops the room session")
eq(closedErr and closedErr.reason, "closed", "room_closed surfaces its reason")
eq(ClientA.closeRoom(), false, "closeRoom with no room is a no-op")
check(ClientC.closeRoom(), "closeRoom sends room_close while in a room")
relay:pump()
eq(relay.closedBy, "c1", "the relay saw room_close")
ClientC.update(0)
eq(ClientC.room(), nil, "the room_close broadcast clears the room")
-- ------------------------------------------------- host leaving closes it
do
local rr = newRelay()
local sh = rr:seat("h1", "HOST")
local sg = rr:seat("g1", "GUEST")
local CH, CG = newClientModule(), newClientModule()
connectTo(CH, sh, "HOST")
connectTo(CG, sg, "GUEST")
rr:pump(); CH.update(0); CG.update(0)
CH.createRoom({ intent = "battle", profile = PROFILE })
rr:pump(); CH.update(0)
CG.joinRoom(ROOM1, "player")
rr:pump(); CH.update(0); CG.update(0)
eq(#CH.room().players, 2, "host and guest share the room")
local function types(seat)
local out = {}
for _, m in ipairs(seat.transport.outbox) do out[#out + 1] = m.type end
return table.concat(out, ",")
end
sg.transport.outbox = {}
check(CG.leaveRoom(), "the guest can leave")
eq(types(sg), "room_leave", "a guest leaving only sends room_leave")
rr:pump(); CH.update(0)
sh.transport.outbox = {}
check(CH.leaveRoom(), "the host can leave")
eq(types(sh), "room_close,room_leave",
"a host leaving closes the room before leaving it")
rr:pump()
eq(rr.closedBy, "h1", "so the relay tears the room down for everyone")
eq(CH.room(), nil, "and the host's room model is gone")
local lateErr
CH.on("error", function(e) if e.scope == "room" then lateErr = e end end)
local made = CH.createTournament({ profile = PROFILE, playing = true,
shotClock = 6, maxSpectators = 8,
party = { { species = "CHARIZARD",
level = 50, hp = 10 } },
partyDigest = "dh" })
CH.update(0)
check(not made.done,
"a late room_closed for the room just left does not fail the next request")
eq(lateErr, nil, "and raises no room error")
rr:pump(); CH.update(0)
check(made.done and made.error == nil, "the tournament create still resolves")
eq(CH.tournament() and CH.tournament().tour, TOUR1,
"and the host holds the tournament")
end
do
local rr = newRelay()
local sh = rr:seat("h1", "HOST")
local CH = newClientModule()
connectTo(CH, sh, "HOST")
rr:pump(); CH.update(0)
local lateErr
CH.on("error", function(e) if e.scope == "room" then lateErr = e end end)
CH.createRoom({ intent = "battle", profile = PROFILE })
rr:pump(); CH.update(0)
eq(CH.room() and CH.room().room, ROOM1, "the host opens a room")
rr:to(sh, { type = "room_closed", reason = "closed", room = rid(0x77) })
CH.update(0)
eq(CH.room() and CH.room().room, ROOM1,
"a room_closed naming another room leaves the current room alone")
eq(lateErr, nil, "and raises no room error either")
rr:to(sh, { type = "room_closed", reason = "closed", room = ROOM1 })
CH.update(0)
eq(CH.room(), nil, "a room_closed for the current room still clears it")
eq(lateErr and lateErr.room, ROOM1, "and surfaces the room error")
lateErr = nil
local joining = CH.joinRoom(rid(0x99), "player")
rr:to(sh, { type = "room_closed", reason = "closed", room = rid(0x99) })
CH.update(0)
check(joining.done and joining.error ~= nil,
"a room_closed for the room a join is waiting on fails that join")
end
-- ------------------------------------------------- reports across a drop
do
local rr = newRelay()
local sh = rr:seat("h1", "HOST")
local sg = rr:seat("g1", "GUEST")
local CH, CG = newClientModule(), newClientModule()
connectTo(CH, sh, "HOST")
connectTo(CG, sg, "GUEST")
local function pump()
rr:pump()
CH.update(0)
CG.update(0)
end
local function countOut(seat, kind)
local n = 0
for _, m in ipairs(seat.transport.outbox) do
if m.type == kind then n = n + 1 end
end
return n
end
local function drop(client, seat)
seat.transport.closed = true
client.update(0)
end
local function relink(client, seat)
seat.transport = newTransport()
client.configure({ connect = function() return seat.transport end })
CLOCK = CLOCK + 30
client.update(0)
end
pump()
local party = { { species = "PIKACHU", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
CH.createRoom({ profile = PROFILE })
pump()
CG.joinRoom(ROOM1, "player", PROFILE)
pump()
CH.ready(party, "dh")
CG.ready(party, "dg")
pump()
local m1 = CH.match()
eq(m1, ROOM1 .. "-m1", "the drop scenario opens on a live match")
local endH
CH.on("match_end", function(p) endH = p end)
drop(CH, sh)
eq(CH.state(), "reconnecting", "the reporting seat drops to reconnecting")
eq(CH.report("win"), false, "a report sent while the socket is down waits")
check(CH.pendingReport() ~= nil and CH.pendingReport().match == m1,
"the report is queued against the match it belongs to")
relink(CH, sh)
rr:pump()
CH.update(0)
eq(CH.state(), "online", "the resume lands")
eq(countOut(sh, "room_report"), 1,
"the queued report goes out once the session is back")
local queuedReport
for _, m in ipairs(sh.transport.outbox) do
if m.type == "room_report" then queuedReport = m end
end
eq(queuedReport and queuedReport.match, m1,
"the delivered report still names its own match")
eq(CH.pendingReport(), nil, "the queue empties once it is sent")
CH.update(0)
eq(countOut(sh, "room_report"), 1, "and it is never sent twice")
pump()
CG.report("lose")
pump()
eq(endH and endH.how, "agreed", "the relay resolves on the resumed report")
CH.ready(party, "dh")
CG.ready(party, "dg")
pump()
local m2 = CH.match()
check(m2 ~= m1, "a rematch takes a fresh token")
drop(CH, sh)
CH.report("win")
check(CH.pendingReport() ~= nil, "the second report queues too")
relink(CH, sh)
rr:pump()
rr:to(sh, { type = "room_result", match = m2, winner = "GUEST",
winnerId = "g1", how = "stall" })
CH.update(0)
eq(countOut(sh, "room_report"), 0,
"a room_result that arrives first cancels the queued report")
eq(CH.pendingReport(), nil, "and clears the queue")
eq(endH and endH.match, m2, "the client still reports the match as ended")
eq(endH and endH.room, ROOM1, "room_result carries the room id")
endH = nil
rr:to(sh, { type = "room_result", match = m2, room = rid(0x777),
winnerId = "g1", how = "stall" })
CH.update(0)
eq(endH and endH.room, rid(0x777), "a room_result with a room id uses that id")
CH.ready(party, "dh")
CG.ready(party, "dg")
pump()
local m3 = CH.match()
drop(CH, sh)
CH.report("win")
eq(CH.pendingReport().match, m3, "the report is queued for match A")
rr:resolve("stall", "guest")
rr:startMatch()
local mB = rr.room.match
check(mB ~= m3, "the relay moved on to match B while the seat was away")
local staleBefore = rr.staleReports or 0
relink(CH, sh)
rr:pump()
CH.update(0)
rr:pump()
eq(countOut(sh, "room_report"), 0,
"a report queued for match A is dropped once the room is on match B")
eq(rr.staleReports or 0, staleBefore,
"and the relay never sees a stale report")
eq(CH.pendingReport(), nil, "the queue is empty after the move")
eq(CH.match(), mB, "the client follows the relay to match B")
local sess = CG.roomSession()
check(sess ~= nil, "the guest still holds a room session")
local before = CG.unackedCount()
for i = 1, 600 do sess:send({ type = "hash", turn = i, value = "v" }) end
eq(CG.unackedCount(), 512, "unacked is capped at the relay's replay bound")
eq(CG.unackedDropped(), before + 600 - 512,
"the overflow is counted as dropped")
rr:pump()
CH.update(0)
local hs = CH.roomSession()
if hs then hs:poll() end
local highest = 0
for _, e in ipairs(rr.room.log) do
if e.clientSeq and e.clientSeq > highest then highest = e.clientSeq end
end
rr:to(sg, { type = "room_replay", from = 0, msgs = {},
yourSeq = highest - 40 })
CG.update(0)
eq(CG.unackedCount(), 40, "room_replay yourSeq trims what the relay acked")
local lost
CG.on("error", function(e) lost = e end)
rr:to(sg, { type = "room_replay", from = 0, msgs = {}, yourSeq = 1 })
CG.update(0)
eq(lost and lost.reason, "resume_incomplete",
"a resume past the replay bound gives up on the room")
eq(CG.room(), nil, "and clears the room")
eq(countOut(sg, "room_leave") >= 1, true, "and tells the relay it left")
local CX = newClientModule()
local sx = rr:seat("x1", "EXPIRED")
connectTo(CX, sx, "EXPIRED")
rr:pump()
CX.update(0)
eq(CX.state(), "online", "the expiring seat connects")
drop(CX, sx)
relink(CX, sx)
eq(countOut(sx, "resume"), 1, "the reconnect asks to resume")
rr:to(sx, { type = "join_error", reason = "resume_expired" })
CX.update(0)
eq(countOut(sx, "lobby_hello"), 1,
"a refused resume falls back to a fresh hello")
eq(CX.room(), nil, "and holds no room")
end
-- ---------------------------------------------------------- tournament run
do
local tr = newRelay()
local s1 = tr:seat("t1", "ONE")
local s2 = tr:seat("t2", "TWO")
local s3 = tr:seat("t3", "THREE")
local s4 = tr:seat("t4", "WATCH")
local C1, C2, C3, C4 = newClientModule(), newClientModule(),
newClientModule(), newClientModule()
local seatsOf = { [C1] = s1, [C2] = s2, [C3] = s3, [C4] = s4 }
connectTo(C1, s1, "ONE"); connectTo(C2, s2, "TWO")
connectTo(C3, s3, "THREE"); connectTo(C4, s4, "WATCH")
local function pump()
tr:pump()
C1.update(0); C2.update(0); C3.update(0); C4.update(0)
end
local function outbox(client, kind)
local found
for _, m in ipairs(seatsOf[client].transport.outbox) do
if m.type == kind then found = m end
end
return found
end
pump()
local tourEvents = 0
C1.on("tournament", function() tourEvents = tourEvents + 1 end)
local playNext, watchNext, byeSeen, overSeen = nil, nil, nil, nil
C1.on("tour_match", function(p) playNext = p end)
C3.on("tour_bye", function(p) byeSeen = p end)
C4.on("tour_spectate", function(p) watchNext = p end)
C1.on("tour_over", function(p) overSeen = p end)
local startedC1, startedC4
C1.on("match_start", function(p) startedC1 = p end)
C4.on("match_start", function(p) startedC4 = p end)
local party1 = { { species = "CHARIZARD", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
local teamless = C1.createTournament({ profile = PROFILE, playing = true,
shotClock = 6 })
pump()
check(teamless.done and teamless.reason == "party_ineligible",
"a playing creator without a team is refused")
eq(C1.tournament(), nil, "and holds no tournament")
local made = C1.createTournament({ profile = PROFILE, playing = true,
shotClock = 6, maxSpectators = 16,
party = party1, partyDigest = "d1",
public = true, note = "open bracket" })
local createMsg = outbox(C1, "tour_create")
eq(createMsg and createMsg.public, true,
"createTournament forwards the public flag")
eq(createMsg and createMsg.note, "open bracket",
"createTournament forwards the note")
eq(createMsg and createMsg.shotClock, 6, "tour_create carries the shot clock")
eq(createMsg and createMsg.playing, true, "tour_create says the creator plays")
eq(createMsg and createMsg.party[1].species, "CHARIZARD",
"a playing creator sends its team with tour_create")
eq(createMsg and createMsg.partyDigest, "d1", "and its digest")
pump()
check(made.done and made.id == TOUR1, "createTournament yields a tournament id")
eq(C1.tournament().tour, TOUR1, "the tournament model holds the id")
eq(C1.tournament().code, nil, "a public tournament has no code")
eq(C1.tournament().stage, "registering", "a fresh tournament is registering")
eq(C1.tournament().creator, "t1", "the creator is named")
eq(#C1.tournament().players, 1, "the playing creator is a player")
eq(tourEvents, 1, "tour_state emits one tournament event")
local party2 = { { species = "BLASTOISE", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
local party3 = { { species = "VENUSAUR", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
C2.joinTournament({ tour = TOUR1, as = "player", party = party2,
partyDigest = "d2" })
local joinMsg2 = outbox(C2, "tour_join")
eq(joinMsg2 and joinMsg2.tour, TOUR1, "tour_join carries the tournament id")
eq(joinMsg2 and joinMsg2.code, nil, "and no code")
eq(joinMsg2 and joinMsg2.as, "player", "tour_join names the seat kind")
eq(joinMsg2 and joinMsg2.party[1].species, "BLASTOISE",
"a player sends its team up front")
eq(joinMsg2 and joinMsg2.partyDigest, "d2", "and its digest")
C3.joinTournament(TOUR1, "player", party3, "d3")
C4.joinTournament({ tour = TOUR1, as = "spectator" })
pump()
eq(#C1.tournament().players, 3, "three players are registered")
eq(#C1.tournament().spectators, 1, "the outside spectator is registered")
eq(C4.tournament().tour, TOUR1, "a spectator holds the same model")
local refused = C2.joinTournament("ZZZZZZ", "player", party2, "d2")
pump()
check(refused.done and refused.reason == "tour_not_found",
"a bad tournament code answers join_error")
local malformed = C2.joinTournament("zz", "player", party2, "d2")
check(malformed.done and malformed.reason == "bad_room",
"a malformed tournament reference fails locally")
eq(Protocol2.joinErrorText({ reason = "tour_started" }),
"That tournament has already started.",
"the new join_error reasons have human text")
eq(C2.startTournament(), true, "a non-creator may still send tour_start")
pump()
eq(C1.tournament().stage, "registering",
"but the relay refuses it: the bracket has not started")
C1.startTournament()
pump()
local tour = C1.tournament()
eq(tour.stage, "running", "tour_start moves the tournament to running")
eq(#tour.bracket, 1, "the first round is drawn")
eq(#tour.bracket[1].matches, 2, "three players make two first-round matches")
eq(tour.bracket[1].matches[2].state, "bye", "the odd player gets a bye")
check(byeSeen ~= nil and byeSeen.match == TOUR1 .. "-r1-m2",
"the walkover player is told about its bye")
local CHA, CHB = rid(0xc1), rid(0xc2)
check(playNext ~= nil, "the paired player gets tour_match")
eq(playNext.room, CHA, "tour_match names the child room")
eq(playNext.match, TOUR1 .. "-r1-m1", "tour_match names the bracket match")
check(watchNext ~= nil, "the outside spectator gets tour_match_spectate")
eq(watchNext.room, CHA, "the spectator gets the same child room")
eq(C1.room().room, CHA, "the client switches to the child room")
eq(C1.roomSession().target, CHA, "roomSession binds to the child room")
eq(C1.match(), TOUR1 .. "-r1-m1", "the match token is the bracket match")
check(startedC1 ~= nil, "the child room fires match_start")
eq(startedC1.match, TOUR1 .. "-r1-m1", "match_start carries the match")
eq(startedC1.role, "host", "the first seat hosts the child room")
eq(startedC1.room, CHA, "match_start names the child room")
check(startedC4 ~= nil, "the spectator boots too")
eq(startedC4.role, "spectator", "and does so as a spectator")
eq(C4.roomSession().target, CHA,
"the spectator's session binds to the child room")
eq(C3.room() and C3.room().room, CHA,
"the waiting player watches the live match")
eq(C1.tournament().deadlines.shot, 7000,
"tour_deadline lands in the tournament model by kind")
eq(C1.room().deadlines.shot, 7000, "and in the child room's deadlines")
local sess = C1.roomSession()
sess:send({ type = "action", kind = "move", slot = 1 })
tr:pump()
C2.update(0)
eq(#C2.roomSession():poll(), 1, "the child room carries the lockstep stream")
playNext = nil
C1.report("win")
C2.report("lose")
pump()
eq(C1.tournament().tour, TOUR1, "the client stays in the tournament")
local advanced = C1.tournament()
eq(advanced.bracket[1].matches[1].state, "done", "the bracket advances")
eq(advanced.bracket[1].matches[1].winner, "t1", "and records the winner")
eq(advanced.bracket[1].matches[1].how, "agreed", "and how it was decided")
eq(#advanced.bracket, 2, "the second round is drawn")
eq(advanced.round, 2, "the tournament is on round 2")
check(playNext ~= nil and playNext.room == CHB,
"the next match uses a fresh child room")
eq(C1.room().room, CHB, "the client follows to the next child room")
eq(C1.roomSession().target, CHB, "and rebinds its session")
local eliminated
for _, p in ipairs(advanced.players) do
if p.eliminated then eliminated = p.id end
end
eq(eliminated, "t2", "the loser is marked eliminated")
C3.report("error")
local tourForfeit = outbox(C3, "forfeit")
check(tourForfeit ~= nil, "a non-result in a tournament match forfeits")
eq(tourForfeit and tourForfeit.match, TOUR1 .. "-r2-m1",
"the forfeit names the bracket match")
pump()
check(overSeen ~= nil, "tour_over reaches the finalists")
eq(overSeen and overSeen.championId, "t1", "tour_over names the champion")
eq(C1.tournament().stage, "finished",
"the model stays finished until leaveTournament")
eq(C1.room(), nil, "the child room is cleared after the final")
check(C1.leaveTournament(), "leaveTournament reports success")
eq(C1.tournament(), nil, "leaveTournament drops the model")
tr:pump()
eq(tr.tourLeft, "t1", "the relay saw tour_leave")
eq(C1.leaveTournament(), false, "leaving twice is a no-op")
eq(C4.tournament().stage, "finished",
"the spectator's model is finished too")
C4.leaveTournament()
C1.disconnect(); C2.disconnect(); C3.disconnect(); C4.disconnect()
end
-- ------------------------------------------------------- tournament closes
do
local tr = newRelay()
local sa = tr:seat("u1", "HOST")
local sb = tr:seat("u2", "GUEST")
local CA, CB = newClientModule(), newClientModule()
connectTo(CA, sa, "HOST"); connectTo(CB, sb, "GUEST")
local function pump()
tr:pump(); CA.update(0); CB.update(0)
end
pump()
local party = { { species = "CHARIZARD", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
CA.createTournament({ profile = PROFILE, playing = true, shotClock = 3,
party = party, partyDigest = "d1", public = false })
pump()
eq(CA.tournament().code, "TRN234", "a private tournament shows its code")
local byId = CB.joinTournament({ tour = TOUR1, as = "player", party = party,
partyDigest = "d2" })
pump()
eq(byId.reason, "tour_private", "a private tournament refuses a bare id")
CB.joinTournament("trn234", "player", party, "d2")
pump()
eq(#CA.tournament().players, 2, "two players are registered by code")
local closedB
CB.on("error", function(e) closedB = e end)
eq(CB.kickFromTournament("u1"), true,
"a non-creator may still send tour_kick")
pump()
eq(#CA.tournament().players, 2, "but the relay refuses it")
CA.kickFromTournament("u2")
pump()
eq(tr.tourKicked, "u2", "tour_kick names its target")
eq(CB.tournament(), nil, "a kicked player's tournament model is cleared")
eq(closedB and closedB.scope, "tournament",
"tour_closed surfaces as a tournament error")
eq(closedB and closedB.reason, "kicked", "and keeps its reason")
eq(Protocol2.tourClosedText({ reason = "kicked" }),
"The creator removed you from the tournament.",
"tour_closed has human text")
eq(#CA.tournament().players, 1, "the creator sees the shrunken field")
local closedA
CA.on("error", function(e) closedA = e end)
CA.closeTournament()
pump()
eq(tr.tourClosedBy, "u1", "the relay saw tour_close")
eq(CA.tournament(), nil, "closing drops the creator's model too")
eq(closedA and closedA.reason, "closed", "with the closed reason")
eq(CA.closeTournament(), false, "closing twice is a no-op")
eq(CA.startTournament(), false, "starting without a tournament is a no-op")
eq(CA.kickFromTournament("u2"), false, "so is kicking")
CA.disconnect(); CB.disconnect()
end
-- ---------------------------------------------------------------- robustness
local droppedBefore = ClientB.dropped()
relay:to(seatB, { type = "room_state", players = {} })
relay:to(seatB, { type = "join_error" })
relay:to(seatB, { notatype = true })
relay:to(seatB, "a bare string")
ClientB.update(0)
check(ClientB.dropped() >= droppedBefore + 3,
"malformed server messages are dropped and counted")
eq(ClientB.state(), "online", "malformed messages do not throw or change state")
local errClient = newClientModule()
errClient.configure({ connect = function() return nil, "no relay" end })
local ok, err = errClient.connect({ name = "RED" })
eq(ok, false, "a failed connect reports failure")
eq(errClient.state(), "error", "a failed connect lands in error")
eq(errClient.error(), "no relay", "the failure reason is kept")
errClient.disconnect()
eq(errClient.state(), "offline", "disconnect returns to offline")
errClient.disconnect()
eq(errClient.state(), "offline", "disconnect is idempotent")
errClient.update(1 / 60)
eq(errClient.state(), "offline", "update on an offline client is a no-op")
local throwing = newClientModule()
local seatT = relay:seat("t1", "TRIP")
throwing.on("state", function() error("boom") end)
connectTo(throwing, seatT, "TRIP")
relay:pump()
throwing.update(0)
eq(throwing.state(), "online", "a throwing event handler does not stop the client")
ClientA.disconnect()
ClientB.disconnect()
ClientC.disconnect()
throwing.disconnect()
do
local r2 = newRelay()
local seat = r2:seat("z1", "ZED")
local Stranded = newClientModule()
connectTo(Stranded, seat, "ZED")
r2:pump(); Stranded.update(0)
Stranded.createRoom({ intent = "battle", profile = PROFILE })
r2:pump(); Stranded.update(0)
local sess = Stranded.roomSession()
check(type(sess.update) == "function",
"a room session exposes update for Game:step")
check(pcall(function() sess:update(1 / 60) end),
"room session update does not throw while connected")
Stranded.disconnect()
check(pcall(function() sess:update(1 / 60) end),
"room session update does not throw with no connection")
eq(sess.closed, true, "a room session closes when the client disconnects")
check(pcall(function()
sess:send({ type = "bye" })
sess:poll()
sess:take("bye")
sess:close()
end), "a stranded room session still answers send/poll/take/close")
end
-- ---------------------------------------------------------------- lockstep
local dataOk = pcall(function()
local Data = require("src.core.Data")
if not Data.pokemon then Data:load() end
require("src.render.Font").load(Data)
end)
if not dataOk then
print("skip lockstep smoke (data/generated is not built in this checkout)")
else
local Data = require("src.core.Data")
local Input = require("src.core.Input")
local LinkBattle = require("src.link.LinkBattle")
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
Input:init()
local function makeGame(name, species)
local save = require("src.core.SaveData").newGame()
save.player.name = name
table.insert(save.party, Pokemon.new(Data, species, 50))
local stack = { list = {} }
function stack:push(s, ...)
table.insert(self.list, s)
if s.enter then s:enter(...) end
end
function stack:pop() return 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
local lockRelay = newRelay()
local hostSeat = lockRelay:seat("h", "RED")
local guestSeat = lockRelay:seat("g", "BLUE")
local Host = newClientModule()
local Guest = newClientModule()
local hostStart, guestStart
Host.on("match_start", function(p) hostStart = p end)
Guest.on("match_start", function(p) guestStart = p end)
connectTo(Host, hostSeat, "RED")
connectTo(Guest, guestSeat, "BLUE")
lockRelay:pump(); Host.update(0); Guest.update(0)
Host.createRoom({ intent = "battle", profile = PROFILE })
lockRelay:pump(); Host.update(0)
Guest.joinRoom(ROOM1, "player")
lockRelay:pump(); Host.update(0); Guest.update(0)
local gameH = makeGame("RED", "CHARIZARD")
local gameG = makeGame("BLUE", "BLASTOISE")
local packedH = Protocol.packParty(gameH.save.party)
local packedG = Protocol.packParty(gameG.save.party)
Host.ready(packedH, "dh")
Guest.ready(packedG, "dg")
lockRelay:pump(); Host.update(0); Guest.update(0)
check(hostStart ~= nil and guestStart ~= nil,
"both ends of the lockstep smoke get match_start")
local sessHost = Host.roomSession()
local sessGuest = Guest.roomSession()
local battleH = LinkBattle.newHost(gameH, sessHost, {
myParty = packedH, theirParty = hostStart.theirParty,
theirName = hostStart.peerName, seed = hostStart.seed,
ruleset = hostStart.ruleset, keepNetOpen = true,
})
local battleG = LinkBattle.newGuest(gameG, sessGuest, {
myParty = packedG, theirParty = guestStart.theirParty,
theirName = guestStart.peerName, seed = guestStart.seed,
ruleset = guestStart.ruleset, keepNetOpen = true,
})
check(battleH ~= nil and battleG ~= nil,
"LinkBattle builds over a room session unchanged")
local resH, resG
battleH.onFinish = function(r) resH = r end
battleG.onFinish = function(r) resG = r end
gameH.stack:push(battleH)
gameG.stack:push(battleG)
local guard = 0
while (resH == nil or resG == nil) and guard < 60000 do
guard = guard + 1
lockRelay:pump()
Input.pressed = { a = true }
gameH.stack:update(1 / 60)
gameG.stack:update(1 / 60)
end
check(resH ~= nil and resG ~= nil,
("the room-session lockstep battle completes (%s / %s)")
:format(tostring(resH), tostring(resG)))
check((resH == "win" and resG == "lose") or (resH == "lose" and resG == "win")
or (resH == "draw" and resG == "draw"),
"both room-session simulations agree on the outcome")
eq(battleH.player.mon.hp, battleG.enemy.mon.hp,
"host mon HP identical across the relay")
eq(battleH.enemy.mon.hp, battleG.player.mon.hp,
"guest mon HP identical across the relay")
sessHost:close()
eq(sessHost.closed, true, "closing a room session marks it closed")
local sawLeave = false
for _, m in ipairs(hostSeat.transport.outbox) do
if m.type == "room_leave" then sawLeave = true end
end
check(sawLeave, "closing a room session sends room_leave")
Host.disconnect()
Guest.disconnect()
end
-- ---------------------------------------------------------------- real relay
love.timer.getTime = function() return os.clock() end
local hasSocket = pcall(require, "socket")
local nodeCheck = os.execute("command -v node >/dev/null 2>&1")
local hasNode = nodeCheck == true or nodeCheck == 0
local serverFile = io.open("../pokeserver/server.js", "r")
if serverFile then serverFile:close() end
local relaySource = io.open("../pokeserver/relay.js", "r")
local relayV3 = false
if relaySource then
relayV3 = (relaySource:read("*a") or ""):find("upgrade_required", 1, true) ~= nil
relaySource:close()
end
if not hasSocket then
print("skip real relay v3 (luasocket not available under this interpreter)")
elseif not hasNode then
print("skip real relay v3 (node not on PATH to spawn pokeserver)")
elseif not serverFile then
print("skip real relay v3 (../pokeserver/server.js is not checked out)")
elseif not relayV3 then
print("skip real relay v3 (../pokeserver predates protocol 3)")
else
local PORT = 17780
local pidFile = os.tmpname()
os.execute(("(cd ../pokeserver && PORT=%d HTTP_PORT=%d node server.js >/tmp/pokeserver_v2_test.log 2>&1 & echo $! > %q)")
:format(PORT, PORT + 1, pidFile))
local function busyWait(seconds)
local t0 = os.clock()
while os.clock() - t0 < seconds do end
end
local function tcpConnectable(host, port)
local socket = require("socket")
local tcp = socket.tcp()
tcp:settimeout(0.2)
local ok = tcp:connect(host, port)
tcp:close()
return ok ~= nil
end
local ready = false
for _ = 1, 50 do
ready = tcpConnectable("127.0.0.1", PORT)
if ready then break end
busyWait(0.1)
end
if not ready then
print("skip real relay v2 (couldn't reach the spawned pokeserver)")
else
local Live = newClientModule()
Live.configure({ relayAddress = "127.0.0.1:" .. PORT })
Live.connect({ name = "RED", profiles = { PROFILE } })
local Peer = newClientModule()
Peer.configure({ relayAddress = "127.0.0.1:" .. PORT })
Peer.connect({ name = "BLUE", profiles = { PROFILE } })
local function pump()
Live.update(1 / 60)
Peer.update(1 / 60)
end
local function waitFor(fn, seconds)
local deadline = os.clock() + (seconds or 3)
while os.clock() < deadline do
pump()
if fn() then return true end
end
return false
end
if not waitFor(function()
return Live.state() == "online" and Peer.state() == "online"
end, 3) then
print("skip real relay v3 (the server did not answer lobby_hello in 3 s: "
.. tostring(Live.state()) .. " " .. tostring(Live.error()) .. ")")
else
check(Live.you() ~= nil, "the real relay welcomes a v3 client")
local room = Live.createRoom({ intent = "battle", profile = PROFILE })
waitFor(function() return room.done end, 3)
check(room.done and room.id ~= nil,
"the real relay creates a v3 room: " .. tostring(room.error))
if room.id then
local joined = Peer.joinRoom(room.id, "player", PROFILE)
waitFor(function() return joined.done end, 3)
check(joined.done and joined.error == nil,
"the real relay admits a matching profile: " .. tostring(joined.error))
local badPeer = newClientModule()
badPeer.configure({ relayAddress = "127.0.0.1:" .. PORT })
badPeer.connect({ name = "GREEN", profiles = { PROFILE } })
local badProfile = copy(PROFILE)
badProfile.fingerprint = "deadbeef"
local refused
local badDeadline = os.clock() + 3
while os.clock() < badDeadline do
badPeer.update(1 / 60)
if badPeer.state() == "online" and not refused then
refused = badPeer.joinRoom(room.id, "spectator", badProfile)
end
if refused and refused.done then break end
end
check(refused and refused.reason == "profile_mismatch",
"the real relay refuses a mismatched spectator profile: "
.. tostring(refused and refused.reason))
badPeer.disconnect()
local startLive, startPeer, endLive
Live.on("match_start", function(p) startLive = p end)
Peer.on("match_start", function(p) startPeer = p end)
Live.on("match_end", function(p) endLive = p end)
local party = { { species = "CHARIZARD", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
Live.ready(party, "dl")
Peer.ready(party, "dp")
waitFor(function() return startLive and startPeer end, 3)
check(startLive ~= nil and startPeer ~= nil,
"the real relay starts the match on both seats")
if startLive and startPeer then
eq(startLive.role, "host", "the real relay makes the creator the host")
eq(startPeer.role, "guest", "the real relay makes the joiner the guest")
eq(startLive.match, startPeer.match,
"both seats share the real relay's match token")
check(startLive.seed ~= nil and startLive.seed == startPeer.seed,
"both seats share the real relay's seed")
Live.report("win")
Peer.report("lose")
waitFor(function() return endLive ~= nil end, 5)
check(endLive ~= nil, "the real relay answers room_report")
if endLive then
eq(endLive.match, startLive.match, "room_result names the match")
eq(endLive.how, "agreed", "the real relay agrees the two reports")
eq(endLive.youWon, true, "the reported winner is told it won")
end
end
end
Live.leaveRoom()
Peer.leaveRoom()
local Third = newClientModule()
Third.configure({ relayAddress = "127.0.0.1:" .. PORT })
Third.connect({ name = "GOLD", profiles = { PROFILE } })
local function pumpAll()
Live.update(1 / 60); Peer.update(1 / 60); Third.update(1 / 60)
end
local function waitAll(fn, seconds)
local deadline = os.clock() + (seconds or 3)
while os.clock() < deadline do
pumpAll()
if fn() then return true end
end
return false
end
waitAll(function() return Third.state() == "online" end, 3)
local hostParty = { { species = "CHARIZARD", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
local made = Live.createTournament({ profile = PROFILE, playing = true,
shotClock = 3, maxSpectators = 8,
party = hostParty,
partyDigest = "dl" })
waitAll(function() return made.done or Live.tournament() ~= nil end, 3)
local mine = Live.tournament()
if not mine then
print("skip real relay tournaments (no tour_create support yet: "
.. tostring(made.reason or made.error or "no reply") .. ")")
else
check(mine.tour ~= nil, "the real relay creates a tournament")
eq(mine.code, nil, "a public tournament carries no code")
local tourId = mine.tour
local tourParty = { { species = "CHARIZARD", level = 50, hp = 10,
moves = { { id = "TACKLE", pp = 35 } } } }
Peer.joinTournament({ tour = tourId, as = "player", party = tourParty,
partyDigest = "dp" })
Third.joinTournament({ tour = tourId, as = "spectator" })
waitAll(function()
local t = Live.tournament()
return t ~= nil and #t.players >= 2 and #t.spectators >= 1
end, 3)
local t = Live.tournament()
check(t ~= nil and #t.players >= 2,
"the real relay registers the second player")
check(t ~= nil and #t.spectators >= 1,
"the real relay registers the outside spectator")
local liveStart, peerStart, specStart, overSeen
Live.on("match_start", function(p) liveStart = p end)
Peer.on("match_start", function(p) peerStart = p end)
Third.on("match_start", function(p) specStart = p end)
Live.on("tour_over", function(p) overSeen = p end)
Live.startTournament()
waitAll(function() return liveStart ~= nil and peerStart ~= nil end, 5)
check(liveStart ~= nil and peerStart ~= nil,
"the real relay starts the first bracket match")
if liveStart and peerStart then
eq(liveStart.match, peerStart.match,
"both finalists share the bracket match token")
check(Live.room() ~= nil and Live.room().room == liveStart.room,
"the client follows the relay into the child room")
check(specStart == nil or specStart.role == "spectator",
"an outside spectator boots as a spectator")
Live.report("win")
Peer.report("lose")
waitAll(function() return overSeen ~= nil end, 5)
check(overSeen ~= nil, "a two-player bracket finishes")
if overSeen then
eq(Live.tournament().stage, "finished",
"the model stays finished until we leave")
end
end
Live.leaveTournament()
Peer.leaveTournament()
Third.leaveTournament()
end
Third.disconnect()
end
Peer.disconnect()
Live.disconnect()
end
local pidHandle = io.open(pidFile, "r")
if pidHandle then
local pid = pidHandle:read("*l")
pidHandle:close()
if pid and pid ~= "" then os.execute("kill " .. pid .. " >/dev/null 2>&1") end
end
os.remove(pidFile)
end
love.timer.getTime = savedGetTime
if failures > 0 then
print(("\n%d online client check(s) failed"):format(failures))
os.exit(1)
end
print("\nonline client tests passed")
return failures