singe/assets/Master.singe
2026-09-12 01:22:02 -05:00

349 lines
11 KiB
Text

--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The master service, from the cabinet's side.
--
-- One place that knows the server address, the signed-in account and the player's name, so the menu
-- and every game agree about them. It lives in the data root rather than a game's own directory
-- because an account belongs to the machine: a game posting a score needs the same token the menu
-- signed in with.
--
-- Everything here is asynchronous -- see Singe/Net.singe for why -- so every call takes a function
-- and calls it back. masterPump() must run once a frame for any of it to make progress.
dofile("Singe/Net.singe")
local cjson = require("cjson")
MASTER_DEFAULT_URL = "https://master.singeengine.com"
MASTER = {
url = MASTER_DEFAULT_URL,
token = nil,
email = nil,
name = nil,
queue = {}, -- scores waiting for a connection
}
local settingsPath = nil
local flushing = false
local lastFlush = 0
local flushDue = false
local FLUSH_EVERY = 30000
local function path()
if not settingsPath then
settingsPath = singeGetSystemInfo().dataRoot .. "master.dat"
end
return settingsPath
end
function masterLoad()
local file = io.open(path(), "r")
if not file then
return
end
local text = file:read("*a")
file:close()
local ok, stored = pcall(cjson.decode, text)
if ok and type(stored) == "table" then
MASTER.url = stored.url or MASTER_DEFAULT_URL
MASTER.token = stored.token
MASTER.email = stored.email
MASTER.name = stored.name
MASTER.queue = stored.queue or {}
end
end
function masterSave()
local file = io.open(path(), "w")
if not file then
return false
end
file:write(cjson.encode({ url = MASTER.url, token = MASTER.token, email = MASTER.email,
name = MASTER.name, queue = MASTER.queue }))
file:close()
return true
end
function masterSignedIn()
return MASTER.token ~= nil
end
-- Every call goes through here so the token, the content type and the JSON decoding are in one
-- place, and so a 401 always means the same thing: whatever we were holding is no longer a
-- session, and pretending otherwise only produces a second failure later.
local function request(method, endpoint, body, onDone, opts)
opts = opts or {}
local headers = { ["Content-Type"] = "application/json" }
if MASTER.token and not opts.anonymous then
headers["Authorization"] = "Bearer " .. MASTER.token
end
netRequest({
url = MASTER.url .. endpoint, method = method, headers = headers,
body = body and cjson.encode(body) or nil,
toFile = opts.toFile, onProgress = opts.onProgress,
}, function(ok, result)
if not ok then
return onDone(false, result.error)
end
local parsed = {}
if result.body and result.body ~= "" then
local decoded, value = pcall(cjson.decode, result.body)
if decoded and type(value) == "table" then
parsed = value
end
end
if result.status == 401 and not opts.anonymous then
MASTER.token = nil
masterSave()
return onDone(false, "signed out by the server", parsed, result)
end
if result.status >= 400 then
return onDone(false, parsed.message or ("the server said " .. result.status), parsed, result)
end
onDone(true, parsed, parsed, result)
end)
end
MASTER_REQUEST = request
function masterSignIn(email, password, onDone)
request("POST", "/v1/account/login", { email = email, password = password }, function(ok, reply)
if not ok then
return onDone(false, reply)
end
MASTER.token = reply.token
MASTER.email = reply.email
masterSave()
-- The display name is not part of signing in, so it is fetched once and kept: a game
-- about to post a score should not have to discover it has none mid-play.
masterWhoAmI(function() end)
onDone(true, reply)
end, { anonymous = true })
end
function masterRegister(email, password, onDone)
request("POST", "/v1/account/register", { email = email, password = password }, onDone, { anonymous = true })
end
function masterRecover(email, onDone)
request("POST", "/v1/account/recover", { email = email }, onDone, { anonymous = true })
end
function masterSignOut(onDone)
local had = MASTER.token
MASTER.token = nil
MASTER.email = nil
MASTER.name = nil
masterSave()
if had then
-- Told after the fact: the cabinet is signed out either way, and a server that cannot be
-- reached must not leave somebody looking signed in.
MASTER.token = had
request("POST", "/v1/account/logout", nil, function() end)
MASTER.token = nil
end
if onDone then onDone(true) end
end
function masterWhoAmI(onDone)
request("GET", "/v1/account", nil, function(ok, reply)
if ok then
MASTER.email = reply.email or MASTER.email
masterSave()
end
onDone(ok, reply)
end)
end
function masterSetName(name, onDone)
request("POST", "/v1/player/name", { name = name }, function(ok, reply)
if ok then
MASTER.name = reply.name
masterSave()
end
onDone(ok, reply)
end)
end
function masterCatalogue(onDone)
request("GET", "/v1/catalogue", nil, function(ok, reply)
if ok then
for _, game in ipairs(reply.games or {}) do
game.version = game.version and math.floor(game.version) or nil
game.installed = game.installed and math.floor(game.installed) or nil
game.size = game.size and math.floor(game.size) or nil
end
end
onDone(ok, ok and reply.games or reply)
end)
end
-- Download a game to a file and check it against the digest the catalogue published. A download
-- that arrives wrong is worse than one that fails: it installs, and then does not run.
function masterDownload(gameId, version, toFile, expectSha, onProgress, onDone)
-- JSON has one number type and Lua 5.4 has two, so a version that went out as 1 comes back as
-- 1.0 and would build a path ending "/1.0" that the service does not recognise.
local path = string.format("/v1/download/%s/%d", gameId, math.floor(version))
request("GET", path, nil, function(ok, reply, _, result)
if not ok then
return onDone(false, reply)
end
local file = io.open(toFile, "rb")
if not file then
return onDone(false, "the download did not land on disk")
end
local bytes = file:read("*a")
file:close()
if expectSha and utilSha256(bytes) ~= expectSha then
os.remove(toFile)
return onDone(false, "the download did not match its published checksum")
end
onDone(true, { bytes = #bytes, file = toFile })
end, { toFile = toFile, onProgress = onProgress })
end
function masterTellInstalled(gameId, version, onDone)
request("POST", "/v1/installed", { game = gameId, version = version and math.floor(version) or nil },
onDone or function() end)
end
function masterBoard(gameId, board, onDone)
request("GET", "/v1/scores/" .. gameId .. (board and ("?board=" .. board) or ""), nil, function(ok, reply)
-- JSON has one number type and Lua 5.4 has two, so a score that went out as 4242 comes back
-- as 4242.0 and would be shown to a player with a decimal point on it.
if ok then
for _, row in ipairs(reply.top or {}) do
row.value = math.floor(row.value)
row.at = row.at and math.floor(row.at) or nil
end
if reply.standing then
reply.standing.value = math.floor(reply.standing.value)
reply.standing.rank = math.floor(reply.standing.rank)
reply.standing.players = math.floor(reply.standing.players)
end
end
onDone(ok, reply)
end)
end
-- A score is queued first and sent afterwards. A cabinet is often offline, and a game must never
-- wait on the wire to show its own board; the queue is written to disk so a power cut between the
-- play and the send does not lose it.
-- Tell the service a play is starting. What comes back travels with the score, so the server can
-- say how long the play took by its own clock; a cabinet that is offline simply has no play id and
-- the score carries no time, which is honest rather than broken.
function masterPlayStart(gameId, onDone)
if not masterSignedIn() then
MASTER.play = nil
if onDone then onDone(false, "not signed in") end
return
end
request("POST", "/v1/play/start", { game = gameId }, function(ok, reply)
MASTER.play = ok and reply.play and math.floor(reply.play) or nil
if onDone then onDone(ok, reply) end
end)
end
function masterSubmitScore(gameId, value, board, meta)
MASTER.queue[#MASTER.queue + 1] = { game = gameId, value = math.floor(value),
board = board or "default", meta = meta,
play = MASTER.play }
masterSave()
-- Send it on the next pump rather than waiting out the retry interval: a player who has just
-- finished wants to see themselves on the board, and the interval exists for retrying what
-- failed, not for delaying what has not been tried. This is a flag rather than a timestamp:
-- singeGetTicks() counts from engine start, so "lastFlush = 0" would mean "once the engine has
-- been up FLUSH_EVERY", which early in a run is never.
flushDue = true
end
function masterQueueLength()
return #MASTER.queue
end
-- Hand the queue to the server one at a time. A refusal that is the score's own fault (the game is
-- unknown, the value is nonsense) drops it, because retrying it forever would block everything
-- behind it; anything else leaves it in place to try again.
local function flushQueue()
if flushing or #MASTER.queue == 0 or not MASTER.token then
return
end
flushing = true
local entry = MASTER.queue[1]
request("POST", "/v1/scores/" .. entry.game, { value = entry.value, board = entry.board, meta = entry.meta, play = entry.play },
function(ok, reply, parsed, result)
local status = result and result.status or 0
if ok or (status >= 400 and status < 500 and status ~= 429) then
table.remove(MASTER.queue, 1)
masterSave()
-- A backlog should drain, not trickle out one per interval.
if #MASTER.queue > 0 then
flushDue = true
end
end
flushing = false
end)
end
function masterPump()
netPump()
if flushDue or (singeGetTicks() - lastFlush > FLUSH_EVERY) then
flushDue = false
lastFlush = singeGetTicks()
flushQueue()
end
end
masterLoad()