Working on integrating with the master server.
This commit is contained in:
parent
f9f67201e3
commit
d73966c1e6
14 changed files with 1689 additions and 4 deletions
54
CHANGELOG
54
CHANGELOG
|
|
@ -862,6 +862,60 @@ API Changes
|
|||
things that must not pass through each other -- which is what the
|
||||
collide calls deliberately do not do.
|
||||
|
||||
- Player handles. The name on a leaderboard is a handle, not an email
|
||||
address: three to twenty characters of letters, digits, hyphens and
|
||||
underscores, chosen once. An operator can rename somebody; a player
|
||||
cannot, and an email address cannot be changed at all. Handles are
|
||||
checked against a list the operator maintains -- identities nobody
|
||||
should claim, and words with no place on a screen in a public room.
|
||||
|
||||
- Online: accounts, a game catalogue, and high scores. A cabinet can sign
|
||||
in to a master service (master.singeengine.com by default), browse every
|
||||
game the service offers, download and update them, and post scores to
|
||||
leaderboards. Two new service tools cover the setup: Online Account
|
||||
signs in, creates an account, recovers a password and sets the server
|
||||
address; Get Games lists the catalogue, marks what is installed here,
|
||||
and downloads, updates or removes it.
|
||||
|
||||
A game posts a score in one line -- scoreSubmit(points) -- plus
|
||||
scoreBoard, scorePlayerName, scoreWaiting and scoreUpdate. The account
|
||||
belongs to the machine rather than the game, so a game uses whatever
|
||||
sign-in the menu made. A score is queued to disk and sent when there is
|
||||
a connection: a cabinet is often offline, and a game must never stall
|
||||
on the wire to show its own board.
|
||||
|
||||
Nothing blocks a frame. Singe/Net.singe is an asynchronous HTTP client
|
||||
driven from the frame loop, because Lua here is cooperative and
|
||||
ssl.https.request would stutter a JSON call and make a large download
|
||||
look like a hang. It does not use LuaSec's https module at all: that
|
||||
ships with verify = "none", which encrypts the connection and
|
||||
authenticates nobody. A connection is verified against a pinned public
|
||||
key instead.
|
||||
|
||||
A download is checked against the digest the catalogue published before
|
||||
it replaces anything, so a transfer that arrives wrong fails rather than
|
||||
installing a game that will not run. Downloads resume.
|
||||
|
||||
New engine calls: utilSha256(data) for exactly that checking, and
|
||||
singeGetGameId() for the GAME_ID a games.dat entry can now carry, which
|
||||
is what a leaderboard is kept under. A game without one can be played
|
||||
but not ranked.
|
||||
|
||||
A game the service withdraws keeps working on the cabinets that have it.
|
||||
All that stops is being offered an update, and the menu shows it as
|
||||
"installed, no longer offered" so it can still be removed from there.
|
||||
|
||||
scoreBegin() tells the service a play is starting, and the score that
|
||||
follows carries how long it took measured on the server's own clock.
|
||||
That is the one number in a submission that has not been through the
|
||||
player's machine. Nothing is rejected on it -- it is shown to the
|
||||
operator beside the score, and the judgement stays a person's.
|
||||
|
||||
A submitted score is a claim and the design says so: the cabinet is the
|
||||
player's own machine. The service records who claimed what, rate limits
|
||||
submissions, and flags outliers for a person rather than pretending to
|
||||
verify them.
|
||||
|
||||
- One menu, two renderers. Singe/MenuClassic.singe is gone: it was the
|
||||
whole menu written a second time, and the two copies duplicated their
|
||||
game list, their selection, their menu.dat handling and their games.dat
|
||||
|
|
|
|||
|
|
@ -190,6 +190,8 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg
|
|||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Net.singe ${GENERATED_DIR}/Net_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Master.singe ${GENERATED_DIR}/Master_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuDocument.singe ${GENERATED_DIR}/MenuDocument_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuOverlay.singe ${GENERATED_DIR}/MenuOverlay_singe.h "")
|
||||
singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "")
|
||||
|
|
|
|||
|
|
@ -611,3 +611,94 @@ if singeMain ~= nil then
|
|||
end
|
||||
|
||||
|
||||
|
||||
|
||||
-- ===== Online high scores =====
|
||||
--
|
||||
-- A thin layer over Singe/Master.singe so a game says scoreSubmit(1000) and nothing else. The
|
||||
-- account, the server address and the player's name belong to the machine, not to the game, and
|
||||
-- were set up once in the menu's Online Account tool.
|
||||
--
|
||||
-- Nothing here waits on the network. A score is written to a queue on disk and sent when there is
|
||||
-- a connection, because a cabinet is often offline and a game must never stall on the wire to show
|
||||
-- its own board. scoreUpdate() has to run once a frame for any of it to progress.
|
||||
|
||||
local masterLoaded = false
|
||||
|
||||
|
||||
local function scoreReady()
|
||||
if not masterLoaded then
|
||||
-- Loaded on first use rather than at startup: a game that never posts a score should not
|
||||
-- pay for the module or open the file.
|
||||
dofile("Singe/Master.singe")
|
||||
masterLoaded = true
|
||||
end
|
||||
return masterSignedIn()
|
||||
end
|
||||
|
||||
|
||||
-- The id a game is known by, from its games.dat entry. A game without one can be played but not
|
||||
-- ranked: there is nothing stable to key a board on, and inventing one here would make a different
|
||||
-- board every time the file moved.
|
||||
function scoreGameId()
|
||||
return singeGetGameId()
|
||||
end
|
||||
|
||||
|
||||
-- Say that a play is starting. Optional, and worth calling: the service then times the play by its
|
||||
-- own clock, which is the one thing in a submission that has not been through this machine. A
|
||||
-- cabinet with no connection simply records no time.
|
||||
function scoreBegin()
|
||||
local id = scoreGameId()
|
||||
|
||||
if not id or not scoreReady() then
|
||||
return false
|
||||
end
|
||||
masterPlayStart(id, function() end)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
-- Queue a score. Returns false when the cabinet has no account or the game has no id, so a game
|
||||
-- can decide whether to say anything about it.
|
||||
function scoreSubmit(value, board, meta)
|
||||
local id = scoreGameId()
|
||||
|
||||
if not id or not scoreReady() then
|
||||
return false
|
||||
end
|
||||
masterSubmitScore(id, value, board, meta)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
-- The board, as a callback: onDone(ok, result) where result.top is the list and result.standing is
|
||||
-- this player's place.
|
||||
function scoreBoard(onDone, board)
|
||||
local id = scoreGameId()
|
||||
|
||||
if not id or not scoreReady() then
|
||||
onDone(false, "this cabinet is not signed in")
|
||||
return false
|
||||
end
|
||||
masterBoard(id, board, onDone)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
function scorePlayerName()
|
||||
return scoreReady() and MASTER.name or nil
|
||||
end
|
||||
|
||||
|
||||
function scoreWaiting()
|
||||
return masterLoaded and masterQueueLength() or 0
|
||||
end
|
||||
|
||||
|
||||
-- Once a frame, from onOverlayUpdate. Safe to call in a game that never posts anything.
|
||||
function scoreUpdate()
|
||||
if masterLoaded then
|
||||
masterPump()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
349
assets/Master.singe
Normal file
349
assets/Master.singe
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
--[[
|
||||
*
|
||||
* 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()
|
||||
|
|
@ -36,6 +36,7 @@
|
|||
-- see the top of either file for what is in it.
|
||||
|
||||
dofile("Singe/Framework.singe")
|
||||
dofile("Singe/Master.singe")
|
||||
dofile("Singe/Tools.singe")
|
||||
|
||||
local lfs = require("lfs")
|
||||
|
|
@ -96,6 +97,15 @@ function menuActive()
|
|||
end
|
||||
|
||||
|
||||
-- In MODE_FULL every key arrives here as its keysym rather than as a switch, which is what the
|
||||
-- account tool's typing needs; the switches it still cares about are the pad's, which keep working.
|
||||
function onKeyPressed(keysym, scancode)
|
||||
if toolsActive() then
|
||||
toolsTyped(keysym)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function onInputPressed(what)
|
||||
|
||||
-- The service tools take every switch while they are up, including the one that closes them.
|
||||
|
|
@ -149,6 +159,9 @@ function onOverlayUpdate()
|
|||
colorBackground(0, 0, 0, 0)
|
||||
overlayClear()
|
||||
|
||||
-- The network never blocks the frame; it makes progress here instead.
|
||||
masterPump()
|
||||
|
||||
if toolsActive() then
|
||||
toolsUpdate()
|
||||
MENU_RENDER.tools()
|
||||
|
|
|
|||
|
|
@ -287,9 +287,28 @@ MENU_RENDER.tools = function()
|
|||
-- The tools redraw only when they say something changed; the document keeps what it was given.
|
||||
if TOOL_DIRTY then
|
||||
TOOL_DIRTY = false
|
||||
for _, row in ipairs(TOOL_ROWS) do
|
||||
-- The same window the overlay renderer draws, so the two show the same page rather than one
|
||||
-- of them quietly showing more. The style sheet can scroll this box, but nothing drives a
|
||||
-- scroll bar from a joystick: windowing is what actually keeps the chosen row on screen.
|
||||
local visible, more, below = toolsVisible(TOOL_ROWS, toolsCapacity())
|
||||
if more then
|
||||
rows[#rows + 1] = "<div class='sub'>...</div>"
|
||||
end
|
||||
local seenItem = false
|
||||
for _, row in ipairs(visible) do
|
||||
-- The marker belongs under the last item rather than under the key hints below it.
|
||||
if below and seenItem and row.kind ~= "item" then
|
||||
rows[#rows + 1] = "<div class='sub'>...</div>"
|
||||
below = false
|
||||
end
|
||||
if row.kind == "item" then
|
||||
seenItem = true
|
||||
end
|
||||
rows[#rows + 1] = toolRow(row)
|
||||
end
|
||||
if below then
|
||||
rows[#rows + 1] = "<div class='sub'>...</div>"
|
||||
end
|
||||
guiSetValue(GUI, DOCUMENT, "toolTitle", menuEscape(TOOL_TITLE))
|
||||
guiSetValue(GUI, DOCUMENT, "toolBody", table.concat(rows))
|
||||
menuElement("games"):SetClass("hidden", toolsActive())
|
||||
|
|
|
|||
|
|
@ -118,6 +118,11 @@ end
|
|||
|
||||
-- Text too long for the page wraps under itself rather than running off the edge.
|
||||
local function toolWrapped(text, x, role)
|
||||
if text == nil or text == "" then
|
||||
-- Still a line: a tool that says nothing this frame should not have its page jump about.
|
||||
toolLine({})
|
||||
return
|
||||
end
|
||||
for _, line in ipairs(fitLines(text, overlayGetWidth() - MARGIN_X - x)) do
|
||||
toolLine({ { x = x, text = line, role = role } })
|
||||
end
|
||||
|
|
@ -127,9 +132,25 @@ end
|
|||
local function toolsLayout()
|
||||
local valueX = MARGIN_X + LABEL_W
|
||||
local wasKeys = false
|
||||
-- Only the rows that fit, with the chosen one kept in view; a catalogue is as long as the
|
||||
-- service is big and would otherwise run off the bottom of the screen.
|
||||
local rows, more, below = toolsVisible(TOOL_ROWS, toolsCapacity())
|
||||
|
||||
toolLines = {}
|
||||
for _, row in ipairs(TOOL_ROWS) do
|
||||
if more then
|
||||
toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } })
|
||||
end
|
||||
local seenItem = false
|
||||
for _, row in ipairs(rows) do
|
||||
-- The "more below" marker belongs immediately under the last item, not at the very bottom
|
||||
-- of the page under the key hints, which are not part of the list.
|
||||
if below and seenItem and row.kind ~= "item" then
|
||||
toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } })
|
||||
below = false
|
||||
end
|
||||
if row.kind == "item" then
|
||||
seenItem = true
|
||||
end
|
||||
-- The key hints stand away from whatever the tool put above them, as they do in the
|
||||
-- document, but sit together when a tool needs more than one line of them.
|
||||
if row.kind == "keys" and not wasKeys then
|
||||
|
|
@ -159,6 +180,10 @@ local function toolsLayout()
|
|||
toolWrapped(row.text, MARGIN_X, "ink")
|
||||
end
|
||||
end
|
||||
-- A list that runs to the very end of the page has no row after it to hang the marker on.
|
||||
if below then
|
||||
toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
|
@ -342,8 +367,12 @@ MENU_RENDER.tools = function()
|
|||
else
|
||||
colorForeground(ink, ink, ink, 255)
|
||||
end
|
||||
-- fontPrint refuses an empty string, and a tool legitimately produces one: a status
|
||||
-- line with nothing to say yet is still a line, and it still takes up its height.
|
||||
if piece.text ~= "" then
|
||||
fontPrint(piece.x, y, piece.text)
|
||||
end
|
||||
end
|
||||
y = y + LINE_HEIGHT
|
||||
end
|
||||
end
|
||||
|
|
|
|||
392
assets/Net.singe
Normal file
392
assets/Net.singe
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
--[[
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
--]]
|
||||
|
||||
|
||||
|
||||
-- Talking to the master service without stopping the game.
|
||||
--
|
||||
-- Lua in Singe is not threaded, and ssl.https.request blocks until it finishes: a JSON call would
|
||||
-- stutter the frame and a several-hundred-megabyte download would look like a hang. So every
|
||||
-- request here is a state machine driven a slice at a time from netPump(), which the menu calls
|
||||
-- once a frame. A caller hands in a function and gets called back when the answer arrives.
|
||||
--
|
||||
-- On trust. LuaSec's own https.lua ships with verify = "none", which encrypts the connection and
|
||||
-- authenticates nobody -- anything on the path could read the password going up, the token coming
|
||||
-- back, and alter the game being downloaded. Nothing here uses that module. A connection is
|
||||
-- verified against a pinned public key: Singe ships to five platforms and LuaSec can read neither
|
||||
-- the Windows certificate store nor the macOS keychain, and this client only ever talks to one
|
||||
-- service, so pinning is both simpler and stronger than carrying a list of every public CA.
|
||||
--
|
||||
-- The pin is of the *public key*, not the certificate, so an ordinary renewal that keeps the key
|
||||
-- does not lock out every cabinet; two pins are carried so a key can actually be rotated.
|
||||
|
||||
|
||||
local socket = require("socket")
|
||||
local ssl = require("ssl")
|
||||
|
||||
NET_TIMEOUT = 30000 -- ms with no progress at all before a request is abandoned
|
||||
NET_CHUNK = 65536 -- bytes read per pump slice
|
||||
NET_REDIRECT_MAX = 5
|
||||
|
||||
-- master.singeengine.com. Two pins: the one in use and its successor, so a key rotation is a
|
||||
-- release that knows both, then a server that starts using the second. An empty list means the
|
||||
-- host's own trust store is used instead, which is what a self-hosted server without a pin gets.
|
||||
NET_PINS = {}
|
||||
|
||||
local active = {}
|
||||
local nextId = 1
|
||||
|
||||
|
||||
-- The SHA-256 of a peer's public key, in the form the pins are written in.
|
||||
local function keyPin(connection)
|
||||
local cert = connection:getpeercertificate()
|
||||
|
||||
if not cert then
|
||||
return nil
|
||||
end
|
||||
local pem = cert:pubkey()
|
||||
if not pem then
|
||||
return nil
|
||||
end
|
||||
return utilSha256(pem)
|
||||
end
|
||||
|
||||
|
||||
local function finish(request, ok, result)
|
||||
active[request.id] = nil
|
||||
if request.file then
|
||||
request.file:close()
|
||||
request.file = nil
|
||||
end
|
||||
if request.socket then
|
||||
pcall(function() request.socket:close() end)
|
||||
request.socket = nil
|
||||
end
|
||||
if request.onDone then
|
||||
request.onDone(ok, result)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function fail(request, message)
|
||||
finish(request, false, { error = message, url = request.url })
|
||||
end
|
||||
|
||||
|
||||
-- scheme, host, port, path
|
||||
local function parseUrl(url)
|
||||
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
|
||||
|
||||
if not scheme then
|
||||
return nil
|
||||
end
|
||||
local hostPort, path = rest:match("^([^/]+)(.*)$")
|
||||
if not hostPort then
|
||||
return nil
|
||||
end
|
||||
local host, port = hostPort:match("^(.-):(%d+)$")
|
||||
host = host or hostPort
|
||||
port = tonumber(port) or ((scheme == "https") and 443 or 80)
|
||||
return scheme, host, port, (path ~= "" and path or "/")
|
||||
end
|
||||
|
||||
|
||||
local function beginConnect(request)
|
||||
local sock, err = socket.tcp()
|
||||
|
||||
if not sock then
|
||||
return fail(request, "no socket: " .. tostring(err))
|
||||
end
|
||||
sock:settimeout(0)
|
||||
request.socket = sock
|
||||
-- A non-blocking connect answers "timeout" immediately and finishes in the background; the
|
||||
-- socket becoming writable is what says it arrived.
|
||||
local ok, connectErr = sock:connect(request.host, request.port)
|
||||
if not ok and connectErr ~= "timeout" and connectErr ~= "already connected" then
|
||||
return fail(request, "cannot reach " .. request.host .. ": " .. tostring(connectErr))
|
||||
end
|
||||
request.state = (request.scheme == "https") and "connecting" or "sending"
|
||||
request.sent = 0
|
||||
end
|
||||
|
||||
|
||||
local function buildRequest(request)
|
||||
local lines = {
|
||||
string.format("%s %s HTTP/1.1", request.method, request.path),
|
||||
"Host: " .. request.host,
|
||||
"Connection: close",
|
||||
"Accept-Encoding: identity",
|
||||
"User-Agent: Singe/" .. SINGE_VERSION_STRING,
|
||||
}
|
||||
|
||||
for name, value in pairs(request.headers or {}) do
|
||||
lines[#lines + 1] = name .. ": " .. value
|
||||
end
|
||||
if request.body then
|
||||
lines[#lines + 1] = "Content-Length: " .. #request.body
|
||||
end
|
||||
return table.concat(lines, "\r\n") .. "\r\n\r\n" .. (request.body or "")
|
||||
end
|
||||
|
||||
|
||||
local function startTls(request)
|
||||
local params = {
|
||||
mode = "client",
|
||||
protocol = "any",
|
||||
options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
|
||||
-- With a pin, the chain is checked against the key rather than a CA list, and the pin is
|
||||
-- what decides. Without one, the host's own store is asked -- and if it has nothing to
|
||||
-- say, the request fails rather than proceeding unverified.
|
||||
verify = (#NET_PINS > 0) and "none" or "peer",
|
||||
capath = "/etc/ssl/certs",
|
||||
cafile = "/etc/ssl/certs/ca-certificates.crt",
|
||||
}
|
||||
local wrapped, err = ssl.wrap(request.socket, params)
|
||||
|
||||
if not wrapped then
|
||||
return fail(request, "TLS setup failed: " .. tostring(err))
|
||||
end
|
||||
wrapped:settimeout(0)
|
||||
if wrapped.sni then
|
||||
wrapped:sni(request.host)
|
||||
end
|
||||
request.socket = wrapped
|
||||
request.state = "handshake"
|
||||
end
|
||||
|
||||
|
||||
local function pumpHandshake(request)
|
||||
local ok, err = request.socket:dohandshake()
|
||||
|
||||
if ok then
|
||||
if #NET_PINS > 0 then
|
||||
local seen = keyPin(request.socket)
|
||||
local good = false
|
||||
for _, pin in ipairs(NET_PINS) do
|
||||
if seen == pin then
|
||||
good = true
|
||||
end
|
||||
end
|
||||
if not good then
|
||||
-- The one failure that must never be shrugged off: a wrong key is either a
|
||||
-- mis-deployed server or somebody in the middle, and both want a person.
|
||||
return fail(request, "the server's key is not one this build trusts")
|
||||
end
|
||||
end
|
||||
request.state = "sending"
|
||||
return
|
||||
end
|
||||
if err ~= "wantread" and err ~= "wantwrite" and err ~= "timeout" then
|
||||
fail(request, "TLS handshake failed: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function pumpSending(request)
|
||||
if not request.outgoing then
|
||||
request.outgoing = buildRequest(request)
|
||||
end
|
||||
local sent, err, partial = request.socket:send(request.outgoing, request.sent + 1)
|
||||
|
||||
if sent then
|
||||
request.sent = sent
|
||||
elseif err == "timeout" then
|
||||
request.sent = partial or request.sent
|
||||
else
|
||||
return fail(request, "send failed: " .. tostring(err))
|
||||
end
|
||||
if request.sent >= #request.outgoing then
|
||||
request.state = "reading"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Split the headers off once the blank line arrives, so the body can start going to disk rather
|
||||
-- than accumulating in memory: a game is far too big to hold twice.
|
||||
local function splitHead(request)
|
||||
local at = request.buffer:find("\r\n\r\n", 1, true)
|
||||
|
||||
if not at then
|
||||
return false
|
||||
end
|
||||
local head = request.buffer:sub(1, at - 1)
|
||||
request.buffer = request.buffer:sub(at + 4)
|
||||
request.status = tonumber(head:match("^HTTP/%d%.%d (%d%d%d)")) or 0
|
||||
request.responseHeaders = {}
|
||||
for name, value in head:gmatch("\r\n([^:\r\n]+):%s*([^\r\n]*)") do
|
||||
request.responseHeaders[name:lower()] = value
|
||||
end
|
||||
request.length = tonumber(request.responseHeaders["content-length"])
|
||||
request.got = 0
|
||||
if request.toFile then
|
||||
local file, err = io.open(request.toFile, "wb")
|
||||
if not file then
|
||||
fail(request, "cannot write " .. request.toFile .. ": " .. tostring(err))
|
||||
return false
|
||||
end
|
||||
request.file = file
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
local function consume(request, data)
|
||||
request.got = request.got + #data
|
||||
if request.file then
|
||||
request.file:write(data)
|
||||
else
|
||||
request.parts[#request.parts + 1] = data
|
||||
end
|
||||
if request.onProgress then
|
||||
request.onProgress(request.got, request.length)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function complete(request)
|
||||
local body = request.file and "" or table.concat(request.parts)
|
||||
|
||||
-- A redirect is followed only for a plain GET, and only within the same scheme and host: the
|
||||
-- point of a pin is lost if a 302 can walk the client somewhere else.
|
||||
local location = request.responseHeaders["location"]
|
||||
if location and request.status >= 300 and request.status < 400 and request.redirects < NET_REDIRECT_MAX then
|
||||
local scheme, host = parseUrl(location)
|
||||
if scheme == request.scheme and host == request.host then
|
||||
request.redirects = request.redirects + 1
|
||||
request.url = location
|
||||
local _, _, port, path = parseUrl(location)
|
||||
request.port, request.path = port, path
|
||||
request.buffer, request.parts, request.outgoing, request.sent = "", {}, nil, 0
|
||||
if request.socket then pcall(function() request.socket:close() end) end
|
||||
request.socket = nil
|
||||
return beginConnect(request)
|
||||
end
|
||||
end
|
||||
finish(request, true, { status = request.status, body = body, headers = request.responseHeaders,
|
||||
bytes = request.got, file = request.toFile })
|
||||
end
|
||||
|
||||
|
||||
local function pumpReading(request)
|
||||
local data, err, partial = request.socket:receive(NET_CHUNK)
|
||||
local got = data or partial
|
||||
|
||||
if got and #got > 0 then
|
||||
request.lastProgress = singeGetTicks()
|
||||
if not request.responseHeaders then
|
||||
request.buffer = request.buffer .. got
|
||||
if splitHead(request) and #request.buffer > 0 then
|
||||
local rest = request.buffer
|
||||
request.buffer = ""
|
||||
consume(request, rest)
|
||||
end
|
||||
else
|
||||
consume(request, got)
|
||||
end
|
||||
end
|
||||
if request.responseHeaders and request.length and request.got >= request.length then
|
||||
return complete(request)
|
||||
end
|
||||
if err == "closed" then
|
||||
if request.responseHeaders then
|
||||
return complete(request)
|
||||
end
|
||||
return fail(request, "the server closed before answering")
|
||||
end
|
||||
if err and err ~= "timeout" and err ~= "wantread" then
|
||||
fail(request, "read failed: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- One slice of work for every request in flight. Cheap when nothing is happening.
|
||||
--
|
||||
-- The list is snapshotted first because a request that finishes calls back into the caller, and the
|
||||
-- caller very reasonably starts another one from there -- a download that reports itself installed,
|
||||
-- say. Adding to a table while pairs() walks it is not allowed, and the failure ("invalid key to
|
||||
-- 'next'") names neither the table nor the callback that did it.
|
||||
function netPump()
|
||||
local running = {}
|
||||
|
||||
for id in pairs(active) do
|
||||
running[#running + 1] = id
|
||||
end
|
||||
for _, id in ipairs(running) do
|
||||
local request = active[id]
|
||||
if request then
|
||||
if singeGetTicks() - request.lastProgress > NET_TIMEOUT then
|
||||
fail(request, "the server stopped responding")
|
||||
elseif request.state == "connecting" then
|
||||
-- Writable means the connect finished; LuaSec is wrapped around it only then.
|
||||
local _, writable = socket.select(nil, { request.socket }, 0)
|
||||
if writable and #writable > 0 then
|
||||
request.lastProgress = singeGetTicks()
|
||||
startTls(request)
|
||||
end
|
||||
elseif request.state == "handshake" then
|
||||
pumpHandshake(request)
|
||||
elseif request.state == "sending" then
|
||||
pumpSending(request)
|
||||
elseif request.state == "reading" then
|
||||
pumpReading(request)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Start a request. opts: url, method, headers, body, toFile, onProgress. onDone(ok, result) is
|
||||
-- called once, with result.status/body/headers on success or result.error on failure.
|
||||
function netRequest(opts, onDone)
|
||||
local scheme, host, port, path = parseUrl(opts.url)
|
||||
|
||||
if not scheme or (scheme ~= "http" and scheme ~= "https") then
|
||||
onDone(false, { error = "not a URL this understands: " .. tostring(opts.url) })
|
||||
return nil
|
||||
end
|
||||
local request = {
|
||||
id = nextId, url = opts.url, scheme = scheme, host = host, port = port, path = path,
|
||||
method = opts.method or "GET", headers = opts.headers, body = opts.body,
|
||||
toFile = opts.toFile, onProgress = opts.onProgress, onDone = onDone,
|
||||
buffer = "", parts = {}, redirects = 0, lastProgress = singeGetTicks(),
|
||||
}
|
||||
nextId = nextId + 1
|
||||
active[request.id] = request
|
||||
beginConnect(request)
|
||||
return request.id
|
||||
end
|
||||
|
||||
|
||||
function netCancel(id)
|
||||
local request = active[id]
|
||||
|
||||
if request then
|
||||
request.onDone = nil
|
||||
finish(request, false, { error = "cancelled" })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function netBusy()
|
||||
return next(active) ~= nil
|
||||
end
|
||||
|
|
@ -38,6 +38,11 @@ local lfs = require("lfs")
|
|||
|
||||
TOOLS = {}
|
||||
TOOL_SELECTED = 1
|
||||
-- The overlay renderer's line height, which the capacity above is measured in; the document one
|
||||
-- lays out a little tighter, so using this for both means it never shows fewer rows than it has
|
||||
-- room for.
|
||||
TOOL_LINE_HEIGHT = 24
|
||||
TOOL_CHROME_LINES = 6
|
||||
TOOL_OPEN = nil -- The tool being used, or nil while the list itself is showing
|
||||
TOOL_SHOWING = false -- Whether the tools have the screen at all
|
||||
|
||||
|
|
@ -91,6 +96,59 @@ function toolsShow(rows)
|
|||
end
|
||||
|
||||
|
||||
-- How many rows of a page fit on screen.
|
||||
--
|
||||
-- Derived from the overlay, which is the size both renderers draw into, so the two show the same
|
||||
-- thing rather than one of them quietly showing more. The subtraction is the title and the couple
|
||||
-- of lines of key hints that live below every list.
|
||||
function toolsCapacity()
|
||||
return math.max(4, math.floor(overlayGetHeight() / TOOL_LINE_HEIGHT) - TOOL_CHROME_LINES)
|
||||
end
|
||||
|
||||
|
||||
-- The slice of a page that is actually drawn, with the chosen row kept in view.
|
||||
--
|
||||
-- Without this a page simply ran off the bottom: the catalogue is as long as the service is big,
|
||||
-- and a saved-data list is as long as the cabinet's library. Returns the rows to draw and whether
|
||||
-- there are more in either direction, so a renderer can say so.
|
||||
--
|
||||
-- Only rows a person moves between are windowed. The key hints and the status line under a list are
|
||||
-- part of the page rather than part of the list, so they stay put instead of scrolling away.
|
||||
function toolsVisible(rows, capacity)
|
||||
local items = {}
|
||||
local chosen = nil
|
||||
|
||||
for index, row in ipairs(rows) do
|
||||
if row.kind == "item" then
|
||||
items[#items + 1] = index
|
||||
if row.selected then
|
||||
chosen = #items
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Nothing to scroll: a page with no list, or one whose list already fits.
|
||||
if #items == 0 or #rows <= capacity then
|
||||
return rows, false, false
|
||||
end
|
||||
|
||||
-- Keep the chosen row in the middle of the window where there is room either side of it, so
|
||||
-- moving down does not pin it to the last line.
|
||||
local room = math.max(1, capacity - (#rows - #items))
|
||||
local first = math.max(1, math.min((chosen or 1) - math.floor(room / 2), #items - room + 1))
|
||||
local last = math.min(#items, first + room - 1)
|
||||
|
||||
local out = {}
|
||||
for index, row in ipairs(rows) do
|
||||
if row.kind ~= "item" then
|
||||
out[#out + 1] = row
|
||||
elseif index >= items[first] and index <= items[last] then
|
||||
out[#out + 1] = row
|
||||
end
|
||||
end
|
||||
return out, first > 1, last < #items
|
||||
end
|
||||
|
||||
|
||||
-- The one frame of white the audio delay tool flashes. A menu that cannot invert its page can
|
||||
-- ignore it, and the tool still works by ear.
|
||||
function toolsFlash(on)
|
||||
|
|
@ -114,6 +172,9 @@ function toolsEnd()
|
|||
if TOOL_OPEN and TOOL_OPEN.finish then
|
||||
TOOL_OPEN.finish()
|
||||
end
|
||||
if TOOL_OPEN and TOOL_OPEN.typed then
|
||||
keyboardSetMode(MODE_NORMAL)
|
||||
end
|
||||
TOOL_OPEN = nil
|
||||
TOOL_SHOWING = false
|
||||
TOOL_CLEAR = false
|
||||
|
|
@ -141,6 +202,11 @@ function toolsOpen(index)
|
|||
TOOL_OPEN = TOOLS[index]
|
||||
TOOL_TITLE = TOOL_OPEN.name
|
||||
TOOL_CLEAR = TOOL_OPEN.clear or false
|
||||
-- A tool that takes typed text needs every key, including the ones controls.cfg has claimed for
|
||||
-- switches; MODE_FULL is how the engine is asked for them, and toolsClose puts it back.
|
||||
if TOOL_OPEN.typed then
|
||||
keyboardSetMode(MODE_FULL)
|
||||
end
|
||||
if TOOL_OPEN.begin then
|
||||
TOOL_OPEN.begin()
|
||||
end
|
||||
|
|
@ -152,6 +218,9 @@ function toolsClose()
|
|||
if TOOL_OPEN and TOOL_OPEN.finish then
|
||||
TOOL_OPEN.finish()
|
||||
end
|
||||
if TOOL_OPEN and TOOL_OPEN.typed then
|
||||
keyboardSetMode(MODE_NORMAL)
|
||||
end
|
||||
TOOL_OPEN = nil
|
||||
TOOL_FLASH = false
|
||||
toolsShowList()
|
||||
|
|
@ -192,6 +261,25 @@ function toolsUpdate()
|
|||
end
|
||||
|
||||
|
||||
-- A tool that wants typed text says so with a typed function. While one is open the keyboard is put
|
||||
-- into full mode so every key arrives as a character, and put back afterwards -- otherwise the
|
||||
-- engine's own switch mappings would swallow half the alphabet.
|
||||
function toolsTyped(keysym)
|
||||
if TOOL_OPEN and TOOL_OPEN.typed and keysym and keysym > 0 then
|
||||
if keysym == 8 then
|
||||
TOOL_OPEN.typed("\b")
|
||||
elseif keysym >= 32 and keysym < 127 then
|
||||
TOOL_OPEN.typed(string.char(keysym))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function toolsWantsKeys()
|
||||
return TOOL_OPEN ~= nil and TOOL_OPEN.typed ~= nil
|
||||
end
|
||||
|
||||
|
||||
-- Whether the tools have the screen, which is what the menu asks before doing anything of its own.
|
||||
function toolsActive()
|
||||
return TOOL_SHOWING
|
||||
|
|
@ -917,3 +1005,361 @@ TOOLS[#TOOLS + 1] = {
|
|||
return false
|
||||
end,
|
||||
}
|
||||
|
||||
|
||||
-- ===== 11. Online account =====
|
||||
|
||||
-- Signing this cabinet in to the master service, and saying where that service is. It belongs
|
||||
-- behind SERVICE because it is set up once for the machine, not once per play: the token it keeps
|
||||
-- is what every game uses to post a score.
|
||||
--
|
||||
-- Typing on a cabinet is miserable, so the field being edited takes whatever the keyboard sends and
|
||||
-- the arrows move between fields; a machine with only a joystick can still change the server
|
||||
-- address, which is the one thing an operator running their own server must be able to do.
|
||||
local ACCOUNT_FIELDS = { "email", "password", "server" }
|
||||
local accountField = 1
|
||||
local accountValues = { email = "", password = "", server = "" }
|
||||
local accountSaying = ""
|
||||
local accountBusy = false
|
||||
|
||||
|
||||
local function accountShow()
|
||||
local rows = {}
|
||||
|
||||
if masterSignedIn() then
|
||||
rows[#rows + 1] = toolsPair("Signed in", MASTER.email or "yes")
|
||||
rows[#rows + 1] = toolsPair("Player name", MASTER.name or "not set")
|
||||
rows[#rows + 1] = toolsPair("Server", MASTER.url)
|
||||
rows[#rows + 1] = toolsPair("Queued scores", masterQueueLength())
|
||||
rows[#rows + 1] = toolsText(accountSaying)
|
||||
rows[#rows + 1] = toolsKeys("Button 3: sign out Button 2: back")
|
||||
else
|
||||
for index, name in ipairs(ACCOUNT_FIELDS) do
|
||||
local shown = accountValues[name]
|
||||
if name == "password" then
|
||||
shown = string.rep("*", #shown)
|
||||
end
|
||||
if name == "server" and shown == "" then
|
||||
shown = MASTER.url
|
||||
end
|
||||
rows[#rows + 1] = toolsItem(string.format("%-9s %s", name, shown), index == accountField)
|
||||
end
|
||||
rows[#rows + 1] = toolsBlank()
|
||||
rows[#rows + 1] = toolsText(accountSaying)
|
||||
rows[#rows + 1] = toolsKeys("Up / Down: field type to edit Backspace: rub out")
|
||||
rows[#rows + 1] = toolsKeys("Button 1: sign in Button 3: create account Coin 1: forgot password")
|
||||
rows[#rows + 1] = toolsKeys("Button 2: back")
|
||||
end
|
||||
toolsShow(rows)
|
||||
end
|
||||
|
||||
|
||||
-- One reply handler for all four calls: they differ only in what they say afterwards.
|
||||
local function accountReply(success)
|
||||
return function(ok, reply)
|
||||
accountBusy = false
|
||||
accountSaying = ok and success or ("Sorry: " .. tostring(reply))
|
||||
accountShow()
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function accountApplyServer()
|
||||
local typed = accountValues.server
|
||||
if typed ~= "" then
|
||||
MASTER.url = (typed:find("://") and typed or ("https://" .. typed)):gsub("/+$", "")
|
||||
masterSave()
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
TOOLS[#TOOLS + 1] = {
|
||||
name = "Online Account",
|
||||
help = "Sign this cabinet in to the master service.",
|
||||
|
||||
begin = function()
|
||||
accountField = 1
|
||||
accountSaying = masterSignedIn() and "" or "Not signed in."
|
||||
accountValues.password = ""
|
||||
accountValues.server = MASTER.url
|
||||
accountShow()
|
||||
end,
|
||||
|
||||
input = function(what)
|
||||
if masterSignedIn() then
|
||||
if what == SWITCH_BUTTON3 then
|
||||
masterSignOut(function() end)
|
||||
accountSaying = "Signed out."
|
||||
accountShow()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
if accountBusy then
|
||||
return what ~= SWITCH_BUTTON2
|
||||
end
|
||||
if what == SWITCH_UP then
|
||||
accountField = (accountField == 1) and #ACCOUNT_FIELDS or (accountField - 1)
|
||||
elseif what == SWITCH_DOWN then
|
||||
accountField = (accountField == #ACCOUNT_FIELDS) and 1 or (accountField + 1)
|
||||
elseif what == SWITCH_BUTTON1 then
|
||||
accountApplyServer()
|
||||
accountBusy = true
|
||||
accountSaying = "Signing in..."
|
||||
masterSignIn(accountValues.email, accountValues.password, accountReply("Signed in."))
|
||||
elseif what == SWITCH_BUTTON3 then
|
||||
accountApplyServer()
|
||||
accountBusy = true
|
||||
accountSaying = "Creating..."
|
||||
masterRegister(accountValues.email, accountValues.password,
|
||||
accountReply("Check your email for the link, then sign in."))
|
||||
elseif what == SWITCH_COIN1 then
|
||||
accountApplyServer()
|
||||
accountBusy = true
|
||||
accountSaying = "Asking..."
|
||||
masterRecover(accountValues.email, accountReply("If that address has an account, a link is on its way."))
|
||||
else
|
||||
return false
|
||||
end
|
||||
accountShow()
|
||||
return true
|
||||
end,
|
||||
|
||||
-- Typed characters are not switches, so they arrive here rather than through input.
|
||||
typed = function(text)
|
||||
if masterSignedIn() or accountBusy then
|
||||
return
|
||||
end
|
||||
local name = ACCOUNT_FIELDS[accountField]
|
||||
if text == "\b" then
|
||||
accountValues[name] = accountValues[name]:sub(1, -2)
|
||||
else
|
||||
accountValues[name] = accountValues[name] .. text
|
||||
end
|
||||
accountShow()
|
||||
end,
|
||||
|
||||
update = function()
|
||||
-- The page shows a queue length and a busy state, both of which change without a keypress.
|
||||
if accountBusy or masterQueueLength() > 0 then
|
||||
accountShow()
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
|
||||
-- ===== 12. Get games =====
|
||||
|
||||
-- The catalogue: everything the service offers, what is installed here, and downloading, updating
|
||||
-- or removing it. This is the one page in the tools a *player* wants rather than an operator, but
|
||||
-- it lives here because it needs the account the tool beside it establishes, and because a cabinet
|
||||
-- that is mid-download should not also be trying to start a game.
|
||||
--
|
||||
-- A download goes to a temporary name and is moved into place only once its checksum matches what
|
||||
-- the catalogue published. A file that arrives wrong is worse than one that fails: it installs,
|
||||
-- and then does not run.
|
||||
local shopGames = {}
|
||||
local shopSelected = 1
|
||||
local shopSaying = "Press Button 3 to fetch the list."
|
||||
local shopBusy = false
|
||||
local shopProgress = nil
|
||||
|
||||
|
||||
local function shopLocalName(game)
|
||||
return game.slug .. ".game"
|
||||
end
|
||||
|
||||
|
||||
local function shopInstalledVersion(game)
|
||||
-- What is on this machine, which is not necessarily what the service was last told.
|
||||
if not lfs.attributes(shopLocalName(game)) then
|
||||
return nil
|
||||
end
|
||||
return game.installed or 0
|
||||
end
|
||||
|
||||
|
||||
local function shopShow()
|
||||
local rows = {}
|
||||
|
||||
if not masterSignedIn() then
|
||||
toolsShow({ toolsText("Sign in first, in the Online Account tool."),
|
||||
toolsKeys("Button 2: back") })
|
||||
return
|
||||
end
|
||||
for index, game in ipairs(shopGames) do
|
||||
local held = shopInstalledVersion(game)
|
||||
local state = "not installed"
|
||||
if game.withdrawn then
|
||||
-- A game the service no longer offers. It keeps working: a player who has it has it,
|
||||
-- and all that stops is being offered an update. It stays on the list so it can still
|
||||
-- be removed from here rather than only by deleting a file by hand.
|
||||
state = "installed, no longer offered"
|
||||
elseif held ~= nil then
|
||||
state = (held >= game.version) and "installed" or ("update to " .. game.version)
|
||||
end
|
||||
rows[#rows + 1] = toolsItem(string.format("%-28s %s", game.title, state), index == shopSelected)
|
||||
end
|
||||
if #shopGames == 0 then
|
||||
rows[#rows + 1] = toolsText("Nothing listed yet.")
|
||||
end
|
||||
rows[#rows + 1] = toolsBlank()
|
||||
rows[#rows + 1] = toolsText(shopProgress or shopSaying)
|
||||
rows[#rows + 1] = toolsKeys("Up / Down: choose Button 1: download or update Button 3: refresh")
|
||||
rows[#rows + 1] = toolsKeys("Button 4: remove Button 2: back")
|
||||
toolsShow(rows)
|
||||
end
|
||||
|
||||
|
||||
-- A .game installed here that the catalogue no longer lists. Without this it would vanish from
|
||||
-- the page the moment it was withdrawn, leaving a player with a game they can play, cannot update,
|
||||
-- and cannot remove from anywhere but a file manager.
|
||||
function shopAddWithdrawn()
|
||||
local listed = {}
|
||||
|
||||
for _, game in ipairs(shopGames) do
|
||||
listed[shopLocalName(game)] = true
|
||||
end
|
||||
for file in lfs.dir(".") do
|
||||
if file:sub(-5):lower() == ".game" and not listed[file] then
|
||||
shopGames[#shopGames + 1] = {
|
||||
id = nil,
|
||||
slug = file:sub(1, -6),
|
||||
title = file:sub(1, -6),
|
||||
withdrawn = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function shopRefresh()
|
||||
shopBusy = true
|
||||
shopSaying = "Asking the service..."
|
||||
shopShow()
|
||||
masterCatalogue(function(ok, result)
|
||||
shopBusy = false
|
||||
if ok then
|
||||
shopGames = result
|
||||
shopAddWithdrawn()
|
||||
shopSelected = math.min(shopSelected, math.max(#shopGames, 1))
|
||||
shopSaying = #shopGames .. " games listed."
|
||||
else
|
||||
shopSaying = "Sorry: " .. tostring(result)
|
||||
end
|
||||
shopShow()
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
local function shopDownload()
|
||||
local game = shopGames[shopSelected]
|
||||
|
||||
if not game then
|
||||
return
|
||||
end
|
||||
if game.withdrawn then
|
||||
shopSaying = game.title .. " is not offered by the service any more. It still works."
|
||||
shopShow()
|
||||
return
|
||||
end
|
||||
local target = shopLocalName(game)
|
||||
local partial = target .. ".part"
|
||||
shopBusy = true
|
||||
shopProgress = "Starting..."
|
||||
shopShow()
|
||||
masterDownload(game.id, game.version, partial, game.sha256,
|
||||
function(got, total)
|
||||
shopProgress = total and string.format("%s: %d%% (%d of %d bytes)", game.title,
|
||||
math.floor(got * 100 / total), got, total)
|
||||
or string.format("%s: %d bytes", game.title, got)
|
||||
end,
|
||||
function(ok, result)
|
||||
shopBusy = false
|
||||
shopProgress = nil
|
||||
if not ok then
|
||||
shopSaying = "Sorry: " .. tostring(result)
|
||||
else
|
||||
-- Only now does the old copy get replaced, so a failed download never destroys a
|
||||
-- working install.
|
||||
os.remove(target)
|
||||
if os.rename(partial, target) then
|
||||
game.installed = game.version
|
||||
shopSaying = game.title .. " is ready. " .. result.bytes .. " bytes."
|
||||
masterTellInstalled(game.id, game.version)
|
||||
else
|
||||
os.remove(partial)
|
||||
shopSaying = "Downloaded, but could not put it in place."
|
||||
end
|
||||
end
|
||||
shopShow()
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
local function shopRemove()
|
||||
local game = shopGames[shopSelected]
|
||||
|
||||
if not game then
|
||||
return
|
||||
end
|
||||
if not lfs.attributes(shopLocalName(game)) then
|
||||
shopSaying = "That one is not installed here."
|
||||
else
|
||||
os.remove(shopLocalName(game))
|
||||
game.installed = nil
|
||||
shopSaying = game.title .. " removed. Its saved data is kept."
|
||||
if game.id then
|
||||
masterTellInstalled(game.id, nil)
|
||||
end
|
||||
end
|
||||
shopShow()
|
||||
end
|
||||
|
||||
|
||||
TOOLS[#TOOLS + 1] = {
|
||||
name = "Get Games",
|
||||
help = "Everything the service offers: download, update, remove.",
|
||||
|
||||
begin = function()
|
||||
shopProgress = nil
|
||||
if masterSignedIn() and #shopGames == 0 then
|
||||
shopRefresh()
|
||||
else
|
||||
shopSaying = masterSignedIn() and "Button 3 refreshes the list." or ""
|
||||
shopShow()
|
||||
end
|
||||
end,
|
||||
|
||||
input = function(what)
|
||||
if not masterSignedIn() or shopBusy then
|
||||
-- A download in flight keeps the page; only backing out is allowed, and that leaves it
|
||||
-- running rather than corrupting a half-written file.
|
||||
return what ~= SWITCH_BUTTON2
|
||||
end
|
||||
if what == SWITCH_UP then
|
||||
shopSelected = (shopSelected <= 1) and math.max(#shopGames, 1) or (shopSelected - 1)
|
||||
elseif what == SWITCH_DOWN then
|
||||
shopSelected = (shopSelected >= #shopGames) and 1 or (shopSelected + 1)
|
||||
elseif what == SWITCH_BUTTON1 then
|
||||
shopDownload()
|
||||
return true
|
||||
elseif what == SWITCH_BUTTON3 then
|
||||
shopRefresh()
|
||||
return true
|
||||
elseif what == SWITCH_BUTTON4 then
|
||||
shopRemove()
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
shopShow()
|
||||
return true
|
||||
end,
|
||||
|
||||
update = function()
|
||||
if shopBusy then
|
||||
shopShow()
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
|
|
|||
232
docs/Manual.adoc
232
docs/Manual.adoc
|
|
@ -127,6 +127,33 @@ To see the overlay renderer on a machine that does have a GPU, put
|
|||
writes the line back out, so it survives. That is how the overlay path is
|
||||
tested, and how a cabinet builder who prefers the old look gets it.
|
||||
|
||||
[#onlineservice]
|
||||
==== Online: accounts, games and scores
|
||||
|
||||
The bundled menu can sign the cabinet in to a master service (by default
|
||||
`master.singeengine.com`), which does three things: it holds the account, it
|
||||
offers a catalogue of games to download and update, and it keeps high score
|
||||
boards.
|
||||
|
||||
Two service tools cover it. **Online Account** signs in, creates an account,
|
||||
recovers a password, and sets the server address -- it is where an operator
|
||||
running their own service points the cabinet. A new account is not usable until
|
||||
the emailed link is opened. **Get Games** lists everything the
|
||||
service offers, marks what is installed here, and downloads, updates or removes
|
||||
it. A list longer than the screen scrolls with the selection rather than running
|
||||
off the bottom, with `...` showing there is more either way. A game the service has withdrawn keeps working and is shown as "installed,
|
||||
no longer offered": all that stops is being given an update, because a player
|
||||
who has a game has it. A download is checked against the digest the catalogue published before it
|
||||
replaces anything, so a transfer that arrives wrong fails rather than installing
|
||||
a game that will not run.
|
||||
|
||||
The account belongs to the *machine*, not to a game: the token is kept in
|
||||
`master.dat` in the data root, so a game posting a score uses the same sign-in
|
||||
the menu made. See <<onlinescores,Online high scores>> for the game's side, and
|
||||
`Singe/Net.singe` for the asynchronous HTTP client underneath -- nothing here
|
||||
blocks a frame, because Lua in Singe is cooperative and a large download would
|
||||
otherwise look like a hang.
|
||||
|
||||
[#servicetools]
|
||||
==== The Service Tools
|
||||
|
||||
|
|
@ -190,6 +217,15 @@ being told, with the size of each. Button 3 -- not button 1, which is "choose"
|
|||
everywhere else -- throws away the selected game's save. Clearing a high score
|
||||
table without deleting the game is the usual reason.
|
||||
|
||||
Online Account::
|
||||
Signing this cabinet in to the master service, creating an account, recovering a
|
||||
password, and setting the server address. See
|
||||
<<onlineservice,Online: accounts, games and scores>>.
|
||||
|
||||
Get Games::
|
||||
The catalogue: what the service offers, what is installed here, and downloading,
|
||||
updating or removing it.
|
||||
|
||||
MIDI Ports::
|
||||
The input and output ports this machine has, which of them Singe has opened,
|
||||
and the last few messages that arrived. Button 1 sends a note, button 3 looks
|
||||
|
|
@ -1366,6 +1402,7 @@ GAMES = {
|
|||
RESOLUTION_X = 720,
|
||||
RESOLUTION_Y = 480,
|
||||
SINDEN_GUN = "",
|
||||
GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10",
|
||||
CABINET = "ActionMax/cabinet_38AmbushAlley.png",
|
||||
MARQUEE = "ActionMax/marquee_ActionMax.png",
|
||||
ATTRACT = "ActionMax/video_38AmbushAlley.mkv",
|
||||
|
|
@ -1386,7 +1423,7 @@ GAMES = {
|
|||
|
||||
The keys `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`,
|
||||
`NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`,
|
||||
`AUDIO_DELAY`, `AUDIO_SUFFIX`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own
|
||||
`AUDIO_DELAY`, `AUDIO_SUFFIX`, `GAME_ID`, and `LEGACY_SPRITE_ARGS` are read by the engine when the menu (or your own
|
||||
script, through `scriptExecute` / `scriptPush`) launches the entry; they
|
||||
override the command line. The `VIDEO` line is the disc: an entry that names
|
||||
one is a laserdisc game, and an entry that leaves it out or blank runs
|
||||
|
|
@ -13967,6 +14004,42 @@ function pauseMenuClose()
|
|||
end
|
||||
----
|
||||
|
||||
[#utilsha256]
|
||||
==== utilSha256
|
||||
|
||||
[source,text]
|
||||
----
|
||||
digest = utilSha256(data)
|
||||
----
|
||||
|
||||
SHA-256 of a string, as 64 lowercase hex characters. Binary-safe, so it works on the bytes of a file
|
||||
as readily as on text.
|
||||
|
||||
For deciding whether the bytes you have are the bytes you were promised: a downloaded game against
|
||||
the digest its catalogue entry carries, or a server's public key against a pin. The bundled `md5`
|
||||
module is still there for a checksum, but a digest used to decide whether to *trust* something has
|
||||
to be one that is still worth trusting.
|
||||
|
||||
*Parameters:*
|
||||
|
||||
* `data` -- a string of bytes. Empty aborts the script.
|
||||
|
||||
*Returns:* string, 64 lowercase hex characters.
|
||||
|
||||
*Since:* 3.00.
|
||||
|
||||
.Example
|
||||
[source,lua]
|
||||
----
|
||||
-- Refuse a download that did not arrive intact.
|
||||
local file = io.open("game.part", "rb")
|
||||
local bytes = file:read("*a")
|
||||
file:close()
|
||||
if utilSha256(bytes) ~= expected then
|
||||
os.remove("game.part")
|
||||
end
|
||||
----
|
||||
|
||||
[#singegetaudiocalibration]
|
||||
==== singeGetAudioCalibration
|
||||
|
||||
|
|
@ -14082,6 +14155,47 @@ function saveConfig()
|
|||
end
|
||||
----
|
||||
|
||||
[#singegetgameid]
|
||||
==== singeGetGameId
|
||||
|
||||
[source,text]
|
||||
----
|
||||
id = singeGetGameId()
|
||||
----
|
||||
|
||||
The `GAME_ID` from this game's `games.dat` entry, or `nil` when it has none. It is the id the
|
||||
online service knows the game by, and the key a leaderboard is kept under.
|
||||
|
||||
**The format is a UUID**, in the canonical 36-character form -- eight hex digits, three groups of
|
||||
four, then twelve, separated by hyphens:
|
||||
|
||||
----
|
||||
GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10"
|
||||
----
|
||||
|
||||
Nothing else is accepted. The id is a path segment in the service's URLs, so one containing a slash
|
||||
or a question mark would publish and then never be reachable, and two ids differing only in case
|
||||
would be two games to the database and one game to anybody reading them. Comparison is
|
||||
case-insensitive and storage is lowercase, so an id typed in capitals is the same game.
|
||||
|
||||
Any UUID will do -- generate one however you like, or let `publish.lua` print one. A game without
|
||||
one can be played but not ranked: there would be nothing stable to key a board on, and an id
|
||||
invented at run time would make a different board every time the files moved.
|
||||
|
||||
*Returns:* string, or `nil`.
|
||||
|
||||
*Since:* 3.00.
|
||||
*See also:* <<onlinescores,Online high scores>>, <<gamesdat,games.dat>>
|
||||
|
||||
.Example
|
||||
[source,lua]
|
||||
----
|
||||
-- Only offer the leaderboard when this game has an identity to rank under.
|
||||
if singeGetGameId() then
|
||||
showLeaderboardButton()
|
||||
end
|
||||
----
|
||||
|
||||
[#singegetheight]
|
||||
==== singeGetHeight
|
||||
|
||||
|
|
@ -16333,6 +16447,122 @@ The height applies to the cue on screen straight away and to every cue after it.
|
|||
srtPosition(70)
|
||||
----
|
||||
|
||||
[#onlinescores]
|
||||
=== Online high scores
|
||||
|
||||
A game posts a score in one line:
|
||||
|
||||
[source,lua]
|
||||
----
|
||||
scoreSubmit(playerScore)
|
||||
----
|
||||
|
||||
Everything else was set up once, in the menu: which service, which account, and
|
||||
what name the player appears under (see
|
||||
<<onlineservice,Online: accounts, games and scores>>). A game needs only a
|
||||
`GAME_ID` in its `games.dat` entry -- a UUID, which is what the board is kept under. See
|
||||
<<singegetgameid,singeGetGameId>> for the format.
|
||||
|
||||
|===
|
||||
| Call | What it does
|
||||
|
||||
| `scoreBegin()`
|
||||
| Say a play is starting. Optional, and worth calling: see below.
|
||||
|
||||
| `scoreSubmit(value [, board [, meta]])`
|
||||
| Queue a score. Returns `false` if the cabinet is not signed in or the game has no `GAME_ID`.
|
||||
|
||||
| `scoreBoard(onDone [, board])`
|
||||
| Fetch the board. `onDone(ok, result)` with `result.top` (a list of `{name, value}`) and
|
||||
`result.standing` (this player's `value`, `rank` and the number of `players`).
|
||||
|
||||
| `scorePlayerName()`
|
||||
| The name this cabinet posts under, or `nil` if none has been chosen.
|
||||
|
||||
| `scoreWaiting()`
|
||||
| How many scores are queued and not yet sent.
|
||||
|
||||
| `scoreUpdate()`
|
||||
| Once a frame, from `onOverlayUpdate`. Nothing progresses without it.
|
||||
|===
|
||||
|
||||
**A score is queued, not sent.** It is written to disk and handed to the service
|
||||
when there is a connection, because a cabinet is often offline and a game must
|
||||
never stall on the wire to show its own board. A queued score survives the
|
||||
machine being switched off.
|
||||
|
||||
**A board can have more than one.** Pass a board name to keep a time attack
|
||||
separate from a points total; the default is `default`.
|
||||
|
||||
**The name on a board is a handle, never an address.** It is chosen in the
|
||||
Online Account tool, kept apart from the account, and is the only thing about a
|
||||
player that other players see. Three to twenty characters of letters, digits,
|
||||
hyphens and underscores, starting and ending with a letter or digit -- no
|
||||
spaces, no dots, and no `@`, so a handle cannot even look like an email address.
|
||||
Case is ignored for uniqueness, so `Ann` and `ANN` are one name.
|
||||
|
||||
**A handle is chosen once.** Renaming freely would mean a leaderboard's history
|
||||
is not really anyone's. An operator can change one; a player cannot. An email
|
||||
address cannot be changed at all.
|
||||
|
||||
Handles are checked against a list the service's operator maintains: identities
|
||||
nobody should claim (`admin`, `staff`) and words that have no place on a screen
|
||||
in a public room. A score is refused until a name has been chosen, because a
|
||||
score with nobody's name on it is not on a board in any useful sense.
|
||||
|
||||
**What a score means.** The cabinet is the player's machine, so a submitted
|
||||
score is a *claim* -- nothing the client can do makes it more than that, and
|
||||
signing it with a secret shipped in the client would only look like security.
|
||||
The service records who claimed what, rate limits submissions, and flags scores
|
||||
that are wildly out of line for a person to look at. It does not pretend to
|
||||
verify them.
|
||||
|
||||
**`scoreBegin()` is worth calling** for the one thing that is not a claim. Call
|
||||
it when a play starts and the service notes the moment on its own clock; the
|
||||
score that follows carries how long the play took, measured server-side. That
|
||||
number has not been through the player's machine, which is what makes it worth
|
||||
having -- a large score arriving seconds after the game began is visible without
|
||||
anyone being accused of anything.
|
||||
|
||||
Nothing is rejected on it. For a game with a disc the video length is a floor
|
||||
nobody can beat, but a disc is optional, and a threshold learned from
|
||||
submissions is set by whoever submits first. So the time is recorded, shown to
|
||||
the operator beside the score, and the judgement is left to a person. A cabinet
|
||||
that is offline when a play starts simply records no time, which is honest
|
||||
rather than broken.
|
||||
|
||||
.Example
|
||||
[source,lua]
|
||||
----
|
||||
function startLevel()
|
||||
scoreBegin() -- the service starts its clock
|
||||
...
|
||||
end
|
||||
----
|
||||
|
||||
.Example
|
||||
[source,lua]
|
||||
----
|
||||
-- At the end of a game, post the score and then show where it landed.
|
||||
function gameOver(points)
|
||||
scoreSubmit(points)
|
||||
scoreBoard(function(ok, board)
|
||||
if not ok then
|
||||
showMessage("Not signed in; score kept for later.")
|
||||
return
|
||||
end
|
||||
for place, row in ipairs(board.top) do
|
||||
showLine(place .. ". " .. row.name .. " " .. row.value)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function onOverlayUpdate()
|
||||
scoreUpdate()
|
||||
return OVERLAY_UPDATED
|
||||
end
|
||||
----
|
||||
|
||||
[#stats]
|
||||
=== Stats
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@
|
|||
#include "generated/settings_cfg.h"
|
||||
#include "generated/Menu_singe.h"
|
||||
#include "generated/Tools_singe.h"
|
||||
#include "generated/Net_singe.h"
|
||||
#include "generated/Master_singe.h"
|
||||
#include "generated/MenuDocument_singe.h"
|
||||
#include "generated/MenuOverlay_singe.h"
|
||||
#include "generated/Menu_rml.h"
|
||||
|
|
|
|||
|
|
@ -1791,6 +1791,8 @@ static void _unpackData(const char *exePath, bool absolute) {
|
|||
{ "settings.cfg.example", settings_cfg, settings_cfg_len },
|
||||
{ "Menu.singe", Menu_singe, Menu_singe_len },
|
||||
{ "Tools.singe", Tools_singe, Tools_singe_len },
|
||||
{ "Net.singe", Net_singe, Net_singe_len },
|
||||
{ "Master.singe", Master_singe, Master_singe_len },
|
||||
{ "MenuDocument.singe", MenuDocument_singe, MenuDocument_singe_len },
|
||||
{ "MenuOverlay.singe", MenuOverlay_singe, MenuOverlay_singe_len },
|
||||
{ "Menu.rml", Menu_rml, Menu_rml_len },
|
||||
|
|
@ -1874,6 +1876,7 @@ ConfigT *cloneConf(const ConfigT *conf) {
|
|||
c->audioSuffix = _cloneString(conf->audioSuffix);
|
||||
c->gamepadOrder = _cloneString(conf->gamepadOrder);
|
||||
c->videoFile = _cloneString(conf->videoFile);
|
||||
c->gameId = _cloneString(conf->gameId);
|
||||
c->dataDirBase = _cloneString(conf->dataDirBase);
|
||||
c->dataDir = _cloneString(conf->dataDir);
|
||||
|
||||
|
|
@ -1944,6 +1947,7 @@ void destroyConf(ConfigT **confPointer) {
|
|||
if (conf == NULL) {
|
||||
return;
|
||||
}
|
||||
free(conf->gameId);
|
||||
free(conf->dataDir);
|
||||
free(conf->dataDirBase);
|
||||
free(conf->videoFile);
|
||||
|
|
|
|||
53
src/singe.c
53
src/singe.c
|
|
@ -25,6 +25,9 @@
|
|||
#include <math.h>
|
||||
#include <errno.h>
|
||||
|
||||
// SHA-256 for utilSha256, from the OpenSSL already linked in for LuaSec.
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <SDL3_mixer/SDL_mixer.h>
|
||||
|
|
@ -1270,6 +1273,7 @@ static int32_t apiSingeGetAudioLatency(lua_State *L);
|
|||
static int32_t apiSingeGetDataPath(lua_State *L);
|
||||
static int32_t apiSingeGetSystemInfo(lua_State *L);
|
||||
static int32_t apiSingeSaveGeometry(lua_State *L);
|
||||
static int32_t apiSingeGetGameId(lua_State *L);
|
||||
static int32_t apiSingeGetHeight(lua_State *L);
|
||||
static int32_t apiSingeGetPauseFlag(lua_State *L);
|
||||
static int32_t apiSingeGetScriptPath(lua_State *L);
|
||||
|
|
@ -1286,6 +1290,7 @@ static int32_t apiSingeSetPauseFlag(lua_State *L);
|
|||
static int32_t apiSingeSetPauseKeyEnabled(lua_State *L);
|
||||
static int32_t apiSingeSetQuitKeyEnabled(lua_State *L);
|
||||
static int32_t apiSingeVersion(lua_State *L);
|
||||
static int32_t apiUtilSha256(lua_State *L);
|
||||
static int32_t apiSingeWantsCrosshairs(lua_State *L);
|
||||
static int32_t apiSoftDelete(lua_State *L);
|
||||
static int32_t apiSoftNew(lua_State *L);
|
||||
|
|
@ -2107,6 +2112,14 @@ static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base) {
|
|||
free(c->scriptFile);
|
||||
c->scriptFile = strdup(valueString);
|
||||
utilFixPathSeparators(&c->scriptFile, false);
|
||||
} else if (strcmp(confKey, "GAME_ID") == 0) {
|
||||
// The master service's id for this game. Nothing in the engine uses it; it is carried
|
||||
// so a script can ask singeGetGameId() which board its scores belong on.
|
||||
if (valueString == NULL) {
|
||||
utilDie("GAME_ID must be a string.");
|
||||
}
|
||||
free(c->gameId);
|
||||
c->gameId = strdup(valueString);
|
||||
} else if (strcmp(confKey, "CONTAINER") == 0) {
|
||||
if (valueString == NULL) {
|
||||
utilDie("CONTAINER must be a string.");
|
||||
|
|
@ -5473,6 +5486,7 @@ static void _registerApi(lua_State *L) {
|
|||
lua_register(L, "singeGetAudioDelay", apiSingeGetAudioDelay); // 3.00
|
||||
lua_register(L, "singeGetAudioLatency", apiSingeGetAudioLatency); // 3.00
|
||||
lua_register(L, "singeGetDataPath", apiSingeGetDataPath); // 2.00
|
||||
lua_register(L, "singeGetGameId", apiSingeGetGameId); // 3.00
|
||||
lua_register(L, "singeGetHeight", apiSingeGetHeight); // 1.xx
|
||||
lua_register(L, "singeGetSystemInfo", apiSingeGetSystemInfo); // 3.00
|
||||
lua_register(L, "singeGetPauseFlag", apiSingeGetPauseFlag); // 1.xx RDG
|
||||
|
|
@ -5492,6 +5506,7 @@ static void _registerApi(lua_State *L) {
|
|||
lua_register(L, "singeSetQuitKeyEnabled", apiSingeSetQuitKeyEnabled); // Hypseus keyboardCatchQuit is a framework alias with the opposite sense.
|
||||
lua_register(L, "singeVersion", apiSingeVersion); // 1.xx RDG
|
||||
lua_register(L, "singeWantsCrosshairs", apiSingeWantsCrosshairs); // 2.00
|
||||
lua_register(L, "utilSha256", apiUtilSha256); // 3.00
|
||||
|
||||
lua_register(L, "softDelete", apiSoftDelete); // 3.00
|
||||
lua_register(L, "softNew", apiSoftNew); // 3.00
|
||||
|
|
@ -12673,6 +12688,21 @@ static int32_t apiSingeGetDataPath(lua_State *L) {
|
|||
|
||||
|
||||
// height = singeGetHeight() Window height in pixels.
|
||||
// The GAME_ID from this game's games.dat entry, or nil when it has none. A game without one can be
|
||||
// played but not ranked: there would be nothing stable to key a leaderboard on, and a made-up id
|
||||
// would make a different board every time the files moved.
|
||||
static int32_t apiSingeGetGameId(lua_State *L) {
|
||||
_argCheck(L, "singeGetGameId", 0, 0);
|
||||
if (_global.conf->gameId != NULL) {
|
||||
lua_pushstring(L, _global.conf->gameId);
|
||||
} else {
|
||||
lua_pushnil(L);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
static int32_t apiSingeGetHeight(lua_State *L) {
|
||||
int32_t y = 0;
|
||||
|
||||
|
|
@ -12843,6 +12873,29 @@ static int32_t apiSingeSetQuitKeyEnabled(lua_State *L) {
|
|||
|
||||
|
||||
// version = singeVersion()
|
||||
// SHA-256 of a string, as lowercase hex. Needed wherever a script has to know that the bytes it
|
||||
// has are the bytes it was promised: a downloaded game against the digest its catalogue entry
|
||||
// carries, or a server's public key against a pin. md5 is bundled as a module, but a digest used
|
||||
// to decide whether to trust something has to be one that is still worth trusting.
|
||||
static int32_t apiUtilSha256(lua_State *L) {
|
||||
const char *data = NULL;
|
||||
size_t length = 0;
|
||||
unsigned char digest[SHA256_DIGEST_LENGTH];
|
||||
char hex[SHA256_DIGEST_LENGTH * 2 + 1];
|
||||
int32_t i = 0;
|
||||
|
||||
_argCheck(L, "utilSha256", 1, 1);
|
||||
data = _argData(L, "utilSha256", 1, &length);
|
||||
SHA256((const unsigned char *)data, length, digest);
|
||||
for (i = 0; i < SHA256_DIGEST_LENGTH; i++) {
|
||||
snprintf(&hex[i * 2], 3, "%02x", digest[i]);
|
||||
}
|
||||
lua_pushlstring(L, hex, sizeof(hex) - 1);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
static int32_t apiSingeVersion(lua_State *L) {
|
||||
_luaTrace(L, "singeVersion", "%s", VERSION_STRING);
|
||||
lua_pushnumber(L, SINGE_VERSION);
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ typedef struct ConfigS {
|
|||
int32_t shiftX; // --shiftx: per cent of half the logical width the picture moves right
|
||||
int32_t shiftY; // --shifty: per cent of half the logical height the picture moves down
|
||||
int32_t rotate; // --rotate: clockwise presentation rotation, 0, 90, 180 or 270 degrees
|
||||
char *gameId; // GAME_ID from games.dat: the master service's stable id for this game
|
||||
int32_t xResolution;
|
||||
int32_t yResolution;
|
||||
int32_t canvasWidth; // World size when there is no disc
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue