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:
+19
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 · {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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -47,3 +47,4 @@ export type Phase =
|
||||
| 'resolve'
|
||||
| 'report'
|
||||
| 'gameover'
|
||||
| 'scores'
|
||||
|
||||
Reference in New Issue
Block a user