Shared leaderboard, a games hub, AGPL, and two new street conditions
The high score table moves out of the browser and into a service, so every player is on one board. It is a Node process with SQLite, no build step and no native modules - node:sqlite ships with the runtime and Node runs the TypeScript directly. Everything in a request is treated as hostile: names forced to a printable subset, every number range-checked, and scores refused if they could not have happened in the days claimed. Bodies capped, submissions rate limited per address. It still cannot prove a score is real, and server/README.md says so plainly rather than implying otherwise. The game degrades properly without it: the board says it cannot reach town, posting happens behind the closing standings, and play is untouched. Deployment is three containers behind the host's nginx - the hub at /, the game at /lemonade/, the scores service at /api/ - so the game keeps its own container and a second game is just another service. Licensing: AGPL-3.0-or-later, Affero because the leaderboard is a network service. NOTICE.md credits Bob Jamison and Charlie Kellner, records that this is clean-room work, and is honest about the one thing it is not: some on-screen wording is quoted from the original and cannot be licensed by us. The street can now get better as well as worse. The summer fair brings the town out and lifts what they will pay; a rival on the next corner takes a share and lingers. Both show in the art and in the crowd. Over 500 seasons a player who reads the briefing survives every time and finishes around $25.80; one who ignores it goes broke 70% of the time.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# 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 30 an hour per
|
||||
address, which is why `TRUST_PROXY=1` matters behind nginx — otherwise every
|
||||
request looks like it came from the proxy.
|
||||
|
||||
**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`.
|
||||
Reference in New Issue
Block a user