Shared leaderboard, a games hub, AGPL, and two new street conditions
The high score table moves out of the browser and into a service, so every player is on one board. It is a Node process with SQLite, no build step and no native modules - node:sqlite ships with the runtime and Node runs the TypeScript directly. Everything in a request is treated as hostile: names forced to a printable subset, every number range-checked, and scores refused if they could not have happened in the days claimed. Bodies capped, submissions rate limited per address. It still cannot prove a score is real, and server/README.md says so plainly rather than implying otherwise. The game degrades properly without it: the board says it cannot reach town, posting happens behind the closing standings, and play is untouched. Deployment is three containers behind the host's nginx - the hub at /, the game at /lemonade/, the scores service at /api/ - so the game keeps its own container and a second game is just another service. Licensing: AGPL-3.0-or-later, Affero because the leaderboard is a network service. NOTICE.md credits Bob Jamison and Charlie Kellner, records that this is clean-room work, and is honest about the one thing it is not: some on-screen wording is quoted from the original and cannot be licensed by us. The street can now get better as well as worse. The summer fair brings the town out and lifts what they will pay; a rival on the next corner takes a share and lingers. Both show in the art and in the crowd. Over 500 seasons a player who reads the briefing survives every time and finishes around $25.80; one who ignores it goes broke 70% of the time.
This commit is contained in:
@@ -452,6 +452,12 @@ html[data-skin='modern'] .statusbar {
|
||||
.cel-sign { font-size: 21px; fill: #d1483f; letter-spacing: 0.5px; }
|
||||
.cel-price { font-size: 12px; fill: #5a3ea8; }
|
||||
|
||||
.cel-bunting { animation: cel-bunting 5s ease-in-out infinite alternate; }
|
||||
@keyframes cel-bunting {
|
||||
from { transform: translateY(0) rotate(-0.4deg); }
|
||||
to { transform: translateY(3px) rotate(0.4deg); }
|
||||
}
|
||||
|
||||
.cel-rays { animation: cel-spin 72s linear infinite; }
|
||||
@keyframes cel-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
|
||||
+45
-31
@@ -5,16 +5,16 @@ 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 { HIGH_SCORE_KEY, addScores, loadScores, type Score } from './game/highscores'
|
||||
import { fetchScores, submitScores, type Score } from './game/highscores'
|
||||
import { activePlayers, initialState, reducer } from './game/reducer'
|
||||
import type { Decision } from './game/types'
|
||||
import type { CarryOver, Decision } from './game/types'
|
||||
import { useSkin } from './skin'
|
||||
import { BootScreen } from './components/BootScreen'
|
||||
import { BriefingScreen } from './components/BriefingScreen'
|
||||
import { Btn, Crt, Fit } from './components/Crt'
|
||||
import { DecideScreen } from './components/DecideScreen'
|
||||
import { GameOverScreen } from './components/GameOverScreen'
|
||||
import { HighScoreScreen } from './components/HighScoreScreen'
|
||||
import { HighScoreScreen, type BoardState } from './components/HighScoreScreen'
|
||||
import { IntroScreen } from './components/IntroScreen'
|
||||
import { ReportScreen } from './components/ReportScreen'
|
||||
import { TradingScreen } from './components/TradingScreen'
|
||||
@@ -30,7 +30,8 @@ export default function App() {
|
||||
const [sfx, setSfx] = useState(true)
|
||||
const started = useRef(false)
|
||||
const { skin, toggle: toggleSkin } = useSkin()
|
||||
const [scores, setScores] = useState<Score[]>(loadScores)
|
||||
const [scores, setScores] = useState<Score[]>([])
|
||||
const [boardState, setBoardState] = useState<BoardState>('loading')
|
||||
const [freshScores, setFreshScores] = useState<string[]>([])
|
||||
|
||||
const active = activePlayers(state)
|
||||
@@ -65,17 +66,15 @@ export default function App() {
|
||||
synth.setSfx(sfx)
|
||||
}, [sfx])
|
||||
|
||||
/**
|
||||
* Two tabs in one browser share localStorage. Nothing else is shared - the
|
||||
* game itself lives entirely in the page - but the table should not go
|
||||
* stale just because the other tab finished a season first.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === HIGH_SCORE_KEY || e.key === null) setScores(loadScores())
|
||||
/** The board lives in town, so it is fetched rather than remembered. */
|
||||
const loadBoard = useCallback(async () => {
|
||||
setBoardState('loading')
|
||||
try {
|
||||
setScores(await fetchScores())
|
||||
setBoardState('ready')
|
||||
} catch {
|
||||
setBoardState('error')
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
const blip = useCallback(() => synth.blip(), [])
|
||||
@@ -101,8 +100,8 @@ export default function App() {
|
||||
// -------------------------------------------------------------- handlers
|
||||
|
||||
const beginDay = useCallback(
|
||||
(day: number, streetCrewYesterday: boolean) => {
|
||||
const conditions = rollDay(day, rng.current, streetCrewYesterday)
|
||||
(day: number, yesterday: CarryOver) => {
|
||||
const conditions = rollDay(day, rng.current, yesterday)
|
||||
dispatch({ type: 'BEGIN_DAY', conditions })
|
||||
if (conditions.heatWave) synth.heat()
|
||||
else if (conditions.weather === 'sunny') synth.sunshine()
|
||||
@@ -114,7 +113,7 @@ export default function App() {
|
||||
wake()
|
||||
synth.select()
|
||||
dispatch({ type: 'START', names })
|
||||
beginDay(1, false)
|
||||
beginDay(1, { streetCrew: false, rival: false })
|
||||
}
|
||||
|
||||
const submit = (decision: Decision) => {
|
||||
@@ -140,27 +139,36 @@ export default function App() {
|
||||
const nextDay = () => {
|
||||
synth.select()
|
||||
dispatch({ type: 'NEXT_DAY' })
|
||||
beginDay(state.day + 1, state.streetCrewYesterday)
|
||||
beginDay(state.day + 1, state.yesterday)
|
||||
}
|
||||
|
||||
/** Close the books: everyone who traded gets a line in the table. */
|
||||
/**
|
||||
* Close the books. The standings show straight away; posting to the town
|
||||
* board happens behind them, so a slow or missing line out never holds the
|
||||
* end of a season hostage.
|
||||
*/
|
||||
const retire = () => {
|
||||
synth.fanfare()
|
||||
const glassesFor = (id: number) =>
|
||||
state.history.reduce((n, r) => (r.playerId === id ? n + r.glassesSold : n), 0)
|
||||
const { table, added } = addScores(
|
||||
state.players.map((p) => ({
|
||||
name: p.name,
|
||||
assets: p.assets,
|
||||
days: p.bankruptDay ?? state.day,
|
||||
glasses: glassesFor(p.id),
|
||||
seed: state.seed,
|
||||
broke: p.bankrupt,
|
||||
})),
|
||||
)
|
||||
setScores(table)
|
||||
setFreshScores(added)
|
||||
const entries = state.players.map((p) => ({
|
||||
name: p.name,
|
||||
assets: p.assets,
|
||||
days: p.bankruptDay ?? state.day,
|
||||
glasses: glassesFor(p.id),
|
||||
seed: state.seed,
|
||||
broke: p.bankrupt,
|
||||
}))
|
||||
|
||||
dispatch({ type: 'RETIRE' })
|
||||
setBoardState('loading')
|
||||
submitScores(entries)
|
||||
.then(({ ids, scores: table }) => {
|
||||
setScores(table)
|
||||
setFreshScores(ids)
|
||||
setBoardState('ready')
|
||||
})
|
||||
.catch(() => setBoardState('error'))
|
||||
}
|
||||
|
||||
const restart = () => {
|
||||
@@ -174,6 +182,7 @@ export default function App() {
|
||||
wake()
|
||||
synth.select()
|
||||
dispatch({ type: 'SHOW_SCORES' })
|
||||
if (boardState !== 'ready') void loadBoard()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- render
|
||||
@@ -317,11 +326,16 @@ export default function App() {
|
||||
{state.phase === 'scores' && (
|
||||
<HighScoreScreen
|
||||
scores={scores}
|
||||
state={boardState}
|
||||
highlight={freshScores}
|
||||
onBack={() => {
|
||||
synth.select()
|
||||
dispatch({ type: 'CLOSE_SCORES' })
|
||||
}}
|
||||
onRetry={() => {
|
||||
synth.select()
|
||||
void loadBoard()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fit>
|
||||
|
||||
@@ -39,6 +39,22 @@ export function BriefingScreen({
|
||||
<Line className="warn">STREET.</Line>
|
||||
</>
|
||||
)}
|
||||
{conditions.festival && (
|
||||
<>
|
||||
<Line />
|
||||
<Line className="accent">THE SUMMER FAIR IS ON TODAY. HALF</Line>
|
||||
<Line className="accent">THE TOWN WILL COME PAST, AND THEY</Line>
|
||||
<Line className="accent">ARE OUT TO SPEND.</Line>
|
||||
</>
|
||||
)}
|
||||
{conditions.rival && (
|
||||
<>
|
||||
<Line />
|
||||
<Line className="warn">SOMEBODY HAS SET UP A STAND ON THE</Line>
|
||||
<Line className="warn">NEXT CORNER. YOU WILL BE SPLITTING</Line>
|
||||
<Line className="warn">THE STREET WITH THEM.</Line>
|
||||
</>
|
||||
)}
|
||||
<Line />
|
||||
<div className="row center">
|
||||
<Btn kind="primary" onClick={onContinue}>
|
||||
|
||||
@@ -86,7 +86,7 @@ export function CelScene({
|
||||
traffic?: number
|
||||
variant?: 'full' | 'strip'
|
||||
}) {
|
||||
const { weather, heatWave, streetCrew, storm } = conditions
|
||||
const { weather, heatWave, streetCrew, festival, rival, storm } = conditions
|
||||
const key = storm ? 'storm' : weather
|
||||
const m = MOODS[key] ?? MOODS.sunny
|
||||
const daylight = !storm && weather !== 'cloudy'
|
||||
@@ -206,6 +206,19 @@ export function CelScene({
|
||||
<rect x="0" y="252" width="640" height="8" fill={m.groundShade} opacity="0.5" />
|
||||
<rect x="0" y="248" width="640" height="5" fill={INK} opacity="0.35" />
|
||||
|
||||
{/* --- the competition, small and further down the street ---- */}
|
||||
{rival && (
|
||||
<g stroke={INK} strokeWidth="3" strokeLinejoin="round" opacity="0.9">
|
||||
<ellipse cx="566" cy="256" rx="52" ry="6" fill={INK} opacity={m.shadow} stroke="none" />
|
||||
<rect x="524" y="208" width="7" height="48" fill="#9A6435" />
|
||||
<rect x="601" y="208" width="7" height="48" fill="#9A6435" />
|
||||
<path d="M516 190 H616 V206 q-12 12 -25 0 q-12 12 -25 0 q-12 12 -25 0 q-12 12 -25 0 Z" fill="#4FC3F7" />
|
||||
<rect x="512" y="184" width="108" height="9" rx="4" fill="#E0A867" />
|
||||
<rect x="518" y="230" width="96" height="12" rx="3" fill="#E0A867" />
|
||||
<rect x="524" y="242" width="84" height="16" rx="3" fill="#FFF6DC" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* --- the stand --------------------------------------------- */}
|
||||
<g stroke={INK} strokeWidth="4" strokeLinejoin="round">
|
||||
<ellipse cx="320" cy="286" rx="150" ry="13" fill={INK} opacity={m.shadow} stroke="none" />
|
||||
@@ -280,6 +293,30 @@ export function CelScene({
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* --- the fair ---------------------------------------------- */}
|
||||
{festival && (
|
||||
<g className="cel-bunting">
|
||||
<path d="M-10 70 Q160 118 330 74 T670 84" fill="none" stroke={INK} strokeWidth="3" />
|
||||
{Array.from({ length: 22 }, (_, i) => {
|
||||
const t = i / 21
|
||||
// Follows the same sag as the line above it.
|
||||
const x = -10 + t * 680
|
||||
const y = 70 + Math.sin(t * Math.PI * 2) * 6 + 34 * Math.sin(t * Math.PI)
|
||||
const fill = ['#FF5D8F', '#FFD93D', '#2EC4B6', '#8E7DFF'][i % 4]
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x - 8} ${y} L${x + 8} ${y} L${x} ${y + 17} Z`}
|
||||
fill={fill}
|
||||
stroke={INK}
|
||||
strokeWidth="2.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* --- the street ------------------------------------------- */}
|
||||
{walkers.map((w) => (
|
||||
<g
|
||||
|
||||
@@ -2,17 +2,23 @@ import { dollars } from '../game/engine'
|
||||
import { MAX_SCORES, type Score } from '../game/highscores'
|
||||
import { Btn, Line } from './Crt'
|
||||
|
||||
export type BoardState = 'loading' | 'ready' | 'error'
|
||||
|
||||
const shortDate = (at: number) =>
|
||||
new Date(at).toLocaleDateString(undefined, { day: '2-digit', month: 'short' }).toUpperCase()
|
||||
|
||||
export function HighScoreScreen({
|
||||
scores,
|
||||
state,
|
||||
highlight,
|
||||
onBack,
|
||||
onRetry,
|
||||
}: {
|
||||
scores: Score[]
|
||||
state: BoardState
|
||||
highlight: string[]
|
||||
onBack: () => void
|
||||
onRetry: () => void
|
||||
}) {
|
||||
const fresh = new Set(highlight)
|
||||
|
||||
@@ -21,14 +27,32 @@ export function HighScoreScreen({
|
||||
<Line className="center inv-line">$$ BEST STANDS IN LEMONSVILLE $$</Line>
|
||||
<Line />
|
||||
|
||||
{scores.length === 0 ? (
|
||||
{state === 'loading' && (
|
||||
<>
|
||||
<Line className="center dim">CALLING LEMONSVILLE</Line>
|
||||
<Line className="center blink">█</Line>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<>
|
||||
<Line className="center warn">CANNOT REACH LEMONSVILLE.</Line>
|
||||
<Line />
|
||||
<Line className="center dim">THE BOARD IS KEPT IN TOWN, NOT IN</Line>
|
||||
<Line className="center dim">THIS BROWSER, SO IT NEEDS A LINE OUT.</Line>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ready' && scores.length === 0 && (
|
||||
<>
|
||||
<Line className="center dim">NO STANDS HAVE CLOSED THEIR BOOKS YET.</Line>
|
||||
<Line />
|
||||
<Line className="center dim">RETIRE AT THE END OF A SUMMER TO</Line>
|
||||
<Line className="center dim">TAKE A PLACE ON THIS LIST.</Line>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{state === 'ready' &&
|
||||
scores.map((s, i) => (
|
||||
<div className={`report-row score-row ${fresh.has(s.id) ? 'is-new' : ''}`} key={s.id}>
|
||||
<span className="report-label">
|
||||
@@ -41,17 +65,19 @@ export function HighScoreScreen({
|
||||
</span>
|
||||
<span className="report-value money">{dollars(s.assets)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
|
||||
<Line />
|
||||
<Line className="center dim">TOP {MAX_SCORES}, KEPT IN THIS BROWSER.</Line>
|
||||
<Line className="center dim">A STAND LEAVES ONLY BY BEING BEATEN.</Line>
|
||||
<Line />
|
||||
<Line className="center dim">
|
||||
{state === 'ready'
|
||||
? `TOP ${MAX_SCORES}, FROM EVERY STAND IN TOWN.`
|
||||
: ' '}
|
||||
</Line>
|
||||
<div className="row center">
|
||||
<Btn kind="primary" onClick={onBack}>
|
||||
BACK
|
||||
</Btn>
|
||||
{state !== 'loading' && <Btn onClick={onRetry}>REFRESH</Btn>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -110,7 +110,7 @@ function cloud(cx: number, cy: number, fill: string): Rect[] {
|
||||
}
|
||||
|
||||
export function PixelScene({ conditions, price }: { conditions: DayConditions; price?: number }) {
|
||||
const { weather, heatWave, streetCrew, storm } = conditions
|
||||
const { weather, heatWave, streetCrew, festival, rival, storm } = conditions
|
||||
|
||||
const sky = useMemo(() => {
|
||||
if (storm) return ['#2b3350', '#3d4468']
|
||||
@@ -138,6 +138,27 @@ export function PixelScene({ conditions, price }: { conditions: DayConditions; p
|
||||
rects.push({ x: 0, y: ROWS - 1, w: COLS, fill: PALETTE.e })
|
||||
rects.push({ x: 0, y: ROWS - 2, w: COLS, fill: '#6fc258' })
|
||||
|
||||
if (festival) {
|
||||
// A line of bunting strung above the stand.
|
||||
for (let x = 2; x < COLS - 2; x += 2) {
|
||||
rects.push({ x, y: 3 + (x % 4 === 0 ? 0 : 1), w: 1, fill: x % 4 === 0 ? PALETTE.r : PALETTE.Y })
|
||||
}
|
||||
}
|
||||
|
||||
if (rival) {
|
||||
// A smaller stand further down the street.
|
||||
rects.push(
|
||||
{ x: 33, y: 13, w: 6, fill: PALETTE.c },
|
||||
{ x: 33, y: 14, w: 6, fill: PALETTE.w },
|
||||
{ x: 33, y: 15, w: 1, fill: PALETTE.n },
|
||||
{ x: 38, y: 15, w: 1, fill: PALETTE.n },
|
||||
{ x: 33, y: 16, w: 6, fill: PALETTE.n },
|
||||
{ x: 33, y: 17, w: 6, fill: PALETTE.w },
|
||||
{ x: 33, y: 18, w: 1, fill: PALETTE.n },
|
||||
{ x: 38, y: 18, w: 1, fill: PALETTE.n },
|
||||
)
|
||||
}
|
||||
|
||||
if (streetCrew) {
|
||||
// A pair of road cones and a barrier where the customers used to walk.
|
||||
for (const cx of [2, 36]) {
|
||||
|
||||
@@ -41,7 +41,9 @@ export function ReportScreen({
|
||||
const events = [
|
||||
WEATHER[conditions.weather].label,
|
||||
conditions.heatWave ? 'HEAT WAVE' : null,
|
||||
conditions.festival ? 'SUMMER FAIR' : null,
|
||||
conditions.streetCrew ? 'STREET CREWS' : null,
|
||||
conditions.rival ? 'RIVAL STAND' : null,
|
||||
conditions.storm ? 'THUNDERSTORM' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -63,6 +63,11 @@ 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
|
||||
/** A fair or a parade: the whole town is out, and out to spend. */
|
||||
export const FESTIVAL_TRAFFIC = 1.9
|
||||
export const FESTIVAL_CEILING = 1.2
|
||||
/** A stand on the next corner takes a little under half the street. */
|
||||
export const RIVAL_TRAFFIC = 0.58
|
||||
|
||||
/** Signs: +20% for the first, tailing off to a hard ceiling near +50%. */
|
||||
export function signFactor(signs: number): number {
|
||||
|
||||
+29
-6
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
FESTIVAL_CEILING,
|
||||
FESTIVAL_TRAFFIC,
|
||||
HEAT_WAVE_CEILING,
|
||||
HEAT_WAVE_TRAFFIC,
|
||||
RIVAL_TRAFFIC,
|
||||
STREET_CREW_TRAFFIC,
|
||||
SIGN_COST,
|
||||
WEATHER,
|
||||
@@ -9,14 +12,14 @@ import {
|
||||
signFactor,
|
||||
} from './constants'
|
||||
import { chance, type Rng } from './rng'
|
||||
import type { DayConditions, DayResult, Decision, Player, Weather } from './types'
|
||||
import type { CarryOver, 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 {
|
||||
export function rollDay(day: number, rng: Rng, yesterday: CarryOver): DayConditions {
|
||||
const roll = rng()
|
||||
let weather: Weather
|
||||
if (roll < 0.5) weather = 'sunny'
|
||||
@@ -29,9 +32,22 @@ export function rollDay(day: number, rng: Rng, streetCrewYesterday: boolean): Da
|
||||
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))
|
||||
const streetCrew = day > 2 && (yesterday.streetCrew ? chance(rng, 0.5) : chance(rng, 0.12))
|
||||
// Nobody holds a fair on a dug-up street, or in the rain.
|
||||
const festival = day > 1 && !streetCrew && !storm && chance(rng, 0.1)
|
||||
// A rival sets up once there is business worth taking, and lingers.
|
||||
const rival = day > 3 && (yesterday.rival ? chance(rng, 0.6) : chance(rng, 0.14))
|
||||
|
||||
return { day, weather, heatWave, streetCrew, storm, costPerGlass: costPerGlass(day) }
|
||||
return {
|
||||
day,
|
||||
weather,
|
||||
heatWave,
|
||||
streetCrew,
|
||||
festival,
|
||||
rival,
|
||||
storm,
|
||||
costPerGlass: costPerGlass(day),
|
||||
}
|
||||
}
|
||||
|
||||
/** The most glasses a player can pay for today, given signs already planned. */
|
||||
@@ -59,6 +75,11 @@ export function simulate(
|
||||
ceiling *= HEAT_WAVE_CEILING
|
||||
}
|
||||
if (cond.streetCrew) traffic *= STREET_CREW_TRAFFIC
|
||||
if (cond.festival) {
|
||||
traffic *= FESTIVAL_TRAFFIC
|
||||
ceiling *= FESTIVAL_CEILING
|
||||
}
|
||||
if (cond.rival) traffic *= RIVAL_TRAFFIC
|
||||
|
||||
const demand =
|
||||
traffic *
|
||||
@@ -99,8 +120,10 @@ export function streetBusyness(cond: DayConditions): number {
|
||||
if (cond.storm) return 0.18
|
||||
const base = WEATHER[cond.weather].traffic / 140
|
||||
const heat = cond.heatWave ? 1.35 : 1
|
||||
const crew = cond.streetCrew ? 0.2 : 1
|
||||
return Math.max(0, Math.min(1, base * heat * crew))
|
||||
const crew = cond.streetCrew ? STREET_CREW_TRAFFIC : 1
|
||||
const fair = cond.festival ? 1.6 : 1
|
||||
const rival = cond.rival ? RIVAL_TRAFFIC : 1
|
||||
return Math.max(0, Math.min(1, base * heat * crew * fair * rival))
|
||||
}
|
||||
|
||||
/** How busy it actually was, from the glasses that crossed the counter. */
|
||||
|
||||
+48
-53
@@ -1,6 +1,14 @@
|
||||
export const HIGH_SCORE_KEY = 'lemonade.highscores.v1'
|
||||
const KEY = HIGH_SCORE_KEY
|
||||
export const MAX_SCORES = 10
|
||||
/**
|
||||
* The leaderboard is one table shared by everybody, held by the scores
|
||||
* service rather than the browser. Nothing about it is trusted on the way in:
|
||||
* the server decides what is plausible, and the client re-checks the shape of
|
||||
* whatever comes back before putting it on screen.
|
||||
*/
|
||||
|
||||
const BASE = (import.meta.env.VITE_SCORES_API ?? '/api').replace(/\/+$/, '')
|
||||
const TIMEOUT_MS = 8000
|
||||
|
||||
export const MAX_SCORES = 50
|
||||
|
||||
export interface Score {
|
||||
id: string
|
||||
@@ -9,15 +17,14 @@ export interface Score {
|
||||
assets: number
|
||||
days: number
|
||||
glasses: number
|
||||
seed: number
|
||||
/** Epoch milliseconds, for the tie-break and the date column. */
|
||||
at: number
|
||||
broke: boolean
|
||||
}
|
||||
|
||||
export type NewScore = Omit<Score, 'id' | 'at'>
|
||||
|
||||
const isScore = (v: unknown): v is Score => {
|
||||
/** Rows arrive over the network, so every field is checked before use. */
|
||||
function isScore(v: unknown): v is Score {
|
||||
if (typeof v !== 'object' || v === null) return false
|
||||
const s = v as Record<string, unknown>
|
||||
return (
|
||||
@@ -26,58 +33,46 @@ const isScore = (v: unknown): v is Score => {
|
||||
Number.isFinite(s.assets) &&
|
||||
Number.isFinite(s.days) &&
|
||||
Number.isFinite(s.glasses) &&
|
||||
Number.isFinite(s.seed) &&
|
||||
Number.isFinite(s.at) &&
|
||||
typeof s.broke === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
const rank = (a: Score, b: Score) => b.assets - a.assets || a.days - b.days || a.at - b.at
|
||||
const parseBoard = (body: unknown): Score[] => {
|
||||
const rows = (body as { scores?: unknown })?.scores
|
||||
if (!Array.isArray(rows)) return []
|
||||
return rows
|
||||
.filter(isScore)
|
||||
.map((s) => ({ ...s, name: s.name.slice(0, 12) }))
|
||||
.slice(0, MAX_SCORES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything already in storage was written by someone who can edit it freely,
|
||||
* so every field is checked before it is trusted.
|
||||
*/
|
||||
export function loadScores(): Score[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY)
|
||||
if (!raw) return []
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.filter(isScore)
|
||||
.map((s) => ({ ...s, name: s.name.slice(0, 12).toUpperCase() }))
|
||||
.sort(rank)
|
||||
.slice(0, MAX_SCORES)
|
||||
} catch {
|
||||
// Private windows, blocked site data, or corrupt JSON: play without a table.
|
||||
return []
|
||||
async function call(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
|
||||
})
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
const why = (body as { error?: string })?.error
|
||||
throw new Error(why ?? `scores service returned ${res.status}`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export async function fetchScores(): Promise<Score[]> {
|
||||
return parseBoard(await call('/scores'))
|
||||
}
|
||||
|
||||
/** Posts a whole table's worth of players at once and returns the new board. */
|
||||
export async function submitScores(
|
||||
entries: NewScore[],
|
||||
): Promise<{ ids: string[]; scores: Score[] }> {
|
||||
const body = await call('/scores', { method: 'POST', body: JSON.stringify({ entries }) })
|
||||
const ids = (body as { ids?: unknown })?.ids
|
||||
return {
|
||||
ids: Array.isArray(ids) ? ids.filter((i): i is string => typeof i === 'string') : [],
|
||||
scores: parseBoard(body),
|
||||
}
|
||||
}
|
||||
|
||||
function persist(scores: Score[]): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, JSON.stringify(scores))
|
||||
} catch {
|
||||
// Nothing to do - the run still shows in the table until the tab closes.
|
||||
}
|
||||
}
|
||||
|
||||
const newId = () =>
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
/**
|
||||
* Returns the saved table and the ids of the entries this call added. The
|
||||
* table only ever loses a row by being pushed past MAX_SCORES by a better
|
||||
* one - there is no way to clear it.
|
||||
*/
|
||||
export function addScores(entries: NewScore[]): { table: Score[]; added: string[] } {
|
||||
const at = Date.now()
|
||||
const fresh: Score[] = entries.map((e) => ({ ...e, id: newId(), at }))
|
||||
const table = [...loadScores(), ...fresh].sort(rank).slice(0, MAX_SCORES)
|
||||
persist(table)
|
||||
const kept = new Set(table.map((s) => s.id))
|
||||
return { table, added: fresh.filter((s) => kept.has(s.id)).map((s) => s.id) }
|
||||
}
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
import { STARTING_ASSETS } from './constants'
|
||||
import { isBankrupt } from './engine'
|
||||
import type { DayConditions, DayResult, Decision, Phase, Player } from './types'
|
||||
import type { CarryOver, DayConditions, DayResult, Decision, Phase, Player } from './types'
|
||||
|
||||
export interface GameState {
|
||||
phase: Phase
|
||||
@@ -13,8 +13,8 @@ export interface GameState {
|
||||
decisions: Record<number, Decision>
|
||||
results: DayResult[]
|
||||
history: DayResult[]
|
||||
/** True while road works were in progress yesterday, so they can run on. */
|
||||
streetCrewYesterday: boolean
|
||||
/** Conditions in force yesterday, so the ones that persist can run on. */
|
||||
yesterday: CarryOver
|
||||
retired: boolean
|
||||
/** Where the high score table was opened from, so BACK can return there. */
|
||||
scoresReturn: Phase
|
||||
@@ -30,7 +30,7 @@ export const initialState = (seed: number, phase: Phase = 'boot'): GameState =>
|
||||
decisions: {},
|
||||
results: [],
|
||||
history: [],
|
||||
streetCrewYesterday: false,
|
||||
yesterday: { streetCrew: false, rival: false },
|
||||
retired: false,
|
||||
scoresReturn: 'title',
|
||||
})
|
||||
@@ -117,7 +117,10 @@ export function reducer(state: GameState, action: Action): GameState {
|
||||
phase: 'trading',
|
||||
results: action.results,
|
||||
history: [...state.history, ...action.results],
|
||||
streetCrewYesterday: state.conditions?.streetCrew ?? false,
|
||||
yesterday: {
|
||||
streetCrew: state.conditions?.streetCrew ?? false,
|
||||
rival: state.conditions?.rival ?? false,
|
||||
},
|
||||
players: state.players.map((p) => {
|
||||
const r = byId.get(p.id)
|
||||
if (!r) return p
|
||||
|
||||
@@ -20,12 +20,23 @@ export interface DayConditions {
|
||||
day: number
|
||||
weather: Weather
|
||||
heatWave: boolean
|
||||
/** Road works. The street empties and they tend to stay a second day. */
|
||||
streetCrew: boolean
|
||||
/** A town event: half of Lemonsville walks past, and in a spending mood. */
|
||||
festival: boolean
|
||||
/** Somebody else has set up on the next corner and takes a share. */
|
||||
rival: boolean
|
||||
/** Only ever true on a cloudy day, and only revealed after decisions are locked in. */
|
||||
storm: boolean
|
||||
costPerGlass: number
|
||||
}
|
||||
|
||||
/** Conditions that run on into tomorrow rather than being rolled fresh. */
|
||||
export interface CarryOver {
|
||||
streetCrew: boolean
|
||||
rival: boolean
|
||||
}
|
||||
|
||||
export interface DayResult {
|
||||
playerId: number
|
||||
decision: Decision
|
||||
|
||||
Reference in New Issue
Block a user