Lemonade Stand: browser recreation of the Atari 8-bit BASIC game
Faithful day loop - weather report, cost of lemonade rising over the summer, glasses/signs/price decisions, thunderstorms, heat waves and street crews - with the $$ LEMONSVILLE DAILY FINANCIAL REPORT $$ laid out the way it printed. The simulation is kept out of React so a season can be run headlessly: a careful player compounds $2.00 into roughly $22 over twelve days, while an all-in player goes broke about seven times in ten. Weather scenes are pixel rectangles in SVG and the music is a small chiptune engine on the Web Audio API, so there are no binary assets. The screen is locked to a 4:3 tube and never scrolls.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import type { Weather } from './types'
|
||||
|
||||
/** Everything is held in whole cents so the books never drift. */
|
||||
export const STARTING_ASSETS = 200 // $2.00
|
||||
export const SIGN_COST = 15 // 15 cents per advertising sign
|
||||
export const MAX_PLAYERS = 4
|
||||
export const MAX_SIGNS = 50
|
||||
|
||||
/**
|
||||
* The cost of a glass climbs as the season goes on, exactly as in the
|
||||
* original: 2 cents for days 1-2, 4 cents through day 7, 5 cents after that.
|
||||
*/
|
||||
export function costPerGlass(day: number): number {
|
||||
if (day <= 2) return 2
|
||||
if (day <= 7) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
export function costMessage(day: number): string[] | null {
|
||||
if (day === 1)
|
||||
return [
|
||||
'ON DAY 1 THE COST OF LEMONADE IS',
|
||||
'2 CENTS PER GLASS.',
|
||||
]
|
||||
if (day === 3)
|
||||
return [
|
||||
'YOUR COST OF LEMONADE HAS GONE UP',
|
||||
'TO 4 CENTS PER GLASS.',
|
||||
]
|
||||
if (day === 8)
|
||||
return [
|
||||
'YOUR COST OF LEMONADE HAS GONE UP',
|
||||
'TO 5 CENTS PER GLASS.',
|
||||
]
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Demand model.
|
||||
*
|
||||
* The original BASIC listing is not reproduced here line for line - this is a
|
||||
* reconstruction tuned to behave the way the game plays: cheap lemonade sells
|
||||
* out, nobody buys at any price once it stops being a bargain, heat pushes both the crowd and the
|
||||
* price people will tolerate upwards, and signs help a lot at first and
|
||||
* hardly at all after the fourth one.
|
||||
*/
|
||||
export interface WeatherProfile {
|
||||
label: string
|
||||
/** Passers-by on an average day. */
|
||||
traffic: number
|
||||
/** Price (in cents) at which sales fall to nothing. */
|
||||
priceCeiling: number
|
||||
}
|
||||
|
||||
export const WEATHER: Record<Weather, WeatherProfile> = {
|
||||
sunny: { label: 'SUNNY', traffic: 90, priceCeiling: 20 },
|
||||
cloudy: { label: 'CLOUDY', traffic: 60, priceCeiling: 15 },
|
||||
hot: { label: 'HOT AND DRY', traffic: 120, priceCeiling: 30 },
|
||||
}
|
||||
|
||||
/** A heat wave on a hot day turns the street into a desert full of buyers. */
|
||||
export const HEAT_WAVE_TRAFFIC = 1.5
|
||||
export const HEAT_WAVE_CEILING = 1.25
|
||||
/** Street crews close the road; a handful of regulars still find you. */
|
||||
export const STREET_CREW_TRAFFIC = 0.2
|
||||
|
||||
/** Signs: +20% for the first, tailing off to a hard ceiling near +50%. */
|
||||
export function signFactor(signs: number): number {
|
||||
return 1 + 0.5 * (1 - Math.pow(0.6, Math.max(0, signs)))
|
||||
}
|
||||
|
||||
export function priceFactor(price: number, ceiling: number): number {
|
||||
if (price >= ceiling) return 0
|
||||
if (price <= 0) return 1
|
||||
return Math.pow((ceiling - price) / ceiling, 1.6)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
HEAT_WAVE_CEILING,
|
||||
HEAT_WAVE_TRAFFIC,
|
||||
STREET_CREW_TRAFFIC,
|
||||
SIGN_COST,
|
||||
WEATHER,
|
||||
costPerGlass,
|
||||
priceFactor,
|
||||
signFactor,
|
||||
} from './constants'
|
||||
import { chance, type Rng } from './rng'
|
||||
import type { DayConditions, DayResult, Decision, Player, Weather } from './types'
|
||||
|
||||
/**
|
||||
* Roll the weather and the day's events. The storm is decided here but must
|
||||
* not be shown to the player until their money is committed - that is the
|
||||
* whole cruelty of a cloudy day.
|
||||
*/
|
||||
export function rollDay(day: number, rng: Rng, streetCrewYesterday: boolean): DayConditions {
|
||||
const roll = rng()
|
||||
let weather: Weather
|
||||
if (roll < 0.5) weather = 'sunny'
|
||||
else if (roll < 0.8) weather = 'cloudy'
|
||||
else weather = 'hot'
|
||||
|
||||
// Day 1 is always sunny so nobody loses their stake before they have played.
|
||||
if (day === 1) weather = 'sunny'
|
||||
|
||||
const heatWave = weather === 'hot' && chance(rng, 0.35)
|
||||
const storm = weather === 'cloudy' && chance(rng, 0.25)
|
||||
// Road works run for two days once they start, and never on day 1 or 2.
|
||||
const streetCrew = day > 2 && (streetCrewYesterday ? chance(rng, 0.5) : chance(rng, 0.12))
|
||||
|
||||
return { day, weather, heatWave, streetCrew, storm, costPerGlass: costPerGlass(day) }
|
||||
}
|
||||
|
||||
/** The most glasses a player can pay for today, given signs already planned. */
|
||||
export function affordableGlasses(assets: number, day: number, signs = 0): number {
|
||||
return Math.max(0, Math.floor((assets - signs * SIGN_COST) / costPerGlass(day)))
|
||||
}
|
||||
|
||||
export function simulate(
|
||||
player: Player,
|
||||
decision: Decision,
|
||||
cond: DayConditions,
|
||||
rng: Rng,
|
||||
): DayResult {
|
||||
const lemonadeCost = decision.glasses * cond.costPerGlass
|
||||
const signCost = decision.signs * SIGN_COST
|
||||
const expenses = lemonadeCost + signCost
|
||||
|
||||
let glassesSold = 0
|
||||
if (!cond.storm) {
|
||||
const profile = WEATHER[cond.weather]
|
||||
let traffic = profile.traffic
|
||||
let ceiling = profile.priceCeiling
|
||||
if (cond.heatWave) {
|
||||
traffic *= HEAT_WAVE_TRAFFIC
|
||||
ceiling *= HEAT_WAVE_CEILING
|
||||
}
|
||||
if (cond.streetCrew) traffic *= STREET_CREW_TRAFFIC
|
||||
|
||||
const demand =
|
||||
traffic *
|
||||
priceFactor(decision.price, ceiling) *
|
||||
signFactor(decision.signs) *
|
||||
// A little daily luck, +/- 10%, so identical days are never identical.
|
||||
(0.9 + rng() * 0.2)
|
||||
|
||||
glassesSold = Math.min(decision.glasses, Math.round(demand))
|
||||
}
|
||||
|
||||
const income = glassesSold * decision.price
|
||||
const profit = income - expenses
|
||||
|
||||
return {
|
||||
playerId: player.id,
|
||||
decision,
|
||||
glassesSold,
|
||||
income,
|
||||
lemonadeCost,
|
||||
signCost,
|
||||
expenses,
|
||||
profit,
|
||||
assetsAfter: player.assets + profit,
|
||||
}
|
||||
}
|
||||
|
||||
/** Broke means you cannot even make a single glass tomorrow. */
|
||||
export function isBankrupt(assets: number, nextDay: number): boolean {
|
||||
return assets < costPerGlass(nextDay)
|
||||
}
|
||||
|
||||
export const dollars = (cents: number): string => {
|
||||
const sign = cents < 0 ? '-' : ''
|
||||
const abs = Math.abs(cents)
|
||||
return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export const randomSeed = () => Math.floor(Math.random() * 1_000_000) + 1
|
||||
@@ -0,0 +1,139 @@
|
||||
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
|
||||
}
|
||||
|
||||
export const initialState = (seed: number): GameState => ({
|
||||
phase: 'title',
|
||||
seed,
|
||||
day: 0,
|
||||
players: [],
|
||||
turn: 0,
|
||||
conditions: null,
|
||||
decisions: {},
|
||||
results: [],
|
||||
history: [],
|
||||
streetCrewYesterday: false,
|
||||
retired: false,
|
||||
})
|
||||
|
||||
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: '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 'RESTART':
|
||||
return initialState(action.seed)
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* mulberry32 — small, fast, seedable PRNG.
|
||||
* The original ran on RND(0); we keep a seed so a run can be replayed or shared.
|
||||
*/
|
||||
export function makeRng(seed: number) {
|
||||
let a = seed >>> 0
|
||||
return function rng(): number {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
export type Rng = ReturnType<typeof makeRng>
|
||||
|
||||
export const randInt = (rng: Rng, min: number, max: number) =>
|
||||
min + Math.floor(rng() * (max - min + 1))
|
||||
|
||||
export const chance = (rng: Rng, p: number) => rng() < p
|
||||
@@ -0,0 +1,49 @@
|
||||
export type Weather = 'sunny' | 'cloudy' | 'hot'
|
||||
|
||||
export interface Player {
|
||||
id: number
|
||||
name: string
|
||||
assets: number // in cents, always integer
|
||||
bankrupt: boolean
|
||||
bankruptDay: number | null
|
||||
}
|
||||
|
||||
/** What a player typed in for a single day. */
|
||||
export interface Decision {
|
||||
glasses: number
|
||||
signs: number
|
||||
price: number // cents per glass
|
||||
}
|
||||
|
||||
/** Everything the world decided for a given day, before anyone buys anything. */
|
||||
export interface DayConditions {
|
||||
day: number
|
||||
weather: Weather
|
||||
heatWave: boolean
|
||||
streetCrew: boolean
|
||||
/** Only ever true on a cloudy day, and only revealed after decisions are locked in. */
|
||||
storm: boolean
|
||||
costPerGlass: number
|
||||
}
|
||||
|
||||
export interface DayResult {
|
||||
playerId: number
|
||||
decision: Decision
|
||||
glassesSold: number
|
||||
income: number
|
||||
lemonadeCost: number
|
||||
signCost: number
|
||||
expenses: number
|
||||
profit: number
|
||||
assetsAfter: number
|
||||
}
|
||||
|
||||
export type Phase =
|
||||
| 'title'
|
||||
| 'intro'
|
||||
| 'setup'
|
||||
| 'briefing'
|
||||
| 'decide'
|
||||
| 'resolve'
|
||||
| 'report'
|
||||
| 'gameover'
|
||||
Reference in New Issue
Block a user