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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user