--[[ * * 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 -- master.dat holds a session token and an email address, so it is not kept as plain text. A cabinet -- signs in unattended, so whatever reads the file back is on the machine too: this keeps the token -- and the address from being read at a glance or copied out of a text editor, and an edited file -- reads as no account at all, but it is no protection against someone with the file and this source. -- The file is SEAL_MAGIC, a random nonce, a tag, and the JSON XORed with a keystream: SHA-256 of the -- key, the nonce, and a block counter. The tag is SHA-256 of the key, the nonce, and the result. local SEAL_MAGIC = "SMD1" local SEAL_KEY = "Singe master.dat" -- Not a secret: see above local SEAL_NONCE = 16 -- Bytes local SEAL_TAG = 16 -- Bytes local SEAL_BLOCK = 32 -- Bytes of keystream a SHA-256 gives local function path() if not settingsPath then settingsPath = singeGetSystemInfo().dataRoot .. "master.dat" end return settingsPath end -- SHA-256 as bytes rather than hex. local function sealDigest(data) return (utilSha256(data):gsub("%x%x", function(pair) return string.char(tonumber(pair, 16)) end)) end -- text XORed with the keystream for nonce: the same call seals and unseals. local function sealStream(nonce, text) local out = {} for block = 0, (#text - 1) // SEAL_BLOCK do local key = sealDigest(SEAL_KEY .. nonce .. string.pack(">I4", block)) local piece = text:sub(block * SEAL_BLOCK + 1, (block + 1) * SEAL_BLOCK) local bytes = {} for i = 1, #piece do bytes[i] = string.char(piece:byte(i) ~ key:byte(i)) end out[#out + 1] = table.concat(bytes) end return table.concat(out) end local function sealTag(nonce, sealed) return sealDigest(SEAL_KEY .. "tag" .. nonce .. sealed):sub(1, SEAL_TAG) end -- text as master.dat keeps it, with a fresh nonce each time. local function seal(text) local nonce = sealDigest(tostring(os.time()) .. os.clock() .. tostring({}) .. math.random()):sub(1, SEAL_NONCE) local sealed = sealStream(nonce, text) return SEAL_MAGIC .. nonce .. sealTag(nonce, sealed) .. sealed end -- The text master.dat holds: unsealed, or as it is when it is still plain JSON (from before files -- were sealed); nil when the tag does not match. local function unseal(data) local nonce = data:sub(#SEAL_MAGIC + 1, #SEAL_MAGIC + SEAL_NONCE) local tag = data:sub(#SEAL_MAGIC + SEAL_NONCE + 1, #SEAL_MAGIC + SEAL_NONCE + SEAL_TAG) local sealed = data:sub(#SEAL_MAGIC + SEAL_NONCE + SEAL_TAG + 1) if data:sub(1, #SEAL_MAGIC) ~= SEAL_MAGIC then return data end if (#nonce ~= SEAL_NONCE) or (sealTag(nonce, sealed) ~= tag) then return nil end return sealStream(nonce, sealed) end function masterLoad() local file = io.open(path(), "rb") local data = nil local text = nil local ok = false local stored = nil if not file then return end data = file:read("a") file:close() text = unseal(data) if text == nil then return end 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 {} -- A file from before sealing is sealed as soon as it is read. if text == data then masterSave() end end end function masterSave() local file = io.open(path(), "wb") if not file then return false end file:write(seal(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 if opts.bodyFile then headers["Content-Type"] = opts.contentType or "application/octet-stream" end netRequest({ url = MASTER.url .. endpoint, method = method, headers = headers, body = body and cjson.encode(body) or nil, bodyFile = opts.bodyFile, 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 -- Every title the download holds, in its games.dat order. for _, entry in ipairs(game.entries or {}) do entry.entry = entry.entry and math.floor(entry.entry) or nil entry.year = entry.year and math.floor(entry.year) or nil entry.tier = entry.tier and math.floor(entry.tier) or nil end 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 recognize. 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 -- What this machine holds, and what is newer. The list is the menu's own, read off every -- games.dat it loaded (GAME_ID and VERSION), and the service keeps none of it: it answers which -- of these have a newer version and which it has never heard of. No sign-in needed to ask. function masterVersions(held, onDone) local games = {} for _, entry in ipairs(held) do games[#games + 1] = { id = entry.id, version = math.floor(entry.version or 0) } end request("POST", "/v1/versions", { games = games }, function(ok, reply) if ok then for _, update in ipairs(reply.updates or {}) do update.installed = update.installed and math.floor(update.installed) or 0 update.latest = update.latest and math.floor(update.latest) or nil update.size = update.size and math.floor(update.size) or nil end end onDone(ok, ok and (reply.updates or {}) or reply, ok and (reply.unknown or {}) or nil) end) end -- Send a packed game up for review. The file goes as the body, from disk, so a big game is not -- held in memory; onProgress(sent, total) says how it goes, and onDone(ok, reply) ends with the -- version number the service gave it and the state it starts in, which is "pending". function masterUpload(file, notes, onProgress, onDone) local endpoint = "/v1/upload" if notes and notes ~= "" then endpoint = endpoint .. "?notes=" .. notes:gsub("[^%w%-%._~]", function(c) return string.format("%%%02X", c:byte()) end) end request("POST", endpoint, nil, function(ok, reply) if ok and reply.version then reply.version = math.floor(reply.version) end onDone(ok, reply) end, { bodyFile = file, onProgress = onProgress }) end -- Is there a newer Singe for this machine? Public, no sign-in: the answer is the newest release -- for the platform when it is newer than the version running, or newer = false. function masterRelease(platform, running, onDone) request("GET", "/v1/release/" .. platform .. "?version=" .. tostring(running), nil, function(ok, reply) onDone(ok, reply) end, { anonymous = true }) end -- Fetch one release to a file, checked against the digest the service published, exactly as a -- game is; a binary that arrives wrong must not be put beside the one that works. function masterDownloadRelease(platform, version, toFile, expectSha, onProgress, onDone) request("GET", "/v1/release/" .. platform .. "/" .. version, 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, anonymous = true }) end -- This account's own games, every version with the review's word on it. function masterMine(onDone) request("GET", "/v1/catalogue/mine", nil, function(ok, reply) onDone(ok, ok and (reply.games or {}) or reply) end) end -- One board. lowest asks for a board that ranks the lowest score first; a board keeps the direction -- its first score fixed, and the reply's order says which it is. function masterBoard(gameId, board, onDone, lowest) local query = {} if board then query[#query + 1] = "board=" .. board end if lowest then query[#query + 1] = "order=lowest" end request("GET", "/v1/scores/" .. gameId .. (#query > 0 and ("?" .. table.concat(query, "&")) 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 -- 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 -- 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. lowest sends it to a board that ranks the lowest first; one -- queued without it (as every score was before boards had a direction) goes the highest first. function masterSubmitScore(gameId, value, board, meta, lowest) MASTER.queue[#MASTER.queue + 1] = { game = gameId, value = math.floor(value), board = board or "default", meta = meta, play = MASTER.play, order = lowest and "lowest" or nil } 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, the board ranks the other way) 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, order = entry.order }, 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()