diff --git a/README.md b/README.md index 0f88ba3..257b154 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,20 @@ pocket money. 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. +## 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 ``` @@ -43,6 +57,7 @@ src/ engine.ts rolls each day and settles the takings reducer.ts the day/turn state machine rng.ts seeded mulberry32, so a run can be replayed + highscores.ts the persisted table, with validation on load audio/ synth.ts pulse-wave voices, noise percussion, look-ahead sequencer tunes.ts the title, trading and closing themes diff --git a/src/App.css b/src/App.css index 56f8d72..8d50566 100644 --- a/src/App.css +++ b/src/App.css @@ -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 { diff --git a/src/App.tsx b/src/App.tsx index 64f3ebe..989c979 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(loadScores) + const [freshScores, setFreshScores] = useState([]) 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() { {state.phase === 'title' && ( - + )} {state.phase === 'intro' && 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' && ( + { + synth.select() + dispatch({ type: 'CLOSE_SCORES' }) + }} + onClear={() => setScores(clearScores())} + onBlip={blip} /> )} @@ -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(() => { diff --git a/src/components/GameOverScreen.tsx b/src/components/GameOverScreen.tsx index 73bb302..fbf5a3a 100644 --- a/src/components/GameOverScreen.tsx +++ b/src/components/GameOverScreen.tsx @@ -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( @@ -69,6 +71,7 @@ export function GameOverScreen({ PLAY AGAIN + HIGH SCORES ) diff --git a/src/components/HighScoreScreen.tsx b/src/components/HighScoreScreen.tsx new file mode 100644 index 0000000..4f5ef4c --- /dev/null +++ b/src/components/HighScoreScreen.tsx @@ -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 ( +
+ $$ BEST STANDS IN LEMONSVILLE $$ + + + {scores.length === 0 ? ( + <> + NO STANDS HAVE CLOSED THEIR BOOKS YET. + + RETIRE AT THE END OF A SUMMER TO + TAKE A PLACE ON THIS LIST. + + ) : ( + scores.map((s, i) => ( +
+ + {String(i + 1).padStart(2, ' ')}. {s.name} + {s.broke ? ' (BROKE)' : ''} + + + + {s.days}D · {shortDate(s.at)} + + {dollars(s.assets)} +
+ )) + )} + + + TOP {MAX_SCORES}, KEPT IN THIS BROWSER. + +
+ + BACK + + {scores.length > 0 && ( + { + onBlip() + if (confirming) { + onClear() + setConfirming(false) + } else { + setConfirming(true) + } + }} + > + {confirming ? 'REALLY WIPE?' : 'WIPE TABLE'} + + )} +
+
+ ) +} diff --git a/src/components/TitleScreen.tsx b/src/components/TitleScreen.tsx index 0dfa202..ea07c0f 100644 --- a/src/components/TitleScreen.tsx +++ b/src/components/TitleScreen.tsx @@ -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 INSTRUCTIONS + HIGH SCORES ) diff --git a/src/game/highscores.ts b/src/game/highscores.ts new file mode 100644 index 0000000..d8da1cd --- /dev/null +++ b/src/game/highscores.ts @@ -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 + +const isScore = (v: unknown): v is Score => { + if (typeof v !== 'object' || v === null) return false + const s = v as Record + 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 [] +} diff --git a/src/game/reducer.ts b/src/game/reducer.ts index 342bae4..bb7997a 100644 --- a/src/game/reducer.ts +++ b/src/game/reducer.ts @@ -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) diff --git a/src/game/types.ts b/src/game/types.ts index 7e1c9c6..7fedb63 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -47,3 +47,4 @@ export type Phase = | 'resolve' | 'report' | 'gameover' + | 'scores'