349 lines
11 KiB
Text
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()
|