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:
2026-09-08 16:17:47 -07:00
parent 7fcb863771
commit 8a5d0cb6df
30 changed files with 1824 additions and 130 deletions
+45 -31
View File
@@ -5,16 +5,16 @@ import { synth } from './audio/synth'
import { WEATHER } from './game/constants'
import { dollars, randomSeed, rollDay, simulate } from './game/engine'
import { makeRng, type Rng } from './game/rng'
import { HIGH_SCORE_KEY, addScores, loadScores, type Score } from './game/highscores'
import { fetchScores, submitScores, type Score } from './game/highscores'
import { activePlayers, initialState, reducer } from './game/reducer'
import type { Decision } from './game/types'
import type { CarryOver, Decision } from './game/types'
import { useSkin } from './skin'
import { BootScreen } from './components/BootScreen'
import { BriefingScreen } from './components/BriefingScreen'
import { Btn, Crt, Fit } from './components/Crt'
import { DecideScreen } from './components/DecideScreen'
import { GameOverScreen } from './components/GameOverScreen'
import { HighScoreScreen } from './components/HighScoreScreen'
import { HighScoreScreen, type BoardState } from './components/HighScoreScreen'
import { IntroScreen } from './components/IntroScreen'
import { ReportScreen } from './components/ReportScreen'
import { TradingScreen } from './components/TradingScreen'
@@ -30,7 +30,8 @@ export default function App() {
const [sfx, setSfx] = useState(true)
const started = useRef(false)
const { skin, toggle: toggleSkin } = useSkin()
const [scores, setScores] = useState<Score[]>(loadScores)
const [scores, setScores] = useState<Score[]>([])
const [boardState, setBoardState] = useState<BoardState>('loading')
const [freshScores, setFreshScores] = useState<string[]>([])
const active = activePlayers(state)
@@ -65,17 +66,15 @@ export default function App() {
synth.setSfx(sfx)
}, [sfx])
/**
* Two tabs in one browser share localStorage. Nothing else is shared - the
* game itself lives entirely in the page - but the table should not go
* stale just because the other tab finished a season first.
*/
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === HIGH_SCORE_KEY || e.key === null) setScores(loadScores())
/** The board lives in town, so it is fetched rather than remembered. */
const loadBoard = useCallback(async () => {
setBoardState('loading')
try {
setScores(await fetchScores())
setBoardState('ready')
} catch {
setBoardState('error')
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const blip = useCallback(() => synth.blip(), [])
@@ -101,8 +100,8 @@ export default function App() {
// -------------------------------------------------------------- handlers
const beginDay = useCallback(
(day: number, streetCrewYesterday: boolean) => {
const conditions = rollDay(day, rng.current, streetCrewYesterday)
(day: number, yesterday: CarryOver) => {
const conditions = rollDay(day, rng.current, yesterday)
dispatch({ type: 'BEGIN_DAY', conditions })
if (conditions.heatWave) synth.heat()
else if (conditions.weather === 'sunny') synth.sunshine()
@@ -114,7 +113,7 @@ export default function App() {
wake()
synth.select()
dispatch({ type: 'START', names })
beginDay(1, false)
beginDay(1, { streetCrew: false, rival: false })
}
const submit = (decision: Decision) => {
@@ -140,27 +139,36 @@ export default function App() {
const nextDay = () => {
synth.select()
dispatch({ type: 'NEXT_DAY' })
beginDay(state.day + 1, state.streetCrewYesterday)
beginDay(state.day + 1, state.yesterday)
}
/** Close the books: everyone who traded gets a line in the table. */
/**
* Close the books. The standings show straight away; posting to the town
* board happens behind them, so a slow or missing line out never holds the
* end of a season hostage.
*/
const retire = () => {
synth.fanfare()
const glassesFor = (id: number) =>
state.history.reduce((n, r) => (r.playerId === id ? n + r.glassesSold : n), 0)
const { table, added } = addScores(
state.players.map((p) => ({
name: p.name,
assets: p.assets,
days: p.bankruptDay ?? state.day,
glasses: glassesFor(p.id),
seed: state.seed,
broke: p.bankrupt,
})),
)
setScores(table)
setFreshScores(added)
const entries = state.players.map((p) => ({
name: p.name,
assets: p.assets,
days: p.bankruptDay ?? state.day,
glasses: glassesFor(p.id),
seed: state.seed,
broke: p.bankrupt,
}))
dispatch({ type: 'RETIRE' })
setBoardState('loading')
submitScores(entries)
.then(({ ids, scores: table }) => {
setScores(table)
setFreshScores(ids)
setBoardState('ready')
})
.catch(() => setBoardState('error'))
}
const restart = () => {
@@ -174,6 +182,7 @@ export default function App() {
wake()
synth.select()
dispatch({ type: 'SHOW_SCORES' })
if (boardState !== 'ready') void loadBoard()
}
// ---------------------------------------------------------------- render
@@ -317,11 +326,16 @@ export default function App() {
{state.phase === 'scores' && (
<HighScoreScreen
scores={scores}
state={boardState}
highlight={freshScores}
onBack={() => {
synth.select()
dispatch({ type: 'CLOSE_SCORES' })
}}
onRetry={() => {
synth.select()
void loadBoard()
}}
/>
)}
</Fit>