--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * 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()