Add a persistent high score table

Retiring writes each player's closing balance to a top-ten table in
localStorage, reachable from the title screen and the standings, with
the summer just finished picked out in yellow.

Stored rows are user-editable, so every field is validated on load and
malformed entries are dropped. Storage being unavailable is a normal
case, not an error: the game plays on with an empty table.

Also stops the status line claiming DAY 00 on screens that have no day.
This commit is contained in:
2026-09-08 15:29:35 -07:00
parent 08b2cc5391
commit 71cbb02353
9 changed files with 280 additions and 5 deletions
+15
View File
@@ -34,6 +34,20 @@ pocket money.
You are broke when you cannot afford a single glass. Otherwise the summer runs You are broke when you cannot afford a single glass. Otherwise the summer runs
as long as you like; **RETIRE** closes the books and shows the standings. as long as you like; **RETIRE** closes the books and shows the standings.
## High scores
Retiring writes every player's closing balance to a top-ten table kept in
`localStorage`, reachable from the title screen and from the standings. Runs
from the summer you just finished are picked out in yellow. Ties break on the
shorter season, then on the earlier date.
The table is per-browser, not per-device, and it is the one piece of state the
game keeps between visits. Anything already in storage can be edited by hand,
so every field is validated on the way back in and malformed rows are dropped
rather than trusted. If storage is unavailable — a private window, or a browser
set to block site data — the game plays normally and the table simply stays
empty. **WIPE TABLE** clears it, and asks once before it does.
## How it is put together ## How it is put together
``` ```
@@ -43,6 +57,7 @@ src/
engine.ts rolls each day and settles the takings engine.ts rolls each day and settles the takings
reducer.ts the day/turn state machine reducer.ts the day/turn state machine
rng.ts seeded mulberry32, so a run can be replayed rng.ts seeded mulberry32, so a run can be replayed
highscores.ts the persisted table, with validation on load
audio/ audio/
synth.ts pulse-wave voices, noise percussion, look-ahead sequencer synth.ts pulse-wave voices, noise percussion, look-ahead sequencer
tunes.ts the title, trading and closing themes tunes.ts the title, trading and closing themes
+19
View File
@@ -268,6 +268,25 @@
.report-value { white-space: nowrap; } .report-value { white-space: nowrap; }
.report-row.strong { color: var(--atari-bright); font-weight: 700; } .report-row.strong { color: var(--atari-bright); font-weight: 700; }
/* ------------------------------------------------------- high scores */
.score-days {
white-space: nowrap;
font-size: 0.82em;
padding-right: 0.8ch;
}
/* Runs added by the summer just finished. */
.score-row.is-new,
.score-row.is-new .report-value,
.score-row.is-new .score-days {
color: var(--atari-yellow);
text-shadow: 0 0 8px rgba(247, 222, 90, 0.45);
font-weight: 700;
}
.score-row.is-new .report-dots { border-bottom-color: rgba(247, 222, 90, 0.4); }
/* ------------------------------------------------------------ buttons */ /* ------------------------------------------------------------ buttons */
.row { .row {
+61 -5
View File
@@ -4,12 +4,14 @@ import { synth } from './audio/synth'
import { WEATHER } from './game/constants' import { WEATHER } from './game/constants'
import { dollars, randomSeed, rollDay, simulate } from './game/engine' import { dollars, randomSeed, rollDay, simulate } from './game/engine'
import { makeRng, type Rng } from './game/rng' import { makeRng, type Rng } from './game/rng'
import { addScores, clearScores, loadScores, type Score } from './game/highscores'
import { activePlayers, initialState, reducer } from './game/reducer' import { activePlayers, initialState, reducer } from './game/reducer'
import type { Decision } from './game/types' import type { Decision } from './game/types'
import { BriefingScreen } from './components/BriefingScreen' import { BriefingScreen } from './components/BriefingScreen'
import { Btn, Crt, Fit } from './components/Crt' import { Btn, Crt, Fit } from './components/Crt'
import { DecideScreen } from './components/DecideScreen' import { DecideScreen } from './components/DecideScreen'
import { GameOverScreen } from './components/GameOverScreen' import { GameOverScreen } from './components/GameOverScreen'
import { HighScoreScreen } from './components/HighScoreScreen'
import { IntroScreen } from './components/IntroScreen' import { IntroScreen } from './components/IntroScreen'
import { ReportScreen } from './components/ReportScreen' import { ReportScreen } from './components/ReportScreen'
import { SetupScreen } from './components/SetupScreen' import { SetupScreen } from './components/SetupScreen'
@@ -23,6 +25,8 @@ export default function App() {
const [music, setMusic] = useState(true) const [music, setMusic] = useState(true)
const [sfx, setSfx] = useState(true) const [sfx, setSfx] = useState(true)
const started = useRef(false) const started = useRef(false)
const [scores, setScores] = useState<Score[]>(loadScores)
const [freshScores, setFreshScores] = useState<string[]>([])
const active = activePlayers(state) const active = activePlayers(state)
const current = active[Math.min(state.turn, Math.max(0, active.length - 1))] const current = active[Math.min(state.turn, Math.max(0, active.length - 1))]
@@ -110,23 +114,51 @@ export default function App() {
beginDay(state.day + 1, state.streetCrewYesterday) beginDay(state.day + 1, state.streetCrewYesterday)
} }
/** Close the books: everyone who traded gets a line in the table. */
const retire = () => { const retire = () => {
synth.fanfare() 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)
dispatch({ type: 'RETIRE' }) dispatch({ type: 'RETIRE' })
} }
const restart = () => { const restart = () => {
const s = randomSeed() const s = randomSeed()
rng.current = makeRng(s) rng.current = makeRng(s)
setFreshScores([])
dispatch({ type: 'RESTART', seed: s }) dispatch({ type: 'RESTART', seed: s })
} }
const showScores = () => {
wake()
synth.select()
dispatch({ type: 'SHOW_SCORES' })
}
// ---------------------------------------------------------------- render // ---------------------------------------------------------------- render
// Only the trading screens have a day, a sky and a till to report on.
const status = useMemo(() => { const status = useMemo(() => {
if (state.phase === 'title' || state.phase === 'intro' || state.phase === 'setup') return null const inPlay =
const w = state.conditions ? WEATHER[state.conditions.weather].label : '' state.phase === 'briefing' || state.phase === 'decide' || state.phase === 'report'
return { day: state.day, weather: w, player: current } if (!inPlay || !state.conditions) return null
return {
day: state.day,
weather: WEATHER[state.conditions.weather].label,
player: current,
}
}, [state.phase, state.day, state.conditions, current]) }, [state.phase, state.day, state.conditions, current])
return ( return (
@@ -171,7 +203,12 @@ export default function App() {
<Fit> <Fit>
{state.phase === 'title' && ( {state.phase === 'title' && (
<TitleGate seed={state.seed} onStart={goSetup} onInstructions={goIntro} /> <TitleGate
seed={state.seed}
onStart={goSetup}
onInstructions={goIntro}
onScores={showScores}
/>
)} )}
{state.phase === 'intro' && <IntroScreen onDone={() => dispatch({ type: 'SHOW_SETUP' })} />} {state.phase === 'intro' && <IntroScreen onDone={() => dispatch({ type: 'SHOW_SETUP' })} />}
@@ -218,6 +255,20 @@ export default function App() {
history={state.history} history={state.history}
days={state.day} days={state.day}
onRestart={restart} onRestart={restart}
onScores={showScores}
/>
)}
{state.phase === 'scores' && (
<HighScoreScreen
scores={scores}
highlight={freshScores}
onBack={() => {
synth.select()
dispatch({ type: 'CLOSE_SCORES' })
}}
onClear={() => setScores(clearScores())}
onBlip={blip}
/> />
)} )}
</Fit> </Fit>
@@ -227,7 +278,12 @@ export default function App() {
} }
/** Enter or Space works as the START key, the way the console did. */ /** Enter or Space works as the START key, the way the console did. */
function TitleGate(props: { seed: number; onStart: () => void; onInstructions: () => void }) { function TitleGate(props: {
seed: number
onStart: () => void
onInstructions: () => void
onScores: () => void
}) {
const { onStart } = props const { onStart } = props
useEffect(() => { useEffect(() => {
+3
View File
@@ -11,11 +11,13 @@ export function GameOverScreen({
history, history,
days, days,
onRestart, onRestart,
onScores,
}: { }: {
players: Player[] players: Player[]
history: DayResult[] history: DayResult[]
days: number days: number
onRestart: () => void onRestart: () => void
onScores: () => void
}) { }) {
const ranked = [...players].sort((a, b) => b.assets - a.assets) const ranked = [...players].sort((a, b) => b.assets - a.assets)
const best = history.reduce<DayResult | null>( const best = history.reduce<DayResult | null>(
@@ -69,6 +71,7 @@ export function GameOverScreen({
<Btn kind="primary" onClick={onRestart}> <Btn kind="primary" onClick={onRestart}>
PLAY AGAIN PLAY AGAIN
</Btn> </Btn>
<Btn onClick={onScores}>HIGH SCORES</Btn>
</div> </div>
</div> </div>
) )
+78
View File
@@ -0,0 +1,78 @@
import { useState } from 'react'
import { dollars } from '../game/engine'
import { MAX_SCORES, type Score } from '../game/highscores'
import { Btn, Line } from './Crt'
const shortDate = (at: number) =>
new Date(at).toLocaleDateString(undefined, { day: '2-digit', month: 'short' }).toUpperCase()
export function HighScoreScreen({
scores,
highlight,
onBack,
onClear,
onBlip,
}: {
scores: Score[]
highlight: string[]
onBack: () => void
onClear: () => void
onBlip: () => void
}) {
const [confirming, setConfirming] = useState(false)
const fresh = new Set(highlight)
return (
<div className="stack">
<Line className="center inv-line">$$ BEST STANDS IN LEMONSVILLE $$</Line>
<Line />
{scores.length === 0 ? (
<>
<Line className="center dim">NO STANDS HAVE CLOSED THEIR BOOKS YET.</Line>
<Line />
<Line className="center dim">RETIRE AT THE END OF A SUMMER TO</Line>
<Line className="center dim">TAKE A PLACE ON THIS LIST.</Line>
</>
) : (
scores.map((s, i) => (
<div className={`report-row score-row ${fresh.has(s.id) ? 'is-new' : ''}`} key={s.id}>
<span className="report-label">
{String(i + 1).padStart(2, ' ')}. {s.name}
{s.broke ? ' (BROKE)' : ''}
</span>
<span className="report-dots" aria-hidden />
<span className="score-days dim">
{s.days}D &middot; {shortDate(s.at)}
</span>
<span className="report-value money">{dollars(s.assets)}</span>
</div>
))
)}
<Line />
<Line className="center dim">TOP {MAX_SCORES}, KEPT IN THIS BROWSER.</Line>
<Line />
<div className="row center">
<Btn kind="primary" onClick={onBack}>
BACK
</Btn>
{scores.length > 0 && (
<Btn
onClick={() => {
onBlip()
if (confirming) {
onClear()
setConfirming(false)
} else {
setConfirming(true)
}
}}
>
{confirming ? 'REALLY WIPE?' : 'WIPE TABLE'}
</Btn>
)}
</div>
</div>
)
}
+3
View File
@@ -6,10 +6,12 @@ const BANNER = bannerRows('LEMONADE')
export function TitleScreen({ export function TitleScreen({
onStart, onStart,
onInstructions, onInstructions,
onScores,
seed, seed,
}: { }: {
onStart: () => void onStart: () => void
onInstructions: () => void onInstructions: () => void
onScores: () => void
seed: number seed: number
}) { }) {
return ( return (
@@ -29,6 +31,7 @@ export function TitleScreen({
START START
</Btn> </Btn>
<Btn onClick={onInstructions}>INSTRUCTIONS</Btn> <Btn onClick={onInstructions}>INSTRUCTIONS</Btn>
<Btn onClick={onScores}>HIGH SCORES</Btn>
</div> </div>
</div> </div>
) )
+87
View File
@@ -0,0 +1,87 @@
const KEY = 'lemonade.highscores.v1'
export const MAX_SCORES = 10
export interface Score {
id: string
name: string
/** Closing assets, in whole cents. */
assets: number
days: number
glasses: number
seed: number
/** Epoch milliseconds, for the tie-break and the date column. */
at: number
broke: boolean
}
export type NewScore = Omit<Score, 'id' | 'at'>
const isScore = (v: unknown): v is Score => {
if (typeof v !== 'object' || v === null) return false
const s = v as Record<string, unknown>
return (
typeof s.id === 'string' &&
typeof s.name === 'string' &&
Number.isFinite(s.assets) &&
Number.isFinite(s.days) &&
Number.isFinite(s.glasses) &&
Number.isFinite(s.seed) &&
Number.isFinite(s.at) &&
typeof s.broke === 'boolean'
)
}
const rank = (a: Score, b: Score) => b.assets - a.assets || a.days - b.days || a.at - b.at
/**
* Anything already in storage was written by someone who can edit it freely,
* so every field is checked before it is trusted.
*/
export function loadScores(): Score[] {
try {
const raw = window.localStorage.getItem(KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.filter(isScore)
.map((s) => ({ ...s, name: s.name.slice(0, 12).toUpperCase() }))
.sort(rank)
.slice(0, MAX_SCORES)
} catch {
// Private windows, blocked site data, or corrupt JSON: play without a table.
return []
}
}
function persist(scores: Score[]): void {
try {
window.localStorage.setItem(KEY, JSON.stringify(scores))
} catch {
// Nothing to do - the run still shows in the table until the tab closes.
}
}
const newId = () =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
/** Returns the saved table and the ids of the entries this call added. */
export function addScores(entries: NewScore[]): { table: Score[]; added: string[] } {
const at = Date.now()
const fresh: Score[] = entries.map((e) => ({ ...e, id: newId(), at }))
const table = [...loadScores(), ...fresh].sort(rank).slice(0, MAX_SCORES)
persist(table)
const kept = new Set(table.map((s) => s.id))
return { table, added: fresh.filter((s) => kept.has(s.id)).map((s) => s.id) }
}
export function clearScores(): Score[] {
try {
window.localStorage.removeItem(KEY)
} catch {
// Ignored - the table is rendered from the return value either way.
}
return []
}
+13
View File
@@ -16,6 +16,8 @@ export interface GameState {
/** True while road works were in progress yesterday, so they can run on. */ /** True while road works were in progress yesterday, so they can run on. */
streetCrewYesterday: boolean streetCrewYesterday: boolean
retired: boolean retired: boolean
/** Where the high score table was opened from, so BACK can return there. */
scoresReturn: Phase
} }
export const initialState = (seed: number): GameState => ({ export const initialState = (seed: number): GameState => ({
@@ -30,6 +32,7 @@ export const initialState = (seed: number): GameState => ({
history: [], history: [],
streetCrewYesterday: false, streetCrewYesterday: false,
retired: false, retired: false,
scoresReturn: 'title',
}) })
export type Action = export type Action =
@@ -42,6 +45,8 @@ export type Action =
| { type: 'RESOLVE'; results: DayResult[] } | { type: 'RESOLVE'; results: DayResult[] }
| { type: 'NEXT_DAY' } | { type: 'NEXT_DAY' }
| { type: 'RETIRE' } | { type: 'RETIRE' }
| { type: 'SHOW_SCORES' }
| { type: 'CLOSE_SCORES' }
| { type: 'RESTART'; seed: number } | { type: 'RESTART'; seed: number }
/** Players who can still afford to open the stand, in seating order. */ /** Players who can still afford to open the stand, in seating order. */
@@ -130,6 +135,14 @@ export function reducer(state: GameState, action: Action): GameState {
case 'RETIRE': case 'RETIRE':
return { ...state, phase: 'gameover', retired: true } return { ...state, phase: 'gameover', retired: true }
case 'SHOW_SCORES':
return state.phase === 'scores'
? state
: { ...state, phase: 'scores', scoresReturn: state.phase }
case 'CLOSE_SCORES':
return { ...state, phase: state.scoresReturn }
case 'RESTART': case 'RESTART':
return initialState(action.seed) return initialState(action.seed)
+1
View File
@@ -47,3 +47,4 @@ export type Phase =
| 'resolve' | 'resolve'
| 'report' | 'report'
| 'gameover' | 'gameover'
| 'scores'