Merge pull request #1 from Coffey-Labs/board-moves-out
Move the board out to the service every game shares
This commit is contained in:
@@ -61,12 +61,21 @@ finished are picked out in yellow.
|
|||||||
There is no way to clear it. A stand leaves the list only by being pushed off
|
There is no way to clear it. A stand leaves the list only by being pushed off
|
||||||
the bottom by a better one, so a good summer stands until somebody beats it.
|
the bottom by a better one, so a good summer stands until somebody beats it.
|
||||||
|
|
||||||
The board lives in `server/`, not in the browser, so it is the one part of the
|
The board is no longer in this repository and is no longer ours alone: every
|
||||||
game that needs a line out. With the service unreachable the game plays exactly
|
game on the site shares one service,
|
||||||
as normal and the board says so plainly rather than breaking; posting happens
|
[games-scores](https://github.com/Coffey-Labs/games-scores), which is one
|
||||||
behind the closing standings, so a slow network never holds up the end of a
|
container and one volume however many games there are. It started here, because
|
||||||
season. See [server/README.md](server/README.md) for the API, the validation
|
when it was written there was one game; a second game would have meant a second
|
||||||
and what a board with no accounts can and cannot promise.
|
container and a second database to back up for every game after that. All this
|
||||||
|
repository holds now is the client in `src/game/highscores.ts`, which names the
|
||||||
|
game on every call and is otherwise what it always was — rows come back in this
|
||||||
|
game's own field names.
|
||||||
|
|
||||||
|
It is still the one part of the game that needs a line out. With the service
|
||||||
|
unreachable the game plays exactly as normal and the board says so plainly
|
||||||
|
rather than breaking; posting happens behind the closing standings, so a slow
|
||||||
|
network never holds up the end of a season. That repository's README has the
|
||||||
|
API, the validation and what a board with no accounts can and cannot promise.
|
||||||
|
|
||||||
## How it is put together
|
## How it is put together
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -7,9 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"scores": "node --watch server/src/index.ts",
|
|
||||||
"dev:all": "node scripts/dev-all.mjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
// Runs the game and the scores service together, so `npm run dev:all` is all
|
|
||||||
// you need for a working leaderboard on localhost.
|
|
||||||
import { spawn } from 'node:child_process'
|
|
||||||
|
|
||||||
const children = [
|
|
||||||
spawn('npm', ['run', 'scores'], { stdio: 'inherit', shell: false }),
|
|
||||||
spawn('npm', ['run', 'dev'], { stdio: 'inherit', shell: false }),
|
|
||||||
]
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
for (const c of children) c.kill('SIGTERM')
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGINT', stop)
|
|
||||||
process.on('SIGTERM', stop)
|
|
||||||
for (const c of children) c.on('exit', stop)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# node:sqlite is built into the runtime and the server runs straight from
|
|
||||||
# TypeScript, so there is no build stage, no compiler and no native module.
|
|
||||||
FROM node:26-alpine
|
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
PORT=5184 \
|
|
||||||
DB_PATH=/data/scores.db \
|
|
||||||
TRUST_PROXY=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY src ./src
|
|
||||||
|
|
||||||
# The database is the only writable path; everything else can stay read-only.
|
|
||||||
RUN mkdir -p /data && chown -R node:node /data
|
|
||||||
USER node
|
|
||||||
VOLUME ["/data"]
|
|
||||||
EXPOSE 5184
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD node -e "fetch('http://127.0.0.1:5184/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
||||||
|
|
||||||
CMD ["node", "src/index.ts"]
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Lemonade scores service
|
|
||||||
|
|
||||||
The leaderboard everybody shares. A single Node process with SQLite, no build
|
|
||||||
step and no native modules: `node:sqlite` ships with the runtime and Node runs
|
|
||||||
the TypeScript directly.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
| ------ | -------------- | ------------------------------------------- |
|
|
||||||
| GET | `/api/health` | liveness, used by the container healthcheck |
|
|
||||||
| GET | `/api/scores` | the top 50, best first |
|
|
||||||
| POST | `/api/scores` | submit 1–4 players from one finished season |
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// POST /api/scores
|
|
||||||
{ "entries": [ { "name": "ADA", "assets": 4635, "days": 12,
|
|
||||||
"glasses": 800, "seed": 1234, "broke": false } ] }
|
|
||||||
// 201 -> { "ids": ["17"], "scores": [ ...the new board... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## What it does and does not guarantee
|
|
||||||
|
|
||||||
Everything in a request is treated as hostile. Names are forced to a printable
|
|
||||||
uppercase subset and cut to 12 characters. Every number must be an integer in
|
|
||||||
range, and a score is refused if it could not have happened: assets above
|
|
||||||
`$2.00 + $25 a day`, or glasses above 400 a day, are rejected as impossible for
|
|
||||||
the days claimed. Bodies are capped at 4 KB and submissions at 120 an hour per
|
|
||||||
address, which is why `TRUST_PROXY=1` matters behind nginx — otherwise every
|
|
||||||
request looks like it came from the proxy and one busy classroom would lock
|
|
||||||
everyone else out.
|
|
||||||
|
|
||||||
**It cannot prove a score is real.** There are no accounts and no signing, so
|
|
||||||
anyone willing to craft a request can post a plausible score under any name.
|
|
||||||
The checks stop nonsense and casual spam, not a determined forger. That is a
|
|
||||||
deliberate trade for a game with no sign-up; if the board ever needs to be
|
|
||||||
trustworthy it needs identities, which is a different piece of work.
|
|
||||||
|
|
||||||
Only the best 500 rows are kept; the rest are pruned on write.
|
|
||||||
|
|
||||||
## Running it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run scores # from the repo root, on :5184
|
|
||||||
npm run dev:all # game and scores service together
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment: `PORT` (5184), `DB_PATH` (`./data/scores.db`), `TRUST_PROXY`
|
|
||||||
(`1` behind a proxy).
|
|
||||||
|
|
||||||
## Deploying
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f server/compose.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
The container is read-only apart from the named volume holding the database —
|
|
||||||
that volume is the one piece of state that must survive a redeploy. In front
|
|
||||||
of it, nginx terminates TLS and proxies `/api/` through:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:5184;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The game itself is a static bundle and needs no server; point it at another
|
|
||||||
origin with `VITE_SCORES_API=https://example.org/api` at build time, or serve
|
|
||||||
both from one origin and leave it on the default `/api`.
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
|
||||||
import { mkdirSync } from 'node:fs'
|
|
||||||
import { dirname } from 'node:path'
|
|
||||||
import { MAX_ENTRIES_PER_POST, validateBatch } from './validate.ts'
|
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 5184)
|
|
||||||
const DB_PATH = process.env.DB_PATH ?? './data/scores.db'
|
|
||||||
/** Behind nginx the socket address is the proxy, so take the forwarded hop. */
|
|
||||||
const TRUST_PROXY = process.env.TRUST_PROXY === '1'
|
|
||||||
const BOARD_LIMIT = 50
|
|
||||||
/** Rows kept on disk. The board only ever shows the top of this. */
|
|
||||||
const KEEP_ROWS = 500
|
|
||||||
const MAX_BODY_BYTES = 4096
|
|
||||||
|
|
||||||
mkdirSync(dirname(DB_PATH), { recursive: true })
|
|
||||||
const db = new DatabaseSync(DB_PATH)
|
|
||||||
db.exec(`
|
|
||||||
PRAGMA journal_mode = WAL;
|
|
||||||
CREATE TABLE IF NOT EXISTS scores (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
assets INTEGER NOT NULL,
|
|
||||||
days INTEGER NOT NULL,
|
|
||||||
glasses INTEGER NOT NULL,
|
|
||||||
seed INTEGER NOT NULL,
|
|
||||||
broke INTEGER NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS scores_rank
|
|
||||||
ON scores (assets DESC, days ASC, created_at ASC);
|
|
||||||
`)
|
|
||||||
|
|
||||||
const selectTop = db.prepare(
|
|
||||||
`SELECT id, name, assets, days, glasses, broke, created_at
|
|
||||||
FROM scores ORDER BY assets DESC, days ASC, created_at ASC LIMIT ?`,
|
|
||||||
)
|
|
||||||
const insertScore = db.prepare(
|
|
||||||
`INSERT INTO scores (name, assets, days, glasses, seed, broke, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
const prune = db.prepare(
|
|
||||||
`DELETE FROM scores WHERE id NOT IN (
|
|
||||||
SELECT id FROM scores ORDER BY assets DESC, days ASC, created_at ASC LIMIT ?
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- throttle
|
|
||||||
|
|
||||||
interface Bucket {
|
|
||||||
count: number
|
|
||||||
resetAt: number
|
|
||||||
}
|
|
||||||
const posts = new Map<string, Bucket>()
|
|
||||||
const POST_WINDOW_MS = 60 * 60 * 1000
|
|
||||||
/*
|
|
||||||
* Per address, per hour. Generous on purpose: a classroom, an office or a
|
|
||||||
* household all arrive from one address, and thirty was low enough that a
|
|
||||||
* class finishing a season together would have started losing scores to a
|
|
||||||
* 429. What actually keeps rubbish off the board is the plausibility check
|
|
||||||
* in validate.ts, not this - this only stops the database being hammered.
|
|
||||||
*/
|
|
||||||
const POST_LIMIT = 120
|
|
||||||
|
|
||||||
function overPostLimit(ip: string): boolean {
|
|
||||||
const now = Date.now()
|
|
||||||
const b = posts.get(ip)
|
|
||||||
if (!b || now > b.resetAt) {
|
|
||||||
posts.set(ip, { count: 1, resetAt: now + POST_WINDOW_MS })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
b.count += 1
|
|
||||||
return b.count > POST_LIMIT
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buckets are tiny, but a long-lived process should still not hoard them.
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now()
|
|
||||||
for (const [ip, b] of posts) if (now > b.resetAt) posts.delete(ip)
|
|
||||||
}, POST_WINDOW_MS).unref()
|
|
||||||
|
|
||||||
function clientIp(req: IncomingMessage): string {
|
|
||||||
if (TRUST_PROXY) {
|
|
||||||
const fwd = req.headers['x-forwarded-for']
|
|
||||||
const first = (Array.isArray(fwd) ? fwd[0] : fwd)?.split(',')[0]?.trim()
|
|
||||||
if (first) return first
|
|
||||||
}
|
|
||||||
return req.socket.remoteAddress ?? 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ replies
|
|
||||||
|
|
||||||
function send(res: ServerResponse, status: number, body: unknown) {
|
|
||||||
const payload = JSON.stringify(body)
|
|
||||||
res.writeHead(status, {
|
|
||||||
'content-type': 'application/json; charset=utf-8',
|
|
||||||
'cache-control': 'no-store',
|
|
||||||
// A public board with no cookies and no credentials.
|
|
||||||
'access-control-allow-origin': '*',
|
|
||||||
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
||||||
'access-control-allow-headers': 'content-type',
|
|
||||||
'access-control-max-age': '86400',
|
|
||||||
})
|
|
||||||
res.end(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBody(req: IncomingMessage): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let size = 0
|
|
||||||
const chunks: Buffer[] = []
|
|
||||||
req.on('data', (c: Buffer) => {
|
|
||||||
size += c.length
|
|
||||||
if (size > MAX_BODY_BYTES) {
|
|
||||||
reject(new Error('body too large'))
|
|
||||||
req.destroy()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chunks.push(c)
|
|
||||||
})
|
|
||||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
||||||
req.on('error', reject)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------- routes
|
|
||||||
|
|
||||||
const board = () =>
|
|
||||||
(selectTop.all(BOARD_LIMIT) as Record<string, unknown>[]).map((r) => ({
|
|
||||||
id: String(r.id),
|
|
||||||
name: r.name as string,
|
|
||||||
assets: r.assets as number,
|
|
||||||
days: r.days as number,
|
|
||||||
glasses: r.glasses as number,
|
|
||||||
broke: r.broke === 1,
|
|
||||||
at: r.created_at as number,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
|
||||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
|
|
||||||
const path = url.pathname.replace(/\/+$/, '') || '/'
|
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') return send(res, 204, {})
|
|
||||||
if (path === '/api/health') return send(res, 200, { ok: true })
|
|
||||||
|
|
||||||
if (path === '/api/scores' && req.method === 'GET') {
|
|
||||||
return send(res, 200, { scores: board() })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/scores' && req.method === 'POST') {
|
|
||||||
const ip = clientIp(req)
|
|
||||||
if (overPostLimit(ip)) return send(res, 429, { error: 'too many submissions' })
|
|
||||||
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(await readBody(req))
|
|
||||||
} catch {
|
|
||||||
return send(res, 400, { error: 'invalid JSON' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const check = validateBatch(parsed)
|
|
||||||
if (!check.ok) return send(res, 400, { error: check.why })
|
|
||||||
|
|
||||||
const now = Date.now()
|
|
||||||
const ids: string[] = []
|
|
||||||
for (const e of check.entries) {
|
|
||||||
const info = insertScore.run(e.name, e.assets, e.days, e.glasses, e.seed, e.broke ? 1 : 0, now)
|
|
||||||
ids.push(String(info.lastInsertRowid))
|
|
||||||
}
|
|
||||||
prune.run(KEEP_ROWS)
|
|
||||||
|
|
||||||
return send(res, 201, { ids, scores: board() })
|
|
||||||
}
|
|
||||||
|
|
||||||
send(res, 404, { error: 'not found' })
|
|
||||||
})
|
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`[scores] listening on :${PORT}, db ${DB_PATH}, max ${MAX_ENTRIES_PER_POST}/post`)
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
|
|
||||||
process.on(sig, () => {
|
|
||||||
server.close(() => {
|
|
||||||
db.close()
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* Everything here treats the request body as hostile. A public leaderboard
|
|
||||||
* with no accounts cannot prove a score is real - what it can do is refuse
|
|
||||||
* anything impossible, keep names printable, and stop one client filling the
|
|
||||||
* table on its own.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Entry {
|
|
||||||
name: string
|
|
||||||
assets: number
|
|
||||||
days: number
|
|
||||||
glasses: number
|
|
||||||
seed: number
|
|
||||||
broke: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MAX_ENTRIES_PER_POST = 4
|
|
||||||
export const NAME_MAX = 12
|
|
||||||
|
|
||||||
/** The simulation's best possible day is about $12.50; $25 leaves headroom. */
|
|
||||||
const MAX_CENTS_PER_DAY = 2500
|
|
||||||
const STARTING_ASSETS = 200
|
|
||||||
const MAX_GLASSES_PER_DAY = 400
|
|
||||||
const MAX_DAYS = 365
|
|
||||||
|
|
||||||
const isInt = (v: unknown, lo: number, hi: number): v is number =>
|
|
||||||
typeof v === 'number' && Number.isInteger(v) && v >= lo && v <= hi
|
|
||||||
|
|
||||||
/** Printable, uppercase, and short enough to fit the board. */
|
|
||||||
export function cleanName(raw: unknown): string {
|
|
||||||
if (typeof raw !== 'string') return 'ANON'
|
|
||||||
const cleaned = raw
|
|
||||||
.toUpperCase()
|
|
||||||
.replace(/[^A-Z0-9 .'-]/g, '')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.slice(0, NAME_MAX)
|
|
||||||
return cleaned || 'ANON'
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Validated = { ok: true; entry: Entry } | { ok: false; why: string }
|
|
||||||
|
|
||||||
export function validateEntry(raw: unknown): Validated {
|
|
||||||
if (typeof raw !== 'object' || raw === null) return { ok: false, why: 'not an object' }
|
|
||||||
const e = raw as Record<string, unknown>
|
|
||||||
|
|
||||||
if (!isInt(e.days, 1, MAX_DAYS)) return { ok: false, why: 'days out of range' }
|
|
||||||
if (!isInt(e.assets, 0, 10_000_00)) return { ok: false, why: 'assets out of range' }
|
|
||||||
if (!isInt(e.glasses, 0, MAX_DAYS * MAX_GLASSES_PER_DAY))
|
|
||||||
return { ok: false, why: 'glasses out of range' }
|
|
||||||
if (!isInt(e.seed, 0, 9_999_999)) return { ok: false, why: 'seed out of range' }
|
|
||||||
if (typeof e.broke !== 'boolean') return { ok: false, why: 'broke must be a boolean' }
|
|
||||||
|
|
||||||
// No stand can earn more than the simulation allows in the days it traded.
|
|
||||||
if (e.assets > STARTING_ASSETS + e.days * MAX_CENTS_PER_DAY)
|
|
||||||
return { ok: false, why: 'assets impossible for that many days' }
|
|
||||||
if (e.glasses > e.days * MAX_GLASSES_PER_DAY)
|
|
||||||
return { ok: false, why: 'glasses impossible for that many days' }
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
entry: {
|
|
||||||
name: cleanName(e.name),
|
|
||||||
assets: e.assets,
|
|
||||||
days: e.days,
|
|
||||||
glasses: e.glasses,
|
|
||||||
seed: e.seed,
|
|
||||||
broke: e.broke,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateBatch(raw: unknown): { ok: true; entries: Entry[] } | { ok: false; why: string } {
|
|
||||||
const list = Array.isArray(raw) ? raw : (raw as { entries?: unknown })?.entries
|
|
||||||
if (!Array.isArray(list)) return { ok: false, why: 'expected an array of entries' }
|
|
||||||
if (list.length === 0) return { ok: false, why: 'no entries' }
|
|
||||||
if (list.length > MAX_ENTRIES_PER_POST) return { ok: false, why: 'too many entries' }
|
|
||||||
|
|
||||||
const entries: Entry[] = []
|
|
||||||
for (const item of list) {
|
|
||||||
const v = validateEntry(item)
|
|
||||||
if (!v.ok) return v
|
|
||||||
entries.push(v.entry)
|
|
||||||
}
|
|
||||||
return { ok: true, entries }
|
|
||||||
}
|
|
||||||
+13
-2
@@ -3,8 +3,16 @@
|
|||||||
* service rather than the browser. Nothing about it is trusted on the way in:
|
* service rather than the browser. Nothing about it is trusted on the way in:
|
||||||
* the server decides what is plausible, and the client re-checks the shape of
|
* the server decides what is plausible, and the client re-checks the shape of
|
||||||
* whatever comes back before putting it on screen.
|
* whatever comes back before putting it on screen.
|
||||||
|
*
|
||||||
|
* The service is no longer ours. It is shared with the other games on the site
|
||||||
|
* -- see https://github.com/Coffey-Labs/games-scores -- which is why every
|
||||||
|
* call names the game. Nothing else about it leaks in here: rows come back in
|
||||||
|
* this game's own field names, so this file is otherwise what it always was.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Which board on the shared service is ours. */
|
||||||
|
export const GAME = 'lemonade'
|
||||||
|
|
||||||
const BASE = (import.meta.env.VITE_SCORES_API ?? '/api').replace(/\/+$/, '')
|
const BASE = (import.meta.env.VITE_SCORES_API ?? '/api').replace(/\/+$/, '')
|
||||||
const TIMEOUT_MS = 8000
|
const TIMEOUT_MS = 8000
|
||||||
|
|
||||||
@@ -62,14 +70,17 @@ async function call(path: string, init?: RequestInit): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchScores(): Promise<Score[]> {
|
export async function fetchScores(): Promise<Score[]> {
|
||||||
return parseBoard(await call('/scores'))
|
return parseBoard(await call(`/scores?game=${GAME}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Posts a whole table's worth of players at once and returns the new board. */
|
/** Posts a whole table's worth of players at once and returns the new board. */
|
||||||
export async function submitScores(
|
export async function submitScores(
|
||||||
entries: NewScore[],
|
entries: NewScore[],
|
||||||
): Promise<{ ids: string[]; scores: Score[] }> {
|
): Promise<{ ids: string[]; scores: Score[] }> {
|
||||||
const body = await call('/scores', { method: 'POST', body: JSON.stringify({ entries }) })
|
const body = await call('/scores', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ game: GAME, entries }),
|
||||||
|
})
|
||||||
const ids = (body as { ids?: unknown })?.ids
|
const ids = (body as { ids?: unknown })?.ids
|
||||||
return {
|
return {
|
||||||
ids: Array.isArray(ids) ? ids.filter((i): i is string => typeof i === 'string') : [],
|
ids: Array.isArray(ids) ? ids.filter((i): i is string => typeof i === 'string') : [],
|
||||||
|
|||||||
+4
-3
@@ -8,9 +8,10 @@ export default defineConfig({
|
|||||||
base: process.env.BASE_PATH ?? '/',
|
base: process.env.BASE_PATH ?? '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
// The game talks to /api in every environment; in development that is the
|
// The game talks to /api in every environment; in production that is
|
||||||
// scores service on 5184, in production it is nginx in front of the
|
// nginx in front of the shared scores container. In development, run
|
||||||
// container. Nothing in the client needs to know the difference.
|
// https://github.com/Coffey-Labs/games-scores alongside this -- or do not,
|
||||||
|
// and the board will say so rather than breaking.
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.SCORES_TARGET ?? 'http://localhost:5184',
|
target: process.env.SCORES_TARGET ?? 'http://localhost:5184',
|
||||||
|
|||||||
Reference in New Issue
Block a user