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.
153 lines
4.1 KiB
TypeScript
153 lines
4.1 KiB
TypeScript
import { STARTING_ASSETS } from './constants'
|
|
import { isBankrupt } from './engine'
|
|
import type { DayConditions, DayResult, Decision, Phase, Player } from './types'
|
|
|
|
export interface GameState {
|
|
phase: Phase
|
|
seed: number
|
|
day: number
|
|
players: Player[]
|
|
/** Index into players; only the ones still solvent take a turn. */
|
|
turn: number
|
|
conditions: DayConditions | null
|
|
decisions: Record<number, Decision>
|
|
results: DayResult[]
|
|
history: DayResult[]
|
|
/** 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 => ({
|
|
phase: 'title',
|
|
seed,
|
|
day: 0,
|
|
players: [],
|
|
turn: 0,
|
|
conditions: null,
|
|
decisions: {},
|
|
results: [],
|
|
history: [],
|
|
streetCrewYesterday: false,
|
|
retired: false,
|
|
scoresReturn: 'title',
|
|
})
|
|
|
|
export type Action =
|
|
| { type: 'SHOW_INTRO' }
|
|
| { type: 'SHOW_SETUP' }
|
|
| { type: 'START'; names: string[] }
|
|
| { type: 'BEGIN_DAY'; conditions: DayConditions }
|
|
| { type: 'OPEN_STAND' }
|
|
| { type: 'SUBMIT'; playerId: number; decision: Decision }
|
|
| { 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. */
|
|
export const activePlayers = (s: GameState) => s.players.filter((p) => !p.bankrupt)
|
|
|
|
export function reducer(state: GameState, action: Action): GameState {
|
|
switch (action.type) {
|
|
case 'SHOW_INTRO':
|
|
return { ...state, phase: 'intro' }
|
|
|
|
case 'SHOW_SETUP':
|
|
return { ...state, phase: 'setup' }
|
|
|
|
case 'START':
|
|
return {
|
|
...state,
|
|
phase: 'briefing',
|
|
day: 1,
|
|
players: action.names.map((name, i) => ({
|
|
id: i,
|
|
name: name.trim().toUpperCase() || `PLAYER ${i + 1}`,
|
|
assets: STARTING_ASSETS,
|
|
bankrupt: false,
|
|
bankruptDay: null,
|
|
})),
|
|
turn: 0,
|
|
decisions: {},
|
|
results: [],
|
|
history: [],
|
|
}
|
|
|
|
case 'BEGIN_DAY':
|
|
return {
|
|
...state,
|
|
phase: 'briefing',
|
|
conditions: action.conditions,
|
|
decisions: {},
|
|
results: [],
|
|
turn: 0,
|
|
}
|
|
|
|
case 'OPEN_STAND':
|
|
return { ...state, phase: 'decide', turn: 0 }
|
|
|
|
case 'SUBMIT': {
|
|
const decisions = { ...state.decisions, [action.playerId]: action.decision }
|
|
const active = activePlayers(state)
|
|
const done = active.every((p) => decisions[p.id] !== undefined)
|
|
return {
|
|
...state,
|
|
decisions,
|
|
turn: done ? state.turn : state.turn + 1,
|
|
phase: done ? 'resolve' : 'decide',
|
|
}
|
|
}
|
|
|
|
case 'RESOLVE': {
|
|
const byId = new Map(action.results.map((r) => [r.playerId, r]))
|
|
const nextDay = state.day + 1
|
|
return {
|
|
...state,
|
|
phase: 'report',
|
|
results: action.results,
|
|
history: [...state.history, ...action.results],
|
|
streetCrewYesterday: state.conditions?.streetCrew ?? false,
|
|
players: state.players.map((p) => {
|
|
const r = byId.get(p.id)
|
|
if (!r) return p
|
|
const assets = r.assetsAfter
|
|
const broke = isBankrupt(assets, nextDay)
|
|
return {
|
|
...p,
|
|
assets,
|
|
bankrupt: p.bankrupt || broke,
|
|
bankruptDay: p.bankrupt ? p.bankruptDay : broke ? state.day : null,
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
case 'NEXT_DAY': {
|
|
if (activePlayers(state).length === 0) return { ...state, phase: 'gameover' }
|
|
return { ...state, phase: 'briefing', day: state.day + 1, turn: 0 }
|
|
}
|
|
|
|
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)
|
|
|
|
default:
|
|
return state
|
|
}
|
|
}
|