Files
2026-09-25 10:28:21 -04:00

939 lines
33 KiB
Lua

local Protocol2 = require("src.online.Protocol2")
local FakeRelay = {}
FakeRelay.__index = FakeRelay
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
FakeRelay.copy = copy
function FakeRelay.transport()
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
function FakeRelay.new(opts)
opts = opts or {}
return setmetatable({
clock = opts.clock or function() return 0 end,
sessions = {},
order = {},
rooms = {},
roomNo = 0,
inviteNo = 0,
tokenNo = 0,
invites = {},
tokens = {},
plazas = { union = {} },
plazaRev = 0,
wireless = {},
groups = {},
groupWatch = {},
queue = {},
directWatch = {},
minProtocol = opts.minProtocol or 3,
epoch = opts.epoch or 1000,
log = {},
}, FakeRelay)
end
function FakeRelay:now()
return self.epoch + math.floor(self.clock() * 1000)
end
function FakeRelay:seat(id, name)
local s = { id = id, name = name, transport = FakeRelay.transport(),
online = true, profiles = {}, presence = {}, since = 0,
clientSeq = {} }
self.sessions[id] = s
self.order[#self.order + 1] = id
return s
end
function FakeRelay:reconnect(s)
s.transport = FakeRelay.transport()
return s.transport
end
function FakeRelay:drop(s)
s.transport.closed = true
s.online = false
end
function FakeRelay:forget(s)
s.forgotten = true
for _, inst in ipairs(self.plazas.union) do inst.slots[s.id] = nil end
self.wireless[s.id] = nil
s.presence.board = nil
end
function FakeRelay:to(s, msg)
if not s or not s.online or s.transport.closed then return end
table.insert(s.transport.inbox, copy(msg))
end
function FakeRelay:sent(s, kind)
local out = {}
for _, m in ipairs(self.log) do
if m.from == s.id and (kind == nil or m.msg.type == kind) then out[#out + 1] = m.msg end
end
return out
end
local function profileOf(s, engine)
for _, p in ipairs(s.profiles or {}) do
if engine == nil or p.engine == engine then return p end
end
return nil
end
local function compatible(a, b, battle)
if type(a) ~= "table" or type(b) ~= "table" then return false end
if a.engine ~= b.engine or a.fingerprint ~= b.fingerprint then return false end
if battle and a.rulesetId ~= b.rulesetId then return false end
return true
end
function FakeRelay:newRoom(fields)
self.roomNo = self.roomNo + 1
local room = {
room = ("r%016x"):format(0x3000 + self.roomNo),
intent = fields.intent or "battle",
engine = fields.engine or 3,
profile = copy(fields.profile),
seats = fields.seats or 2,
locked = fields.pin ~= nil,
pin = fields.pin,
listed = fields.listed ~= false,
auto = fields.auto == true,
origin = fields.origin or "create",
players = {},
spectators = {},
stage = "waiting",
host = nil,
seed = nil,
match = nil,
matchNo = 0,
maxSpectators = 8,
leader = fields.intent == "minigame" and 0 or nil,
epoch = 1,
log = {},
seq = 0,
barrier = { n = 1, confirms = {} },
pinFails = {},
since = self:now() + self.roomNo,
}
self.rooms[room.room] = room
return room
end
function FakeRelay:roomStateMsg(room)
local players, spectators = {}, {}
for _, p in ipairs(room.players) do
local s = self.sessions[p.id]
players[#players + 1] = { id = p.id, name = p.name, verified = true,
ready = false, online = s and s.online or false,
seat = p.seat }
end
for _, sp in ipairs(room.spectators) do
spectators[#spectators + 1] = { id = sp.id, name = sp.name, verified = true,
online = true }
end
return { type = "room_state", room = room.room, intent = room.intent,
engine = room.engine, profile = room.profile, seats = room.seats,
locked = room.locked, listed = room.listed, auto = room.auto,
origin = room.origin, players = players, spectators = spectators,
stage = room.stage, host = room.host, seed = room.seed,
match = room.match, maxSpectators = room.maxSpectators,
leader = room.leader, deadlines = {} }
end
function FakeRelay:roomBroadcast(room, msg)
for _, p in ipairs(room.players) do self:to(self.sessions[p.id], msg) end
for _, sp in ipairs(room.spectators) do self:to(self.sessions[sp.id], msg) end
end
function FakeRelay:roomState(room)
self:roomBroadcast(room, self:roomStateMsg(room))
end
function FakeRelay:seatPlayer(room, s)
local p = { id = s.id, name = s.name, seat = #room.players }
room.players[#room.players + 1] = p
s.room = room.room
if not room.host then room.host = s.id end
return p
end
function FakeRelay:startRoom(room)
room.stage = "battling"
room.matchNo = room.matchNo + 1
room.match = room.room .. "-m" .. room.matchNo
room.seed = 424242
self:roomState(room)
local players = {}
for _, p in ipairs(room.players) do
players[#players + 1] = { id = p.id, name = p.name, seat = p.seat }
end
local ruleset = room.profile and room.profile.rulesetId or nil
for _, p in ipairs(room.players) do
self:to(self.sessions[p.id], {
type = "match_start", room = room.room, match = room.match,
role = Protocol2.SEAT_ROLES[p.seat], seat = p.seat, seats = room.seats,
engine = room.engine, seed = room.seed, ruleset = ruleset,
players = players })
end
for _, sp in ipairs(room.spectators) do
self:to(self.sessions[sp.id], {
type = "match_start_spectate", room = room.room, match = room.match,
role = "spectator", seats = room.seats, engine = room.engine,
seed = room.seed, ruleset = ruleset, players = players })
end
end
function FakeRelay:seatOf(room, id)
for _, p in ipairs(room.players) do
if p.id == id then return p.seat end
end
return nil
end
function FakeRelay:isSpectator(room, id)
for _, sp in ipairs(room.spectators) do
if sp.id == id then return true end
end
return false
end
function FakeRelay:appendLog(room, entry)
room.seq = room.seq + 1
entry.seq = room.seq
room.log[#room.log + 1] = entry
return entry
end
function FakeRelay:relayInner(room, inner)
local entry = self:appendLog(room, { seat = -1, relay = true, msg = copy(inner) })
self:roomBroadcast(room, { type = "room_msg", seq = entry.seq, seat = -1,
relay = true, msg = copy(inner) })
end
local RELAY_ONLY = Protocol2.RELAY_INNER
function FakeRelay:fanout(s, clientSeq, inner)
local room = s.room and self.rooms[s.room]
if not room or type(inner) ~= "table" then return end
local seat = self:seatOf(room, s.id)
if seat == nil then return end
if RELAY_ONLY[inner.type] then return end
if inner.type == "game3_mg_state" and room.leader ~= seat then return end
room.clientSeq = room.clientSeq or {}
if clientSeq and room.clientSeq[s.id] and clientSeq <= room.clientSeq[s.id] then
return
end
if clientSeq then room.clientSeq[s.id] = clientSeq end
local entry = self:appendLog(room, { seat = seat, clientSeq = clientSeq,
msg = copy(inner) })
for _, p in ipairs(room.players) do
if p.id ~= s.id then
self:to(self.sessions[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.sessions[sp.id], { type = "room_msg", seq = entry.seq,
clientSeq = clientSeq, seat = seat,
msg = copy(inner) })
end
if inner.type == "game3_trade_confirm" or inner.type == "trade_confirm" then
local b = room.barrier
b.confirms[seat] = inner.digest
if b.confirms[0] and b.confirms[1] then
if b.confirms[0] == b.confirms[1] then
self:relayInner(room, { type = "trade_commit", n = b.n,
digests = { b.confirms[0], b.confirms[1] } })
else
self:relayInner(room, { type = "trade_abort", n = b.n, why = "digest" })
end
room.barrier = { n = b.n + 1, confirms = {} }
end
end
end
function FakeRelay:replay(s, from)
local room = s.room and self.rooms[s.room]
if not room then return end
local mine = self:seatOf(room, s.id)
local msgs = {}
for _, e in ipairs(room.log) do
if e.seq > from and (e.relay or e.seat ~= mine) then
msgs[#msgs + 1] = { seq = e.seq, clientSeq = e.clientSeq, seat = e.seat,
relay = e.relay, msg = copy(e.msg) }
end
end
self:to(s, { type = "room_replay", from = from, msgs = msgs,
yourSeq = (room.clientSeq or {})[s.id] or 0 })
end
function FakeRelay:migrateLeader(roomId)
local room = self.rooms[roomId]
local prev = room.leader
local nextSeat
for _, p in ipairs(room.players) do
local s = self.sessions[p.id]
if p.seat > prev and s and s.online then nextSeat = p.seat break end
end
if nextSeat == nil then
for _, p in ipairs(room.players) do
local s = self.sessions[p.id]
if p.seat ~= prev and s and s.online then nextSeat = p.seat break end
end
end
room.leader = nextSeat
room.epoch = room.epoch + 1
self:relayInner(room, { type = "game3_mg_leader", seat = nextSeat, prev = prev,
epoch = room.epoch })
self:roomState(room)
end
function FakeRelay:joinError(s, reason, extra)
local msg = { type = "join_error", reason = reason }
for k, v in pairs(extra or {}) do msg[k] = v end
self:to(s, msg)
end
function FakeRelay:handleRoomJoin(s, msg)
local room
local viaInvite = false
if msg.invite then
local tok = self.tokens[msg.invite]
if not tok then return self:joinError(s, "invite_expired") end
room = self.rooms[tok.room]
viaInvite = true
elseif msg.room then
room = self.rooms[msg.room]
else
return self:joinError(s, "bad_room")
end
if not room then return self:joinError(s, "not_found") end
if not room.listed and not viaInvite then return self:joinError(s, "not_found") end
if room.locked and not viaInvite then
local fails = room.pinFails[s.id] or 0
if fails >= 5 then return self:joinError(s, "pin_locked", { retryAt = self:now() + 600000 }) end
if msg.pin == nil then return self:joinError(s, "pin_required") end
if msg.pin ~= room.pin then
fails = fails + 1
room.pinFails[s.id] = fails
if fails >= 5 then
return self:joinError(s, "pin_locked", { retryAt = self:now() + 600000 })
end
return self:joinError(s, "bad_pin", { triesLeft = 5 - fails })
end
end
if not compatible(msg.profile, room.profile, room.intent == "battle") then
return self:joinError(s, "profile_mismatch")
end
if msg.as == "spectator" then
room.spectators[#room.spectators + 1] = { id = s.id, name = s.name }
s.room = room.room
self:roomState(room)
if room.stage == "battling" then
self:to(s, { type = "match_start_spectate", room = room.room, match = room.match,
role = "spectator", seats = room.seats, engine = room.engine,
seed = room.seed })
self:replay(s, 0)
end
return
end
if #room.players >= room.seats then return self:joinError(s, "full") end
self:seatPlayer(room, s)
if #room.players == room.seats and room.engine == 3 then
self:startRoom(room)
else
self:roomState(room)
end
end
local function memberOf(s, slot)
return { id = s.id, name = s.name, verified = true, slot = slot,
online = s.online, status = s.status or (s.recruiting and "recruiting" or "idle"),
avatar = copy(s.avatar), recruiting = copy(s.recruiting),
board = copy(s.presence.board), group = copy(s.group) }
end
function FakeRelay:plazaInstance(s)
for i, inst in ipairs(self.plazas.union) do
if inst.slots[s.id] then return inst, i end
end
return nil
end
FakeRelay.PLAZA_CAP = 40
function FakeRelay:plazaDelta(inst, index, fields, except)
self.plazaRev = self.plazaRev + 1
inst.rev = self.plazaRev
local msg = { type = "plaza_delta", kind = "union", instance = index, rev = inst.rev,
joined = fields.joined or {}, left = fields.left or {},
changed = fields.changed or {} }
for id in pairs(inst.slots) do
if id ~= except then self:to(self.sessions[id], msg) end
end
end
function FakeRelay:plazaStateMsg(s)
local inst, index = self:plazaInstance(s)
local members = {}
for id, slot in pairs(inst.slots) do
members[#members + 1] = memberOf(self.sessions[id], slot)
end
table.sort(members, function(a, b) return a.slot < b.slot end)
return { type = "plaza_state", kind = "union", instance = index, cap = FakeRelay.PLAZA_CAP,
rev = inst.rev, you = inst.slots[s.id], members = members }
end
function FakeRelay:plazaJoin(s, msg)
if msg.kind == "wireless" then
self.wireless[s.id] = true
return
end
if type(msg.profile) ~= "table" or msg.profile.engine ~= 3 then
return self:joinError(s, "bad_profile")
end
if not (math.floor(tonumber(msg.cap) or 0) >= FakeRelay.PLAZA_CAP) then
self:to(s, { type = "upgrade_required", protocol = self.minProtocol, minBuild = nil,
text = "This build is too old for online play. Please update." })
s.transport.closed = true
return
end
s.avatar = copy(msg.avatar)
s.where = "union"
if self:plazaInstance(s) then return self:to(s, self:plazaStateMsg(s)) end
local best, bestIndex
for i, inst in ipairs(self.plazas.union) do
local n = 0
for _ in pairs(inst.slots) do n = n + 1 end
if n < FakeRelay.PLAZA_CAP and (not best or n > best.n) then best, bestIndex = { inst = inst, n = n }, i end
end
local inst, index
if best then
inst, index = best.inst, bestIndex
else
inst = { slots = {}, rev = 0 }
self.plazas.union[#self.plazas.union + 1] = inst
index = #self.plazas.union
end
local used = {}
for _, slot in pairs(inst.slots) do used[slot] = true end
local slot = 1
while used[slot] do slot = slot + 1 end
inst.slots[s.id] = slot
self:plazaDelta(inst, index, { joined = { memberOf(s, slot) } }, s.id)
self:to(s, self:plazaStateMsg(s))
end
function FakeRelay:plazaLeave(s, kind)
if kind == "wireless" then
self.wireless[s.id] = nil
return
end
local inst, index = self:plazaInstance(s)
if not inst then return end
inst.slots[s.id] = nil
s.where = "launcher"
self:plazaDelta(inst, index, { left = { s.id } })
end
function FakeRelay:plazaChanged(s)
local inst, index = self:plazaInstance(s)
if not inst then return end
self:plazaDelta(inst, index, { changed = { memberOf(s, inst.slots[s.id]) } })
end
function FakeRelay:counts()
local union, trade, battle, minigame = 0, 0, 0, 0
for _, inst in ipairs(self.plazas.union) do
for _ in pairs(inst.slots) do union = union + 1 end
end
for _, room in pairs(self.rooms) do
if room.engine == 3 then
local n = #room.players
if room.intent == "trade" then trade = trade + n
elseif room.intent == "battle" then battle = battle + n
elseif room.intent == "minigame" then minigame = minigame + n end
end
end
return { type = "plaza_counts", union = union, trade = trade, battle = battle,
chat = 0, minigame = minigame, total = trade + battle + union }
end
function FakeRelay:closeInvite(inv, why, room)
self.invites[inv.id] = nil
local msg = { type = "invite_closed", id = inv.id, why = why, room = room }
self:to(self.sessions[inv.from], msg)
self:to(self.sessions[inv.to], msg)
end
function FakeRelay:acceptInvite(inv)
local from, to = self.sessions[inv.from], self.sessions[inv.to]
if inv.activity == "watch" then
local room = self.rooms[inv.detail and inv.detail.room or ""]
self:closeInvite(inv, "accepted", room and room.room)
if room then
room.spectators[#room.spectators + 1] = { id = to.id, name = to.name }
to.room = room.room
self:roomState(room)
end
return
end
local profile = copy(inv.profile or profileOf(from, 3))
local room = self:newRoom({ intent = Protocol2.ACTIVITY_INTENT[inv.activity] or "battle",
engine = profile and profile.engine or 3,
profile = profile, seats = 2, listed = false,
origin = "invite" })
self:closeInvite(inv, "accepted", room.room)
self:seatPlayer(room, from)
self:seatPlayer(room, to)
if room.engine == 3 then self:startRoom(room) else self:roomState(room) end
end
function FakeRelay:handleInvite(s, msg)
local refuse = function(why)
self:to(s, { type = "invite_closed", why = why, to = msg.to,
activity = msg.activity })
end
if msg.to == s.id then return refuse("self") end
local target = self.sessions[msg.to or ""]
if not target or not target.online then return refuse("offline") end
if target.room or target.presence.where == "game" then return refuse("busy") end
for _, inv in pairs(self.invites) do
if inv.from == target.id and inv.to == s.id and inv.activity == msg.activity then
self.inviteNo = self.inviteNo + 1
local mine = ("i%016x"):format(self.inviteNo)
self:to(s, { type = "invite_closed", id = mine, why = "crossed",
to = msg.to, activity = msg.activity })
self:acceptInvite(inv)
return
end
end
self.inviteNo = self.inviteNo + 1
local inv = { id = ("i%016x"):format(self.inviteNo), from = s.id, to = target.id,
activity = msg.activity, detail = copy(msg.detail),
profile = copy(msg.profile), expiresAt = self:now() + 20000 }
self.invites[inv.id] = inv
local queued = self.queue[target.id]
if queued and queued.auto and queued.activity == msg.activity then
self.queue[target.id] = nil
self:to(target, { type = "direct_state", activity = msg.activity, queued = false,
why = "paired" })
self:acceptInvite(inv)
return
end
self:to(s, { type = "invite_sent", id = inv.id, to = inv.to,
activity = inv.activity, expiresAt = inv.expiresAt })
self:to(target, { type = "invite_in", id = inv.id,
from = { id = s.id, name = s.name, verified = true,
where = s.presence.where or "launcher",
avatar = copy(s.avatar) },
activity = inv.activity, detail = copy(inv.detail),
expiresAt = inv.expiresAt })
end
function FakeRelay:handleInviteReply(s, msg)
local inv = self.invites[msg.id or ""]
if not inv or inv.to ~= s.id then return end
if msg.accept then self:acceptInvite(inv) else self:closeInvite(inv, "declined") end
end
function FakeRelay:groupStateMsg(g)
return { type = "group_state", leader = g.leader, activity = g.activity,
min = g.min, max = g.max, members = copy(g.members),
pending = copy(g.pending) }
end
function FakeRelay:groupBroadcast(g, msg)
for _, m in ipairs(g.members) do self:to(self.sessions[m.id], msg) end
for _, m in ipairs(g.pending) do self:to(self.sessions[m.id], msg) end
end
function FakeRelay:groupListMsg(activity)
local groups = {}
for _, g in pairs(self.groups) do
if g.activity == activity then
local leader = self.sessions[g.leader]
groups[#groups + 1] = { leader = g.leader, name = leader.name,
avatar = copy(leader.avatar), version = "firered",
joined = #g.members, min = g.min, max = g.max }
end
end
table.sort(groups, function(a, b) return a.leader < b.leader end)
return { type = "group_list", activity = activity, groups = groups }
end
function FakeRelay:handleGroup(s, msg)
local kind = msg.type
if kind == "group_open" then
local cap = Protocol2.GROUP_CAPACITY[msg.activity or ""]
if not cap then return self:joinError(s, "bad_activity") end
s.avatar = copy(msg.avatar) or s.avatar
local g = { leader = s.id, activity = msg.activity, min = cap[1], max = cap[2],
members = { { id = s.id, name = s.name, avatar = copy(s.avatar), seat = 0 } },
pending = {} }
self.groups[s.id] = g
s.recruiting = { activity = msg.activity, joined = 1, min = cap[1], max = cap[2] }
self:to(s, self:groupStateMsg(g))
self:plazaChanged(s)
elseif kind == "group_list" then
if msg.activity == nil then
self.groupWatch[s.id] = nil
return
end
self.groupWatch[s.id] = msg.activity
self:to(s, self:groupListMsg(msg.activity))
elseif kind == "group_join" then
local g = self.groups[msg.leader or ""]
if not g then return self:joinError(s, "group_not_found") end
if #g.members >= g.max then return self:joinError(s, "group_full") end
s.avatar = copy(msg.avatar) or s.avatar
g.pending[#g.pending + 1] = { id = s.id, name = s.name, avatar = copy(s.avatar) }
self:to(self.sessions[g.leader], { type = "group_request", from = s.id,
name = s.name, avatar = copy(s.avatar) })
self:groupBroadcast(g, self:groupStateMsg(g))
elseif kind == "group_accept" then
local g = self.groups[s.id]
if not g then return self:joinError(s, "not_leader") end
for i, p in ipairs(g.pending) do
if p.id == msg.from then
table.remove(g.pending, i)
if msg.ok then
p.seat = #g.members
g.members[#g.members + 1] = p
else
self:to(self.sessions[p.id], { type = "group_closed", leader = g.leader,
why = "declined" })
end
break
end
end
s.recruiting.joined = #g.members
self:groupBroadcast(g, self:groupStateMsg(g))
elseif kind == "group_start" then
local g = self.groups[s.id]
if not g then return self:joinError(s, "not_leader") end
if #g.members < g.min then return self:joinError(s, "group_below_min") end
self.groups[s.id] = nil
s.recruiting = nil
local room = self:newRoom({ intent = Protocol2.ACTIVITY_INTENT[g.activity],
engine = 3, profile = profileOf(s, 3),
seats = #g.members, listed = false, origin = "group" })
for _, m in ipairs(g.members) do self:seatPlayer(room, self.sessions[m.id]) end
self:startRoom(room)
for _, m in ipairs(g.pending) do
self:to(self.sessions[m.id], { type = "group_closed", leader = g.leader, why = "full" })
end
g.pending = {}
self:groupBroadcast(g, { type = "group_closed", leader = g.leader, why = "started" })
elseif kind == "group_leave" then
local g = self.groups[s.id]
if g then
self.groups[s.id] = nil
s.recruiting = nil
self:groupBroadcast(g, { type = "group_closed", leader = g.leader, why = "leader_left" })
end
end
end
function FakeRelay:directEntries(activity, viewer)
local entries = {}
for id, q in pairs(self.queue) do
if id ~= viewer.id and q.activity == activity and q.auto then
local s = self.sessions[id]
entries[#entries + 1] = { kind = "player", id = id, name = s.name, verified = true,
avatar = copy(q.avatar), version = "firered",
preview = copy(q.preview), since = q.since }
end
end
for _, room in pairs(self.rooms) do
if room.origin == "direct" and room.listed and room.stage == "waiting"
and room.activity == activity and #room.players < room.seats then
local host = self.sessions[room.host]
entries[#entries + 1] = { kind = "room", room = room.room, host = room.host,
name = host.name, avatar = copy(host.avatar),
version = "firered", locked = room.locked,
auto = room.auto, seats = room.seats,
players = #room.players, since = room.since }
end
end
table.sort(entries, function(a, b) return (a.since or 0) < (b.since or 0) end)
return entries
end
function FakeRelay:pushDirect(activity)
for id, act in pairs(self.directWatch) do
if act == activity then
local s = self.sessions[id]
self:to(s, { type = "direct_list", activity = activity,
entries = self:directEntries(activity, s) })
end
end
end
function FakeRelay:paired(s, activity)
self.queue[s.id] = nil
self:to(s, { type = "direct_state", activity = activity, queued = false,
why = "paired" })
end
function FakeRelay:tryPair(activity)
local seats = activity == "battle_multi" and 4 or 2
local waiting = {}
for id, q in pairs(self.queue) do
if q.activity == activity and q.auto then waiting[#waiting + 1] = { id = id, q = q } end
end
table.sort(waiting, function(a, b) return a.q.since < b.q.since end)
for _, room in pairs(self.rooms) do
if room.origin == "direct" and room.activity == activity and room.stage == "waiting"
and (not room.locked or room.auto) and #waiting > 0 then
while #waiting > 0 and #room.players < room.seats do
local w = table.remove(waiting, 1)
local s = self.sessions[w.id]
self:paired(s, activity)
self:seatPlayer(room, s)
end
if #room.players == room.seats then self:startRoom(room) else self:roomState(room) end
end
end
while #waiting >= seats do
local picked = {}
for i = 1, seats do picked[i] = table.remove(waiting, 1) end
local first = self.sessions[picked[1].id]
local room = self:newRoom({ intent = Protocol2.ACTIVITY_INTENT[activity],
engine = 3, profile = picked[1].q.profile,
seats = seats, listed = false, origin = "direct" })
room.activity = activity
for _, w in ipairs(picked) do
local s = self.sessions[w.id]
self:paired(s, activity)
self:seatPlayer(room, s)
end
room.host = first.id
self:startRoom(room)
end
self:pushDirect(activity)
end
function FakeRelay:handleDirect(s, msg)
local kind = msg.type
if kind == "direct_queue" then
if not Protocol2.DIRECT_ACTIVITIES[msg.activity or ""] then
return self:joinError(s, "bad_activity")
end
s.avatar = copy(msg.avatar) or s.avatar
if msg.pin ~= nil or msg.auto == false then
local room = self:newRoom({ intent = Protocol2.ACTIVITY_INTENT[msg.activity],
engine = 3, profile = msg.profile,
seats = msg.activity == "battle_multi" and 4 or 2,
pin = msg.pin, auto = msg.auto, origin = "direct" })
room.activity = msg.activity
self:seatPlayer(room, s)
self:to(s, { type = "direct_state", activity = msg.activity, queued = false,
hosting = room.room })
self:roomState(room)
self:tryPair(msg.activity)
return
end
self.queue[s.id] = { activity = msg.activity, auto = true, profile = copy(msg.profile),
avatar = copy(msg.avatar), preview = copy(msg.preview),
since = self:now() + #self.log }
self:to(s, { type = "direct_state", activity = msg.activity, queued = true })
self:tryPair(msg.activity)
elseif kind == "direct_list" then
if msg.activity == nil then
self.directWatch[s.id] = nil
return
end
self.directWatch[s.id] = msg.activity
self:to(s, { type = "direct_list", activity = msg.activity,
entries = self:directEntries(msg.activity, s) })
elseif kind == "direct_leave" then
self.queue[s.id] = nil
self.directWatch[s.id] = nil
self:to(s, { type = "direct_state", queued = false, why = "left" })
end
end
function FakeRelay:welcome(s, resumed)
s.online = true
s.forgotten = false
self:to(s, { type = "lobby_welcome", session = "S-" .. s.id,
you = { id = s.id, name = s.name, verified = true },
serverTime = self:now(), heartbeatMs = 10000,
resumed = resumed == true })
end
function FakeRelay:handle(s, msg)
local kind = msg.type
self.log[#self.log + 1] = { from = s.id, msg = copy(msg) }
if kind == "lobby_hello" then
if (msg.protocol or 0) < self.minProtocol then
self:to(s, { type = "upgrade_required", protocol = self.minProtocol,
minBuild = nil,
text = "This build is too old for online play. Please update." })
s.transport.closed = true
return
end
s.name = msg.name or s.name
s.profiles = copy(msg.profiles or {})
if msg.presence then
for k, v in pairs(msg.presence) do
if k ~= "board" or self:plazaInstance(s) then s.presence[k] = copy(v) end
end
end
self:welcome(s, false)
elseif kind == "resume" then
if s.forgotten then
s.online = true
return self:joinError(s, "resume_expired")
end
self:welcome(s, true)
local room = s.room and self.rooms[s.room]
if room then
self:to(s, self:roomStateMsg(room))
self:replay(s, math.max(0, msg.ack or 0))
end
if self:plazaInstance(s) then self:to(s, self:plazaStateMsg(s)) end
local q = self.queue[s.id]
if q then self:to(s, { type = "direct_state", activity = q.activity, queued = true }) end
if self.groups[s.id] then self:to(s, self:groupStateMsg(self.groups[s.id])) end
for _, inv in pairs(self.invites) do
if inv.to == s.id then
self:to(s, { type = "invite_in", id = inv.id,
from = { id = inv.from, name = self.sessions[inv.from].name,
verified = true, where = "launcher" },
activity = inv.activity, detail = copy(inv.detail),
expiresAt = inv.expiresAt })
end
end
elseif kind == "set_profiles" then
s.profiles = copy(msg.profiles or {})
elseif kind == "presence" then
for _, k in ipairs({ "where", "status", "version", "engine" }) do
if msg[k] ~= nil then s.presence[k] = msg[k] end
end
if msg.board == false then
s.presence.board = nil
self:plazaChanged(s)
elseif type(msg.board) == "table" and self:plazaInstance(s) then
s.presence.board = copy(msg.board)
self:plazaChanged(s)
end
elseif kind == "room_create" then
if msg.private and not msg.pin then return self:joinError(s, "bad_pin") end
local profile = msg.profile
local engine = profile and profile.engine or 1
local seats = msg.seats or 2
if engine == 3 then seats = profile.rulesetId == "g3_multi" and 4 or 2 end
local room = self:newRoom({ intent = msg.intent, engine = engine, profile = profile,
seats = seats, pin = msg.private and msg.pin or nil,
auto = msg.auto, origin = "create" })
self:seatPlayer(room, s)
self:roomState(room)
elseif kind == "room_join" then
self:handleRoomJoin(s, msg)
elseif kind == "invite_token" then
local room = self.rooms[msg.room or ""]
if not room then return self:joinError(s, "not_found") end
self.tokenNo = self.tokenNo + 1
local token = ("%032x"):format(0xbeef00 + self.tokenNo)
self.tokens[token] = { room = room.room }
self:to(s, { type = "invite_token", room = room.room, token = token,
expiresAt = self:now() + 1800000 })
elseif kind == "room_msg" then
self:fanout(s, msg.seq, msg.msg)
elseif kind == "room_ack" then
s.ack = math.max(s.ack or 0, msg.seq or 0)
elseif kind == "room_leave" then
local room = s.room and self.rooms[s.room]
s.room = nil
if room then
for i = #room.players, 1, -1 do
if room.players[i].id == s.id then table.remove(room.players, i) end
end
for i = #room.spectators, 1, -1 do
if room.spectators[i].id == s.id then table.remove(room.spectators, i) end
end
if room.barrier and next(room.barrier.confirms) ~= nil then
self:relayInner(room, { type = "trade_abort", n = room.barrier.n, why = "left" })
room.barrier = { n = room.barrier.n + 1, confirms = {} }
end
if #room.players == 0 and #room.spectators == 0 then
self.rooms[room.room] = nil
return
end
if room.host == s.id then
room.host = (room.players[1] or room.spectators[1]).id
end
self:roomState(room)
end
elseif kind == "room_close" then
local room = s.room and self.rooms[s.room]
if not room or room.host ~= s.id then return end
self.closedRooms = (self.closedRooms or 0) + 1
self:roomBroadcast(room, { type = "room_closed", reason = "closed", room = room.room })
for _, list in ipairs({ room.players, room.spectators }) do
for _, p in ipairs(list) do
local member = self.sessions[p.id]
if member then member.room = nil end
end
end
self.rooms[room.room] = nil
elseif kind == "invite" then
self:handleInvite(s, msg)
elseif kind == "invite_reply" then
self:handleInviteReply(s, msg)
elseif kind == "plaza_join" then
self:plazaJoin(s, msg)
elseif kind == "plaza_leave" then
self:plazaLeave(s, msg.kind)
elseif kind:sub(1, 6) == "group_" then
self:handleGroup(s, msg)
elseif kind:sub(1, 7) == "direct_" then
self:handleDirect(s, msg)
end
end
function FakeRelay:tick()
local counts = self:counts()
for id in pairs(self.wireless) do self:to(self.sessions[id], counts) end
for id, activity in pairs(self.groupWatch) do
self:to(self.sessions[id], self:groupListMsg(activity))
end
end
function FakeRelay:pump()
for _, id in ipairs(self.order) do
local s = self.sessions[id]
local outbox = s.transport.outbox
s.transport.outbox = {}
for _, msg in ipairs(outbox) do
if type(msg) == "table" and type(msg.type) == "string" then self:handle(s, msg) end
end
end
end
return FakeRelay