Files
gen1recomp/src/online/Client.lua
T
2026-08-28 10:14:44 -04:00

1020 lines
27 KiB
Lua

local Protocol2 = require("src.online.Protocol2")
local Session = require("src.link.Session")
local Version = require("src.core.Version")
local Wire = require("src.link.Wire")
local Client = {}
local BACKOFF = { 1, 2, 4, 8, 15 }
local MAX_ATTEMPTS = 12
local MATCH_STAGES = { battling = true }
local ROOM_STAGES = { waiting = true, ready = true, battling = true,
ended = true }
local UNACKED_MAX = 512
local function now()
if love and love.timer and love.timer.getTime then
return love.timer.getTime()
end
return os.clock()
end
local S
local clearRoom
local clearTournament
local resumeLost
local function blankState()
return {
relayAddress = nil,
connectFn = nil,
platform = nil,
engineVersion = Version.engine,
status = "offline",
err = nil,
opts = nil,
net = nil,
session = nil,
sessionId = nil,
you = nil,
heartbeatMs = nil,
serverTime = nil,
serverTimeAt = nil,
lobby = {},
lobbyById = {},
room = nil,
roomSession = nil,
pending = nil,
seq = 0,
ack = 0,
rxSeq = 0,
unacked = {},
unackedFloor = 0,
unackedDropped = 0,
pendingReport = nil,
reportSent = nil,
roomInbox = {},
delivered = {},
matchStarted = false,
match = nil,
role = nil,
resuming = false,
attempt = 0,
retryAt = nil,
handlers = {},
dropped = 0,
duplicates = 0,
advertised = nil,
online = nil,
tournament = nil,
tourMatch = nil,
tourFinished = {},
}
end
S = blankState()
function Client.reset()
if S.session then pcall(function() S.session:close() end) end
S = blankState()
end
-- ---------------------------------------------------------------- events
function Client.on(event, fn)
if type(event) ~= "string" or type(fn) ~= "function" then return end
local list = S.handlers[event]
if not list then
list = {}
S.handlers[event] = list
end
list[#list + 1] = fn
end
function Client.off(event, fn)
local list = S.handlers[event]
if not list then return end
for i = #list, 1, -1 do
if list[i] == fn or fn == nil then table.remove(list, i) end
end
end
local function emit(event, payload)
local list = S.handlers[event]
if not list then return end
for i = 1, #list do
local ok, err = pcall(list[i], payload)
if not ok then
S.err = tostring(err)
end
end
end
local function setStatus(status)
if S.status == status then return end
S.status = status
emit("state", status)
end
-- ---------------------------------------------------------------- accessors
function Client.state() return S.status end
function Client.match() return S.match end
function Client.serverTime()
if not S.serverTime then return nil end
return S.serverTime + (now() - (S.serverTimeAt or now())) * 1000
end
function Client.role() return S.role end
function Client.error() return S.err end
function Client.you() return S.you end
function Client.lobby() return S.lobby end
function Client.room() return S.room end
function Client.tournament() return S.tournament end
function Client.tourMatch() return S.tourMatch end
function Client.dropped() return S.dropped end
function Client.unackedDropped() return S.unackedDropped end
function Client.unackedCount() return #S.unacked end
function Client.pendingReport() return S.pendingReport end
function Client.duplicates() return S.duplicates end
function Client.sessionId() return S.sessionId end
-- ---------------------------------------------------------------- transport
local function openTransport()
if S.connectFn then
local transport, err = S.connectFn(S.relayAddress)
if not transport then return nil, err or "no transport" end
return transport
end
local Net = require("src.link.Net")
local net = Net.new()
local address = S.relayAddress or Net.defaultRelayAddress()
if not net:connectTCP(address) then
return nil, net.error or ("can't reach the relay at " .. tostring(address))
end
net.mode = "onlineLobby"
net.v2 = true
return net
end
local function sendRaw(msg)
if not msg or not S.session then return false end
S.session:send(msg)
return true
end
Client.sendRaw = sendRaw
-- ---------------------------------------------------------------- room session
local RoomSession = {}
RoomSession.__index = RoomSession
local function refreshRoomSession()
local rs = S.roomSession
if not rs then return end
local room = S.room
local players = room and room.players or {}
rs.paired = room ~= nil and #players >= 2
rs.error = S.err
if not room and not rs.left then rs.closed = true end
if S.status == "error" or S.status == "offline" then rs.closed = true end
end
local function shaped(entry)
if not entry.side then return entry.msg end
if not entry.shape then
entry.shape = { type = "spectate", side = entry.side, msg = entry.msg }
end
return entry.shape
end
local function deliver(entries)
local out = {}
for i = 1, #entries do
out[i] = shaped(entries[i])
local seq = entries[i].seq
if type(seq) == "number" and seq > S.ack then S.delivered[seq] = true end
end
local before = S.ack
while S.delivered[S.ack + 1] do
S.delivered[S.ack + 1] = nil
S.ack = S.ack + 1
end
if S.ack > before then sendRaw(Protocol2.roomAck(S.ack)) end
return out
end
function RoomSession:update()
Client.update(0)
end
function RoomSession:send(msg)
if type(msg) ~= "table" then return end
if self.closed then return end
S.seq = S.seq + 1
local wrapped = Protocol2.roomMsg(S.seq, msg)
if not wrapped then return end
S.unacked[#S.unacked + 1] = wrapped
while #S.unacked > UNACKED_MAX do
local oldest = table.remove(S.unacked, 1)
S.unackedDropped = S.unackedDropped + 1
S.unackedFloor = (oldest.seq or 0) + 1
end
sendRaw(wrapped)
end
function RoomSession:poll()
local entries = S.roomInbox
S.roomInbox = {}
return deliver(entries)
end
function RoomSession:pollOne()
if #S.roomInbox == 0 then return nil end
local entry = table.remove(S.roomInbox, 1)
return deliver({ entry })[1]
end
function RoomSession:take(messageType, predicate)
for index = 1, #S.roomInbox do
local entry = S.roomInbox[index]
local msg = shaped(entry)
if msg.type == messageType
and (predicate == nil or predicate(msg) == true) then
table.remove(S.roomInbox, index)
return deliver({ entry })[1]
end
end
return nil
end
function RoomSession:unread(messages)
if type(messages) ~= "table" then return end
for index = #messages, 1, -1 do
local msg = messages[index]
if type(msg) == "table" and type(msg.type) == "string" then
table.insert(S.roomInbox, 1, { seq = nil, msg = msg })
end
end
end
function RoomSession:hasPending() return #S.roomInbox > 0 end
function RoomSession:close()
if self.left then return end
self.left = true
self.closed = true
sendRaw(Protocol2.roomLeave())
clearRoom()
end
function Client.roomSession()
if not S.room then return nil end
if not S.roomSession or S.roomSession.left then
S.roomSession = setmetatable({
paired = false, closed = false, error = nil, left = false,
code = S.room.code, target = S.room.code,
}, RoomSession)
end
refreshRoomSession()
return S.roomSession
end
-- ---------------------------------------------------------------- lobby model
local function lobbyLess(a, b)
local sa, sb = a.since or 0, b.since or 0
if sa ~= sb then return sa < sb end
return tostring(a.id) < tostring(b.id)
end
local function sortLobby()
table.sort(S.lobby, lobbyLess)
end
local function putEntry(entry)
if not entry or not entry.id then return end
local existing = S.lobbyById[entry.id]
if existing then
for i = 1, #S.lobby do
if S.lobby[i].id == entry.id then S.lobby[i] = entry end
end
else
S.lobby[#S.lobby + 1] = entry
end
S.lobbyById[entry.id] = entry
end
local function dropEntry(id)
if not id or not S.lobbyById[id] then return end
S.lobbyById[id] = nil
for i = #S.lobby, 1, -1 do
if S.lobby[i].id == id then table.remove(S.lobby, i) end
end
end
function Client.openRooms()
local mine = S.you and S.you.id or nil
local out = {}
for i = 1, #S.lobby do
local entry = S.lobby[i]
if entry.code and entry.open == true and entry.id ~= mine then
out[#out + 1] = entry
end
end
table.sort(out, lobbyLess)
return out
end
function Client.watchable()
local mine = S.you and S.you.id or nil
local out = {}
for i = 1, #S.lobby do
local entry = S.lobby[i]
if entry.code and entry.id ~= mine then
local room = entry.stage == "battling"
and (entry.spectators or 0) < (entry.maxSpectators or 0)
if room or entry.intent == "tournament" then out[#out + 1] = entry end
end
end
table.sort(out, lobbyLess)
return out
end
function Client.counts()
local seen, players = {}, 0
for i = 1, #S.lobby do
local id = S.lobby[i].id
if id and not seen[id] then
seen[id] = true
players = players + 1
end
end
if type(S.online) == "number" then players = S.online end
return { players = players, openRooms = #Client.openRooms() }
end
-- ---------------------------------------------------------------- room model
local function seats(room)
local players = room and room.players or {}
return players[1], players[2]
end
local function myRole(room)
local me = S.you and S.you.id
local host, guest = seats(room)
if me then
if host and host.id == me then return "host" end
if guest and guest.id == me then return "guest" end
end
return "spectator"
end
local function applyRoomState(msg)
local previous = S.room
local deadlines = {}
for _, d in ipairs(msg.deadlines or {}) do
if d.kind then deadlines[d.kind] = d.at end
end
if previous and previous.code == msg.code then
for kind, at in pairs(previous.deadlines or {}) do
if deadlines[kind] == nil then deadlines[kind] = at end
end
end
if not previous or previous.code ~= msg.code then
S.seq, S.ack, S.rxSeq = 0, 0, 0
S.unacked = {}
S.unackedFloor = 0
S.delivered = {}
S.roomInbox = {}
S.matchStarted = false
S.pendingReport = nil
end
S.room = {
code = msg.code,
players = msg.players or {},
spectators = msg.spectators or {},
stage = ROOM_STAGES[msg.stage or ""] and msg.stage or "waiting",
profile = msg.profile,
host = msg.host,
seed = msg.seed,
rule = msg.rule or (msg.profile and msg.profile.rule) or nil,
intent = msg.intent,
maxSpectators = msg.maxSpectators,
match = msg.match,
deadlines = deadlines,
}
S.match = msg.match or S.match
S.role = myRole(S.room)
if S.roomSession and S.roomSession.left then S.roomSession = nil end
if S.pending and S.pending.kind ~= "tournament"
and (S.pending.code == nil or S.pending.code == msg.code) then
S.pending.code = msg.code
S.pending.room = S.room
S.pending.done = true
end
if not MATCH_STAGES[S.room.stage] then S.matchStarted = false end
emit("room", S.room)
refreshRoomSession()
end
clearRoom = function()
S.room = nil
S.match = nil
S.role = nil
S.matchStarted = false
S.seq, S.ack, S.rxSeq = 0, 0, 0
S.unacked = {}
S.unackedFloor = 0
S.delivered = {}
S.roomInbox = {}
S.pendingReport = nil
if S.roomSession then
S.roomSession.closed = true
S.roomSession.left = true
S.roomSession = nil
end
end
clearTournament = function()
S.pendingReport = nil
S.tournament = nil
S.tourMatch = nil
S.tourFinished = {}
end
resumeLost = function()
local code = S.room and S.room.code or nil
sendRaw(Protocol2.roomLeave())
clearRoom()
emit("room", nil)
emit("error", { scope = "room", reason = "resume_incomplete", code = code,
text = Protocol2.roomClosedText({
reason = "resume_incomplete" }) })
end
local function enterChildRoom(code, msg)
if not code then return end
if S.room and S.room.code == code then
S.room.match = msg and msg.match or S.room.match
return
end
clearRoom()
local tour = S.tournament
S.room = {
code = code,
players = {},
spectators = {},
stage = "waiting",
profile = tour and tour.profile or nil,
rule = tour and tour.rule or nil,
host = nil,
seed = nil,
intent = "tournament",
match = msg and msg.match or nil,
deadlines = {},
}
S.match = S.room.match
emit("room", S.room)
refreshRoomSession()
end
local function applyTourState(msg)
local previous = S.tournament
local deadlines = {}
if previous and previous.code == msg.code then
for kind, at in pairs(previous.deadlines or {}) do deadlines[kind] = at end
end
if not previous or previous.code ~= msg.code then S.tourFinished = {} end
S.tournament = {
code = msg.code,
creator = msg.creator,
stage = msg.stage or "registering",
players = msg.players or {},
spectators = msg.spectators or {},
profile = msg.profile,
rule = msg.rule or (msg.profile and msg.profile.rule) or nil,
shotClock = msg.shotClock,
round = msg.round,
bracket = msg.bracket or {},
live = msg.live,
champion = msg.champion,
championId = msg.championId,
maxSpectators = msg.maxSpectators,
deadlines = deadlines,
}
if S.pending and S.pending.kind == "tournament"
and (S.pending.code == nil or S.pending.code == msg.code) then
S.pending.code = msg.code
S.pending.tournament = S.tournament
S.pending.done = true
end
emit("tournament", S.tournament)
end
local function matchStart(msg)
local role = msg.role
if role ~= "host" and role ~= "guest" then role = "spectator" end
S.role = role
S.match = msg.match or S.match
if S.room then
S.room.seed = msg.seed or S.room.seed
S.room.match = S.match
if msg.rule then S.room.rule = msg.rule end
end
if S.reportSent ~= S.match then S.reportSent = nil end
if S.matchStarted then return end
S.matchStarted = true
emit("match_start", {
code = msg.code or (S.room and S.room.code) or nil,
match = S.match,
role = role,
seed = msg.seed,
profile = S.room and S.room.profile or nil,
ruleset = msg.ruleset,
rule = msg.rule,
peerName = msg.peerName,
hostName = msg.hostName,
guestName = msg.guestName,
myParty = nil,
theirParty = msg.theirParty,
hostParty = msg.hostParty,
guestParty = msg.guestParty,
})
end
local function acceptRoomMsg(entry)
local seq = entry.seq
if type(seq) == "number" and seq > 0 then
if seq <= S.rxSeq then
S.duplicates = S.duplicates + 1
return
end
S.rxSeq = seq
end
S.roomInbox[#S.roomInbox + 1] = { seq = seq, side = entry.side,
msg = entry.msg }
end
-- ---------------------------------------------------------------- dispatch
local helloMessage
local function handle(msg)
local kind = msg.type
if kind == "lobby_welcome" then
local resumed = msg.resumed == true
and (S.resuming or (S.sessionId ~= nil and msg.session == S.sessionId))
S.sessionId = msg.session
S.you = { name = msg.you.name, verified = msg.you.verified,
id = msg.you.id, session = msg.session }
S.heartbeatMs = msg.heartbeatMs
S.serverTime = msg.serverTime
S.serverTimeAt = now()
S.attempt = 0
S.retryAt = nil
S.err = nil
S.resuming = false
if not resumed then
clearRoom()
clearTournament()
end
setStatus("online")
elseif kind == "lobby_list" then
S.lobby, S.lobbyById = {}, {}
S.online = type(msg.online) == "number" and msg.online or nil
for _, entry in ipairs(msg.entries) do putEntry(entry) end
sortLobby()
emit("lobby", S.lobby)
elseif kind == "lobby_delta" then
for _, entry in ipairs(msg.added or {}) do putEntry(entry) end
for _, entry in ipairs(msg.changed or {}) do putEntry(entry) end
for _, id in ipairs(msg.removed or {}) do dropEntry(id) end
for _, entry in ipairs(msg.add or {}) do putEntry(entry) end
for _, entry in ipairs(msg.update or {}) do putEntry(entry) end
for _, id in ipairs(msg.remove or {}) do dropEntry(id) end
if msg.op == "add" or msg.op == "update" then putEntry(msg.entry)
elseif msg.op == "remove" then dropEntry(msg.id or (msg.entry and msg.entry.id)) end
sortLobby()
emit("lobby", S.lobby)
elseif kind == "room_state" then
if not (S.tournament and S.tourFinished[msg.code]) then
applyRoomState(msg)
end
elseif kind == "match_start" or kind == "match_start_spectate" then
matchStart(msg)
elseif kind == "room_replay" then
if type(msg.yourSeq) == "number" then
for i = #S.unacked, 1, -1 do
if (S.unacked[i].seq or 0) <= msg.yourSeq then table.remove(S.unacked, i) end
end
if S.unackedFloor > 0 and msg.yourSeq + 1 < S.unackedFloor then
resumeLost()
return
end
for i = 1, #S.unacked do sendRaw(S.unacked[i]) end
end
for _, entry in ipairs(msg.msgs) do acceptRoomMsg(entry) end
if S.rxSeq > S.ack then sendRaw(Protocol2.roomAck(S.ack)) end
refreshRoomSession()
elseif kind == "room_msg" then
acceptRoomMsg(msg)
elseif kind == "room_deadline" then
if S.room then
S.room.deadlines = S.room.deadlines or {}
S.room.deadlines[msg.kind] = msg.at
emit("room", S.room)
end
elseif kind == "room_result" then
S.matchStarted = false
if S.pendingReport
and (msg.match == nil or msg.match == S.pendingReport.match) then
S.pendingReport = nil
end
local me = S.you and S.you.id
emit("match_end", {
match = msg.match,
code = msg.code or (S.room and S.room.code) or nil,
winner = msg.winner,
winnerId = msg.winnerId,
how = msg.how,
youWon = me ~= nil and msg.winnerId ~= nil and msg.winnerId == me,
})
if S.tournament then
local code = msg.code or (S.room and S.room.code) or nil
if code then S.tourFinished[code] = true end
S.tourMatch = nil
clearRoom()
emit("room", nil)
end
elseif kind == "room_closed" then
local code = msg.code or (S.room and S.room.code) or nil
clearRoom()
if S.pending and not S.pending.done then
S.pending.error = Protocol2.roomClosedText(msg)
S.pending.reason = msg.reason
S.pending.done = true
end
emit("room", nil)
emit("error", { scope = "room", reason = msg.reason, code = code,
text = Protocol2.roomClosedText(msg) })
elseif kind == "tour_state" then
applyTourState(msg)
elseif kind == "tour_match" or kind == "tour_match_spectate" then
local payload = { match = msg.match, round = msg.round, code = msg.code,
role = kind == "tour_match" and "player" or "spectator" }
S.tourMatch = payload
S.tourFinished[msg.code] = nil
enterChildRoom(msg.code, msg)
emit(kind == "tour_match" and "tour_match" or "tour_spectate", payload)
elseif kind == "tour_bye" then
emit("tour_bye", { match = msg.match, round = msg.round })
elseif kind == "tour_deadline" then
if S.tournament then
S.tournament.deadlines = S.tournament.deadlines or {}
S.tournament.deadlines[msg.kind] = msg.at
emit("tournament", S.tournament)
end
if S.room then
S.room.deadlines = S.room.deadlines or {}
S.room.deadlines[msg.kind] = msg.at
emit("room", S.room)
end
elseif kind == "tour_closed" then
local code = msg.code or (S.tournament and S.tournament.code) or nil
local text = Protocol2.tourClosedText(msg)
clearRoom()
clearTournament()
if S.pending and not S.pending.done then
S.pending.error = text
S.pending.reason = msg.reason
S.pending.done = true
end
emit("room", nil)
emit("tournament", nil)
emit("error", { scope = "tournament", reason = msg.reason, code = code,
text = text })
elseif kind == "tour_over" then
if S.tournament and S.tournament.code == msg.code then
S.tournament.stage = "finished"
S.tournament.champion = msg.championId or S.tournament.champion
S.tournament.championName = msg.champion
emit("tournament", S.tournament)
end
S.tourMatch = nil
emit("tour_over", { code = msg.code, champion = msg.champion,
championId = msg.championId })
elseif kind == "join_error" then
local text = Protocol2.joinErrorText(msg)
if S.resuming then
S.resuming = false
S.sessionId = nil
clearRoom()
sendRaw(helloMessage())
return
end
if S.pending then
S.pending.error = text
S.pending.reason = msg.reason
S.pending.field = msg.field
S.pending.done = true
end
emit("error", { scope = "join", reason = msg.reason, field = msg.field,
detail = msg.detail, text = text })
end
end
-- ---------------------------------------------------------------- connection
local function bind()
local transport, err = openTransport()
if not transport then return false, err end
S.net = transport
S.session = Session.new(transport, { role = "client", kind = "lobby" })
return true
end
helloMessage = function()
local opts = S.opts or {}
return Protocol2.lobbyHello({
ticket = opts.ticket,
name = opts.name,
engineVersion = S.engineVersion,
platform = S.platform,
profiles = opts.profiles,
})
end
local function scheduleRetry()
S.attempt = S.attempt + 1
local wait = BACKOFF[math.min(S.attempt, #BACKOFF)]
S.retryAt = now() + wait
end
local function onDisconnected(detail)
S.session = nil
S.net = nil
if S.sessionId and S.opts and S.attempt < MAX_ATTEMPTS then
S.err = detail
setStatus("reconnecting")
scheduleRetry()
else
S.err = detail or S.err or "disconnected"
setStatus("error")
end
refreshRoomSession()
end
local function tryReconnect()
S.retryAt = nil
local ok, err = bind()
if not ok then
if S.attempt >= MAX_ATTEMPTS then
S.err = err
setStatus("error")
return
end
S.err = err
scheduleRetry()
return
end
S.resuming = true
sendRaw(Protocol2.resume(S.sessionId, S.ack))
end
function Client.configure(opts)
opts = opts or {}
if opts.relayAddress ~= nil then S.relayAddress = opts.relayAddress end
if opts.connect ~= nil then S.connectFn = opts.connect end
if opts.platform ~= nil then S.platform = opts.platform end
if opts.engineVersion ~= nil then S.engineVersion = opts.engineVersion end
return S.relayAddress
end
function Client.connect(opts)
opts = opts or {}
if S.session then Client.disconnect() end
S.opts = { name = opts.name, ticket = opts.ticket,
profiles = opts.profiles or {} }
S.err = nil
S.sessionId = nil
S.attempt = 0
S.retryAt = nil
setStatus("connecting")
local ok, err = bind()
if not ok then
S.err = err
setStatus("error")
return false, err
end
sendRaw(helloMessage())
return true
end
function Client.disconnect()
if S.session then
if S.room then sendRaw(Protocol2.roomLeave()) end
pcall(function() S.session:close() end)
end
S.session = nil
S.net = nil
S.sessionId = nil
S.you = nil
S.opts = nil
S.lobby, S.lobbyById = {}, {}
S.attempt = 0
S.retryAt = nil
S.resuming = false
S.advertised = nil
S.online = nil
clearRoom()
clearTournament()
setStatus("offline")
end
-- ---------------------------------------------------------------- pump
local function pump(dt)
if S.status == "reconnecting" then
if S.retryAt and now() >= S.retryAt then tryReconnect() end
end
local session = S.session
if not session then return end
session:update()
local messages = session:poll()
for i = 1, #messages do
local msg, reason = Protocol2.validate(messages[i])
if msg then
handle(msg)
else
S.dropped = S.dropped + 1
S.lastDrop = reason
end
end
S.dropped = S.dropped + (session.dropped or 0)
session.dropped = 0
if session.closed then
onDisconnected(session.error or "the relay closed the connection")
return
end
local queued = S.pendingReport
if queued and S.status == "online" then
S.pendingReport = nil
if S.match and queued.match == S.match then
S.reportSent = queued.match
sendRaw(queued.msg)
end
end
refreshRoomSession()
end
function Client.update(dt)
if S.status == "offline" then return end
local ok, err = pcall(pump, dt or 0)
if not ok then
S.err = tostring(err)
setStatus("error")
end
end
-- ---------------------------------------------------------------- actions
function Client.advertise(intent, profile, note)
S.advertised = { intent = intent, profile = profile, note = note }
return sendRaw(Protocol2.advertise(intent, profile, note))
end
function Client.unadvertise()
S.advertised = nil
return sendRaw(Protocol2.unadvertise())
end
local function newPending(code, kind)
S.pending = { code = code, room = nil, error = nil, done = false,
kind = kind or "room" }
return S.pending
end
function Client.createRoom(opts)
local pending = newPending(nil)
sendRaw(Protocol2.roomCreate(opts))
return pending
end
local function defaultProfile()
local profiles = S.opts and S.opts.profiles or {}
return profiles[1]
end
function Client.joinRoom(code, as, profile)
local pending = newPending(Wire.code(code))
sendRaw(Protocol2.roomJoin(code, as, profile or defaultProfile()))
return pending
end
function Client.leaveRoom()
local rs = S.roomSession
if rs then
rs:close()
S.roomSession = nil
return true
end
if not S.room then return false end
sendRaw(Protocol2.roomLeave())
clearRoom()
return true
end
function Client.ready(packedParty, digest)
return sendRaw(Protocol2.roomReady(packedParty, digest))
end
local function sendResult(msg)
if not msg then return false end
local match = S.match
if S.session and S.status == "online" then
if match then S.reportSent = match end
return sendRaw(msg)
end
if match and S.reportSent ~= match then
S.pendingReport = { match = match, msg = msg }
end
return false
end
function Client.report(result)
if not Protocol2.RESULTS[result] then
return sendResult(Protocol2.forfeit(S.match))
end
return sendResult(Protocol2.roomReport(S.match, result))
end
function Client.forfeit()
return sendResult(Protocol2.forfeit(S.match))
end
function Client.kick(id)
return sendRaw(Protocol2.roomKick(id))
end
function Client.closeRoom()
if not S.room then return false end
local ok = sendRaw(Protocol2.roomClose())
return ok
end
function Client.createTournament(opts)
opts = opts or {}
local pending = newPending(nil, "tournament")
sendRaw(Protocol2.tourCreate({
profile = opts.profile or defaultProfile(),
rule = opts.rule,
playing = opts.playing,
shotClock = opts.shotClock,
maxSpectators = opts.maxSpectators,
party = opts.party,
partyDigest = opts.partyDigest,
public = opts.public,
note = opts.note,
}))
return pending
end
function Client.joinTournament(code, as, packedParty, digest, profile)
local pending = newPending(Wire.code(code), "tournament")
sendRaw(Protocol2.tourJoin(code, as, profile or defaultProfile(),
packedParty, digest))
return pending
end
function Client.leaveTournament()
if not S.tournament then return false end
sendRaw(Protocol2.tourLeave())
clearRoom()
clearTournament()
emit("room", nil)
emit("tournament", nil)
return true
end
function Client.startTournament()
if not S.tournament then return false end
return sendRaw(Protocol2.tourStart())
end
function Client.kickFromTournament(id)
if not S.tournament then return false end
return sendRaw(Protocol2.tourKick(id))
end
function Client.closeTournament()
if not S.tournament then return false end
return sendRaw(Protocol2.tourClose())
end
return Client