Files
lemonade/src/game/reducer.ts
T
jcoffey-dev 7fcb863771 Remaster: cel-shaded skin, a crowd that tracks trade, and a funk band
Two skins over one game. 1979 is the machine as it was; the remaster is
the same rules in cel-shaded vector art with a modern palette. The
simulation is untouched - only the paint differs - and the toggle sits
under the set.

The street is now driven by the game. Before prices are in, the number
of people walking on comes from the forecast; afterwards it comes from
the glasses that actually sold, so the report shows the crowd you
earned. A heat wave has them fanning themselves; a downpour leaves one
figure hurrying past under an umbrella.

Selling no longer snaps straight to the books. The stand trades for a
few seconds first, tally climbing and till filling, which is where that
crowd animation pays off. Skippable.

The synth grew a kit (pitched-sine kick, snare with body, hats), a
sweeping resonant lowpass for the bass, chords and a shuffle - enough
for a funk band. The chiptunes stay for the 1979 skin.

Boot is now a cassette load, which doubles as the gesture browsers
require before audio will play.

Fixes found on the way: the picture scaled about its centre while flex
auto-margins collapsed to zero on overflow, which pushed the report's
buttons off the bottom; and the validation line moved the button when
it appeared, so it is now reserved and faded. Sibling tabs in one
browser also keep their score tables in step.
2026-09-08 15:59:52 -07:00

163 lines
4.4 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, phase: Phase = 'boot'): GameState => ({
phase,
seed,
day: 0,
players: [],
turn: 0,
conditions: null,
decisions: {},
results: [],
history: [],
streetCrewYesterday: false,
retired: false,
scoresReturn: 'title',
})
export type Action =
| { type: 'BOOTED' }
| { 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: 'SHOW_REPORT' }
| { 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 'BOOTED':
return { ...state, phase: 'title' }
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,
// The stand trades before the books are opened.
phase: 'trading',
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 'SHOW_REPORT':
return { ...state, phase: 'report' }
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':
// The tape only loads once a session; a new game starts at the title.
return initialState(action.seed, 'title')
default:
return state
}
}