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
+19
View File
@@ -268,6 +268,25 @@
.report-value { white-space: nowrap; }
.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 */
.row {
+61 -5
View File
@@ -4,12 +4,14 @@ 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 { addScores, clearScores, loadScores, type Score } from './game/highscores'
import { activePlayers, initialState, reducer } from './game/reducer'
import type { Decision } from './game/types'
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 { IntroScreen } from './components/IntroScreen'
import { ReportScreen } from './components/ReportScreen'
import { SetupScreen } from './components/SetupScreen'
@@ -23,6 +25,8 @@ export default function App() {
const [music, setMusic] = useState(true)
const [sfx, setSfx] = useState(true)
const started = useRef(false)
const [scores, setScores] = useState<Score[]>(loadScores)
const [freshScores, setFreshScores] = useState<string[]>([])
const active = activePlayers(state)
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)
}
/** Close the books: everyone who traded gets a line in the table. */
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)
dispatch({ type: 'RETIRE' })
}
const restart = () => {
const s = randomSeed()
rng.current = makeRng(s)
setFreshScores([])
dispatch({ type: 'RESTART', seed: s })
}
const showScores = () => {
wake()
synth.select()
dispatch({ type: 'SHOW_SCORES' })
}
// ---------------------------------------------------------------- render
// Only the trading screens have a day, a sky and a till to report on.
const status = useMemo(() => {
if (state.phase === 'title' || state.phase === 'intro' || state.phase === 'setup') return null
const w = state.conditions ? WEATHER[state.conditions.weather].label : ''
return { day: state.day, weather: w, player: current }
const inPlay =
state.phase === 'briefing' || state.phase === 'decide' || state.phase === 'report'
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])
return (
@@ -171,7 +203,12 @@ export default function App() {
<Fit>
{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' })} />}
@@ -218,6 +255,20 @@ export default function App() {
history={state.history}
days={state.day}
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>
@@ -227,7 +278,12 @@ export default function App() {
}
/** 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
useEffect(() => {
+3
View File
@@ -11,11 +11,13 @@ export function GameOverScreen({
history,
days,
onRestart,
onScores,
}: {
players: Player[]
history: DayResult[]
days: number
onRestart: () => void
onScores: () => void
}) {
const ranked = [...players].sort((a, b) => b.assets - a.assets)
const best = history.reduce<DayResult | null>(
@@ -69,6 +71,7 @@ export function GameOverScreen({
<Btn kind="primary" onClick={onRestart}>
PLAY AGAIN
</Btn>
<Btn onClick={onScores}>HIGH SCORES</Btn>
</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({
onStart,
onInstructions,
onScores,
seed,
}: {
onStart: () => void
onInstructions: () => void
onScores: () => void
seed: number
}) {
return (
@@ -29,6 +31,7 @@ export function TitleScreen({
START
</Btn>
<Btn onClick={onInstructions}>INSTRUCTIONS</Btn>
<Btn onClick={onScores}>HIGH SCORES</Btn>
</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. */
streetCrewYesterday: boolean
retired: boolean
/** Where the high score table was opened from, so BACK can return there. */
scoresReturn: Phase
}
export const initialState = (seed: number): GameState => ({
@@ -30,6 +32,7 @@ export const initialState = (seed: number): GameState => ({
history: [],
streetCrewYesterday: false,
retired: false,
scoresReturn: 'title',
})
export type Action =
@@ -42,6 +45,8 @@ export type Action =
| { type: 'RESOLVE'; results: DayResult[] }
| { type: 'NEXT_DAY' }
| { type: 'RETIRE' }
| { type: 'SHOW_SCORES' }
| { type: 'CLOSE_SCORES' }
| { type: 'RESTART'; seed: number }
/** 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':
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':
return initialState(action.seed)
+1
View File
@@ -47,3 +47,4 @@ export type Phase =
| 'resolve'
| 'report'
| 'gameover'
| 'scores'