Files
gen1recomp/tests/online_client_gen3.lua
2026-09-25 10:28:21 -04:00

997 lines
42 KiB
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 FakeRelay = require("tests.support.fake_relay")
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 savedGetTime = love.timer.getTime
local CLOCK = 0
love.timer.getTime = function() return CLOCK end
local function newClient()
package.loaded["src.online.Client"] = nil
local Client = require("src.online.Client")
Client.reset()
return Client
end
local function pid(n) return ("%08x"):format(n) end
local PROFILE3 = {
engine = 3, version = "firered", engineVersion = "0.0.0-dev", apiVersion = "2",
fingerprint = "g3fingerprint", rulesetId = "g3_single", kind = "vanilla",
rule = { partySize = 3 },
}
local function profile3(rulesetId, version)
local p = FakeRelay.copy(PROFILE3)
p.rulesetId = rulesetId or p.rulesetId
p.version = version or p.version
return p
end
local function avatar(name, trainerId, gender)
return { name = name, trainerId = trainerId, gender = gender or 0, version = "firered" }
end
local function outTypes(seat)
local out = {}
for _, m in ipairs(seat.transport.outbox) do out[#out + 1] = m.type end
return out
end
local World = {}
World.__index = World
local function newWorld(opts)
local w = setmetatable({ relay = FakeRelay.new({ clock = function() return CLOCK end,
minProtocol = opts and opts.minProtocol,
epoch = opts and opts.epoch }),
clients = {} }, World)
return w
end
function World:add(n, name, profiles, presence)
local id = pid(n)
local seat = self.relay:seat(id, name)
local C = newClient()
C.configure({ relayAddress = "fake:3", connect = function() return seat.transport end })
C.connect({ name = name, profiles = profiles or { PROFILE3 }, presence = presence })
self.clients[#self.clients + 1] = C
return C, seat
end
function World:pump(rounds)
for _ = 1, rounds or 3 do
self.relay:pump()
for _, C in ipairs(self.clients) do C.update(0) end
end
end
do
eq(Wire.roomId("R0123456789ABCDEF"), "r0123456789abcdef", "a room id normalizes to lower case")
eq(Wire.roomId("ABC234"), nil, "a code is no room id")
eq(Wire.roomId("r0123456789abcdeg"), nil, "a non-hex room id is refused")
eq(Wire.tourId("t0000000000000001"), "t0000000000000001", "a tournament id validates")
eq(Wire.inviteId("i00000000000000ff"), "i00000000000000ff", "an invite id validates")
eq(Wire.inviteToken(("ab"):rep(16)), ("ab"):rep(16), "an invite token validates")
eq(Wire.inviteToken("abc"), nil, "a short invite token is refused")
eq(Wire.playerId("0000abcd"), "0000abcd", "a player id validates")
eq(Wire.playerId("RED"), nil, "a name is no player id")
eq(Wire.pin("0000"), "0000", "0000 is a valid PIN")
eq(Wire.pin("12a4"), nil, "a PIN is four digits")
eq(Wire.pin(1234), nil, "a numeric PIN is refused")
eq(Wire.code("trn234"), "TRN234", "Wire.code survives for private tournaments")
local mon = Wire.mon3({ species = 25, level = 12, personality = 4000000000,
moves = { { id = 85, pp = 15, ppUps = 9 }, "junk" },
ivs = { hp = 31, atk = 40 }, gender = "X", extra = "drop" })
eq(mon.species, 25, "mon3 keeps the species")
eq(mon.personality, 4000000000, "mon3 keeps a 32-bit personality")
eq(mon.item, 0, "mon3 always carries item")
eq(mon.nickname, "", "mon3 defaults the nickname")
eq(mon.status, "", "mon3 defaults the status")
eq(mon.gender, "U", "an unknown gender reads as U")
eq(#mon.moves, 1, "non-table moves are dropped")
eq(mon.moves[1].ppUps, 3, "ppUps clamp to 3")
eq(mon.ivs.atk, 40, "ivs are left for the strict unpacker to judge")
eq(mon.ivs.spd, 0, "every iv key is present")
eq(mon.extra, nil, "unknown mon fields are dropped")
eq(mon.isEgg, false, "isEgg defaults to false")
local party = Wire.party3({ mon, mon, mon, mon, mon, mon, mon })
eq(#party, 6, "a Gen 3 party is capped at six")
local av = Wire.avatar({ name = "LEAF", trainerId = 99999, gender = 3, version = "leafgreen" })
eq(av.trainerId, 65535, "an avatar trainer id clamps to 16 bits")
eq(av.gender, 1, "an avatar gender clamps to 0/1")
eq(Wire.member({ id = "nothex", name = "X" }), nil, "a member needs a player id")
local hello = Wire.sanitize({ type = "game3_hello", name = "RED", generation = 3,
seat = 1, game3 = { linkType = 0x2233, trainerId = 12345,
gender = 1, seat = 1 } })
eq(hello.game3.linkType, 0x2233, "game3_hello keeps its game3 block")
eq(hello.seat, 1, "game3_hello keeps the transport seat")
eq(Wire.sanitize({ type = "game3_made_up", x = 1 }), nil,
"an unknown game3_* type is dropped, never passed through")
local chat = Wire.sanitize({ type = "game3_chat_line", name = "LONGNAMEX",
text = "abcdefghijklmnopqrstuvwxyz" })
eq(#chat.text, 15, "a chat line is clipped to 15")
eq(#chat.name, 7, "a chat name is clipped to 7")
local setup = Wire.sanitize({ type = "game3_battle_setup", mode = "single",
party = { { species = 6, level = 50, moves = {} } } })
eq(setup.party[1].species, 6, "game3_battle_setup carries mon3")
eq(setup.party[1].item, 0, "and the mon3 item is present")
local act = Wire.sanitize({ type = "game3_battle_action", turn = 3, kind = "list",
actions = { { kind = "move", slot = 1 },
{ kind = "move", slot = 2 },
{ kind = "move", slot = 3 } } })
eq(#act.actions, 2, "a doubles action list is capped at two")
local commit = Wire.sanitize({ type = "trade_commit", n = 2, seat = -1, relay = true,
digests = { ("a"):rep(16), ("b"):rep(16), ("c"):rep(16) } })
eq(#commit.digests, 2, "trade_commit carries two digests")
eq(commit.seat, -1, "a relay-authored inner keeps seat -1")
eq(commit.relay, true, "and its relay flag")
eq(Wire.sanitize({ type = "game3_trade_confirm", digest = "xyz" }).digest, nil,
"a malformed digest is dropped")
local leader = Wire.sanitize({ type = "game3_mg_leader", seat = 2, prev = 0, epoch = 2 })
eq(leader.seat, 2, "game3_mg_leader keeps the new leader's seat")
local ms = Wire.sanitize({ type = "match_start", room = "r0000000000000001", match = "m",
role = "seat2", seat = 2, seats = 4, engine = 3,
parties = { { { species = 1 } }, {}, { { species = 4 } } } })
eq(ms.parties[1][1].species, 1, "match_start parties hold mon3 per seat")
eq(#ms.parties[2], 0, "an empty seat party stays empty")
eq(ms.parties[3][1].item, 0, "and every seat's party is mon3-shaped")
local entry = Wire.sanitize({ type = "lobby_list", entries = { {
id = "p1", room = "r000000000000000a", code = "ABC234", locked = true, auto = true,
seats = 4, engine = 3, where = "union", status = "recruiting" } } }).entries[1]
eq(entry.code, nil, "a lobby entry never keeps a code")
eq(entry.locked, true, "a lobby entry keeps its lock")
eq(entry.engine, 3, "and its engine")
eq(Wire.profile({ engine = 5 }).engine, 3, "the profile engine clamps to 1..3")
end
do
for _, t in ipairs({ "set_profiles", "presence", "invite_token", "invite",
"invite_reply", "plaza_join", "plaza_leave", "group_open",
"group_list", "group_join", "group_accept", "group_leave",
"group_start", "direct_queue", "direct_list", "direct_leave" }) do
check(Protocol2.CLIENT_TYPES[t], "CLIENT_TYPES has " .. t)
end
for _, t in ipairs({ "upgrade_required", "invite_token", "invite_sent", "invite_in",
"invite_closed", "plaza_state", "plaza_delta", "plaza_counts",
"group_state", "group_request", "group_list", "group_closed",
"direct_state", "direct_list" }) do
check(Protocol2.SERVER_TYPES[t], "SERVER_TYPES has " .. t)
end
local reasons = { "lobby_disabled", "resume_unknown", "resume_expired", "already_in_room",
"not_found", "full", "spectators_full", "bad_profile", "bad_party", "profile_mismatch",
"party_ineligible", "spectate_late", "tour_not_found", "tour_started", "tour_full",
"not_creator", "bad_room", "bad_pin", "pin_required", "pin_locked", "bad_seats",
"bad_stage", "invite_expired", "tour_private", "group_not_found", "group_full",
"group_below_min", "not_leader", "already_in_group", "bad_activity", "already_queued",
"rate_limited" }
for _, r in ipairs(reasons) do
check(not Protocol2.joinErrorText({ reason = r }):find("Couldn't join", 1, true),
"join_error " .. r .. " has its own text")
end
check(Protocol2.joinErrorText({ reason = "bad_pin", triesLeft = 2 }):find("2", 1, true) ~= nil,
"bad_pin text says how many tries are left")
check(Protocol2.joinErrorText({ reason = "pin_locked", retryAt = 1790000300000 }, 1790000000000)
:find("5 min", 1, true) ~= nil,
"pin_locked text says when to retry")
check(Protocol2.joinErrorText({ reason = "pin_locked", retryAt = 1790000000000 }, 1790000300000)
:find("min", 1, true) == nil,
"a past retryAt adds nothing")
local whys = { "accepted", "declined", "timeout", "busy", "offline", "self", "target_left",
"sender_left", "rate_limited", "profile_mismatch", "bad_activity", "crossed", "no_room",
"no_group", "group_full" }
for _, w in ipairs(whys) do
check(not Protocol2.inviteClosedText({ why = w }):find("invite closed", 1, true),
"invite_closed " .. w .. " has its own text")
end
eq(select(2, Protocol2.validate({ type = "invite_in", id = "i0000000000000001",
activity = "trade" })),
"invite_in without a sender", "invite_in needs a sender")
eq(select(2, Protocol2.validate({ type = "plaza_state", kind = "union" })),
"plaza_state without members", "plaza_state needs members")
eq(select(2, Protocol2.validate({ type = "match_start", role = "host", match = "m" })),
"match_start without a room id", "match_start needs a room id")
local q = Protocol2.directQueue({ activity = "battle_double", auto = true, pin = "1234",
profile = PROFILE3, preview = { 1, 4, 7 } })
eq(q.ruleset, "g3_double", "direct_queue fills the ruleset from the activity")
eq(q.pin, "1234", "direct_queue carries a SET PIN")
eq(#q.preview, 3, "and the preview species")
eq(Protocol2.presence({ board = false }).board, false, "presence can clear the board")
eq(Protocol2.seatRole(2), "seat2", "seat 2 reads as seat2")
eq(Protocol2.roleSeat("guest"), 1, "guest reads as seat 1")
eq(Protocol2.ACTIVITY_RULESET.battle_multi, "g3_multi", "multi rides g3_multi")
eq(Protocol2.GROUP_CAPACITY.minigame_pick[1], 3, "Dodrio needs three")
end
do
local w = newWorld({ minProtocol = 4 })
local C, seat = w:add(1, "OLD")
local upgrades = 0
C.on("upgrade_required", function() upgrades = upgrades + 1 end)
w:pump()
eq(C.state(), "error", "upgrade_required lands the client in error")
check(C.upgradeRequired() ~= nil, "and the payload is kept")
check(tostring(C.error()):find("too old", 1, true) ~= nil, "the error is the upgrade text")
eq(upgrades, 1, "the upgrade_required event fires once")
local hellos = #w.relay:sent(seat, "lobby_hello")
CLOCK = CLOCK + 60
w:pump()
eq(C.state(), "error", "an upgrade_required client never reconnects")
eq(#w.relay:sent(seat, "lobby_hello"), hellos, "and never says hello again")
C.disconnect()
eq(C.upgradeRequired(), C.upgradeRequired(), "disconnect keeps the accessor callable")
end
do
local w = newWorld()
local Host, hs = w:add(0x10, "HOST")
local Joiner = w:add(0x11, "JOIN")
local Other = w:add(0x12, "OTHER")
w:pump()
eq(Host.state(), "online", "the host is online on protocol 3")
eq(w.relay:sent(hs, "lobby_hello")[1].protocol, 3, "lobby_hello says protocol 3")
local made = Host.createRoom({ intent = "battle", profile = PROFILE3, private = true,
pin = "4321" })
w:pump()
check(made.done and Wire.roomId(made.id) ~= nil, "a private room is created with an id")
eq(Host.room().locked, true, "the private room is locked")
local roomId = made.id
local noPin = Joiner.joinRoom(roomId, "player", PROFILE3)
w:pump()
eq(noPin.reason, "pin_required", "a locked room asks for a PIN")
local wrong = Joiner.joinRoom(roomId, "player", PROFILE3, "0000")
w:pump()
eq(wrong.reason, "bad_pin", "a wrong PIN is refused")
eq(wrong.triesLeft, 4, "and says how many tries are left")
local last
for _ = 1, 4 do
last = Joiner.joinRoom(roomId, "player", PROFILE3, "1111")
w:pump()
end
eq(last.reason, "pin_locked", "the fifth wrong PIN locks the joiner out")
check(last.retryAt ~= nil, "with a retry time")
local locked = Joiner.joinRoom(roomId, "player", PROFILE3, "4321")
w:pump()
eq(locked.reason, "pin_locked", "even the right PIN waits out the lock")
local starts = {}
Host.on("match_start", function(p) starts.host = p end)
Other.on("match_start", function(p) starts.other = p end)
local ok = Other.joinRoom(roomId, "player", PROFILE3, "4321")
w:pump()
check(ok.done and ok.error == nil, "the right PIN seats another joiner")
eq(Other.room().stage, "battling", "an engine 3 room starts when the last seat fills")
eq(starts.other and starts.other.seat, 1, "the joiner is seat 1")
eq(starts.other and starts.other.role, "guest", "with the guest role")
eq(starts.host and starts.host.seat, 0, "the host is seat 0")
eq(starts.host and starts.host.engine, 3, "match_start says engine 3")
eq(Other.roomSession().paired, true, "a full room reads as paired")
end
do
local w = newWorld()
local A = w:add(0x20, "AAA", { profile3("g3_link") })
local B = w:add(0x21, "BBB", { profile3("g3_link") })
w:pump()
local inbound, closedA, closedB = nil, {}, {}
B.on("invite_in", function(m) inbound = m end)
A.on("invite_closed", function(m) closedA[#closedA + 1] = m end)
B.on("invite_closed", function(m) closedB[#closedB + 1] = m end)
local h = A.invite(pid(0x21), "trade", {}, profile3("g3_link"))
eq(h.state, "sending", "a fresh invite is sending")
w:pump()
eq(h.state, "sent", "invite_sent marks it sent")
check(Wire.inviteId(h.id) ~= nil, "and gives it an id")
eq(#A.outgoing(), 1, "the outgoing list holds it")
check(inbound ~= nil and inbound.activity == "trade", "the target gets invite_in")
eq(inbound and inbound.from.id, pid(0x20), "naming the sender")
eq(#B.invites(), 1, "the target's invite list holds it")
local starts = {}
A.on("match_start", function(p) starts.a = p end)
B.on("match_start", function(p) starts.b = p end)
check(B.replyInvite(inbound.id, true), "the target accepts")
eq(#B.invites(), 0, "a replied invite leaves the list")
w:pump()
eq(h.state, "accepted", "the sender's handle is accepted")
check(Wire.roomId(h.room) ~= nil, "with the new room")
eq(#A.outgoing(), 0, "and leaves the outgoing list")
eq(A.room() and A.room().origin, "invite", "both land in an invite room")
eq(A.room() and A.room().listed, false, "which is unlisted")
eq(A.room() and A.room().intent, "trade", "with the trade intent")
eq(starts.a and starts.a.seat, 0, "the sender is seat 0")
eq(starts.b and starts.b.seat, 1, "the target is seat 1")
eq(closedA[#closedA].why, "accepted", "invite_closed accepted reaches the sender")
eq(closedB[#closedB].why, "accepted", "and the target")
A.leaveRoom(); B.leaveRoom()
w:pump()
local h2 = A.invite(pid(0x21), "chat", {}, profile3("g3_link"))
w:pump()
B.replyInvite(B.invites()[1].id, false)
w:pump()
eq(h2.state, "closed", "a declined invite closes")
eq(h2.why, "declined", "with why declined")
eq(A.room(), nil, "and no room")
local selfH = A.invite(pid(0x20), "battle_single", {}, profile3("g3_single"))
w:pump()
eq(selfH.state, "closed", "inviting yourself is refused")
eq(selfH.why, "self", "with why self")
local ghost = A.invite(pid(0x99), "battle_single", {}, profile3("g3_single"))
w:pump()
eq(ghost.why, "offline", "an unknown target is offline")
eq(#A.outgoing(), 0, "refused invites leave the outgoing list")
local ha = A.invite(pid(0x21), "battle_single", {}, profile3("g3_single"))
w:pump()
local hb = B.invite(pid(0x20), "battle_single", {}, profile3("g3_single"))
w:pump()
eq(ha.state, "accepted", "crossed invites accept the earlier one")
eq(hb.state, "accepted", "the later one reads as accepted too")
eq(hb.why, "crossed", "with why crossed")
eq(#B.invites(), 0, "the crossed incoming invite is gone")
check(A.room() ~= nil and B.room() ~= nil and A.room().room == B.room().room,
"and both share one room")
A.leaveRoom(); B.leaveRoom()
w:pump()
local h3 = A.invite(pid(0x21), "card", {}, profile3("g3_link"))
w:pump()
eq(#B.invites(), 1, "a pending invite waits")
CLOCK = CLOCK + 30
w:pump()
eq(#B.invites(), 0, "an expired invite is pruned")
eq(h3.state, "sent", "the sender waits for the relay's timeout")
end
do
local w = newWorld()
local A, sA = w:add(0x30, "ANN")
local B = w:add(0x31, "BEN")
local Mon = w:add(0x32, "MON")
w:pump()
local plazaEvents = 0
A.on("plaza", function() plazaEvents = plazaEvents + 1 end)
A.joinPlaza("union", PROFILE3, avatar("ANN", 100, 1))
w:pump()
local p = A.plaza()
check(p ~= nil and p.kind == "union", "plaza_state lands")
eq(p and p.you, 1, "the first member takes slot 1")
eq(p and #p.members, 1, "and is alone")
eq(p and p.cap, 40, "plaza_state carries the 40 cap")
local sentJoin = w.relay:sent(sA, "plaza_join")[1]
eq(sentJoin and sentJoin.cap, 40, "a union plaza_join announces cap 40")
local inst0, rev0 = A.plazaRev()
check(inst0 ~= nil and type(rev0) == "number", "plazaRev reports (instance, rev)")
B.joinPlaza("union", PROFILE3, avatar("BEN", 201, 0))
w:pump()
local inst1, rev1 = A.plazaRev()
eq(inst1, inst0, "the joiner lands in the same instance")
check(rev1 > rev0, "a plaza_delta moves the rev forward")
eq(select(2, B.plazaRev()), rev1, "the joiner's plaza_state rev matches the delta rev")
eq(#A.plaza().members, 2, "plaza_delta adds the joiner")
eq(A.plaza().members[2].slot, 2, "members sort by slot")
eq(A.plaza().members[2].avatar.trainerId, 201, "and carry their avatar")
eq(B.plaza().you, 2, "the joiner gets the next slot")
B.setPresence({ board = { species = 25, level = 10, wantType = 12 } })
w:pump()
eq(A.plaza().members[2].board and A.plaza().members[2].board.species, 25,
"a trading board change reaches the plaza")
B.setPresence({ board = false })
w:pump()
eq(A.plaza().members[2].board, nil, "clearing the board clears it")
Mon.joinPlaza("wireless")
w:pump()
w.relay:tick()
w:pump()
eq(Mon.plazaCounts() and Mon.plazaCounts().union, 2, "the wireless monitor counts the plaza")
eq(Mon.plazaCounts() and Mon.plazaCounts().total, 2, "and the total")
B.leavePlaza("union")
w:pump()
eq(#A.plaza().members, 1, "plaza_delta removes a leaver")
eq(B.plaza(), nil, "the leaver's plaza is gone")
check(plazaEvents >= 4, "every plaza change emits a plaza event")
local wrong = B.joinPlaza("union", { engine = 1, fingerprint = "x" }, avatar("BEN", 1))
local errs = {}
B.on("error", function(e) errs[#errs + 1] = e end)
w:pump()
check(wrong and errs[1] and errs[1].reason == "bad_profile",
"the union plaza refuses a non-engine-3 profile")
end
do
local w = newWorld()
local L = w:add(0x40, "LEAD", { profile3("g3_link") })
local M = w:add(0x41, "MEMB", { profile3("g3_link") })
w:pump()
L.openGroup("minigame_jump", nil, avatar("LEAD", 7))
w:pump()
local g = L.group()
eq(g and g.leader, pid(0x40), "group_open makes a group")
eq(g and g.min, 2, "with the cart's minimum")
eq(g and g.max, 5, "and maximum")
local errs = {}
L.on("error", function(e) errs[#errs + 1] = e end)
L.startGroup()
w:pump()
eq(errs[1] and errs[1].reason, "group_below_min", "a lone leader cannot start")
M.groupList("minigame_jump")
w:pump()
eq(#M.groups("minigame_jump"), 1, "the watcher sees the group")
local req
L.on("group_request", function(m) req = m end)
M.joinGroup(pid(0x40), nil, avatar("MEMB", 8))
w:pump()
eq(req and req.from, pid(0x41), "the leader gets group_request")
eq(#L.group().pending, 1, "the joiner is pending")
L.acceptGroup(pid(0x41), true)
w:pump()
eq(#L.group().members, 2, "accepting seats the joiner")
eq(M.group() and #M.group().members, 2, "the member sees the group state")
local P = w:add(0x42, "PEND", { profile3("g3_link") })
w:pump()
P.joinGroup(pid(0x40), nil, avatar("PEND", 9))
w:pump()
eq(#L.group().pending, 1, "a second joiner waits unanswered")
local closedP = {}
P.on("group_closed", function(m) closedP[#closedP + 1] = m end)
local closed
M.on("group_closed", function(m) closed = m end)
local startM
M.on("match_start", function(p) startM = p end)
L.startGroup()
w:pump()
eq(closed and closed.why, "started", "group_closed says started")
eq(#closedP, 1, "the pending joiner gets one group_closed")
eq(closedP[1] and closedP[1].why, "full", "saying full, not started")
eq(P.group(), nil, "and its group is cleared")
eq(P.room(), nil, "without a seat in the room")
eq(M.group(), nil, "the member's group is cleared")
eq(L.group(), nil, "and the leader's")
eq(M.room() and M.room().origin, "group", "a group room is born")
eq(M.room() and M.room().intent, "minigame", "with the minigame intent")
eq(M.room() and M.room().leader, 0, "the leader seat is 0")
eq(M.room() and M.room().stage, "battling", "born battling")
eq(startM and startM.seat, 1, "the member is seat 1")
eq(startM and startM.seats, 2, "of two")
end
do
local w = newWorld()
local A = w:add(0x50, "AUTOA")
local B = w:add(0x51, "AUTOB")
local C = w:add(0x52, "PINHOST")
local D = w:add(0x53, "CHOOSER")
w:pump()
local directA
A.on("direct", function(m) directA = m end)
A.queueDirect({ activity = "battle_single", auto = true, avatar = avatar("AUTOA", 1),
preview = { 1, 4 } })
w:pump()
eq(directA and directA.queued, true, "AUTO queues")
CLOCK = CLOCK + 1
local sa, sb
A.on("match_start", function(p) sa = p end)
B.on("match_start", function(p) sb = p end)
B.queueDirect({ activity = "battle_single", auto = true, avatar = avatar("AUTOB", 2) })
w:pump()
eq(directA and directA.why, "paired", "the earlier AUTO player is paired")
eq(A.direct() and A.direct().queued, false, "and no longer queued")
eq(sa and sa.seat, 0, "the earlier-queued player is seat 0")
eq(sb and sb.seat, 1, "the later one seat 1")
eq(A.room() and A.room().origin, "direct", "in a direct room")
eq(A.room() and A.room().stage, "battling", "born battling")
C.queueDirect({ activity = "trade", auto = true, pin = "7777",
profile = profile3("g3_link"), avatar = avatar("PINHOST", 3) })
w:pump()
check(C.direct() and Wire.roomId(C.direct().hosting) ~= nil, "SET PIN hosts a room")
eq(C.room() and C.room().locked, true, "which is locked")
D.directList("trade", profile3("g3_link"))
w:pump()
local entries = D.directEntries("trade")
eq(#entries, 1, "CHOOSE lists the hosted room")
eq(entries[1] and entries[1].kind, "room", "as a room entry")
eq(entries[1] and entries[1].locked, true, "with its lock")
eq(entries[1] and entries[1].auto, true, "and its AUTO opt-in")
local sd
D.on("match_start", function(p) sd = p end)
D.queueDirect({ activity = "trade", auto = true, profile = profile3("g3_link"),
avatar = avatar("CHOOSER", 4) })
w:pump()
eq(sd and sd.seat, 1, "AUTO pairs into a PIN host that opted in, PIN bypassed")
eq(D.room() and D.room().room, C.room() and C.room().room, "both share the host's room")
D.leaveDirect()
w:pump()
eq(D.direct() and D.direct().why, "left", "direct_leave answers direct_state left")
end
do
local w = newWorld()
local cs, seats = {}, {}
for i = 1, 4 do
cs[i], seats[i] = w:add(0x60 + i, "MULTI" .. i, { profile3("g3_multi") })
end
local Spec = w:add(0x6f, "WATCHER", { profile3("g3_multi") })
w:pump()
for i = 1, 4 do
cs[i].queueDirect({ activity = "battle_multi", auto = true, avatar = avatar("M" .. i, i) })
CLOCK = CLOCK + 1
w:pump()
end
eq(cs[1].room() and cs[1].room().seats, 4, "four AUTO players form a multi room")
eq(cs[3].seat(), 2, "the third queued is seat 2")
eq(cs[3].role(), "seat2", "with the seat2 role")
local rs3 = cs[3].roomSession()
eq(rs3:seats(), 4, "the room session knows four seats")
eq(rs3.paired, true, "and is paired once all four sit")
local token
cs[1].on("invite_token", function(m) token = m.token end)
check(cs[1].inviteToken(), "a seated player mints an invite token")
w:pump()
check(Wire.inviteToken(token) ~= nil, "the token comes back")
local watched = Spec.joinRoomByInvite(token, "spectator")
w:pump()
check(watched.done and watched.error == nil, "a spectator joins the unlisted room by token")
eq(Spec.role(), "spectator", "as a spectator")
local rsSpec = Spec.roomSession()
rsSpec:poll()
for i = 1, 4 do cs[i].roomSession():poll() end
rs3:send({ type = "game3_battle_action", turn = 1, kind = "move", slot = 2 })
w:pump()
for _, i in ipairs({ 1, 2, 4 }) do
local got = cs[i].roomSession():poll()
eq(#got, 1, ("seat %d gets seat 2's action"):format(i - 1))
eq(got[1] and got[1].seat, 2, ("tagged with seat 2 at seat %d"):format(i - 1))
end
eq(#rs3:poll(), 0, "the sender never gets its own message")
local sg = rsSpec:poll()
eq(#sg, 1, "the spectator gets it too")
eq(sg[1] and sg[1].type, "game3_battle_action", "bare, without a spectate wrapper")
eq(sg[1] and sg[1].seat, 2, "and tagged with the sender's seat")
cs[1].roomSession():send({ type = "game3_battle_action", turn = 1, kind = "move",
slot = 1, seat = 3, relay = true })
w:pump()
local spoof = cs[2].roomSession():take("game3_battle_action")
eq(spoof and spoof.seat, 0, "the envelope seat wins over a spoofed inner seat")
eq(spoof and spoof.relay, nil, "and a spoofed relay flag is stripped")
cs[1].roomSession():send({ type = "trade_commit", n = 1, digests = {} })
w:pump()
eq(cs[2].roomSession():take("trade_commit"), nil,
"a client-authored trade_commit never reaches a peer")
eq(cs[2].roomSession():peerOnline(3), true, "peerOnline reads the seat's online flag")
end
do
local w = newWorld()
local A, sa = w:add(0x70, "TRA", { profile3("g3_link") })
local B = w:add(0x71, "TRB", { profile3("g3_link") })
w:pump()
A.invite(pid(0x71), "trade", {}, profile3("g3_link"))
w:pump()
B.replyInvite(B.invites()[1].id, true)
w:pump()
local ra, rb = A.roomSession(), B.roomSession()
check(ra ~= nil and rb ~= nil, "both traders hold a room session")
ra:poll(); rb:poll()
local commitsA, commitsB = {}, {}
A.on("trade_commit", function(m) commitsA[#commitsA + 1] = m end)
B.on("trade_commit", function(m) commitsB[#commitsB + 1] = m end)
local digest = ("0123456789abcdef")
ra:send({ type = "game3_trade_confirm", digest = digest })
w:pump()
w.relay:drop(sa)
A.update(0)
eq(A.state(), "reconnecting", "the first confirmer drops")
rb:send({ type = "game3_trade_confirm", digest = digest })
w:pump()
local gotB = rb:take("trade_commit")
check(gotB ~= nil, "the connected seat gets trade_commit through its inbox")
eq(gotB and gotB.seat, -1, "tagged as relay-authored")
eq(gotB and gotB.relay, true, "with the relay flag")
eq(gotB and gotB.digests[1], digest, "carrying both digests")
eq(#commitsB, 1, "and the trade_commit event fires")
eq(#commitsA, 0, "the dropped seat has not seen it yet")
w.relay:reconnect(sa)
CLOCK = CLOCK + 2
A.update(0)
w:pump()
eq(A.state(), "online", "the dropped seat resumes")
check(A.roomSession() == ra, "its room session survives the resume")
local replayed = ra:take("trade_commit")
check(replayed ~= nil, "resume replays trade_commit to the dropped seat")
eq(replayed and replayed.n, 1, "the first barrier")
eq(replayed and replayed.seat, -1, "tagged relay-authored")
eq(#commitsA, 1, "and fires the event once")
eq(ra:take("game3_trade_confirm") and true or false, true,
"the peer's confirm is replayed too")
ra:send({ type = "game3_trade_confirm", digest = ("1"):rep(16) })
rb:send({ type = "game3_trade_confirm", digest = ("2"):rep(16) })
local aborts = {}
A.on("trade_abort", function(m) aborts[#aborts + 1] = m end)
w:pump()
eq(aborts[1] and aborts[1].why, "digest", "differing digests abort")
eq(aborts[1] and aborts[1].n, 2, "on the second barrier")
end
do
local w = newWorld()
local A, sa = w:add(0x80, "REJOIN", { PROFILE3 },
{ where = "launcher", status = "idle", version = "firered" })
w:pump()
A.setProfiles({ PROFILE3, profile3("g3_link") })
A.setPresence({ status = "busy" })
A.joinPlaza("union", PROFILE3, avatar("REJOIN", 5))
A.queueDirect({ activity = "battle_double", auto = true, profile = profile3("g3_double") })
A.openGroup("minigame_crush", profile3("g3_link"), avatar("REJOIN", 5))
A.groupList("minigame_pick", profile3("g3_link"))
w:pump()
local hello = w.relay:sent(sa, "lobby_hello")[1]
eq(hello.presence and hello.presence.version, "firered", "the hello carries presence")
w.relay:drop(sa)
A.update(0)
w.relay:reconnect(sa)
CLOCK = CLOCK + 2
A.update(0)
local plazaStates = 0
A.on("plaza", function(p) if p then plazaStates = plazaStates + 1 end end)
w:pump()
eq(A.state(), "online", "a resumed session is online")
eq(plazaStates, 1, "the relay re-sends plaza_state on resume")
local resent = 0
for _, t in ipairs(outTypes(sa)) do
if t == "plaza_join" or t == "direct_queue" or t == "group_open" then resent = resent + 1 end
end
eq(resent, 0, "a resumed session re-sends nothing")
w.relay:drop(sa)
w.relay:forget(sa)
A.update(0)
w.relay:reconnect(sa)
CLOCK = CLOCK + 4
A.update(0)
w.relay:pump()
A.update(0)
local before = #w.relay.log
w.relay:pump()
A.update(0)
w.relay:pump()
local order = {}
for i = before + 1, #w.relay.log do
local m = w.relay.log[i]
if m.from == sa.id then order[#order + 1] = m.msg.type end
end
local want = { "lobby_hello", "set_profiles", "presence", "plaza_join", "direct_queue",
"group_open", "group_list" }
local seq = table.concat(order, ",")
eq(seq, table.concat(want, ","), "a fresh welcome re-sends the session state in order")
local rejoin = w.relay:sent(sa, "plaza_join")
eq(rejoin[#rejoin] and rejoin[#rejoin].cap, 40, "the re-sent plaza_join keeps cap 40")
local presence = w.relay:sent(sa, "presence")
eq(presence[#presence].status, "busy", "the re-sent presence is the latest")
local sp = w.relay:sent(sa, "set_profiles")
eq(#sp[#sp].profiles, 2, "the re-sent profiles are the latest")
A.leavePlaza("union")
A.leaveDirect()
A.leaveGroup()
A.groupList(nil)
w:pump()
w.relay:drop(sa)
w.relay:forget(sa)
A.update(0)
w.relay:reconnect(sa)
CLOCK = CLOCK + 8
A.update(0)
local mark = #w.relay.log
w:pump(4)
local again = {}
for i = mark + 1, #w.relay.log do
local m = w.relay.log[i]
if m.from == sa.id then again[#again + 1] = m.msg.type end
end
eq(table.concat(again, ","), "resume,lobby_hello,set_profiles,presence",
"left plazas, queues and groups are not re-joined")
end
do
local w = newWorld()
local L = w:add(0x90, "MGL", { profile3("g3_link") })
local M = w:add(0x91, "MGM", { profile3("g3_link") })
local N = w:add(0x92, "MGN", { profile3("g3_link") })
w:pump()
L.openGroup("minigame_crush", nil, avatar("MGL", 1))
w:pump()
M.joinGroup(pid(0x90), nil, avatar("MGM", 2))
N.joinGroup(pid(0x90), nil, avatar("MGN", 3))
w:pump()
L.acceptGroup(pid(0x91), true)
L.acceptGroup(pid(0x92), true)
w:pump()
L.startGroup()
w:pump()
local roomId = M.room() and M.room().room
eq(M.room() and M.room().seats, 3, "a three-member Berry Crush room")
local rm = M.roomSession()
rm:poll()
N.roomSession():poll()
M.roomSession():send({ type = "game3_mg_state", f = 3, e = 1, s = { x = 1 } })
w:pump()
eq(N.roomSession():take("game3_mg_state"), nil, "a non-leader's mg_state is dropped")
w.relay:drop(w.relay.sessions[pid(0x90)])
w.relay:migrateLeader(roomId)
w:pump()
local lead = rm:take("game3_mg_leader")
eq(lead and lead.seat, 1, "game3_mg_leader names the new leader seat")
eq(lead and lead.prev, 0, "and the previous one")
eq(lead and lead.relay, true, "and is relay-authored")
eq(M.room().leader, 1, "the room state follows the new leader")
end
do
local w = newWorld()
local L = w:add(0x94, "MGL", { profile3("g3_link") })
local M = w:add(0x95, "MGM", { profile3("g3_link") })
w:pump()
L.openGroup("minigame_jump", nil, avatar("MGL", 1))
w:pump()
M.joinGroup(pid(0x94), nil, avatar("MGM", 2))
w:pump()
L.acceptGroup(pid(0x95), true)
w:pump()
L.startGroup()
w:pump()
eq(L.room() and L.room().intent, "minigame", "a minigame room")
eq(L.room() and L.room().players[1].seat, 0, "the leader holds seat 0")
L.leaveRoom()
w:pump()
local sa = w.relay.sessions[pid(0x94)]
eq(#w.relay:sent(sa, "room_close"), 0, "seat 0 leaving a minigame room sends no room_close")
eq(#w.relay:sent(sa, "room_leave"), 1, "only a plain room_leave")
end
do
local EPOCH = 1790000000000
local w = newWorld({ epoch = EPOCH })
local A = w:add(0xa0, "AAA", { profile3("g3_link") })
local B = w:add(0xa1, "BBB", { profile3("g3_link") })
w:pump()
check((B.serverTime() or 0) >= EPOCH, "lobby_welcome.serverTime keeps epoch milliseconds")
local h = A.invite(pid(0xa1), "trade", {}, profile3("g3_link"))
w:pump()
local held = B.invites()[1]
eq(#B.invites(), 1, "an epoch-stamped invite_in is not pruned on arrival")
check(held and (held.expiresAt or 0) > EPOCH, "expiresAt keeps epoch milliseconds")
check(B.replyInvite(held and held.id, true), "and can be answered")
w:pump()
eq(h.state, "accepted", "the sender sees it accepted")
end
do
local w = newWorld()
local A = w:add(0xb0, "AAA", { profile3("g3_link") })
local sa = w.relay.sessions[pid(0xb0)]
w:pump()
A.createRoom({ intent = "trade", profile = profile3("g3_link") })
w:pump()
local old = A.room() and A.room().room
check(Wire.roomId(old) ~= nil, "a room to leave")
A.leaveRoom()
local stale = { type = "room_state", room = old, intent = "trade", engine = 3, seats = 2,
players = { { id = pid(0xb0), name = "AAA", seat = 0 } }, stage = "waiting" }
w.relay:to(sa, stale)
w.relay:to(sa, { type = "match_start", room = old, seat = 0, seats = 2, role = "host",
engine = 3, seed = 7 })
local started = false
A.on("match_start", function() started = true end)
A.update(0)
eq(A.room(), nil, "a room_state for the room just left is dropped")
eq(started, false, "and so is its match_start")
local p = A.createRoom({ intent = "trade", profile = profile3("g3_link") })
w.relay:to(sa, stale)
A.update(0)
eq(p.done, false, "a stale room_state does not answer the next create")
w:pump()
check(p.done and A.room() and A.room().room ~= old, "the new room lands")
A.leaveRoom()
local rejoin = A.joinRoom(old, "player", profile3("g3_link"))
w.relay:to(sa, stale)
A.update(0)
eq(A.room() and A.room().room, old, "an explicit rejoin of the left room is still applied")
eq(rejoin.done, true, "and answers that join")
end
do
local m = Wire.member({ id = pid(1), group = { leader = pid(1), members = { pid(1), pid(2) },
activity = "chat" } })
eq(m and m.group and m.group.leader, pid(1), "plaza members keep group.leader")
eq(m and m.group and #m.group.members, 2, "and group.members")
eq(m and m.group and m.group.activity, "chat", "and group.activity")
local inv = Wire.sanitize({ type = "invite", to = pid(2), activity = "chat",
detail = { join = true } })
eq(inv and inv.detail.join, true, "invites keep detail.join")
local line = Wire.sanitize({ type = "game3_chat_line", name = "\195\137LODIE12",
text = "HI\238\131\144\195\169\226\128\166ABCDEFGHIJKLMNOP" })
eq(line and line.text, "HI\238\131\144\195\169\226\128\166ABCDEFGHIJ",
"a chat line clips to 15 characters, not 15 bytes")
eq(line and line.name, "\195\137LODIE1", "a chat name clips to 7 characters")
local ready = Wire.sanitize({ type = "game3_mg_ready", species = 25, partySlot = 0,
name = "RED", trainerId = 12345, gender = 1 })
eq(ready and ready.name, "RED", "game3_mg_ready keeps the OT name")
eq(ready and ready.trainerId, 12345, "and the trainer id")
eq(ready and ready.gender, 1, "and the gender")
end
do
local Session = require("src.link.Session")
local queue = {
{ type = "forfeit", seat = 2, reason = "gave up", match = "r0123456789abcdef-m1" },
{ type = "bye", seat = 3, relay = false },
}
local transport = { closed = false }
function transport.update() end
function transport.poll()
local out = queue
queue = {}
return out
end
function transport.send() end
function transport.close() end
local s = Session.new(transport, { role = "host", kind = "game3" })
s:update()
local f = s:take("forfeit")
eq(f and f.seat, 2, "an engine 3 forfeit keeps its sender seat through the session's sanitize")
eq(f and f.reason, "gave up", "and its reason")
eq(f and f.match, "r0123456789abcdef-m1", "and its match")
local b = s:take("bye")
eq(b and b.seat, 3, "an engine 3 bye keeps its sender seat")
eq(Wire.sanitize({ type = "forfeit", seat = 9 }).seat, 4, "a forfeit seat clamps to the wire range")
end
do
local w = newWorld()
local A, sa = w:add(0xB0, "BOARD", { profile3("g3_link") })
local B = w:add(0xB1, "WATCH", { profile3("g3_link") })
w:pump()
A.setPresence({ board = { species = 1, level = 5, wantType = 3 } })
w:pump()
eq(sa.presence.board, nil, "the relay ignores a board sent before plaza_join")
A.joinPlaza("union", profile3("g3_link"), avatar("BOARD", 1))
B.joinPlaza("union", profile3("g3_link"), avatar("WATCH", 2))
w:pump()
A.setPresence({ board = { species = 25, level = 12, wantType = 10 } })
w:pump()
eq(sa.presence.board and sa.presence.board.species, 25, "the relay holds the board once in the plaza")
w.relay:drop(sa)
w.relay:forget(sa)
A.update(0)
w.relay:reconnect(sa)
CLOCK = CLOCK + 4
A.update(0)
w:pump(6)
eq(A.state(), "online", "a forgotten session comes back on a fresh welcome")
eq(A.plaza() ~= nil, true, "and re-joins the union plaza")
local order = {}
for _, m in ipairs(w.relay:sent(sa)) do
if m.type == "plaza_join" or (m.type == "presence" and type(m.board) == "table") then
order[#order + 1] = m.type
end
end
eq(order[#order], "presence", "the board presence goes out after the fresh plaza_join")
eq(sa.presence.board and sa.presence.board.species, 25,
"so the relay holds the trading board again after a fresh welcome")
local seen
for _, m in ipairs(B.plaza() and B.plaza().members or {}) do
if m.id == pid(0xB0) then seen = m.board end
end
eq(seen and seen.species, 25, "and the other plaza member sees it")
end
do
local w = newWorld()
local H, sh = w:add(0xC0, "HOST", { profile3("g3_link") })
local G, sg = w:add(0xC1, "GUEST", { profile3("g3_link") })
w:pump()
H.createRoom({ intent = "trade", profile = profile3("g3_link") })
w:pump()
local roomId = H.room() and H.room().room
G.joinRoom(roomId, "player", profile3("g3_link"))
w:pump()
local hrs, grs = H.roomSession(), G.roomSession()
eq(grs and grs:seat(), 1, "the guest holds seat 1 of the trade room")
local digest = ("ab"):rep(8)
hrs:send({ type = "game3_trade_confirm", digest = digest })
grs:send({ type = "game3_trade_confirm", digest = digest })
w.relay:pump()
H.update(0)
local hostCommit
for _, m in ipairs(hrs:poll()) do
if m.type == "trade_commit" then hostCommit = m end
end
eq(hostCommit and hostCommit.n, 1, "the host gets trade_commit")
hrs:close()
w.relay:pump()
eq(#w.relay:sent(sh, "room_close"), 0, "the host leaving a trade room sends no room_close")
eq(#w.relay:sent(sh, "room_leave"), 1, "only a plain room_leave")
eq(w.relay.rooms[roomId] ~= nil, true, "so the room and its log outlive the host")
G.update(0)
local guestCommit
for _, m in ipairs(grs:poll()) do
if m.type == "trade_commit" then guestCommit = m end
end
eq(guestCommit and guestCommit.n, 1, "the guest still gets trade_commit")
w.relay:drop(sg)
G.update(0)
w.relay:reconnect(sg)
CLOCK = CLOCK + 4
G.update(0)
w:pump(4)
eq(G.room() and G.room().room, roomId, "a resuming guest still finds the room")
end
do
local t = FakeRelay.transport()
local C = newClient()
C.configure({ relayAddress = "fake:1", connect = function() return t end })
C.connect({ name = "X", profiles = { PROFILE3 } })
t.inbox[#t.inbox + 1] = { type = "lobby_welcome", session = "s-b", resumed = false,
you = { id = pid(0xD1), name = "X", verified = true }, serverTime = 1790000000000,
heartbeatMs = 10000 }
C.update(0)
local room = "r00000000000000d0"
t.inbox[#t.inbox + 1] = { type = "room_state", room = room, intent = "trade", engine = 3,
profile = profile3("g3_link"), seats = 2, stage = "battling", host = pid(0xD0),
match = room .. "-m1", origin = "invite",
players = { { id = pid(0xD0), name = "H", seat = 0 }, { id = pid(0xD1), name = "X", seat = 1 } } }
C.update(0)
local rs = C.roomSession()
t.inbox[#t.inbox + 1] = { type = "room_msg", seq = 3, seat = 0,
msg = { type = "game3_trade_cmd", cmd = 1 } }
t.inbox[#t.inbox + 1] = { type = "room_msg", seq = 4, seat = -1, relay = true,
msg = { type = "trade_commit", n = 1, digests = { ("cd"):rep(8), ("cd"):rep(8) } } }
t.inbox[#t.inbox + 1] = { type = "room_closed", reason = "closed", room = room }
C.update(0)
eq(C.room(), nil, "room_closed clears the room")
eq(rs.closed, true, "and closes the room session")
eq(rs:hasPending(), true, "a relay trade_commit sharing the pump stays pending on it")
local got = rs:take("trade_commit")
eq(got and got.n, 1, "and is still delivered")
eq(got and got.seat, -1, "as a relay message")
eq(rs:take("game3_trade_cmd"), nil, "peer traffic of the closed room is not kept")
eq(rs:hasPending(), false, "the leftover drains once")
end
love.timer.getTime = savedGetTime
if failures > 0 then
print(("\n%d online client gen 3 check(s) failed"):format(failures))
os.exit(1)
end
print("\nonline client gen 3 tests passed")
return failures