From 5f8ad511f06895624193fc77f05a974af9b63a0e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 8 Sep 2026 15:23:46 -0700 Subject: [PATCH] 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. --- .gitignore | 24 + .oxlintrc.json | 8 + README.md | 77 ++ index.html | 18 + package-lock.json | 1349 +++++++++++++++++++++++++++++ package.json | 25 + public/favicon.svg | 9 + src/App.css | 337 +++++++ src/App.tsx | 242 ++++++ src/audio/synth.ts | 297 +++++++ src/audio/tunes.ts | 142 +++ src/components/BriefingScreen.tsx | 49 ++ src/components/Crt.tsx | 79 ++ src/components/DecideScreen.tsx | 136 +++ src/components/GameOverScreen.tsx | 75 ++ src/components/IntroScreen.tsx | 63 ++ src/components/ReportScreen.tsx | 132 +++ src/components/Scene.tsx | 218 +++++ src/components/SetupScreen.tsx | 61 ++ src/components/TitleScreen.tsx | 35 + src/components/logo.ts | 22 + src/game/constants.ts | 76 ++ src/game/engine.ts | 100 +++ src/game/reducer.ts | 139 +++ src/game/rng.ts | 21 + src/game/types.ts | 49 ++ src/index.css | 57 ++ src/main.tsx | 10 + tsconfig.app.json | 26 + tsconfig.json | 7 + tsconfig.node.json | 23 + vite.config.ts | 7 + 32 files changed, 3913 insertions(+) create mode 100644 .gitignore create mode 100644 .oxlintrc.json create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 src/App.css create mode 100644 src/App.tsx create mode 100644 src/audio/synth.ts create mode 100644 src/audio/tunes.ts create mode 100644 src/components/BriefingScreen.tsx create mode 100644 src/components/Crt.tsx create mode 100644 src/components/DecideScreen.tsx create mode 100644 src/components/GameOverScreen.tsx create mode 100644 src/components/IntroScreen.tsx create mode 100644 src/components/ReportScreen.tsx create mode 100644 src/components/Scene.tsx create mode 100644 src/components/SetupScreen.tsx create mode 100644 src/components/TitleScreen.tsx create mode 100644 src/components/logo.ts create mode 100644 src/game/constants.ts create mode 100644 src/game/engine.ts create mode 100644 src/game/reducer.ts create mode 100644 src/game/rng.ts create mode 100644 src/game/types.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f88ba3 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Lemonade Stand + +A browser recreation of the Atari 8-bit BASIC classic — the one you typed in, +ran on a TV, and lost two dollars to on a cloudy day. + +React + TypeScript + Vite. No art assets and no audio files: the weather is +drawn as pixel rectangles in SVG, and the music is generated by a small +chiptune engine built on the Web Audio API. + +## Playing + +``` +npm install +npm run dev +``` + +One to four players share the keyboard, hot-seat style. Each morning you see +the weather, then decide three things: how many glasses to make, how many +15-cent signs to put up, and what to charge. Then the town votes with its +pocket money. + +- Lemonade costs 2 cents a glass on days 1–2, 4 cents through day 7, and + 5 cents after that. +- Everything you make is made *today*. Unsold glasses are poured away. +- A hot, dry day brings a bigger crowd that will pay more. A heat wave is + better still. +- A cloudy day can turn into a thunderstorm, and a thunderstorm ruins every + glass you made. +- Street crews close the road now and then, and they tend to stay for a + second day. +- Signs work, but with sharp diminishing returns — the fourth one barely + earns its 15 cents. + +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. + +## How it is put together + +``` +src/ + game/ pure simulation - no React, no DOM + constants.ts costs, weather profiles, the demand curve + engine.ts rolls each day and settles the takings + reducer.ts the day/turn state machine + rng.ts seeded mulberry32, so a run can be replayed + audio/ + synth.ts pulse-wave voices, noise percussion, look-ahead sequencer + tunes.ts the title, trading and closing themes + components/ the screens, and the CRT they are drawn on +``` + +The simulation is deliberately kept out of React: `engine.ts` takes a player, a +decision and a day and hands back a `DayResult`. That is what makes it possible +to run a few hundred seasons in a script and check that a careful player grows +their two dollars while a reckless one goes broke about seven times in ten. + +### The demand curve + +The original BASIC listing is not reproduced line for line. `constants.ts` +holds a reconstruction tuned to behave the way the game plays: cheap lemonade +sells out, sales fall to nothing once the price stops being a bargain, heat +lifts both the crowd and the price people will tolerate, and signs help a lot +at first and hardly at all later. + +### The television + +An Atari 800 fed a 4:3 television, and a television has no scrollbar. The tube +is locked to 4:3 and everything inside it is sized in container units, so the +picture stays in proportion at any size. If a page ever did come out taller +than the tube, `Fit` scales it down rather than clipping or scrolling it. + +## Sound + +Audio starts on the first click or key press, as browsers require. Two pulse +voices, a triangle bass and filtered white noise for percussion, driven by a +scheduler that queues notes 200 ms ahead so the music does not stutter when +React re-renders. `MUSIC` and `SOUND` toggle independently. diff --git a/index.html b/index.html new file mode 100644 index 0000000..17223ac --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Lemonade Stand + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..14bd2e8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1349 @@ +{ + "name": "lemonade", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lemonade", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.82.0.tgz", + "integrity": "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.82.0.tgz", + "integrity": "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.82.0.tgz", + "integrity": "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.82.0.tgz", + "integrity": "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.82.0.tgz", + "integrity": "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.82.0.tgz", + "integrity": "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.82.0.tgz", + "integrity": "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.82.0.tgz", + "integrity": "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.82.0.tgz", + "integrity": "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.82.0.tgz", + "integrity": "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.82.0.tgz", + "integrity": "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.82.0.tgz", + "integrity": "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.82.0.tgz", + "integrity": "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.82.0.tgz", + "integrity": "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.82.0.tgz", + "integrity": "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.82.0.tgz", + "integrity": "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.82.0.tgz", + "integrity": "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.82.0.tgz", + "integrity": "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.82.0.tgz", + "integrity": "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz", + "integrity": "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.82.0", + "@oxlint/binding-android-arm64": "1.82.0", + "@oxlint/binding-darwin-arm64": "1.82.0", + "@oxlint/binding-darwin-x64": "1.82.0", + "@oxlint/binding-freebsd-x64": "1.82.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", + "@oxlint/binding-linux-arm-musleabihf": "1.82.0", + "@oxlint/binding-linux-arm64-gnu": "1.82.0", + "@oxlint/binding-linux-arm64-musl": "1.82.0", + "@oxlint/binding-linux-ppc64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-musl": "1.82.0", + "@oxlint/binding-linux-s390x-gnu": "1.82.0", + "@oxlint/binding-linux-x64-gnu": "1.82.0", + "@oxlint/binding-linux-x64-musl": "1.82.0", + "@oxlint/binding-openharmony-arm64": "1.82.0", + "@oxlint/binding-win32-arm64-msvc": "1.82.0", + "@oxlint/binding-win32-ia32-msvc": "1.82.0", + "@oxlint/binding-win32-x64-msvc": "1.82.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7cc6002 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "lemonade", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..d3e7ab2 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..b509121 --- /dev/null +++ b/src/App.css @@ -0,0 +1,337 @@ +.app { + flex: 1; + width: 100%; + display: flex; + justify-content: center; +} + +.cabinet { + /* Wide as the room allows, but never so wide that the 4:3 tube plus its + bezel and controls run off the bottom of the window. */ + width: min(1000px, 100%, calc((100dvh - 175px) * 4 / 3)); + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ------------------------------------------------------------- the box */ + +.bezel { + background: linear-gradient(180deg, #3a3d47 0%, #23252c 45%, #16171c 100%); + border-radius: 22px; + padding: clamp(12px, 2.4vw, 24px); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.14) inset, + 0 -2px 0 rgba(0, 0, 0, 0.6) inset, + 0 24px 60px rgba(0, 0, 0, 0.65); +} + +.screen { + position: relative; + /* An Atari 800 fed a 4:3 television. The picture never changed shape, so + neither does this one - it scales to the tube instead. */ + aspect-ratio: 4 / 3; + /* Everything inside is sized in cqi, so the whole picture stays in + proportion no matter how big the set is. */ + container-type: inline-size; + background: var(--atari-screen); + border: clamp(6px, 1.2vw, 12px) solid var(--atari-border); + border-radius: 14px; + overflow: hidden; + box-shadow: + 0 0 0 2px #000, + 0 0 60px rgba(70, 120, 255, 0.35), + 0 0 120px rgba(40, 80, 220, 0.25) inset; + animation: power-on 900ms ease-out 1; +} + +@keyframes power-on { + 0% { filter: brightness(3) blur(2px); transform: scaleY(0.02); } + 35% { filter: brightness(1.6) blur(1px); transform: scaleY(1); } + 100% { filter: none; transform: none; } +} + +.screen-inner { + position: relative; + z-index: 2; + --pad: 1.25em; + padding: var(--pad); + font-size: 2.15cqi; + line-height: 1.4; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--atari-text); + text-shadow: 0 0 6px rgba(150, 190, 255, 0.55); + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + /* No scrollbars, ever - a television has none. */ + overflow: hidden; +} + +.screen-body { + flex: 1 1 auto; + min-height: 0; + display: flex; + overflow: hidden; +} + +.scanlines { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + background: repeating-linear-gradient( + to bottom, + rgba(0, 0, 0, 0.22) 0px, + rgba(0, 0, 0, 0.22) 1px, + transparent 1px, + transparent 3px + ); + mix-blend-mode: multiply; +} + +.vignette { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; + background: radial-gradient(120% 100% at 50% 50%, transparent 55%, rgba(0, 0, 0, 0.55) 100%); +} + +/* --------------------------------------------------------------- text */ + +.stack { + display: flex; + flex-direction: column; + max-width: 46ch; + width: 100%; + height: max-content; + margin: auto; + transform: scale(var(--fit, 1)); + transform-origin: center; +} + +.line { + white-space: pre-wrap; + min-height: 1.4em; +} + +.center { text-align: center; } +.dim { color: #7f9adf; text-shadow: none; } +.accent { color: var(--atari-yellow); text-shadow: 0 0 8px rgba(247, 222, 90, 0.5); } +.warn { color: var(--atari-red); text-shadow: 0 0 8px rgba(255, 138, 122, 0.5); } +.money { color: var(--atari-green); } + +.inv, +.inv-line { + background: var(--atari-text); + color: var(--atari-screen-dark); + text-shadow: none; + padding: 0 0.4ch; +} + +.inv-line { + display: block; + padding: 2px 0; +} + +.blink { animation: blink 1.1s steps(1, end) infinite; } +@keyframes blink { 50% { opacity: 0; } } + +.banner { + margin: 0 auto; + font: inherit; + font-size: 0.56em; + line-height: 1.05; + margin-bottom: 0.5em; + letter-spacing: 0; + color: var(--atari-yellow); + text-shadow: 0 0 10px rgba(247, 222, 90, 0.6); + white-space: pre; +} + +.banner.small { opacity: 0.35; font-size: 0.38em; } + +.statusbar { + display: flex; + justify-content: space-between; + gap: 1ch; + background: var(--atari-text); + color: var(--atari-screen-dark); + text-shadow: none; + padding: 0.15em 0.7em; + margin: calc(-1 * var(--pad)) calc(-1 * var(--pad)) 0; + flex: 0 0 auto; + font-weight: 700; +} + +/* -------------------------------------------------------------- scene */ + +.scene { + display: block; + width: 100%; + max-width: 32ch; + margin: 0.5em auto; + border: 2px solid rgba(0, 0, 0, 0.5); + image-rendering: pixelated; + background: #000; +} + +.stand-sign, +.stand-price { + font-family: inherit; + fill: #b03a2e; + text-shadow: none; +} + +.stand-sign { font-size: 11px; font-weight: 700; letter-spacing: 0.5px; } +.stand-price { font-size: 8px; fill: #2b45b8; } + +.shimmer { animation: shimmer 2.4s ease-in-out infinite; } +@keyframes shimmer { + 0%, 100% { transform: translateY(0); opacity: 0.3; } + 50% { transform: translateY(-3px); opacity: 0.55; } +} + +.rain { animation: rain 0.5s linear infinite; } +@keyframes rain { + from { transform: translateY(-14px); } + to { transform: translateY(0); } +} + +.bolt { animation: bolt 3s steps(1, end) infinite; opacity: 0; } +@keyframes bolt { + 0%, 6% { opacity: 0; } + 7%, 9% { opacity: 1; } + 10%, 13% { opacity: 0; } + 14%, 16% { opacity: 1; } + 17%, 100% { opacity: 0; } +} + +/* ------------------------------------------------------------- fields */ + +.field { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 1ch; + padding: 0.1em 0; +} + +.field-label { color: var(--atari-text); } + +.field-input { + width: 7ch; + font: inherit; + text-transform: uppercase; + text-align: right; + color: var(--atari-screen-dark); + background: var(--atari-text); + border: 0; + border-radius: 0.15em; + padding: 0.1em 0.3em; + outline: 0.13em solid transparent; +} + +.field-input-name { + width: 14ch; + text-align: left; + justify-self: end; +} + +.field-input::placeholder { color: #5a6ba8; } +.field-input:focus { outline-color: var(--atari-yellow); } + +.field-note { + min-width: 7ch; + text-align: right; + color: var(--atari-yellow); +} + +/* ------------------------------------------------------------ reports */ + +.report-row { + display: flex; + align-items: baseline; + gap: 0.5ch; +} + +.report-label { white-space: nowrap; } + +.report-dots { + flex: 1; + border-bottom: 1px dotted rgba(185, 207, 255, 0.35); + transform: translateY(-0.25em); +} + +.report-value { white-space: nowrap; } +.report-row.strong { color: var(--atari-bright); font-weight: 700; } + +/* ------------------------------------------------------------ buttons */ + +.row { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + align-items: center; + padding: 0.3em 0; +} + +.row.center { justify-content: center; } + +.btn { + font: inherit; + text-transform: uppercase; + letter-spacing: 0.06em; + cursor: pointer; + padding: 0.35em 0.85em; + border-radius: 0.25em; + border: 0.13em solid var(--atari-text); + background: transparent; + color: var(--atari-text); + transition: transform 60ms ease, background 120ms ease, color 120ms ease; +} + +.btn:hover:not(:disabled) { background: rgba(185, 207, 255, 0.18); } +.btn:active:not(:disabled) { transform: translateY(1px); } + +.btn-primary { + background: var(--atari-yellow); + border-color: var(--atari-yellow); + color: #2a2000; + font-weight: 700; +} + +.btn-primary:hover:not(:disabled) { background: #fff0a0; } + +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.btn-ghost { + border-color: #3d4453; + color: #8fa0c4; + background: #14161d; + padding: 5px 10px; + font-size: 0.85em; +} + +.btn-ghost:hover:not(:disabled) { background: #1e222c; color: var(--atari-bright); } + +.controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: center; + font-family: inherit; + font-size: clamp(11px, 1.1vw, 14px); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.seed { color: #566079; margin-left: auto; } + +@media (max-width: 560px) { + .seed { margin-left: 0; } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..64f3ebe --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,242 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { DAY_TUNE, END_TUNE, TITLE_TUNE } from './audio/tunes' +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 { 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 { IntroScreen } from './components/IntroScreen' +import { ReportScreen } from './components/ReportScreen' +import { SetupScreen } from './components/SetupScreen' +import { TitleScreen } from './components/TitleScreen' +import './App.css' + +export default function App() { + const [seed] = useState(randomSeed) + const [state, dispatch] = useReducer(reducer, seed, initialState) + const rng = useRef(makeRng(seed)) + const [music, setMusic] = useState(true) + const [sfx, setSfx] = useState(true) + const started = useRef(false) + + const active = activePlayers(state) + const current = active[Math.min(state.turn, Math.max(0, active.length - 1))] + + // ------------------------------------------------------------ audio glue + + const wake = useCallback(() => { + if (started.current) return + started.current = true + synth.ensure() + synth.setMusic(music) + synth.setSfx(sfx) + }, [music, sfx]) + + useEffect(() => { + if (!started.current) return + const tune = + state.phase === 'gameover' + ? END_TUNE + : state.phase === 'title' || state.phase === 'intro' || state.phase === 'setup' + ? TITLE_TUNE + : DAY_TUNE + synth.playTune(tune, false) + }, [state.phase]) + + useEffect(() => { + synth.setMusic(music) + }, [music]) + useEffect(() => { + synth.setSfx(sfx) + }, [sfx]) + + const blip = useCallback(() => synth.blip(), []) + + const goSetup = useCallback(() => { + wake() + synth.select() + dispatch({ type: 'SHOW_SETUP' }) + }, [wake]) + + const goIntro = useCallback(() => { + wake() + synth.select() + dispatch({ type: 'SHOW_INTRO' }) + }, [wake]) + + // -------------------------------------------------------------- handlers + + const beginDay = useCallback( + (day: number, streetCrewYesterday: boolean) => { + const conditions = rollDay(day, rng.current, streetCrewYesterday) + dispatch({ type: 'BEGIN_DAY', conditions }) + if (conditions.heatWave) synth.heat() + else if (conditions.weather === 'sunny') synth.sunshine() + }, + [], + ) + + const start = (names: string[]) => { + wake() + synth.select() + dispatch({ type: 'START', names }) + beginDay(1, false) + } + + const submit = (decision: Decision) => { + if (!state.conditions || !current) return + const decisions = { ...state.decisions, [current.id]: decision } + dispatch({ type: 'SUBMIT', playerId: current.id, decision }) + + const everyoneIn = active.every((p) => decisions[p.id] !== undefined) + if (!everyoneIn) return + + const results = active.map((p) => simulate(p, decisions[p.id], state.conditions!, rng.current)) + dispatch({ type: 'RESOLVE', results }) + + if (state.conditions.storm) synth.thunder() + else if (results.some((r) => r.profit > 0)) synth.cash() + else synth.sad() + } + + const nextDay = () => { + synth.select() + dispatch({ type: 'NEXT_DAY' }) + beginDay(state.day + 1, state.streetCrewYesterday) + } + + const retire = () => { + synth.fanfare() + dispatch({ type: 'RETIRE' }) + } + + const restart = () => { + const s = randomSeed() + rng.current = makeRng(s) + dispatch({ type: 'RESTART', seed: s }) + } + + // ---------------------------------------------------------------- render + + 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 } + }, [state.phase, state.day, state.conditions, current]) + + return ( +
+ + { + wake() + setMusic((m) => !m) + }} + title="Background music" + > + MUSIC {music ? 'ON' : 'OFF'} + + { + wake() + setSfx((s) => !s) + }} + title="Sound effects" + > + SOUND {sfx ? 'ON' : 'OFF'} + + + NEW GAME + + SEED {state.seed} +
+ } + > + {status && ( +
+ DAY {String(status.day).padStart(2, '0')} + {status.weather} + {status.player ? dollars(status.player.assets) : '--'} +
+ )} + + + {state.phase === 'title' && ( + + )} + + {state.phase === 'intro' && dispatch({ type: 'SHOW_SETUP' })} />} + + {state.phase === 'setup' && } + + {state.phase === 'briefing' && state.conditions && ( + { + synth.select() + dispatch({ type: 'OPEN_STAND' }) + }} + /> + )} + + {state.phase === 'decide' && state.conditions && current && ( + synth.reject()} + /> + )} + + {state.phase === 'report' && state.conditions && ( + + )} + + {state.phase === 'gameover' && ( + + )} + + + + ) +} + +/** Enter or Space works as the START key, the way the console did. */ +function TitleGate(props: { seed: number; onStart: () => void; onInstructions: () => void }) { + const { onStart } = props + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') onStart() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onStart]) + + return +} diff --git a/src/audio/synth.ts b/src/audio/synth.ts new file mode 100644 index 0000000..1125d23 --- /dev/null +++ b/src/audio/synth.ts @@ -0,0 +1,297 @@ +/** + * A very small chiptune engine: pulse waves for voices, filtered white noise + * for percussion, and a look-ahead scheduler so patterns stay in time even + * when React is busy re-rendering. + */ + +const NOTE_INDEX: Record = { + C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5, + 'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11, +} + +/** "C#4" -> Hz. A4 = 440. */ +export function noteToFreq(note: string): number { + const m = /^([A-G]#?)(-?\d)$/.exec(note) + if (!m) return 0 + const semis = NOTE_INDEX[m[1]] + (Number(m[2]) + 1) * 12 + return 440 * Math.pow(2, (semis - 69) / 12) +} + +export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noise' + +export interface Track { + wave: Wave + gain: number + /** One entry per step. '.' rest, '=' sustain previous, otherwise a note. */ + notes: string[] +} + +export interface Tune { + bpm: number + stepsPerBeat: number + tracks: Track[] +} + +/** Fourier series for a pulse wave of the given duty cycle. */ +function pulseWave(ctx: AudioContext, duty: number, harmonics = 24): PeriodicWave { + const real = new Float32Array(harmonics + 1) + const imag = new Float32Array(harmonics + 1) + for (let n = 1; n <= harmonics; n++) { + imag[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * duty) + } + return ctx.createPeriodicWave(real, imag, { disableNormalization: false }) +} + +export class Synth { + private ctx: AudioContext | null = null + private master!: GainNode + private musicBus!: GainNode + private sfxBus!: GainNode + private waves: Partial> = {} + private noiseBuffer!: AudioBuffer + + private tune: Tune | null = null + private step = 0 + private nextStepTime = 0 + private timer: number | null = null + + musicOn = true + sfxOn = true + + /** Must be called from a user gesture the first time. */ + ensure(): AudioContext { + if (this.ctx) { + if (this.ctx.state === 'suspended') void this.ctx.resume() + return this.ctx + } + const ctx = new AudioContext() + this.ctx = ctx + this.master = ctx.createGain() + this.master.gain.value = 0.5 + this.master.connect(ctx.destination) + + this.musicBus = ctx.createGain() + this.musicBus.gain.value = 0.55 + this.musicBus.connect(this.master) + + this.sfxBus = ctx.createGain() + this.sfxBus.gain.value = 0.9 + this.sfxBus.connect(this.master) + + this.waves.pulse12 = pulseWave(ctx, 0.125) + this.waves.pulse25 = pulseWave(ctx, 0.25) + this.waves.pulse50 = pulseWave(ctx, 0.5) + + const len = Math.floor(ctx.sampleRate * 1.5) + const buf = ctx.createBuffer(1, len, ctx.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1 + this.noiseBuffer = buf + + return ctx + } + + setMusic(on: boolean) { + this.musicOn = on + if (!this.ctx) return + this.musicBus.gain.setTargetAtTime(on ? 0.55 : 0, this.ctx.currentTime, 0.05) + } + + setSfx(on: boolean) { + this.sfxOn = on + if (!this.ctx) return + this.sfxBus.gain.setTargetAtTime(on ? 0.9 : 0, this.ctx.currentTime, 0.02) + } + + // ---------------------------------------------------------------- voices + + private voice( + dest: AudioNode, + wave: Wave, + freq: number, + at: number, + dur: number, + gain: number, + ) { + const ctx = this.ensure() + const osc = ctx.createOscillator() + if (wave === 'triangle') osc.type = 'triangle' + else if (wave === 'saw') osc.type = 'sawtooth' + else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!) + osc.frequency.setValueAtTime(freq, at) + + const env = ctx.createGain() + const peak = Math.max(0.0001, gain) + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(peak, at + 0.008) + env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6)) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + + osc.connect(env).connect(dest) + osc.start(at) + osc.stop(at + dur + 0.02) + } + + private percussion(dest: AudioNode, kind: string, at: number, gain: number) { + const ctx = this.ensure() + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + const filter = ctx.createBiquadFilter() + const env = ctx.createGain() + + let dur = 0.06 + if (kind === 'K') { + filter.type = 'lowpass' + filter.frequency.value = 220 + dur = 0.12 + } else if (kind === 'S') { + filter.type = 'bandpass' + filter.frequency.value = 1800 + dur = 0.12 + } else { + filter.type = 'highpass' + filter.frequency.value = 6000 + dur = 0.04 + } + + env.gain.setValueAtTime(gain, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + src.connect(filter).connect(env).connect(dest) + src.start(at) + src.stop(at + dur + 0.02) + } + + // ------------------------------------------------------------- sequencer + + playTune(tune: Tune, restart = true) { + this.ensure() + if (this.tune === tune && this.timer !== null && !restart) return + this.stopTune() + this.tune = tune + this.step = 0 + this.nextStepTime = this.ctx!.currentTime + 0.08 + this.timer = window.setInterval(() => this.schedule(), 25) + } + + stopTune() { + if (this.timer !== null) window.clearInterval(this.timer) + this.timer = null + this.tune = null + } + + get playing() { + return this.timer !== null + } + + private schedule() { + const ctx = this.ctx + const tune = this.tune + if (!ctx || !tune) return + const stepDur = 60 / tune.bpm / tune.stepsPerBeat + const length = Math.max(...tune.tracks.map((t) => t.notes.length)) + + while (this.nextStepTime < ctx.currentTime + 0.2) { + for (const track of tune.tracks) { + const note = track.notes[this.step % track.notes.length] + if (!note || note === '.' || note === '=') continue + + // A note runs until the next step that is not a sustain marker. + let held = 1 + for (let i = 1; i < length; i++) { + if (track.notes[(this.step + i) % track.notes.length] === '=') held++ + else break + } + const dur = held * stepDur * 0.95 + + if (track.wave === 'noise') { + this.percussion(this.musicBus, note, this.nextStepTime, track.gain) + } else { + const f = noteToFreq(note) + if (f) this.voice(this.musicBus, track.wave, f, this.nextStepTime, dur, track.gain) + } + } + this.nextStepTime += stepDur + this.step = (this.step + 1) % length + } + } + + // ------------------------------------------------------------------ sfx + + private seq(notes: [string, number][], wave: Wave = 'pulse25', gain = 0.22) { + const ctx = this.ensure() + let t = ctx.currentTime + 0.01 + for (const [note, dur] of notes) { + if (note !== '.') this.voice(this.sfxBus, wave, noteToFreq(note), t, dur, gain) + t += dur + } + } + + blip() { + this.seq([['E5', 0.05]], 'pulse12', 0.15) + } + + select() { + this.seq([['C5', 0.05], ['G5', 0.09]], 'pulse25', 0.18) + } + + reject() { + this.seq([['A3', 0.09], ['E3', 0.16]], 'saw', 0.2) + } + + cash() { + this.seq([['C5', 0.07], ['E5', 0.07], ['G5', 0.07], ['C6', 0.22]], 'pulse25', 0.2) + } + + sad() { + this.seq([['G4', 0.12], ['F#4', 0.12], ['F4', 0.12], ['E4', 0.4]], 'triangle', 0.22) + } + + thunder() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + src.loop = true + const filter = ctx.createBiquadFilter() + filter.type = 'lowpass' + filter.frequency.setValueAtTime(1400, at) + filter.frequency.exponentialRampToValueAtTime(120, at + 1.4) + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(0.35, at + 0.05) + env.gain.exponentialRampToValueAtTime(0.0001, at + 1.5) + src.connect(filter).connect(env).connect(this.sfxBus) + src.start(at) + src.stop(at + 1.6) + } + + sunshine() { + this.seq([['C5', 0.06], ['D5', 0.06], ['E5', 0.06], ['G5', 0.06], ['A5', 0.18]], 'pulse12', 0.16) + } + + heat() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(300, at) + osc.frequency.exponentialRampToValueAtTime(900, at + 0.5) + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(0.2, at + 0.1) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.6) + osc.connect(env).connect(this.sfxBus) + osc.start(at) + osc.stop(at + 0.7) + } + + fanfare() { + this.seq( + [['C5', 0.11], ['E5', 0.11], ['G5', 0.11], ['C6', 0.11], ['G5', 0.11], ['C6', 0.4]], + 'pulse25', + 0.2, + ) + } +} + +export const synth = new Synth() diff --git a/src/audio/tunes.ts b/src/audio/tunes.ts new file mode 100644 index 0000000..426939a --- /dev/null +++ b/src/audio/tunes.ts @@ -0,0 +1,142 @@ +import type { Tune } from './synth' + +const bar = (...notes: string[]) => notes + +/** + * Title theme - the sort of thing an ice cream van would play if it had a + * POKEY chip. Four bars of C - F - G - C, twice around. + */ +export const TITLE_TUNE: Tune = { + bpm: 132, + stepsPerBeat: 2, + tracks: [ + { + wave: 'pulse25', + gain: 0.16, + notes: [ + ...bar('G4', '.', 'E4', '.', 'G4', '.', 'C5', '='), + ...bar('B4', '.', 'C5', '.', 'D5', '=', '=', '.'), + ...bar('A4', '.', 'F4', '.', 'A4', '.', 'C5', '='), + ...bar('D5', '.', 'C5', '.', 'B4', '=', '=', '.'), + ...bar('G4', '.', 'B4', '.', 'D5', '.', 'G5', '='), + ...bar('F5', '.', 'E5', '.', 'D5', '=', '=', '.'), + ...bar('E5', '.', 'C5', '.', 'G4', '.', 'E4', '.'), + ...bar('C4', '=', '=', '=', '=', '.', '.', '.'), + ], + }, + { + wave: 'pulse12', + gain: 0.07, + notes: [ + ...bar('E5', '.', '.', '.', 'G5', '.', '.', '.'), + ...bar('.', '.', 'G5', '.', '.', '.', 'F5', '.'), + ...bar('F5', '.', '.', '.', 'A5', '.', '.', '.'), + ...bar('.', '.', 'A5', '.', '.', '.', 'G5', '.'), + ...bar('D5', '.', '.', '.', 'G5', '.', '.', '.'), + ...bar('.', '.', 'B5', '.', '.', '.', 'A5', '.'), + ...bar('G5', '.', '.', '.', 'E5', '.', '.', '.'), + ...bar('C5', '=', '=', '=', '.', '.', '.', '.'), + ], + }, + { + wave: 'triangle', + gain: 0.3, + notes: [ + ...bar('C2', '.', 'G2', '.', 'C3', '.', 'G2', '.'), + ...bar('C2', '.', 'G2', '.', 'E2', '.', 'G2', '.'), + ...bar('F2', '.', 'C3', '.', 'F3', '.', 'C3', '.'), + ...bar('G2', '.', 'D3', '.', 'G3', '.', 'D3', '.'), + ...bar('G2', '.', 'D3', '.', 'G3', '.', 'D3', '.'), + ...bar('G2', '.', 'B2', '.', 'D3', '.', 'B2', '.'), + ...bar('C2', '.', 'G2', '.', 'C3', '.', 'G2', '.'), + ...bar('C2', '=', '=', '=', 'G2', '.', 'C2', '.'), + ], + }, + { + wave: 'noise', + gain: 0.16, + notes: [ + ...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'), + ...bar('K', 'H', '.', 'H', 'S', 'H', 'K', 'H'), + ...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'), + ...bar('K', 'H', '.', 'H', 'S', 'H', 'K', 'H'), + ...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'), + ...bar('K', 'H', '.', 'H', 'S', 'H', 'K', 'H'), + ...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'), + ...bar('K', '.', 'S', '.', 'K', 'S', 'S', 'S'), + ], + }, + ], +} + +/** Trading music: slower, sparser, meant to sit under the numbers. */ +export const DAY_TUNE: Tune = { + bpm: 104, + stepsPerBeat: 2, + tracks: [ + { + wave: 'pulse12', + gain: 0.1, + notes: [ + ...bar('C5', '.', 'E5', '.', 'G5', '.', 'E5', '.'), + ...bar('D5', '.', 'F5', '.', 'A5', '=', '.', '.'), + ...bar('B4', '.', 'D5', '.', 'G5', '.', 'D5', '.'), + ...bar('C5', '=', '=', '.', 'E5', '.', 'G4', '.'), + ...bar('A4', '.', 'C5', '.', 'E5', '.', 'C5', '.'), + ...bar('F4', '.', 'A4', '.', 'D5', '=', '.', '.'), + ...bar('G4', '.', 'B4', '.', 'D5', '.', 'F5', '.'), + ...bar('E5', '=', '=', '.', 'C5', '=', '.', '.'), + ], + }, + { + wave: 'triangle', + gain: 0.26, + notes: [ + ...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'), + ...bar('D2', '.', '.', '.', 'A2', '.', '.', '.'), + ...bar('G2', '.', '.', '.', 'D3', '.', '.', '.'), + ...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'), + ...bar('A2', '.', '.', '.', 'E3', '.', '.', '.'), + ...bar('F2', '.', '.', '.', 'C3', '.', '.', '.'), + ...bar('G2', '.', '.', '.', 'D3', '.', '.', '.'), + ...bar('C2', '.', '.', '.', 'G2', '.', 'B2', '.'), + ], + }, + { + wave: 'noise', + gain: 0.1, + notes: [ + ...bar('.', '.', 'H', '.', '.', '.', 'H', '.'), + ...bar('.', '.', 'H', '.', '.', '.', 'H', 'H'), + ], + }, + ], +} + +/** Plays once behind the closing standings. */ +export const END_TUNE: Tune = { + bpm: 96, + stepsPerBeat: 2, + tracks: [ + { + wave: 'pulse25', + gain: 0.15, + notes: [ + ...bar('G4', '.', 'C5', '=', 'E5', '.', 'G5', '='), + ...bar('E5', '.', 'C5', '=', 'G4', '=', '=', '.'), + ...bar('A4', '.', 'D5', '=', 'F5', '.', 'A5', '='), + ...bar('G5', '.', 'E5', '=', 'C5', '=', '=', '.'), + ], + }, + { + wave: 'triangle', + gain: 0.28, + notes: [ + ...bar('C2', '.', '.', '.', 'C3', '.', '.', '.'), + ...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'), + ...bar('D2', '.', '.', '.', 'D3', '.', '.', '.'), + ...bar('C2', '.', '.', '.', 'G2', '.', 'C2', '.'), + ], + }, + ], +} diff --git a/src/components/BriefingScreen.tsx b/src/components/BriefingScreen.tsx new file mode 100644 index 0000000..4b99653 --- /dev/null +++ b/src/components/BriefingScreen.tsx @@ -0,0 +1,49 @@ +import { WEATHER, costMessage } from '../game/constants' +import type { DayConditions } from '../game/types' +import { Btn, Line } from './Crt' +import { Scene } from './Scene' + +export function BriefingScreen({ + conditions, + onContinue, +}: { + conditions: DayConditions + onContinue: () => void +}) { + const cost = costMessage(conditions.day) + + return ( +
+ DAY {conditions.day} IN LEMONSVILLE + + + WEATHER REPORT: {WEATHER[conditions.weather].label} + + + {cost?.map((t, i) => ( + {t} + ))} + {conditions.heatWave && ( + <> + + A HEAT WAVE IS PREDICTED FOR TODAY! + EVERYONE IN TOWN IS THIRSTY. + + )} + {conditions.streetCrew && ( + <> + + THE STREET CREWS ARE WORKING TODAY. + THERE WILL BE NO TRAFFIC ON YOUR + STREET. + + )} + +
+ + OPEN THE STAND + +
+
+ ) +} diff --git a/src/components/Crt.tsx b/src/components/Crt.tsx new file mode 100644 index 0000000..12f9de3 --- /dev/null +++ b/src/components/Crt.tsx @@ -0,0 +1,79 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react' + +export function Crt({ children, footer }: { children: ReactNode; footer?: ReactNode }) { + return ( +
+
+
+
{children}
+
+
+
+
+ {footer} +
+ ) +} + +/** + * A television shows one fixed picture; it never scrolls. If a page is taller + * than the tube, shrink the picture until it fits rather than clipping it. + */ +export function Fit({ children }: { children: ReactNode }) { + const box = useRef(null) + const [scale, setScale] = useState(1) + + useLayoutEffect(() => { + const measure = () => { + const el = box.current + const page = el?.firstElementChild as HTMLElement | null + if (!el || !page) return + // offsetHeight is the laid-out size and ignores the transform, so + // measuring here cannot feed back into itself. + const h = page.offsetHeight + const avail = el.clientHeight + setScale(h > 0 && avail > 0 ? Math.min(1, avail / h) : 1) + } + + measure() + const ro = new ResizeObserver(measure) + if (box.current) ro.observe(box.current) + if (box.current?.firstElementChild) ro.observe(box.current.firstElementChild) + return () => ro.disconnect() + }) + + return ( +
+ {children} +
+ ) +} + +export function Line({ children, className = '' }: { children?: ReactNode; className?: string }) { + return
{children ?? ' '}
+} + +/** Atari inverse video: light background, dark characters. */ +export function Inv({ children }: { children: ReactNode }) { + return {children} +} + +export function Btn({ + children, + onClick, + kind = 'normal', + disabled, + title, +}: { + children: ReactNode + onClick: () => void + kind?: 'normal' | 'primary' | 'ghost' + disabled?: boolean + title?: string +}) { + return ( + + ) +} diff --git a/src/components/DecideScreen.tsx b/src/components/DecideScreen.tsx new file mode 100644 index 0000000..2edd5bc --- /dev/null +++ b/src/components/DecideScreen.tsx @@ -0,0 +1,136 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { MAX_SIGNS, SIGN_COST } from '../game/constants' +import { dollars } from '../game/engine' +import type { DayConditions, Decision, Player } from '../game/types' +import { Btn, Line } from './Crt' +import { Scene } from './Scene' + +const clampInt = (v: string, max: number) => { + const n = Math.floor(Number(v.replace(/[^0-9]/g, ''))) + if (!Number.isFinite(n) || n < 0) return 0 + return Math.min(n, max) +} + +export function DecideScreen({ + player, + conditions, + playerCount, + onSubmit, + onBlip, + onReject, +}: { + player: Player + conditions: DayConditions + playerCount: number + onSubmit: (d: Decision) => void + onBlip: () => void + onReject: () => void +}) { + // App remounts this per player per day, so plain initial state is the reset. + const [glasses, setGlasses] = useState('0') + const [signs, setSigns] = useState('0') + const [price, setPrice] = useState('5') + const first = useRef(null) + + useEffect(() => { + first.current?.focus() + }, []) + + const d: Decision = { + glasses: clampInt(glasses, 9999), + signs: clampInt(signs, MAX_SIGNS), + price: clampInt(price, 100), + } + + const lemonadeCost = d.glasses * conditions.costPerGlass + const signCost = d.signs * SIGN_COST + const total = lemonadeCost + signCost + const left = player.assets - total + + const error = useMemo(() => { + if (signCost > player.assets) return 'YOU CANNOT AFFORD THAT MANY SIGNS.' + if (total > player.assets) return "YOU DON'T HAVE ENOUGH MONEY TO MAKE THAT MANY GLASSES." + if (d.glasses === 0) return 'YOU MUST MAKE AT LEAST ONE GLASS.' + return null + }, [signCost, total, player.assets, d.glasses]) + + const submit = () => { + if (error) { + onReject() + return + } + onBlip() + onSubmit(d) + } + + const onKey = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter') return + const form = e.currentTarget.closest('.stack') + const inputs = Array.from(form?.querySelectorAll('input') ?? []) + const i = inputs.indexOf(e.target as HTMLInputElement) + if (i >= 0 && i < inputs.length - 1) inputs[i + 1].focus() + else submit() + } + + return ( +
+ + {playerCount > 1 ? `${player.name} - DAY ${conditions.day}` : `DAY ${conditions.day}`} + + + + ASSETS {dollars(player.assets)} · LEMONADE COSTS{' '} + {conditions.costPerGlass}¢ A GLASS + + + + + + + + + + + TODAY'S OUTLAY {dollars(total)} · LEFT IN TIN{' '} + {dollars(left)} + + {error && {error}} +
+ + SELL LEMONADE + +
+
+ ) +} diff --git a/src/components/GameOverScreen.tsx b/src/components/GameOverScreen.tsx new file mode 100644 index 0000000..73bb302 --- /dev/null +++ b/src/components/GameOverScreen.tsx @@ -0,0 +1,75 @@ +import { STARTING_ASSETS } from '../game/constants' +import { dollars } from '../game/engine' +import type { DayResult, Player } from '../game/types' +import { Btn, Line } from './Crt' +import { bannerRows } from './logo' + +const BANNER = bannerRows('LEMONADE') + +export function GameOverScreen({ + players, + history, + days, + onRestart, +}: { + players: Player[] + history: DayResult[] + days: number + onRestart: () => void +}) { + const ranked = [...players].sort((a, b) => b.assets - a.assets) + const best = history.reduce( + (acc, r) => (acc === null || r.profit > acc.profit ? r : acc), + null, + ) + const bestPlayer = best ? players.find((p) => p.id === best.playerId) : null + const totalGlasses = history.reduce((n, r) => n + r.glassesSold, 0) + + return ( +
+
+        {BANNER.join('\n')}
+      
+ THE SUMMER IS OVER + + + {days} {days === 1 ? 'DAY' : 'DAYS'} OF TRADING · {totalGlasses} GLASSES SOLD + + + {ranked.map((p, i) => { + const net = p.assets - STARTING_ASSETS + return ( +
+ + {i + 1}. {p.name} + {p.bankrupt ? ' (BROKE)' : ''} + + + = 0 ? 'money' : 'warn'}`}> + {dollars(p.assets)} + +
+ ) + })} + + {best && bestPlayer && ( + + BEST DAY: {bestPlayer.name} MADE {dollars(best.profit)} ON DAY{' '} + {Math.floor(history.indexOf(best) / Math.max(1, players.length)) + 1} + + )} + + + {ranked[0].assets > STARTING_ASSETS + ? 'NOT BAD FOR A CARD TABLE AND A PITCHER.' + : 'THE LEMONS WON THIS TIME.'} + + +
+ + PLAY AGAIN + +
+
+ ) +} diff --git a/src/components/IntroScreen.tsx b/src/components/IntroScreen.tsx new file mode 100644 index 0000000..c612284 --- /dev/null +++ b/src/components/IntroScreen.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { Btn, Line } from './Crt' + +const PAGES: string[][] = [ + [ + 'HI! WELCOME TO LEMONSVILLE, CALIFORNIA!', + '', + 'IN THIS SMALL TOWN YOU ARE IN CHARGE OF', + 'RUNNING YOUR OWN LEMONADE STAND. YOU CAN', + 'COMPETE WITH AS MANY OTHER PEOPLE AS YOU', + 'WISH, BUT HOW MUCH PROFIT YOU MAKE IS UP', + 'TO YOU. IF YOU MAKE THE MOST MONEY,', + "YOU'RE THE WINNER!", + '', + 'TO MAKE LEMONADE YOU WILL NEED LEMONS,', + 'SUGAR, ICE AND PAPER CUPS. THE COST OF A', + 'GLASS STARTS AT 2 CENTS AND CLIMBS AS', + 'THE SUMMER WEARS ON.', + ], + [ + 'EACH DAY YOU DECIDE THREE THINGS:', + '', + ' 1. HOW MANY GLASSES TO MAKE', + ' 2. HOW MANY SIGNS TO PUT UP', + ' (15 CENTS EACH)', + ' 3. WHAT TO CHARGE PER GLASS', + '', + 'SIGNS BRING CUSTOMERS, BUT THE FOURTH', + 'SIGN HELPS A LOT LESS THAN THE FIRST.', + '', + 'WATCH THE WEATHER. A HOT DAY IS WORTH', + 'MORE GLASSES AND A HIGHER PRICE. A', + 'CLOUDY DAY MIGHT TURN INTO A STORM AND', + 'RUIN EVERY GLASS YOU MADE.', + '', + 'YOU START WITH $2.00. GOOD LUCK!', + ], +] + +export function IntroScreen({ onDone }: { onDone: () => void }) { + const [page, setPage] = useState(0) + const last = page === PAGES.length - 1 + + return ( +
+ HOW TO RUN A LEMONADE STAND + + {PAGES[page].map((t, i) => ( + {t} + ))} + +
+ {page > 0 && setPage(page - 1)}>BACK} + (last ? onDone() : setPage(page + 1))}> + {last ? 'START' : 'MORE'} + + + PAGE {page + 1} OF {PAGES.length} + +
+
+ ) +} diff --git a/src/components/ReportScreen.tsx b/src/components/ReportScreen.tsx new file mode 100644 index 0000000..2e24685 --- /dev/null +++ b/src/components/ReportScreen.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react' +import { WEATHER } from '../game/constants' +import { dollars } from '../game/engine' +import type { DayConditions, DayResult, Player } from '../game/types' +import { Btn, Line } from './Crt' +import { Scene } from './Scene' + +function Row({ label, value, strong }: { label: string; value: string; strong?: boolean }) { + return ( +
+ {label} + + {value} +
+ ) +} + +export function ReportScreen({ + results, + players, + conditions, + onNextDay, + onRetire, + onBlip, +}: { + results: DayResult[] + players: Player[] + conditions: DayConditions + onNextDay: () => void + onRetire: () => void + onBlip: () => void +}) { + // App remounts this each day, so these start fresh without a reset effect. + const [index, setIndex] = useState(0) + // The storm gets its own screen before the books are opened. + const [stormSeen, setStormSeen] = useState(false) + + const r = results[index] + const player = players.find((p) => p.id === r.playerId)! + const isLast = index === results.length - 1 + const everyoneBroke = players.every((p) => p.bankrupt) + + if (conditions.storm && !stormSeen) { + return ( +
+ DAY {conditions.day} IN LEMONSVILLE + + A THUNDERSTORM HIT LEMONSVILLE EARLIER + TODAY, JUST AS THE STANDS WERE BEING + SET UP. EVERYTHING WAS RUINED!! + +
+ { + onBlip() + setStormSeen(true) + }} + > + SEE THE DAMAGE + +
+
+ ) + } + + const events = [ + WEATHER[conditions.weather].label, + conditions.heatWave ? 'HEAT WAVE' : null, + conditions.streetCrew ? 'STREET CREWS' : null, + conditions.storm ? 'THUNDERSTORM' : null, + ] + .filter(Boolean) + .join(' \u00b7 ') + + return ( +
+ $$ LEMONSVILLE DAILY FINANCIAL REPORT $$ + + + + DAY {conditions.day} — {player.name} + + {events} + + + + + + + + + + + + + + {player.bankrupt && ( + <> + + {player.name}, YOU DO NOT HAVE ENOUGH + MONEY LEFT TO STAY IN BUSINESS. + + )} + + +
+ {!isLast && ( + { + onBlip() + setIndex(index + 1) + }} + > + NEXT REPORT ({index + 2}/{results.length}) + + )} + {isLast && !everyoneBroke && ( + + DAY {conditions.day + 1} + + )} + {isLast && ( + + {everyoneBroke ? 'SEE FINAL STANDINGS' : 'RETIRE'} + + )} +
+
+ ) +} diff --git a/src/components/Scene.tsx b/src/components/Scene.tsx new file mode 100644 index 0000000..34a8cd9 --- /dev/null +++ b/src/components/Scene.tsx @@ -0,0 +1,218 @@ +import { useMemo } from 'react' +import type { DayConditions } from '../game/types' + +const PALETTE: Record = { + r: '#d24b3e', // awning red + w: '#f2f6ff', // awning white + n: '#8a5a2b', // timber + N: '#5e3c1c', // timber shadow + y: '#e0a92b', // lemon shade + Y: '#f7de5a', // lemon highlight + g: '#9aa6bd', // cloud grey + G: '#5f6b85', // storm grey + k: '#20263a', + c: '#7fd4ff', + o: '#f08a24', + e: '#5fae4a', +} + +const CELL = 8 +const COLS = 40 +const ROWS = 22 + +/** Body of the stand, 28 cells wide. The awning above it is generated. */ +const STAND = [ + ' N yyyyy N ', + ' N yYYYYYy N ', + ' N yYYYYYyy N ', + ' N yYYYYYy N ', + ' N yyyyy N ', + ' nnnnnnnnnnnnnnnnnnnnn ', + ' nwwwwwwwwwwwwwwwwwwwn ', + ' nwwwwwwwwwwwwwwwwwwwn ', + ' nwwwwwwwwwwwwwwwwwwwn ', + ' nwwwwwwwwwwwwwwwwwwwn ', + ' N N ', + ' N N ', +] + +const STAND_X = 6 +const AWNING_Y = 5 +const STAND_Y = 8 +/** Each awning row is symmetric about the counter, and one wider than the last. */ +const AWNING_ROWS: [number, number][] = [ + [5, 21], + [4, 22], + [3, 23], +] + +interface Rect { + x: number + y: number + w: number + fill: string +} + +/** Turn a character grid into horizontal runs so the SVG stays small. */ +function runs(rows: string[], ox: number, oy: number): Rect[] { + const out: Rect[] = [] + rows.forEach((row, ry) => { + let x = 0 + while (x < row.length) { + const ch = row[x] + if (ch === ' ' || !PALETTE[ch]) { + x++ + continue + } + let w = 1 + while (row[x + w] === ch) w++ + out.push({ x: ox + x, y: oy + ry, w, fill: PALETTE[ch] }) + x += w + } + }) + return out +} + +/** A filled circle, quantised to the pixel grid. */ +function disc(cx: number, cy: number, r: number, fill: string): Rect[] { + const out: Rect[] = [] + for (let y = -r; y <= r; y++) { + const half = Math.floor(Math.sqrt(Math.max(0, r * r - y * y))) + out.push({ x: cx - half, y: cy + y, w: half * 2 + 1, fill }) + } + return out +} + +/** Red and white stripes that line up vertically from row to row. */ +function awning(): Rect[] { + const out: Rect[] = [] + AWNING_ROWS.forEach(([from, to], row) => { + for (let col = from; col <= to; col++) { + out.push({ + x: STAND_X + col, + y: AWNING_Y + row, + w: 1, + fill: (col >> 1) % 2 === 0 ? PALETTE.r : PALETTE.w, + }) + } + }) + return out +} + +function cloud(cx: number, cy: number, fill: string): Rect[] { + return [ + ...disc(cx, cy, 2, fill), + ...disc(cx - 3, cy + 1, 2, fill), + ...disc(cx + 3, cy + 1, 2, fill), + { x: cx - 6, y: cy + 2, w: 13, fill }, + { x: cx - 5, y: cy + 3, w: 11, fill }, + ] +} + +export function Scene({ conditions, price }: { conditions: DayConditions; price?: number }) { + const { weather, heatWave, streetCrew, storm } = conditions + + const sky = useMemo(() => { + if (storm) return ['#2b3350', '#3d4468'] + if (weather === 'cloudy') return ['#5b6b93', '#8fa0c4'] + if (weather === 'hot') return heatWave ? ['#c9541f', '#f0a13a'] : ['#e07a24', '#f5c164'] + return ['#2f7fd8', '#8fd0f5'] + }, [weather, heatWave, storm]) + + const rects: Rect[] = [] + + if (storm) { + rects.push(...cloud(10, 4, PALETTE.G), ...cloud(24, 3, PALETTE.G), ...cloud(33, 5, PALETTE.G)) + } else if (weather === 'cloudy') { + rects.push(...cloud(9, 4, PALETTE.g), ...cloud(26, 3, PALETTE.g), ...cloud(34, 6, PALETTE.g)) + } else { + const sunX = weather === 'hot' ? 32 : 33 + const sunR = weather === 'hot' ? 5 : 3 + rects.push(...disc(sunX, 5, sunR, heatWave ? '#fff3b0' : PALETTE.Y)) + if (weather === 'sunny') rects.push(...cloud(9, 3, PALETTE.w)) + } + + rects.push(...awning(), ...runs(STAND, STAND_X, STAND_Y)) + + // Grass line under the stand. + rects.push({ x: 0, y: ROWS - 1, w: COLS, fill: PALETTE.e }) + rects.push({ x: 0, y: ROWS - 2, w: COLS, fill: '#6fc258' }) + + if (streetCrew) { + // A pair of road cones and a barrier where the customers used to walk. + for (const cx of [2, 36]) { + rects.push( + { x: cx, y: ROWS - 5, w: 1, fill: PALETTE.o }, + { x: cx - 1, y: ROWS - 4, w: 3, fill: PALETTE.o }, + { x: cx - 1, y: ROWS - 3, w: 3, fill: PALETTE.o }, + { x: cx - 2, y: ROWS - 2, w: 5, fill: '#3a3f52' }, + ) + } + } + + return ( + + + + + + + + + + {heatWave && ( + + {[13, 15, 17].map((y) => ( + + ))} + + )} + + {rects.map((r, i) => ( + + ))} + + {storm && ( + <> + + {Array.from({ length: 26 }, (_, i) => ( + + ))} + + + + )} + + + LEMONADE + + {price !== undefined && ( + + {price}¢ A GLASS + + )} + + ) +} diff --git a/src/components/SetupScreen.tsx b/src/components/SetupScreen.tsx new file mode 100644 index 0000000..782cd89 --- /dev/null +++ b/src/components/SetupScreen.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react' +import { MAX_PLAYERS } from '../game/constants' +import { Btn, Line } from './Crt' + +export function SetupScreen({ + onStart, + onBlip, +}: { + onStart: (names: string[]) => void + onBlip: () => void +}) { + const [count, setCount] = useState(1) + const [names, setNames] = useState(['', '', '', '']) + + const setName = (i: number, v: string) => + setNames((prev) => prev.map((n, j) => (j === i ? v.slice(0, 12) : n))) + + return ( +
+ LEMONSVILLE BUSINESS LICENCE + + HOW MANY PEOPLE WILL PLAY? +
+ {Array.from({ length: MAX_PLAYERS }, (_, i) => i + 1).map((n) => ( + { + onBlip() + setCount(n) + }} + > + {n} + + ))} +
+ + WHAT ARE THEIR NAMES? + {Array.from({ length: count }, (_, i) => ( + + ))} + + EVERY STAND OPENS WITH $2.00 IN THE TIN. + +
+ onStart(names.slice(0, count))}> + OPEN FOR BUSINESS + +
+
+ ) +} diff --git a/src/components/TitleScreen.tsx b/src/components/TitleScreen.tsx new file mode 100644 index 0000000..0dfa202 --- /dev/null +++ b/src/components/TitleScreen.tsx @@ -0,0 +1,35 @@ +import { Btn, Line } from './Crt' +import { bannerRows } from './logo' + +const BANNER = bannerRows('LEMONADE') + +export function TitleScreen({ + onStart, + onInstructions, + seed, +}: { + onStart: () => void + onInstructions: () => void + seed: number +}) { + return ( +
+
+        {BANNER.join('\n')}
+      
+ S T A N D + + LEMONSVILLE, CALIFORNIA + ATARI 8-BIT EDITION · SEED {seed} + + PRESS START + +
+ + START + + INSTRUCTIONS +
+
+ ) +} diff --git a/src/components/logo.ts b/src/components/logo.ts new file mode 100644 index 0000000..05d0973 --- /dev/null +++ b/src/components/logo.ts @@ -0,0 +1,22 @@ +/** 4x5 block letters, drawn the way you would on graph paper. */ +const GLYPHS: Record = { + L: ['#...', '#...', '#...', '#...', '####'], + E: ['####', '#...', '###.', '#...', '####'], + M: ['#..#', '####', '####', '#..#', '#..#'], + O: ['####', '#..#', '#..#', '#..#', '####'], + N: ['#..#', '##.#', '#.##', '#..#', '#..#'], + A: ['####', '#..#', '####', '#..#', '#..#'], + D: ['###.', '#..#', '#..#', '#..#', '###.'], + S: ['####', '#...', '####', '...#', '####'], + T: ['####', '.#..', '.#..', '.#..', '.#..'], + ' ': ['....', '....', '....', '....', '....'], +} + +export function bannerRows(word: string): string[] { + const rows = ['', '', '', '', ''] + for (const ch of word.toUpperCase()) { + const g = GLYPHS[ch] ?? GLYPHS[' '] + for (let r = 0; r < 5; r++) rows[r] += (rows[r] ? ' ' : '') + g[r] + } + return rows.map((r) => r.replace(/#/g, '█').replace(/\./g, ' ')) +} diff --git a/src/game/constants.ts b/src/game/constants.ts new file mode 100644 index 0000000..6bcc1a4 --- /dev/null +++ b/src/game/constants.ts @@ -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 = { + 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) +} diff --git a/src/game/engine.ts b/src/game/engine.ts new file mode 100644 index 0000000..5af621f --- /dev/null +++ b/src/game/engine.ts @@ -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 diff --git a/src/game/reducer.ts b/src/game/reducer.ts new file mode 100644 index 0000000..342bae4 --- /dev/null +++ b/src/game/reducer.ts @@ -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 + 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 + } +} diff --git a/src/game/rng.ts b/src/game/rng.ts new file mode 100644 index 0000000..7154282 --- /dev/null +++ b/src/game/rng.ts @@ -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 + +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 diff --git a/src/game/types.ts b/src/game/types.ts new file mode 100644 index 0000000..7e1c9c6 --- /dev/null +++ b/src/game/types.ts @@ -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' diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..83252b8 --- /dev/null +++ b/src/index.css @@ -0,0 +1,57 @@ +:root { + /* Atari 8-bit GR.0 defaults: dark border, blue field, light blue characters. */ + --atari-border: #0a1030; + --atari-screen: #2b45b8; + --atari-screen-dark: #1d3193; + --atari-text: #b9cfff; + --atari-bright: #eaf2ff; + --atari-yellow: #f7de5a; + --atari-amber: #f0a13a; + --atari-red: #ff8a7a; + --atari-green: #8ef0a8; + + color-scheme: dark; + font-synthesis: none; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +/* The root is a flex item of body; without this the cabinet shrinks to fit. */ +#root { + flex: 1; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +body { + background: + radial-gradient(120% 80% at 50% 0%, #1a1c26 0%, #0b0c12 55%, #050609 100%); + color: var(--atari-text); + font-family: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(8px, 2vw, 28px); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9982072 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})