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