SLOPSHOPPER

coaster-tycoon

A RollerCoaster Tycoon-style park that Claude builds while it works: tool calls lay track, coasters get test runs, crowds pour in, and parks compete on a…

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coaster-tycoon
│ ┃ app Park ✕ › fix the failing auth test and add an audit log call │ ┃ app Park$8,740👥 0 in park (0 total)⭐ 330🎢 │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /park │ ┃ ⎿ coaster-tycoon: Welcome to app Park. `/park` opens it · `/park t │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · app Park
app Park$8,740👥 0 in park (0 total)⭐ 330🎢 0 open · 8 plots ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 🚧 Opus crew: Race Condition (Looping Coaster) 21/60, finish… 📰 The Opus crew started building Race Condition (Looping C…
README

Coaster Tycoon (plugin)

Claude builds you a RollerCoaster Tycoon-style theme park while it works. Full description, screenshots and install steps: the repository README.

This file states exactly what the mod reads, runs, contacts and sends.

What each hook does

All hooks pass the event on unchanged. The mod never blocks, rewrites or delays a tool call, a prompt or a model response.

HookWhat it readsWhat it does with it
tool.callThe tool's name (Edit, Bash, …), which loop made the call, and whether the result was an errorLays track pieces in the park; failed calls count against the ride's test. It reads no tool input or output (no file contents, commands or results).
turn.stepThe model name of each model request (main loop or subagent)Picks the construction crew: Opus/Fable → coasters, Sonnet → family rides, Haiku → flat rides. It reads no messages; the stream passes through untouched.
turn.start, turn.completeThat a turn started, and the turn's end reason (answer, aborted, error)Marks the crews working or idle, and finishes or crash-tests unfinished rides.
command.run (/park)The arguments typed after /parkRuns the /park subcommands: open, top, join, leave, name, reset.
session.start, session.endNothingLoads and saves the park, starts the animation timer, opens the pane.
ui.render (its own pane)Nothing beyond its own stateDraws the park and the leaderboard.

Programs it runs

ProgramWhenWhy
git rev-parse --show-toplevelOnce per sessionFinds the project's root folder, so each repository gets its own park.
gh api user --jq .loginOnly when you type /park joinGets your GitHub username.
gh gist create --public --filename park.json …Once, on your first /park joinCreates the public gist that holds your parks' stats.
gh issue create --repo thickiran/claude-coaster-tycoon …Once, on your first /park joinRegisters that gist with the leaderboard.
gh api gists/<your gist id> … and gh api -X PATCH gists/<your gist id> …Every 10 minutes, only for parks you joinedReads and updates your park's stats in your own gist.

gh is the GitHub CLI, using the login you set up with gh auth login (the only credential the mod uses, and only after /park join). The mod never reads, stores or sends the token itself; gh does the authenticating.

Hosts it contacts

  • raw.githubusercontent.com: a GET of https://raw.githubusercontent.com/thickiran/claude-coaster-tycoon/main/leaderboard/leaderboard.json every 5 minutes, to show the leaderboard. This request sends nothing about you.
  • api.github.com: only through gh, only after /park join, as listed above.

What it sends, and where

Nothing, until you type /park join for a park. After that, every 10 minutes, it writes these fields for that park to a public gist on your own GitHub account:

id (a hash of the project folder's path, not the path itself), name (the park's display name, which defaults to the folder name; change it with /park name), rating, guests, guestsTotal, rides, ridesOpen, coasters, riders, money, best (the best ride's generated name, kind and rating), updatedAt.

Your GitHub username becomes public on the leaderboard. The mod sends no file paths, file contents, code, prompts, conversation text, tool inputs or tool outputs. /park leave removes the park from your gist.

What it stores locally

The park itself (rides, guests, money) in Claude Code's per-plugin store, one entry per project, plus your GitHub username and gist id after joining.

Source 6 files
hooks/register.tsx 449 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Leaderboard, ParkBoard, ParkView } from '../types'
5import { GIST_FILE, ago, cleanName, pad, parkId, parkStats } from './board'
6import type { ParkStats } from './board'
7import { renderFrame, toRasterCells, toSvg } from './draw'
8import { renderSvg } from './vector'
9import {
10  buildingRides, createWorld, loadWorld, parkRating, ratingLabel, saveWorld, step,
11  toolCalled, toolFailed, turnEnded, turnStarted,
12} from './sim'
13import type { World } from './sim'
14
15const PANE = 'coaster-park'
16const DT = 0.15
17const PUSH_MS = 10 * 60 * 1000
18const FETCH_MS = 5 * 60 * 1000
19const tick = atom({ plugin: 'coaster-tycoon', key: 'tick' } as const, 0)
20const view = atom({ plugin: 'coaster-tycoon', key: 'view' } as const, 'park' as ParkView)
21const board = atom({ plugin: 'coaster-tycoon', key: 'board' } as const, { board: null, fetchedAt: 0, error: '' } as ParkBoard)
22
23type Account = { user: string; gist: string }
24
25const money = (n: number) => `${n < 0 ? '-' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`
26let world: World | undefined
27let isBusy = false
28let frames = 0
29let lastStatus = ''
30let myRank = 0
31// The model each loop last stepped with: 'main', or a subagent's id.
32const models = new Map<string, string>()
33
34// One park per project: the git repository's root when there is one (so its
35// subfolders share a park), else the folder the session started in.
36let parkKey = ''
37let parkName = 'Claude Park'
38let myParkId = ''
39
40async function project($: EngineInterface): Promise<string> {
41  if (parkKey) return parkKey
42  const cwd = await $.session.cwd()
43  let root = cwd
44  try {
45    const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 3000 })
46    const top = git.stdout.trim()
47    if (git.exitCode === 0 && top) root = top
48  } catch {
49    // Not a repository, or no git: the folder itself is the project.
50  }
51  parkKey = `park:${root}`
52  myParkId = await parkId(root)
53  const base = root.split('/').filter(Boolean).pop() ?? 'Claude'
54  parkName = `${base} Park`
55
56  return parkKey
57}
58
59async function park($: EngineInterface): Promise<World> {
60  if (!world) {
61    const key = await project($)
62    let saved = await $.store.get(key)
63    if (!saved) {
64      // The park from before parks were per project moves into this one.
65      saved = await $.store.get('park')
66      if (saved) await $.store.delete('park')
67    }
68    world = saved ? loadWorld(saved) : createWorld()
69  }
70
71  return world
72}
73
74function displayName(w: World): string {
75  return w.shareName || parkName
76}
77
78async function save($: EngineInterface) {
79  if (!world) return
80  world.isDirty = false
81  await $.store.set(await project($), saveWorld(world))
82}
83
84async function resetPark($: EngineInterface) {
85  const was = world
86  world = createWorld()
87  if (was) {
88    world.isShared = was.isShared
89    world.shareName = was.shareName
90  }
91  await save($)
92}
93
94async function frame($: EngineInterface) {
95  if (isBusy || !world) return
96  isBusy = true
97  try {
98    step(world, DT)
99    frames += 1
100    for (const text of world.toasts.splice(0)) $.ui.toast(text, { timeoutMs: 6000 })
101    if (frames % 14 === 0) {
102      const open = world.rides.filter(r => r.status === 'open').length
103      const rank = myRank > 0 && world.isShared ? `🏆 #${myRank} · ` : ''
104      const status = `${rank}🎢 ${open} ride${open === 1 ? '' : 's'} · 👥 ${world.guests.length} · ${money(world.money)}`
105      if (status !== lastStatus) {
106        lastStatus = status
107        $.ui.status(status)
108      }
109    }
110    if ((world.isDirty && frames % 20 === 0) || frames % 200 === 0) await save($)
111    await update($, tick, n => (n + 1) % 1_000_000)
112  } finally {
113    isBusy = false
114  }
115}
116
117// ---- The global leaderboard ---------------------------------------------
118// A joined park lives in a public gist of the player's own (through their
119// `gh` login); the repository's Action reads every registered gist and
120// publishes the ranking, which the pane fetches.
121
122// Every program the mod runs is written out in full at its call: `git` once
123// to find the project, and `gh` (the player's GitHub CLI) for the
124// leaderboard, only after `/park join`. See the plugin's README.
125const GH = { timeoutMs: 20_000 }
126
127async function account($: EngineInterface): Promise<Account | undefined> {
128  const saved = (await $.store.get('account')) as Account | undefined
129
130  return saved && typeof saved.user === 'string' && typeof saved.gist === 'string' ? saved : undefined
131}
132
133async function readGist($: EngineInterface, gist: string): Promise<Record<string, ParkStats>> {
134  const got = await $.process.run(['gh', 'api', `gists/${gist}`, '--jq', '.files["park.json"].content'], GH)
135  if (got.exitCode !== 0) throw new Error(got.stderr.trim() || 'could not read the park gist')
136  try {
137    const parsed = JSON.parse(got.stdout || '{}') as { parks?: Record<string, ParkStats> }
138    return parsed.parks ?? {}
139  } catch {
140    return {}
141  }
142}
143
144async function writeGist($: EngineInterface, gist: string, parks: Record<string, ParkStats>) {
145  const content = JSON.stringify({ v: 1, parks }, null, 2)
146  const body = JSON.stringify({ files: { [GIST_FILE]: { content } } })
147  const sent = await $.process.run(['gh', 'api', '-X', 'PATCH', `gists/${gist}`, '--input', '-'], { ...GH, stdin: body })
148  if (sent.exitCode !== 0) throw new Error(sent.stderr.trim() || 'could not update the park gist')
149}
150
151async function pushPark($: EngineInterface) {
152  const w = await park($)
153  const acct = await account($)
154  if (!w.isShared || !acct) return
155  try {
156    const parks = await readGist($, acct.gist)
157    parks[myParkId] = parkStats(w, myParkId, displayName(w))
158    await writeGist($, acct.gist, parks)
159  } catch {
160    // Offline or gh signed out: the next period tries again.
161  }
162}
163
164async function fetchBoard($: EngineInterface) {
165  try {
166    const got = await $.http.fetch('https://raw.githubusercontent.com/thickiran/claude-coaster-tycoon/main/leaderboard/leaderboard.json')
167    if (!got.ok) throw new Error(`HTTP ${got.status}`)
168    const parsed = JSON.parse(got.text) as Leaderboard
169    const acct = await account($)
170    const mine = acct ? parsed.entries.find(x => x.user === acct.user && x.id === myParkId) : undefined
171    myRank = mine?.rank ?? 0
172    await update($, board, () => ({ board: parsed, fetchedAt: Date.now(), error: '' }))
173  } catch (err) {
174    await update($, board, b => ({ ...b, fetchedAt: Date.now(), error: err instanceof Error ? err.message : 'could not fetch' }))
175  }
176}
177
178async function join($: EngineInterface, name: string): Promise<string> {
179  const w = await park($)
180  if (name) w.shareName = cleanName(name)
181  const me = await $.process.run(['gh', 'api', 'user', '--jq', '.login'], GH).catch(() => undefined)
182  if (!me || me.exitCode !== 0 || !me.stdout.trim()) {
183    return 'Joining needs the GitHub CLI, signed in: install `gh` from https://cli.github.com and run `gh auth login`, then `/park join` again.'
184  }
185  const user = me.stdout.trim()
186  let acct = await account($)
187  const stats = parkStats(w, myParkId, displayName(w))
188  if (!acct || acct.user !== user) {
189    const first = JSON.stringify({ v: 1, parks: { [myParkId]: stats } }, null, 2)
190    const made = await $.process.run(
191      ['gh', 'gist', 'create', '--public', '--filename', 'park.json', '--desc', 'My Claude Code Coaster Tycoon parks (github.com/thickiran/claude-coaster-tycoon)', '-'],
192      { ...GH, stdin: first },
193    )
194    const url = made.stdout.trim().split('\n').pop() ?? ''
195    const gist = url.split('/').pop() ?? ''
196    if (made.exitCode !== 0 || !/^[0-9a-f]{20,40}$/.test(gist)) {
197      return `Could not create your park gist: ${made.stderr.trim() || 'unknown error'}`
198    }
199    const filed = await $.process.run([
200      'gh', 'issue', 'create', '--repo', 'thickiran/claude-coaster-tycoon', '--title', `[park] join: ${user}`,
201      '--body', `Registering my park gist for the global leaderboard.\n\n\`\`\`json\n${JSON.stringify({ gist })}\n\`\`\``,
202    ], GH)
203    if (filed.exitCode !== 0) return `Your park gist is up, but registering it failed: ${filed.stderr.trim()}`
204    acct = { user, gist }
205    await $.store.set('account', acct)
206  }
207  w.isShared = true
208  w.isDirty = true
209  await save($)
210  await pushPark($)
211
212  return `🏆 ${displayName(w)} joined the global leaderboard as ${user}. Scores update every 10 minutes, and the board refreshes about every 20. Open it with the Leaderboard button or \`/park top\`. Only game stats and the park's name are shared; \`/park leave\` takes it off.`
213}
214
215async function leave($: EngineInterface): Promise<string> {
216  const w = await park($)
217  const acct = await account($)
218  w.isShared = false
219  await save($)
220  if (acct) {
221    try {
222      const parks = await readGist($, acct.gist)
223      delete parks[myParkId]
224      await writeGist($, acct.gist, parks)
225    } catch {
226      return `${displayName(w)} stopped sharing, but its last stats could not be removed from your gist just now.`
227    }
228  }
229  myRank = 0
230
231  return `${displayName(w)} is off the leaderboard. It drops out at the board's next update.`
232}
233
234async function showBoard($: EngineInterface) {
235  await update($, view, () => 'leaderboard')
236  const b = await read($, board)
237  if (Date.now() - b.fetchedAt > 60_000) await fetchBoard($)
238}
239
240const USAGE = '`/park` opens it · `/park top` leaderboard · `/park join [name]` · `/park name <name>` · `/park leave` · `/park reset`'
241
242export const register: Register = on => {
243  on('session.start', async ($, e, next) => {
244    await park($)
245    await $.command.register({
246      name: 'park',
247      description: 'Your Coaster Tycoon park: top, join, leave, name, reset',
248      argumentHint: '[top | join [name] | leave | name <name> | reset]',
249      immediate: true,
250    })
251    $.clock.every(DT * 1000, () => void frame($))
252    $.clock.every(PUSH_MS, () => void pushPark($))
253    $.clock.every(FETCH_MS, () => void fetchBoard($))
254    void fetchBoard($)
255    void $.ui.open({ id: PANE, title: parkName, rows: 26 })
256
257    return next(e)
258  })
259
260  on('command.run', { command: 'park' }, async ($, e) => {
261    const [sub = '', ...rest] = e.args.trim().split(/\s+/)
262    const arg = rest.join(' ')
263    const w = await park($)
264    const openPane = () => $.ui.open({ id: PANE, title: parkName, rows: 26, focus: true })
265    switch (sub) {
266      case 'reset':
267        await resetPark($)
268        await openPane()
269        return { text: `${displayName(w)} was bulldozed. Fresh grass, $10,000, and hungry construction crews.` }
270      case 'join':
271        return { text: await join($, arg) }
272      case 'leave':
273        return { text: await leave($) }
274      case 'name':
275        if (!arg) return { text: `This park is called ${displayName(w)}. Rename it with \`/park name <name>\`.` }
276        w.shareName = cleanName(arg)
277        await save($)
278        await pushPark($)
279        return { text: `Renamed to ${w.shareName}.` }
280      case 'top':
281      case 'leaderboard':
282        await openPane()
283        await showBoard($)
284        return { text: 'Opened the global leaderboard.' }
285      case '':
286        await update($, view, () => 'park')
287        await openPane()
288        return { text: `Welcome to ${displayName(w)}. ${USAGE}` }
289      default:
290        return { text: USAGE }
291    }
292  })
293
294  on('turn.start', async ($, e, next) => {
295    turnStarted(await park($))
296
297    return next(e)
298  })
299
300  on('turn.step', async function* ($, e, next) {
301    models.set(e.agentId ?? 'main', e.model)
302
303    return yield* next(e)
304  })
305
306  on('tool.call', async ($, e, next) => {
307    const w = await park($)
308    const model = models.get(e.agentId ?? 'main') ?? models.get('main') ?? 'opus'
309    const rideName = toolCalled(w, e.tool, model)
310    const ran = await next(e)
311    if (rideName && ran.deny === undefined && ran.isError === true) toolFailed(w, rideName)
312
313    return ran
314  })
315
316  on('turn.complete', async ($, e, next) => {
317    turnEnded(await park($), e.reason)
318
319    return next(e)
320  })
321
322  on('session.end', async ($, e, next) => {
323    await save($)
324    await pushPark($)
325
326    return next(e)
327  })
328
329  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
330    await read($, tick)
331    const w = await park($)
332    const showing = await read($, view)
333    const els = $.ui.resolve(e) as Record<string, (props: object) => unknown>
334    const Box = els.Box as any
335    const Text = els.Text as any
336    const Button = els.Button as any
337    const open = w.rides.filter(r => r.status === 'open')
338
339    if (showing === 'leaderboard') {
340      const b = await read($, board)
341      const acct = await account($)
342      const cols: [string, number, boolean][] = [['#', 4, true], ['Player', 16, false], ['Park', 22, false], ['Score', 7, true], ['⭐', 5, true], ['Guests', 7, true], ['Rides', 6, true], ['Updated', 10, true]]
343      const row = (cells: (string | number)[], isMine: boolean, isHead = false) => (
344        <Box flexDirection="row">
345          {cells.map((c, i) => (
346            <Box width={cols[i]![1] + 1}>
347              <Text bold={isHead || isMine} dimColor={isHead} color={isMine ? '#f0d020' : undefined} wrap="truncate">
348                {pad(c, cols[i]![1], cols[i]![2])}
349              </Text>
350            </Box>
351          ))}
352        </Box>
353      )
354      const entries = b.board?.entries.slice(0, 20) ?? []
355
356      return (
357        <Box flexDirection="column">
358          <Box flexDirection="row" justifyContent="space-between">
359            <Text bold>🏆 Global leaderboard{b.board ? ` · ${b.board.players} players · updated ${ago(b.board.updatedAt, Date.now())}` : ''}</Text>
360            <Box flexDirection="row" gap={1}>
361              <Button key="refresh" label="Refresh" onPress={() => fetchBoard($)} />
362              <Button key="to-park" label="Back to park" variant="primary" onPress={() => update($, view, () => 'park')} />
363            </Box>
364          </Box>
365          {row(cols.map(c => c[0]), false, true)}
366          {entries.map(x => row(
367            [x.rank, x.user, x.name, x.score.toLocaleString('en-US'), x.rating, x.guestsTotal.toLocaleString('en-US'), x.ridesOpen, ago(x.updatedAt, Date.now())],
368            acct?.user === x.user && x.id === myParkId,
369          ))}
370          {entries.length === 0 && <Text dimColor>{b.error ? `Could not load the board: ${b.error}` : b.board ? 'No parks yet. Be the first: /park join' : 'Loading...'}</Text>}
371          <Text dimColor>
372            {w.isShared
373              ? `You're playing as ${acct?.user ?? '?'} with ${displayName(w)}${myRank ? `, rank #${myRank}` : ', waiting for the next board update'}.`
374              : 'This park is not on the board. Type /park join [name] to enter it. Only game stats and the park name are shared.'}
375          </Text>
376        </Box>
377      )
378    }
379
380    const crews = buildingRides(w)
381    const testing = w.rides.filter(r => r.status === 'testing')
382    const hasNews = w.time - w.newsAt < 7 || !w.thought
383    const ticker = hasNews ? `📰 ${w.news}` : `💭 ${w.thought}`
384    const jobs = [
385      ...testing.map(r => `🧪 Testing ${r.name}`),
386      ...crews.map(r => `🚧 ${r.builtBy} crew: ${r.name} (${r.kind}) ${Math.min(r.built, r.size)}/${r.size}${w.isWorking ? '' : ', finishing up'}`),
387    ]
388    const job = jobs.length > 0
389      ? jobs.join('  ·  ')
390      : w.isWorking ? '🚧 Crews standing by for the next ride' : '🌳 Crews on break. Give Claude work to build more rides.'
391    const header = (
392      <Box flexDirection="row" justifyContent="space-between">
393        <Text bold>{displayName(w)}</Text>
394        <Text bold color="#f0d020">{money(w.money)}</Text>
395        <Text>👥 {w.guests.length} in park ({w.guestsTotal} total)</Text>
396        <Text>⭐ {parkRating(w)}</Text>
397        <Text>🎢 {open.length} open · {w.cols * 2} plots</Text>
398        <Button key="to-board" label={myRank && w.isShared ? `🏆 #${myRank}` : '🏆 Leaderboard'} onPress={() => showBoard($)} />
399      </Box>
400    )
401
402    if (e.surface === 'terminal') {
403      const Raster = els.Raster as any
404      const cols = Math.max(24, Math.min(512, e.props.bodyColumns))
405      const rows = Math.max(8, Math.min(120, e.props.scroll.bodyRows - 4))
406      const px = renderFrame(w, cols, rows * 2, 4)
407
408      return (
409        <Box flexDirection="column">
410          {header}
411          <Raster key="park" columns={cols} rows={rows} cells={toRasterCells(px, cols, rows)} />
412          <Text wrap="truncate">{job}</Text>
413          <Text wrap="truncate" dimColor>{ticker}</Text>
414        </Box>
415      )
416    }
417
418    const Svg = els.Svg as any
419    const W = 220
420    const H = 150
421    let svg = renderSvg(w, 6)
422    if (svg.length > 130_000) svg = toSvg(renderFrame(w, W, H), W, H, 4)
423    if (svg.length > 130_000) svg = toSvg(renderFrame(w, 160, 110), 160, 110, 5.5)
424    const best = [...w.rides].sort((a, b) => b.excitement - a.excitement)
425
426    return (
427      <Box flexDirection="column" gap={1}>
428        {header}
429        <Svg source={svg} alt={`${displayName(w)}: ${open.length} rides open, ${w.guests.length} guests`} />
430        <Text>{job}</Text>
431        <Text dimColor>{ticker}</Text>
432        {best.length > 0 && (
433          <Box flexDirection="column">
434            {best.map(r => (
435              <Text wrap="truncate">
436                <Text bold>{r.name}</Text>
437                <Text dimColor> {r.tier === 'big' ? '🎢' : r.tier === 'medium' ? '🎠' : '🎡'} {r.kind} by {r.builtBy} · </Text>
438                {r.status === 'open'
439                  ? `E ${r.excitement.toFixed(2)} (${ratingLabel(r.excitement)}) · I ${r.intensity.toFixed(2)} · N ${r.nausea.toFixed(2)} · $${r.price} · ${r.riders} riders`
440                  : r.status === 'crashed' ? '💥 crashed, awaiting repairs' : r.status === 'testing' ? '🧪 testing' : '🚧 under construction'}
441              </Text>
442            ))}
443          </Box>
444        )}
445      </Box>
446    )
447  })
448}
449
hooks/board.ts 78 lines
1// The global leaderboard: what a shared park publishes, and how the board
2// reads back. The repository's GitHub Action ranks the parks with the same
3// score formula (scripts/update-leaderboard.mjs); keep the two in step.
4
5import { parkRating } from './sim'
6import type { World } from './sim'
7
8export const GIST_FILE = 'park.json'
9
10export type ParkStats = {
11  id: string
12  name: string
13  rating: number
14  guests: number
15  guestsTotal: number
16  rides: number
17  ridesOpen: number
18  coasters: number
19  riders: number
20  money: number
21  best: string
22  updatedAt: string
23}
24
25// Only game numbers and the park's display name leave the machine: never a
26// path, a file, a prompt or a line of code.
27export function parkStats(w: World, id: string, name: string): ParkStats {
28  const open = w.rides.filter(r => r.status === 'open')
29  const best = [...open].sort((a, b) => b.excitement - a.excitement)[0]
30
31  return {
32    id,
33    name: cleanName(name),
34    rating: parkRating(w),
35    guests: w.guests.length,
36    guestsTotal: w.guestsTotal,
37    rides: w.rides.length,
38    ridesOpen: open.length,
39    coasters: open.filter(r => r.tier === 'big').length,
40    riders: w.rides.reduce((a, r) => a + r.riders, 0),
41    money: Math.round(w.money),
42    best: best ? `${best.name} (${best.kind}, E ${best.excitement.toFixed(2)})` : '',
43    updatedAt: new Date().toISOString(),
44  }
45}
46
47export function score(p: Pick<ParkStats, 'rating' | 'ridesOpen' | 'guestsTotal' | 'riders'>): number {
48  return Math.round(p.rating * (1 + 0.25 * p.ridesOpen) + p.guestsTotal * 0.05 + p.riders * 0.02)
49}
50
51export function cleanName(name: string): string {
52  return name.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 40) || 'Unnamed Park'
53}
54
55export async function parkId(root: string): Promise<string> {
56  const bytes = new TextEncoder().encode(`coaster-tycoon:${root}`)
57  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
58
59  return [...digest.slice(0, 6)].map(b => b.toString(16).padStart(2, '0')).join('')
60}
61
62export function ago(iso: string, now: number): string {
63  const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000))
64  if (!Number.isFinite(s)) return '?'
65  if (s < 90) return 'just now'
66  if (s < 5400) return `${Math.round(s / 60)}m ago`
67  if (s < 129600) return `${Math.round(s / 3600)}h ago`
68
69  return `${Math.round(s / 86400)}d ago`
70}
71
72export function pad(text: string | number, width: number, isRight = false): string {
73  const t = String(text)
74  const cut = t.length > width ? `${t.slice(0, width - 1)}…` : t
75
76  return isRight ? cut.padStart(width) : cut.padEnd(width)
77}
78
hooks/draw.ts 432 lines
1// Paints the park into a pixel buffer (0xRRGGBB per pixel) in the original
2// game's isometric style, then packs it for the terminal (half-block Raster
3// cells) or the desktop (an SVG of horizontal runs).
4
5import { BLVD, GY, footprint, gridW, nodeAt, queueTile } from './sim'
6import type { Ride, World } from './sim'
7
8type View = { W: number; H: number; a: number; b: number; hs: number; ox: number; oy: number }
9
10const BG = 0x0e1210
11const GRASS = [0x5aa02e, 0x4f9428]
12const GRASS_SPECK = 0x6cb43a
13const DIRT = [0x9a7444, 0x8c6a3c]
14const PATH = [0xa2a2a2, 0x989898]
15const QUEUE = [0xc09868, 0xb48c5c]
16const CLIFF = [0x7a5a30, 0x5e4424, 0x4a361c]
17const EDGE_DARK = 0.86
18
19// Fits the whole park when it can; otherwise zooms to `minA` and follows the
20// camera, as the original's scrolling viewport did.
21function makeView(W: number, H: number, minA: number, camX: number, camY: number, GX: number): View {
22  const span = GX + GY
23  const cliff = 3
24  const fit = Math.max(1.2, Math.min((W - 2) / span, (H - cliff - 2) / (span / 2 + 5.5)))
25  const a = Math.max(fit, minA)
26  const b = a / 2
27  const hs = a * 0.62
28  if (a === fit) {
29    const ox = Math.round(W / 2 - ((GX - GY) * a) / 2)
30    const oy = Math.round(H - cliff - span * b - 1)
31
32    return { W, H, a, b, hs, ox, oy }
33  }
34  const ox = Math.round(W / 2 - (camX - camY) * a)
35  const oy = Math.round(H / 2 - (camX + camY) * b + 1.5 * hs)
36
37  return { W, H, a, b, hs, ox, oy }
38}
39
40const proj = (v: View, x: number, y: number, h: number): [number, number] => [
41  Math.round(v.ox + (x - y) * v.a),
42  Math.round(v.oy + (x + y) * v.b - h * v.hs),
43]
44
45export function shade(c: number, k: number): number {
46  const r = Math.min(255, Math.round(((c >> 16) & 255) * k))
47  const g = Math.min(255, Math.round(((c >> 8) & 255) * k))
48  const b = Math.min(255, Math.round((c & 255) * k))
49
50  return (r << 16) | (g << 8) | b
51}
52
53function hash(x: number, y: number): number {
54  let h = (x * 374761393 + y * 668265263) | 0
55  h = Math.imul(h ^ (h >>> 13), 1274126177)
56
57  return ((h ^ (h >>> 16)) >>> 0) / 4294967296
58}
59
60// Tile kinds: 0 grass, 1 path, 2 queue, 3 construction dirt.
61export function tileMap(w: World): Uint8Array {
62  const GX = gridW(w)
63  const t = new Uint8Array(GX * GY)
64  for (let x = 0; x < GX; x++) t[BLVD * GX + x] = 1
65  for (const r of w.rides) {
66    const q = queueTile(r)
67    t[q.y * GX + q.x] = 2
68    if (r.status === 'building') {
69      const s = footprint(r)
70      for (let y = s.y0; y < s.y0 + s.h; y++) for (let x = s.x0; x < s.x0 + s.w; x++) t[y * GX + x] = 3
71    }
72  }
73
74  return t
75}
76
77let groundKey = ''
78let groundBuf = new Uint32Array(0)
79
80function ground(w: World, v: View): Uint32Array {
81  const key = `${v.W}x${v.H}:${v.a}:${v.ox}:${v.oy}:${w.groundVer}:${w.rides.map(r => r.status[0]).join('')}`
82  if (key === groundKey) return groundBuf
83  const tiles = tileMap(w)
84  const GX = gridW(w)
85  const buf = new Uint32Array(v.W * v.H)
86  const edge = v.a >= 5 ? 0.9 / v.a : 0
87  const drop = Math.max(2, Math.round(v.a * 0.8))
88  const onMap = (tx: number, ty: number) => tx >= 0 && ty >= 0 && tx < GX && ty < GY
89  for (let py = 0; py < v.H; py++) {
90    for (let px = 0; px < v.W; px++) {
91      const u = (px + 0.5 - v.ox) / v.a
92      const vv = (py + 0.5 - v.oy) / v.b
93      const tx = (u + vv) / 2
94      const ty = (vv - u) / 2
95      let c = BG
96      if (onMap(tx, ty)) {
97        const ix = Math.floor(tx)
98        const iy = Math.floor(ty)
99        const kind = tiles[iy * GX + ix]!
100        const odd = (ix + iy) & 1
101        c = kind === 1 ? PATH[odd]! : kind === 2 ? QUEUE[odd]! : kind === 3 ? DIRT[odd]! : GRASS[odd]!
102        if (kind === 0 && hash(px, py) < 0.06) c = GRASS_SPECK
103        if (edge > 0 && (tx - ix < edge || ty - iy < edge)) c = shade(c, EDGE_DARK)
104        if (kind === 0 && ix === 0 && iy === BLVD) c = PATH[0]!
105      } else {
106        for (let k = 1; k <= drop; k++) {
107          const v2 = (py + 0.5 - k - v.oy) / v.b
108          if (onMap((u + v2) / 2, (v2 - u) / 2)) {
109            c = CLIFF[Math.min(2, Math.floor(((k - 1) / drop) * 3))]!
110            if (u < 0) c = shade(c, 0.8)
111            break
112          }
113        }
114      }
115      buf[py * v.W + px] = c
116    }
117  }
118  groundKey = key
119  groundBuf = buf
120
121  return buf
122}
123
124class Canvas {
125  readonly v: View
126  readonly px: Uint32Array
127
128  constructor(v: View, px: Uint32Array) {
129    this.v = v
130    this.px = px
131  }
132
133  set(x: number, y: number, c: number) {
134    if (x < 0 || y < 0 || x >= this.v.W || y >= this.v.H) return
135    this.px[y * this.v.W + x] = c
136  }
137
138  line(x0: number, y0: number, x1: number, y1: number, c: number) {
139    const dx = Math.abs(x1 - x0)
140    const dy = -Math.abs(y1 - y0)
141    const sx = x0 < x1 ? 1 : -1
142    const sy = y0 < y1 ? 1 : -1
143    let err = dx + dy
144    for (let guard = 0; guard < 2000; guard++) {
145      this.set(x0, y0, c)
146      if (x0 === x1 && y0 === y1) break
147      const e2 = 2 * err
148      if (e2 >= dy) {
149        err += dy
150        x0 += sx
151      }
152      if (e2 <= dx) {
153        err += dx
154        y0 += sy
155      }
156    }
157  }
158
159  disc(cx: number, cy: number, r: number, c: number) {
160    const rr = Math.ceil(r)
161    for (let y = -rr; y <= rr; y++) {
162      for (let x = -rr; x <= rr; x++) {
163        if (x * x + y * y <= r * r + 0.3) this.set(cx + x, cy + y, c)
164      }
165    }
166  }
167}
168
169type Sprite = { d: number; draw: () => void }
170
171export function renderFrame(w: World, W: number, H: number, minA = 0): Uint32Array {
172  const v = makeView(W, H, minA, w.camX, w.camY, gridW(w))
173  const cv = new Canvas(v, ground(w, v).slice())
174  const big = v.a >= 5
175  const sprites: Sprite[] = []
176  const add = (d: number, draw: () => void) => sprites.push({ d, draw })
177
178  for (const f of w.flowers) {
179    add(f.x + f.y - 0.5, () => {
180      const [x, y] = proj(v, f.x, f.y, 0)
181      cv.set(x, y, f.c)
182    })
183  }
184
185  for (const t of w.trees) {
186    add(t.x + t.y, () => {
187      const [x, y] = proj(v, t.x, t.y, 0)
188      const trunk = Math.max(1, Math.round(v.a * 0.35))
189      const r = Math.max(0.8, v.a * 0.5)
190      cv.line(x, y, x, y - trunk, 0x6a4424)
191      cv.disc(x, y - trunk - Math.round(r), r, 0x24642a)
192      if (big) {
193        cv.disc(x - 1, y - trunk - Math.round(r) - 1, r * 0.45, 0x3a8a34)
194      }
195    })
196  }
197
198  // The park entrance: two posts and a banner on the left edge.
199  add(BLVD + 0.2, () => {
200    const [ax, ay] = proj(v, 0.05, BLVD + 0.05, 0)
201    const [bx, by] = proj(v, 0.05, BLVD + 0.95, 0)
202    const tall = Math.max(3, Math.round(v.hs * 2))
203    cv.line(ax, ay, ax, ay - tall, 0xe8e8e8)
204    cv.line(bx, by, bx, by - tall, 0xe8e8e8)
205    cv.line(ax, ay - tall, bx, by - tall, 0xe03030)
206    if (big) {
207      cv.line(ax, ay - tall + 1, bx, by - tall + 1, 0xf0d020)
208      cv.line(ax, ay - tall - 1, bx, by - tall - 1, 0xe03030)
209    }
210  })
211
212  for (const r of w.rides) drawRide(w, r, v, cv, add, big)
213
214  for (const g of w.guests) {
215    if (g.state === 'ride') continue
216    let gx = g.x
217    let gy = g.y
218    if (g.state === 'queue') {
219      const r = w.rides.find(one => one.id === g.rideId)
220      if (r) {
221        const k = r.queue.indexOf(g.id)
222        const q = queueTile(r)
223        gx = q.x + 0.15 + (k % 5) * 0.17
224        gy = q.y + 0.15 + Math.floor(k / 5) * 0.16
225      }
226    }
227    add(gx + gy, () => {
228      const bob = g.state === 'queue' ? 0 : (w.frame + g.id) & 1
229      const [x, y] = proj(v, gx, gy, 0)
230      if (big) {
231        cv.set(x, y, bob ? 0x303040 : 0x404058)
232        cv.set(x, y - 1, g.shirt)
233        cv.set(x, y - 2, 0xf0c8a0)
234      } else {
235        cv.set(x, y, g.shirt)
236      }
237    })
238  }
239
240  for (const p of w.particles) {
241    add(p.x + p.y + 0.2, () => {
242      const [x, y] = proj(v, p.x, p.y, p.h)
243      cv.set(x, y, p.life < 0.4 ? 0x606060 : p.c)
244      if (big) cv.set(x + 1, y, p.life < 0.4 ? 0x505050 : p.c)
245    })
246  }
247
248  sprites.sort((a, b) => a.d - b.d)
249  for (const s of sprites) s.draw()
250
251  return cv.px
252}
253
254// A flat ride in a few pixels: its base, and a few dots that turn with it.
255function drawFlat(w: World, r: Ride, v: View, cv: Canvas, add: (d: number, f: () => void) => void) {
256  const frac = r.status === 'building' ? r.built / r.size : 1
257  const phase = r.train?.mode === 'run' ? r.train.s * 2.4 : 0
258  add(r.cx + r.cy, () => {
259    const [x, y] = proj(v, r.cx, r.cy, 0)
260    const rad = Math.max(1.5, v.a * 0.7)
261    cv.disc(x, y, rad * 0.6, r.status === 'building' ? 0x8c6a3c : shade(r.color, 0.6))
262    if (frac < 1) {
263      cv.line(x, y, x, y - Math.round(v.hs * 2 * frac), 0xc0c0c0)
264      if (w.frame & 2) cv.set(x, y - Math.round(v.hs * 2 * frac) - 1, 0xffe040)
265      return
266    }
267    const tall = r.kind === 'Observation Tower' ? 5 : r.kind === 'Ferris Wheel' || r.kind === 'Spiral Slide' ? 2.6 : 1.2
268    cv.line(x, y, x, y - Math.round(v.hs * tall), 0xd0d0d0)
269    for (let k = 0; k < 4; k++) {
270      const t = phase + (k * Math.PI) / 2
271      const px = Math.round(x + Math.cos(t) * rad)
272      const py = r.kind === 'Ferris Wheel'
273        ? Math.round(y - v.hs * 1.4 + Math.sin(t) * rad)
274        : Math.round(y - v.hs * 0.6 + Math.sin(t) * rad * 0.5)
275      cv.set(px, py, k % 2 ? r.color : r.carColor)
276    }
277    if (r.status === 'crashed' && w.frame & 2) cv.set(x, y - Math.round(v.hs * tall) - 2, 0x808080)
278  })
279}
280
281function drawRide(w: World, r: Ride, v: View, cv: Canvas, add: (d: number, f: () => void) => void, big: boolean) {
282  if (r.cat === 'flat') {
283    drawFlat(w, r, v, cv, add)
284    return
285  }
286  const n = r.nodes.length
287  const built = Math.min(r.built, n)
288  const pts = r.nodes.map(node => proj(v, node.x, node.y, node.h))
289  const dark = shade(r.color, 0.65)
290
291  for (let i = 0; i < built; i++) {
292    const node = r.nodes[i]!
293    if (node.isLoop || node.h < 0.25) continue
294    if (i % (big ? (r.isWood ? 1 : 2) : r.isWood ? 2 : 3) !== 0) continue
295    add(node.x + node.y - 0.03, () => {
296      const [x, y] = pts[i]!
297      const [, gy] = proj(v, node.x, node.y, 0)
298      cv.line(x, y + 1, x, gy, r.support)
299      if (r.isWood && big && i % 2 === 0) cv.line(x - 1, gy, x + 1, gy, shade(r.support, 0.8))
300    })
301  }
302
303  for (let i = 0; i < r.stationLen && i < built; i++) {
304    const node = r.nodes[i]!
305    add(node.x + node.y - 0.02, () => {
306      const [x, y] = pts[i]!
307      const half = Math.max(1, Math.round(v.a * 0.45))
308      cv.line(x - half, y + 1, x + half, y + 1, 0xd8d8d8)
309      if (big) cv.line(x - half, y + 2, x + half, y + 2, 0xb0b0b0)
310    })
311  }
312
313  const segs = built >= n ? n : built - 1
314  for (let i = 0; i < segs; i++) {
315    const a = r.nodes[i]!
316    const b = r.nodes[(i + 1) % n]!
317    add((a.x + b.x + a.y + b.y) / 2 + 0.05, () => {
318      const [x0, y0] = pts[i]!
319      const [x1, y1] = pts[(i + 1) % n]!
320      if (big) cv.line(x0, y0 + 1, x1, y1 + 1, dark)
321      cv.line(x0, y0, x1, y1, r.color)
322    })
323  }
324
325  if (r.status === 'building' && built > 0) {
326    const end = r.nodes[built - 1]!
327    add(end.x + end.y + 0.3, () => {
328      const [x, y] = pts[built - 1]!
329      if (w.frame & 2) {
330        cv.set(x, y - 1, 0xffe040)
331        cv.set(x, y - 2, 0xffe040)
332      }
333      const [gx, gy] = proj(v, end.x + 0.3, end.y + 0.3, 0)
334      cv.set(gx, gy, 0x2858e0)
335      cv.set(gx, gy - 1, 0xffd000)
336    })
337  }
338
339  if (r.status === 'crashed') {
340    const at = r.nodes[Math.min(n - 1, r.crashAt)]!
341    add(at.x + at.y + 0.4, () => {
342      const [x, y] = proj(v, at.x, at.y, at.h)
343      for (let k = 0; k < 5; k++) {
344        const rise = (w.frame * 0.5 + k * 3) % 12
345        const sx = x + Math.round(Math.sin(w.frame * 0.3 + k * 2) * 1.5)
346        cv.set(sx, y - Math.round(rise), rise > 8 ? 0x505050 : 0x808080)
347      }
348      const [gx, gy] = proj(v, at.x + 0.2, at.y + 0.1, 0)
349      cv.set(gx, gy, 0x404040)
350      cv.set(gx + 1, gy, r.carColor)
351    })
352  }
353
354  const t = r.train
355  if (!t) return
356  const cars = 4
357  for (let k = 0; k < cars; k++) {
358    const s = t.s - k * 0.9
359    if (s < 0 && t.isTest) continue
360    const p = nodeAt(r, s < 0 ? s + n : s)
361    add(p.x + p.y + 0.08, () => {
362      const [x, y] = proj(v, p.x, p.y, p.h)
363      const color = t.isTest && k === 0 ? 0xffffff : r.carColor
364      cv.set(x, y - 1, color)
365      cv.set(x + 1, y - 1, color)
366      if (big) {
367        cv.set(x - 1, y - 1, color)
368        cv.set(x - 1, y - 2, shade(color, 0.8))
369        cv.set(x + 1, y - 2, shade(color, 0.8))
370      }
371      for (let seat = 0; seat < 2; seat++) {
372        const id = t.riders[k * 2 + seat]
373        if (id === undefined) continue
374        const g = w.guests.find(one => one.id === id)
375        const sx = x + seat
376        cv.set(sx, y - 2, g?.shirt ?? 0xe02828)
377        if (big) cv.set(sx, y - 3, 0xf0c8a0)
378      }
379    })
380  }
381}
382
383export function toRasterCells(px: Uint32Array, W: number, rows: number): string {
384  const words = new Uint32Array(W * rows * 3)
385  for (let r = 0; r < rows; r++) {
386    for (let c = 0; c < W; c++) {
387      const top = px[2 * r * W + c] ?? BG
388      const bottom = px[(2 * r + 1) * W + c] ?? BG
389      const i = (r * W + c) * 3
390      words[i] = 0x2580
391      words[i + 1] = top
392      words[i + 2] = bottom
393    }
394  }
395
396  return base64(new Uint8Array(words.buffer))
397}
398
399function base64(bytes: Uint8Array): string {
400  const native = (bytes as unknown as { toBase64?: () => string }).toBase64
401  if (typeof native === 'function') return native.call(bytes)
402  let bin = ''
403  for (let i = 0; i < bytes.length; i += 0x8000) {
404    bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
405  }
406
407  return btoa(bin)
408}
409
410export function toSvg(px: Uint32Array, W: number, H: number, scale: number): string {
411  const runs = new Map<number, string[]>()
412  for (let y = 0; y < H; y++) {
413    let x = 0
414    while (x < W) {
415      const c = px[y * W + x]!
416      let end = x + 1
417      while (end < W && px[y * W + end] === c) end++
418      if (c !== BG) {
419        let list = runs.get(c)
420        if (!list) runs.set(c, (list = []))
421        list.push(`M${x} ${y}h${end - x}v1h${x - end}z`)
422      }
423      x = end
424    }
425  }
426  const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
427  let body = ''
428  for (const [c, list] of runs) body += `<path fill="${hex(c)}" d="${list.join('')}"/>`
429
430  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * scale}" height="${H * scale}" shape-rendering="crispEdges"><rect width="${W}" height="${H}" fill="${hex(BG)}"/>${body}</svg>`
431}
432
hooks/vector.ts 497 lines
1// The desktop's park: the same isometric scene as draw.ts, drawn as vector
2// shapes instead of pixels, so it stays sharp at whatever size the pane is.
3
4import { shade } from './draw'
5import { BLVD, GY, footprint, gridW, nodeAt, queueTile, slotAt } from './sim'
6import type { Ride, World } from './sim'
7
8const A = 20
9const B = A / 2
10const HS = A * 0.62
11const PAD = 12
12const CLIFF = 16
13const OX = GY * A + PAD
14const OY = 7 * HS + PAD
15
16// The drawing's size grows with the park.
17export function viewSize(w: World): { width: number; height: number } {
18  const gx = gridW(w)
19  return { width: (gx + GY) * A + PAD * 2, height: OY + (gx + GY) * B + CLIFF + PAD }
20}
21
22const P = (x: number, y: number, h: number): [number, number] => [
23  OX + (x - y) * A,
24  OY + (x + y) * B - h * HS,
25]
26const n1 = (v: number) => Math.round(v * 10) / 10
27const pt = ([x, y]: [number, number]) => `${n1(x)} ${n1(y)}`
28const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
29
30const GRASS = [0x5aa02e, 0x4f9428]
31const DIRT = [0x9a7444, 0x8c6a3c]
32const PATH = [0xa8a8a8, 0x9c9c9c]
33const QUEUE = [0xc09868, 0xb48c5c]
34const SKIN = '#f0c8a0'
35
36const peep = (step: number) =>
37  `<g id="g${step}"><path d="M-.8 0l${step * 0.8} -3M.8 0l${-step * 0.8} -3" stroke="#30304a" stroke-width="1.1"/><rect x="-1.7" y="-6.5" width="3.4" height="4" rx=".8"/><circle cy="-8" r="1.6" fill="${SKIN}"/></g>`
38// A 2×2 tile checker in ground coordinates, with faint tile edges.
39const checker = (id: string, [even, odd]: number[]) =>
40  `<pattern id="${id}" width="2" height="2" patternUnits="userSpaceOnUse"><rect width="2" height="2" fill="${hex(odd!)}"/>` +
41  `<path d="M0 0h1v1H0zM1 1h1v1H1z" fill="${hex(even!)}"/><path d="M0 0h2M0 1h2M0 0v2M1 0v2" stroke="#000" stroke-opacity=".12" stroke-width=".04"/></pattern>`
42const DEFS =
43  checker('pg', GRASS) + checker('pp', PATH) + checker('pq', QUEUE) + checker('pd', DIRT) +
44  '<g id="t"><ellipse rx="6" ry="3" fill="#000" opacity=".18"/><rect x="-1.2" y="-9" width="2.4" height="9" fill="#6a4424"/>' +
45  '<circle cy="-14" r="7" fill="#1f5a24"/><circle cx="-2" cy="-16" r="4" fill="#2f7a30"/><circle cx="-3" cy="-17.5" r="1.6" fill="#4a9a40"/></g>' +
46  peep(0) + peep(1)
47
48type Shape = { d: number; svg: string }
49
50function line(a: [number, number], b: [number, number], color: string, width: number, cap = ''): string {
51  return `<path d="M${pt(a)}L${pt(b)}" stroke="${color}" stroke-width="${width}"${cap ? ` stroke-linecap="${cap}"` : ''}/>`
52}
53
54// `maxCols`: past this many columns the drawing shows a window that wide,
55// centred on the camera, instead of shrinking the whole park to fit.
56export function renderSvg(w: World, maxCols = Infinity): string {
57  const out: string[] = []
58  const shapes: Shape[] = []
59  const add = (d: number, svg: string) => shapes.push({ d, svg })
60  const blink = (w.frame & 2) !== 0
61
62  // The ground and the cliff under its front edges.
63  const GX = gridW(w)
64  const left = P(0, GY, 0)
65  const bottom = P(GX, GY, 0)
66  const right = P(GX, 0, 0)
67  const down = (p: [number, number]): [number, number] => [p[0], p[1] + CLIFF]
68  out.push(`<polygon points="${pt(left)} ${pt(bottom)} ${pt(down(bottom))} ${pt(down(left))}" fill="#6e5028"/>`)
69  out.push(`<polygon points="${pt(bottom)} ${pt(right)} ${pt(down(right))} ${pt(down(bottom))}" fill="#54391c"/>`)
70  out.push(line([left[0], left[1] + CLIFF * 0.45], [bottom[0], bottom[1] + CLIFF * 0.45], '#5e4424', 1))
71  out.push(line([bottom[0], bottom[1] + CLIFF * 0.45], [right[0], right[1] + CLIFF * 0.45], '#46301a', 1))
72  // The tiles, drawn in ground coordinates (one unit per tile) through the
73  // projection as a transform: a few patterned shapes, however big the park.
74  let ground = `<rect width="${GX}" height="${GY}" fill="url(#pg)"/><rect y="${BLVD}" width="${GX}" height="1" fill="url(#pp)"/>`
75  for (const r of w.rides) {
76    const q = queueTile(r)
77    ground += `<rect x="${q.x}" y="${q.y}" width="1" height="1" fill="url(#pq)"/>`
78    if (r.status === 'building') {
79      const f = footprint(r)
80      ground += `<rect x="${f.x0}" y="${f.y0}" width="${f.w}" height="${f.h}" fill="url(#pd)"/>`
81    }
82  }
83  out.push(`<g transform="matrix(${A} ${B} ${-A} ${B} ${OX} ${OY})">${ground}</g>`)
84
85  for (const f of w.flowers) {
86    const [x, y] = P(f.x, f.y, 0)
87    add(f.x + f.y - 0.5, `<circle cx="${n1(x)}" cy="${n1(y - 1)}" r="1.6" fill="${hex(f.c)}"/>`)
88  }
89
90  for (const t of w.trees) {
91    const [x, y] = P(t.x, t.y, 0)
92    add(t.x + t.y, `<use href="#t" x="${n1(x)}" y="${n1(y)}"/>`)
93  }
94
95  // The park entrance: two striped posts and a banner.
96  {
97    const a = P(0.08, BLVD + 0.05, 0)
98    const b = P(0.08, BLVD + 0.95, 0)
99    const tall = HS * 2.6
100    const top = (p: [number, number]): [number, number] => [p[0], p[1] - tall]
101    add(BLVD + 0.2,
102      line(a, top(a), '#eeeeee', 3) + line(b, top(b), '#eeeeee', 3) +
103      `<polygon points="${pt(top(a))} ${pt(top(b))} ${pt([b[0], b[1] - tall + 9])} ${pt([a[0], a[1] - tall + 9])}" fill="#d82828" stroke="#f0d020" stroke-width="1.2"/>`)
104  }
105
106  for (const r of w.rides) drawRide(w, r, add, blink)
107
108  for (const g of w.guests) {
109    if (g.state === 'ride') continue
110    let gx = g.x
111    let gy = g.y
112    if (g.state === 'queue') {
113      const r = w.rides.find(one => one.id === g.rideId)
114      if (r) {
115        const k = r.queue.indexOf(g.id)
116        const q = queueTile(r)
117        gx = q.x + 0.15 + (k % 5) * 0.17
118        gy = q.y + 0.15 + Math.floor(k / 5) * 0.16
119      }
120    }
121    const [x, y] = P(gx, gy, 0)
122    const step = g.state === 'queue' ? 0 : (w.frame + g.id) & 1
123    add(gx + gy, `<use href="#g${step}" x="${n1(x)}" y="${n1(y)}" fill="${hex(g.shirt)}"/>`)
124  }
125
126  for (const p of w.particles) {
127    const [x, y] = P(p.x, p.y, p.h)
128    const isSmoke = p.life < 0.4
129    add(p.x + p.y + 0.2, `<circle cx="${n1(x)}" cy="${n1(y)}" r="${isSmoke ? 3 : 2}" fill="${isSmoke ? '#707070' : hex(p.c)}"${isSmoke ? ' opacity=".6"' : ''}/>`)
130  }
131
132  shapes.sort((a, b) => a.d - b.d)
133  for (const s of shapes) out.push(s.svg)
134
135  const { width: full, height } = viewSize(w)
136  const width = w.cols > maxCols ? viewSize({ ...w, cols: maxCols }).width : full
137  const x0 = Math.min(full - width, Math.max(0, P(w.camX, w.camY, 0)[0] - width / 2))
138  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${n1(x0)} 0 ${n1(width)} ${n1(height)}" width="${Math.round(width * 2)}" height="${Math.round(height * 2)}" stroke-linecap="round" stroke-linejoin="round"><defs>${DEFS}</defs><rect x="${n1(x0)}" width="${n1(width)}" height="${n1(height)}" fill="#0e1210"/>${out.join('')}</svg>`
139}
140
141function drawRide(w: World, r: Ride, add: (d: number, svg: string) => void, blink: boolean) {
142  if (r.cat === 'flat') {
143    drawFlat(w, r, add, blink)
144    return
145  }
146  const n = r.nodes.length
147  const built = Math.min(r.built, n)
148  const pts = r.nodes.map(node => P(node.x, node.y, node.h))
149  const rail = hex(r.color)
150  const under = hex(shade(r.color, 0.55))
151  const support = hex(r.support)
152  const brace = hex(shade(r.support, 0.75))
153  // The whole ride is one sprite, sorted by its plot: behind the boulevard
154  // when it lies beyond it, in front when it lies before it.
155  const f = footprint(r)
156  const depth = slotAt(r.slot).isTop ? f.x0 + f.w + f.y0 + f.h : f.x0 + f.y0
157
158  let posts = ''
159  let braces = ''
160  let plates = ''
161  for (let i = 0; i < built; i++) {
162    const node = r.nodes[i]!
163    if (node.isLoop || node.h < 0.25) continue
164    if (!r.isWood && i % 2 === 1) continue
165    const top = pts[i]!
166    const foot = P(node.x, node.y, 0)
167    posts += `M${n1(top[0])} ${n1(top[1] + 2)}V${n1(foot[1])}`
168    if (r.isWood && node.h > 1.2) {
169      const mid = n1((top[1] + foot[1]) / 2)
170      braces += `M${n1(foot[0] - 3)} ${n1(foot[1])}L${n1(foot[0])} ${mid}L${n1(foot[0] + 3)} ${n1(foot[1])}`
171    }
172    if (!r.isWood) plates += `M${n1(foot[0] - 1.5)} ${n1(foot[1] - 1)}h3v2h-3z`
173  }
174  let svg = (posts ? `<path d="${posts}" stroke="${support}" stroke-width="${r.isWood ? 1.6 : 1.3}"/>` : '') +
175    (braces ? `<path d="${braces}" fill="none" stroke="${brace}" stroke-width="1"/>` : '') +
176    (plates ? `<path d="${plates}" fill="#808088"/>` : '')
177
178  for (let i = 0; i < r.stationLen && i < built; i++) {
179    const [x, y] = pts[i]!
180    svg += `<rect x="${n1(x - 8)}" y="${n1(y + 1)}" width="16" height="4" fill="#d8d8d8" stroke="#a0a0a0" stroke-width=".6"/>` +
181      (i % 2 === 0 ? `<rect x="${n1(x - 8)}" y="${n1(y - 13)}" width="16" height="3" fill="${rail}"/>${line([x - 7, y + 1], [x - 7, y - 10], '#c0c0c0', 1)}` : '')
182  }
183
184  const ends = built >= n ? [...pts, pts[0]!] : pts.slice(0, built)
185  if (ends.length > 1) {
186    const d = (dy: number) => 'M' + ends.map(([x, y]) => `${n1(x)} ${n1(y + dy)}`).join('L')
187    svg += r.kind === 'Log Flume'
188      ? `<path d="${d(1)}" fill="none" stroke="#6a4424" stroke-width="7"/><path d="${d(0)}" fill="none" stroke="#5ab4f4" stroke-width="3.6"/>`
189      : `<path d="${d(1.6)}" fill="none" stroke="${under}" stroke-width="2.4"/><path d="${d(0)}" fill="none" stroke="${rail}" stroke-width="2.6"/>`
190  }
191  add(depth, svg)
192
193  if (r.status === 'building' && built > 0) {
194    const end = r.nodes[built - 1]!
195    const [x, y] = pts[built - 1]!
196    const [gx, gy] = P(end.x + 0.3, end.y + 0.3, 0)
197    add(depth + 0.02,
198      (blink ? `<circle cx="${n1(x)}" cy="${n1(y - 3)}" r="3" fill="#ffe040" stroke="#a08000" stroke-width=".8"/>` : '') +
199      `<rect x="${n1(gx - 1.7)}" y="${n1(gy - 6.5)}" width="3.4" height="4" fill="#2858e0"/>` +
200      `<circle cx="${n1(gx)}" cy="${n1(gy - 8)}" r="1.6" fill="${SKIN}"/>` +
201      `<path d="M${n1(gx - 2.2)} ${n1(gy - 8.6)}h4.4" stroke="#ffd000" stroke-width="1.6"/>`)
202  }
203
204  if (r.status === 'crashed') {
205    const at = r.nodes[Math.min(n - 1, r.crashAt)]!
206    const [x, y] = P(at.x, at.y, at.h)
207    const [gx, gy] = P(at.x + 0.25, at.y + 0.15, 0)
208    let svg = `<path d="M${n1(gx - 5)} ${n1(gy)}l4 -3l5 1l3 2z" fill="#404040"/><rect x="${n1(gx - 2)}" y="${n1(gy - 4)}" width="6" height="3" fill="${hex(r.carColor)}" transform="rotate(25 ${n1(gx)} ${n1(gy)})"/>`
209    for (let k = 0; k < 5; k++) {
210      const rise = (w.frame * 0.8 + k * 6) % 30
211      const sx = x + Math.sin(w.frame * 0.25 + k * 2) * 3
212      svg += `<circle cx="${n1(sx)}" cy="${n1(y - rise)}" r="${n1(2 + rise / 8)}" fill="#808080" opacity="${n1(0.7 - rise / 50)}"/>`
213    }
214    add(depth + 0.02, svg)
215  }
216
217  const t = r.train
218  if (!t) return
219  const car = hex(t.isTest ? 0xffffff : r.carColor)
220  const carDark = hex(shade(t.isTest ? 0xdddddd : r.carColor, 0.7))
221  for (let k = 0; k < 4; k++) {
222    const s = t.s - k * 0.9
223    if (s < 0 && t.isTest) continue
224    const at = s < 0 ? s + n : s
225    const p = nodeAt(r, at)
226    const front = P(...xyh(nodeAt(r, at + 0.3)))
227    const back = P(...xyh(nodeAt(r, at - 0.3)))
228    let svg = line([back[0], back[1] - 2], [front[0], front[1] - 2], carDark, 7, 'round') + line([back[0], back[1] - 3], [front[0], front[1] - 3], car, 5, 'round')
229    const [cx, cy] = P(p.x, p.y, p.h)
230    for (let seat = 0; seat < 2; seat++) {
231      const id = t.riders[k * 2 + seat]
232      if (id === undefined) continue
233      const g = w.guests.find(one => one.id === id)
234      const sx = cx + (seat === 0 ? -1.8 : 1.8)
235      svg += `<rect x="${n1(sx - 1.4)}" y="${n1(cy - 8)}" width="2.8" height="3" fill="${hex(g?.shirt ?? 0xe02828)}"/><circle cx="${n1(sx)}" cy="${n1(cy - 9.5)}" r="1.5" fill="${SKIN}"/>`
236    }
237    add(depth + 0.01, svg)
238  }
239}
240
241const xyh = (p: { x: number; y: number; h: number }): [number, number, number] => [p.x, p.y, p.h]
242
243// ---- Flat rides ------------------------------------------------------------
244// Each is drawn around its centre (cx, cy) on the ground; `phase` turns while
245// a cycle runs, and the riders sit on whatever moves.
246
247type Ctx = { r: Ride; phase: number; swing: number; riders: string[] }
248
249const ring = (cx: number, cy: number, rad: number, h: number, k: number, from = 0): [number, number][] =>
250  Array.from({ length: k }, (_, i) => {
251    const t = from + (i / k) * Math.PI * 2
252    return P(cx + Math.cos(t) * rad, cy + Math.sin(t) * rad, h)
253  })
254
255const poly = (pts: [number, number][], fill: string, extra = '') => `<polygon points="${pts.map(pt).join(' ')}" fill="${fill}"${extra}/>`
256const ellipse = (c: [number, number], rad: number, fill: string, extra = '') =>
257  `<ellipse cx="${n1(c[0])}" cy="${n1(c[1])}" rx="${n1(rad * A * 1.414)}" ry="${n1(rad * B * 1.414)}" fill="${fill}"${extra}/>`
258const rider = (x: number, y: number, shirt: string) =>
259  `<rect x="${n1(x - 1.3)}" y="${n1(y - 3)}" width="2.6" height="2.6" fill="${shirt}"/><circle cx="${n1(x)}" cy="${n1(y - 4.3)}" r="1.4" fill="${SKIN}"/>`
260
261function drawFlat(w: World, r: Ride, add: (d: number, svg: string) => void, blink: boolean) {
262  const t = r.train
263  const running = t?.mode === 'run'
264  const elapsed = t?.elapsed ?? 0
265  const ramp = running ? Math.min(1, elapsed / 1.5, Math.max(0, (7 - elapsed) / 1.5)) : 0
266  const riders = (t?.riders ?? []).map(id => hex(w.guests.find(g => g.id === id)?.shirt ?? 0xe02828))
267  const ctx: Ctx = { r, phase: (t?.s ?? 0) * 2.2 * (0.3 + ramp * 0.7), swing: ramp, riders }
268  const d = r.cx + r.cy
269  const base = ellipse(P(r.cx, r.cy, 0), 0.95, '#000', ' opacity=".15"')
270  if (r.status === 'building') {
271    const frac = r.built / r.size
272    const top = P(r.cx, r.cy, 3 * frac)
273    const foot = P(r.cx, r.cy, 0)
274    add(d, base +
275      `<g opacity="${n1(0.25 + frac * 0.55)}">${drawKind(ctx)}</g>` +
276      line([foot[0] - 7, foot[1]], [top[0] - 7, top[1]], '#c8c8c8', 1) + line([foot[0] + 7, foot[1]], [top[0] + 7, top[1]], '#c8c8c8', 1) +
277      line([foot[0] - 7, top[1]], [foot[0] + 7, top[1]], '#c8c8c8', 1) +
278      (blink ? `<circle cx="${n1(top[0])}" cy="${n1(top[1] - 3)}" r="3" fill="#ffe040" stroke="#a08000" stroke-width=".8"/>` : '') +
279      `<use href="#g0" x="${n1(foot[0] + 10)}" y="${n1(foot[1] + 4)}" fill="#2858e0"/>`)
280    return
281  }
282  let svg = base + drawKind(ctx)
283  if (r.status === 'crashed') {
284    const [x, y] = P(r.cx, r.cy, 1.5)
285    for (let k = 0; k < 5; k++) {
286      const rise = (w.frame * 0.8 + k * 6) % 30
287      svg += `<circle cx="${n1(x + Math.sin(w.frame * 0.25 + k * 2) * 3)}" cy="${n1(y - rise)}" r="${n1(2 + rise / 8)}" fill="#808080" opacity="${n1(0.7 - rise / 50)}"/>`
288    }
289  }
290  add(d, svg)
291}
292
293function drawKind(c: Ctx): string {
294  switch (c.r.kind) {
295    case 'Merry-Go-Round': return carousel(c)
296    case 'Spiral Slide': return slide(c)
297    case 'Dodgems': return dodgems(c)
298    case 'Twist': return twist(c)
299    case 'Ferris Wheel': return ferris(c)
300    case 'Swinging Ship': return ship(c)
301    default: return tower(c)
302  }
303}
304
305function carousel({ r, phase, riders }: Ctx): string {
306  const { cx, cy } = r
307  let svg = ellipse(P(cx, cy, 0.15), 0.85, '#b89058') + ellipse(P(cx, cy, 0.3), 0.85, '#e8d8b0')
308  const horses: { y: number; svg: string }[] = []
309  for (let k = 0; k < 8; k++) {
310    const t = phase + (k * Math.PI) / 4
311    const bob = Math.sin(phase * 2 + k) * 0.12
312    const foot = P(cx + Math.cos(t) * 0.62, cy + Math.sin(t) * 0.62, 0.3)
313    const body = P(cx + Math.cos(t) * 0.62, cy + Math.sin(t) * 0.62, 0.9 + bob)
314    const top = P(cx + Math.cos(t) * 0.62, cy + Math.sin(t) * 0.62, 1.7)
315    horses.push({
316      y: body[1],
317      svg: line(foot, top, '#e0c040', 0.8) +
318        `<rect x="${n1(body[0] - 3)}" y="${n1(body[1] - 2)}" width="6" height="3" rx="1.2" fill="${k % 2 ? '#ffffff' : '#c08040'}"/>` +
319        (riders[k] ? rider(body[0], body[1] - 1.5, riders[k]!) : ''),
320    })
321  }
322  horses.sort((a, b) => a.y - b.y)
323  const back = horses.filter(h => h.y < P(cx, cy, 0.9)[1])
324  const front = horses.filter(h => h.y >= P(cx, cy, 0.9)[1])
325  svg += back.map(h => h.svg).join('') + line(P(cx, cy, 0.3), P(cx, cy, 2.3), '#e0c040', 2) + front.map(h => h.svg).join('')
326  const rim = ring(cx, cy, 0.9, 1.7, 12, phase * 0.2)
327  const apex = P(cx, cy, 2.5)
328  const wedges = rim.map((p, i) => ({ y: (p[1] + rim[(i + 1) % 12]![1]) / 2, svg: poly([apex, p, rim[(i + 1) % 12]!], i % 2 ? '#ffffff' : hex(r.color), ' stroke="#00000022" stroke-width=".4"') }))
329  wedges.sort((a, b) => a.y - b.y)
330  svg += wedges.map(x => x.svg).join('')
331  svg += `<circle cx="${n1(apex[0])}" cy="${n1(apex[1] - 1.5)}" r="1.6" fill="#f0d020"/>`
332
333  return svg
334}
335
336function slide({ r, phase, riders }: Ctx): string {
337  const { cx, cy } = r
338  const tall = 3.4
339  const w = 0.32
340  const a = P(cx - w, cy + w, 0)
341  const b = P(cx + w, cy + w, 0)
342  const c2 = P(cx + w, cy - w, 0)
343  const lift = (p: [number, number]): [number, number] => [p[0], p[1] - tall * HS]
344  let back = ''
345  let front = ''
346  const turns = 2.6
347  const pts: [number, number][] = []
348  for (let k = 0; k <= 40; k++) {
349    const t = (k / 40) * Math.PI * 2 * turns
350    pts.push(P(cx + Math.cos(t) * 0.75, cy + Math.sin(t) * 0.75, tall - (k / 40) * (tall - 0.2)))
351  }
352  for (let k = 0; k < 40; k++) {
353    const t = (k / 40) * Math.PI * 2 * turns
354    const seg = line(pts[k]!, pts[k + 1]!, '#f0c020', 3.2) + line([pts[k]![0], pts[k]![1] + 1.2], [pts[k + 1]![0], pts[k + 1]![1] + 1.2], '#b08010', 1)
355    if (Math.sin(t) < 0) back += seg
356    else front += seg
357  }
358  const tower = poly([a, b, lift(b), lift(a)], hex(r.color)) + poly([b, c2, lift(c2), lift(b)], hex(shade(r.color, 0.7))) +
359    poly([lift(a), lift(b), [lift(b)[0], lift(b)[1] - 8], [(lift(a)[0] + lift(b)[0]) / 2, lift(a)[1] - 14]], '#d82828')
360  let sliders = ''
361  riders.slice(0, 3).forEach((shirt, i) => {
362    const k = Math.floor(((phase * 4 + i * 13) % 40 + 40) % 40)
363    sliders += rider(pts[k]![0], pts[k]![1] + 1, shirt)
364  })
365
366  return back + tower + front + sliders
367}
368
369function dodgems({ r, phase, riders }: Ctx): string {
370  const { cx, cy } = r
371  const s = 0.85
372  const floor = [P(cx - s, cy - s, 0), P(cx + s, cy - s, 0), P(cx + s, cy + s, 0), P(cx - s, cy + s, 0)]
373  let svg = poly(floor, '#4a4a58', ' stroke="#e8c020" stroke-width="1.6"')
374  const cars = ['#e02828', '#2858e0', '#28b048', '#f0d028', '#b030c8', '#18c0c0']
375  const placed = cars.map((color, i) => {
376    const x = cx + Math.sin(phase * (0.7 + i * 0.13) + i * 1.7) * 0.6
377    const y = cy + Math.cos(phase * (0.6 + i * 0.11) + i * 2.3) * 0.6
378    return { y: x + y, p: P(x, y, 0), color, shirt: riders[i] }
379  })
380  placed.sort((a, b) => a.y - b.y)
381  for (const car of placed) {
382    svg += `<rect x="${n1(car.p[0] - 3.5)}" y="${n1(car.p[1] - 3)}" width="7" height="4" rx="1.5" fill="${car.color}" stroke="#202020" stroke-width=".6"/>`
383    svg += line([car.p[0] + 2, car.p[1] - 3], [car.p[0] + 2, car.p[1] - 16], '#909090', 0.5)
384    if (car.shirt) svg += rider(car.p[0] - 0.5, car.p[1] - 2, car.shirt)
385  }
386  const roof = [P(cx - s, cy - s, 1.4), P(cx + s, cy - s, 1.4), P(cx + s, cy + s, 1.4), P(cx - s, cy + s, 1.4)]
387  for (let i = 0; i < 4; i++) svg += line(floor[i]!, roof[i]!, '#c0c0c0', 1.2)
388  svg += poly(roof, hex(r.color), ' opacity=".35" stroke="#ffffff" stroke-width="1"')
389
390  return svg
391}
392
393function twist({ r, phase, riders }: Ctx): string {
394  const { cx, cy } = r
395  let svg = ellipse(P(cx, cy, 0.1), 0.9, '#707080') + ellipse(P(cx, cy, 0.2), 0.85, '#9a9aaa')
396  const cars: { y: number; svg: string }[] = []
397  for (let arm = 0; arm < 3; arm++) {
398    const ta = phase * 0.8 + (arm * Math.PI * 2) / 3
399    const hx = cx + Math.cos(ta) * 0.5
400    const hy = cy + Math.sin(ta) * 0.5
401    svg += line(P(cx, cy, 0.8), P(hx, hy, 0.6), '#c8c8d0', 1.4)
402    for (let k = 0; k < 3; k++) {
403      const tc = -phase * 2.2 + (k * Math.PI * 2) / 3
404      const p = P(hx + Math.cos(tc) * 0.28, hy + Math.sin(tc) * 0.28, 0.45)
405      const shirt = riders[arm * 3 + k]
406      cars.push({ y: p[1], svg: `<ellipse cx="${n1(p[0])}" cy="${n1(p[1])}" rx="3.4" ry="2.2" fill="${hex(k % 2 ? r.color : r.carColor)}" stroke="#202020" stroke-width=".5"/>` + (shirt ? rider(p[0], p[1], shirt) : '') })
407    }
408  }
409  cars.sort((a, b) => a.y - b.y)
410  svg += line(P(cx, cy, 0.2), P(cx, cy, 1.1), '#e0c040', 2.4) + cars.map(c => c.svg).join('')
411
412  return svg
413}
414
415function ferris({ r, phase, riders }: Ctx): string {
416  const { cx, cy } = r
417  const rh = 2.1
418  const rt = 1.3
419  const hub = P(cx, cy, rh + 0.35)
420  const at = (t: number, k = 1): [number, number] => P(cx, cy + Math.cos(t) * rt * k, rh + 0.35 + Math.sin(t) * rh * k)
421  let svg = ''
422  for (const side of [-0.28, 0.28]) {
423    svg += line(P(cx + side, cy - 0.8, 0), P(cx + side * 0.3, cy, rh + 0.35), '#8a8a94', 1.8)
424    svg += line(P(cx + side, cy + 0.8, 0), P(cx + side * 0.3, cy, rh + 0.35), '#8a8a94', 1.8)
425  }
426  const rim = Array.from({ length: 28 }, (_, i) => at((i / 28) * Math.PI * 2))
427  svg += `<polygon points="${rim.map(pt).join(' ')}" fill="none" stroke="${hex(r.color)}" stroke-width="2"/>`
428  svg += `<polygon points="${Array.from({ length: 28 }, (_, i) => at((i / 28) * Math.PI * 2, 0.6)).map(pt).join(' ')}" fill="none" stroke="${hex(shade(r.color, 0.7))}" stroke-width="1"/>`
429  for (let k = 0; k < 8; k++) {
430    const t = -phase * 0.5 + (k * Math.PI) / 4
431    const p = at(t)
432    svg += line(hub, p, '#d0d0d8', 0.7)
433  }
434  for (let k = 0; k < 8; k++) {
435    const t = -phase * 0.5 + (k * Math.PI) / 4
436    const p = at(t)
437    const color = ['#e02828', '#2858e0', '#f0d028', '#28b048'][k % 4]!
438    svg += line(p, [p[0], p[1] + 3], '#606060', 0.8)
439    svg += `<rect x="${n1(p[0] - 3)}" y="${n1(p[1] + 3)}" width="6" height="4.5" rx="1" fill="${color}"/>`
440    if (riders[k]) svg += `<circle cx="${n1(p[0])}" cy="${n1(p[1] + 3.4)}" r="1.3" fill="${SKIN}"/>`
441  }
442  svg += `<circle cx="${n1(hub[0])}" cy="${n1(hub[1])}" r="2.2" fill="#f0d020"/>`
443
444  return svg
445}
446
447function ship({ r, phase, swing, riders }: Ctx): string {
448  const { cx, cy } = r
449  const ph = 2.9
450  const pivot = P(cx, cy, ph)
451  const theta = Math.sin(phase * 1.3) * 1.15 * swing
452  const len = 2.3
453  const toScreen = (along: number, up: number): [number, number] => {
454    const ca = Math.cos(theta)
455    const sa = Math.sin(theta)
456    const dy = along * ca - (up - len) * sa
457    const dh = along * sa + (up - len) * ca
458    return P(cx, cy + dy * 0.62, ph + dh)
459  }
460  let svg = ''
461  for (const side of [-0.45, 0.45]) {
462    svg += line(P(cx + side, cy - 1, 0), pivot, '#7a7a84', 2)
463    svg += line(P(cx + side, cy + 1, 0), pivot, '#7a7a84', 2)
464  }
465  svg += line(pivot, toScreen(-1, 0.4), '#a0a0a8', 1.4) + line(pivot, toScreen(1, 0.4), '#a0a0a8', 1.4)
466  const hull = [toScreen(-1.4, 0.55), toScreen(1.4, 0.55), toScreen(1.0, 0), toScreen(-1.0, 0)]
467  svg += poly(hull, '#8a5a2a', ' stroke="#5a3a1a" stroke-width="1"')
468  svg += poly([toScreen(-1.3, 0.55), toScreen(1.3, 0.55), toScreen(1.25, 0.42), toScreen(-1.25, 0.42)], hex(r.color))
469  riders.slice(0, 10).forEach((shirt, i) => {
470    const p = toScreen(-1.1 + (i / 9) * 2.2, 0.6)
471    svg += rider(p[0], p[1], shirt)
472  })
473  svg += `<circle cx="${n1(pivot[0])}" cy="${n1(pivot[1])}" r="2" fill="#f0d020"/>`
474
475  return svg
476}
477
478function tower({ r, phase, riders }: Ctx): string {
479  const { cx, cy } = r
480  const tall = 6
481  const lift = (1 - Math.cos(phase * 0.45)) / 2
482  const h = 0.4 + lift * (tall - 1.2)
483  let svg = ellipse(P(cx, cy, 0), 0.5, '#9a9aaa')
484  svg += line(P(cx, cy, 0), P(cx, cy, tall), '#b8b8c4', 4) + line(P(cx, cy, 0), P(cx, cy, tall), '#e0e0e8', 1.5)
485  svg += ellipse(P(cx, cy, h), 0.45, hex(shade(r.color, 0.7))) + ellipse(P(cx, cy, h + 0.25), 0.45, hex(r.color))
486  riders.slice(0, 6).forEach((shirt, i) => {
487    const t = (i / 6) * Math.PI * 2
488    if (Math.sin(t) < -0.2) return
489    const p = P(cx + Math.cos(t) * 0.38, cy + Math.sin(t) * 0.38, h + 0.25)
490    svg += rider(p[0], p[1], shirt)
491  })
492  const top = P(cx, cy, tall)
493  svg += `<circle cx="${n1(top[0])}" cy="${n1(top[1])}" r="3" fill="#d82828"/>`
494
495  return svg
496}
497
hooks/sim.ts 1079 lines
1// The park simulation: rides under construction, test runs, trains, guests.
2// Plain data and functions, no `$`: register.tsx drives it, draw.ts and
3// vector.ts paint it.
4//
5// Which model does the work picks the ride: a frontier model's crew builds
6// big coasters, Sonnet's builds family rides, Haiku's builds small flat rides.
7// Each model has its own crew, so a Haiku subagent builds beside Opus.
8
9// The park grows along its boulevard: each column is 4 tiles wide and holds
10// two plots, one either side. It opens with 4 columns and buys more land
11// whenever a crew runs out of room, up to COLS_MAX.
12export const COLS_START = 4
13export const COLS_MAX = 12
14export const GY = 13
15export const BLVD = 6
16
17export type Tier = 'big' | 'medium' | 'small'
18export type Node = { x: number; y: number; h: number; isLoop?: true }
19export type RideStatus = 'building' | 'testing' | 'open' | 'crashed'
20
21export type Train = {
22  s: number
23  mode: 'load' | 'run'
24  wait: number
25  elapsed: number
26  riders: number[]
27  isTest: boolean
28}
29
30export type Ride = {
31  id: number
32  name: string
33  kind: string
34  tier: Tier
35  cat: 'track' | 'flat'
36  builtBy: string
37  slot: number
38  half: number
39  color: number
40  carColor: number
41  support: number
42  isWood: boolean
43  cars: number
44  capacity: number
45  nodes: Node[]
46  built: number
47  size: number
48  stationLen: number
49  liftEnd: number
50  splashAt: number
51  cx: number
52  cy: number
53  qx: number
54  qy: number
55  status: RideStatus
56  excitement: number
57  intensity: number
58  nausea: number
59  price: number
60  riders: number
61  calls: number
62  fails: number
63  crashAt: number
64  train?: Train
65  queue: number[]
66  willCrash?: boolean
67}
68
69export type Guest = {
70  id: number
71  x: number
72  y: number
73  tx: number
74  state: 'walk' | 'toQueue' | 'queue' | 'ride' | 'leave'
75  rideId: number
76  rides: number
77  maxRides: number
78  shirt: number
79  speed: number
80  lastRide?: string
81}
82
83export type Particle = { x: number; y: number; h: number; vx: number; vy: number; vh: number; life: number; c: number }
84
85export type World = {
86  rides: Ride[]
87  guests: Guest[]
88  money: number
89  guestsTotal: number
90  nextGuest: number
91  rideCounter: number
92  trees: { x: number; y: number }[]
93  flowers: { x: number; y: number; c: number }[]
94  particles: Particle[]
95  news: string
96  newsAt: number
97  thought: string
98  time: number
99  frame: number
100  groundVer: number
101  spawnAcc: number
102  thoughtAcc: number
103  finishAcc: number
104  isFinishing: boolean
105  isWorking: boolean
106  toasts: string[]
107  milestone: number
108  isDirty: boolean
109  camX: number
110  camY: number
111  cols: number
112  isShared: boolean
113  shareName: string
114}
115
116type Slot = { x0: number; y0: number; w: number; h: number; isTop: boolean }
117
118// Plot i: column i / 2, above the boulevard when even, below it when odd.
119export function slotAt(i: number): Slot {
120  const isTop = i % 2 === 0
121  return { x0: 1 + 4 * Math.floor(i / 2), y0: isTop ? 0 : 8, w: 4, h: 5, isTop }
122}
123
124// The map's width in tiles: a border column at each end of the plots.
125export function gridW(w: { cols: number }): number {
126  return 2 + 4 * w.cols
127}
128
129// Math.random may repeat itself in the plugin's environment, so the park
130// rolls its own dice, seeded from the host's entropy.
131let seed = (() => {
132  try {
133    return crypto.getRandomValues(new Uint32Array(1))[0]! ^ Date.now()
134  } catch {
135    return Date.now() >>> 0
136  }
137})()
138
139export function rnd(): number {
140  seed = (seed + 0x6d2b79f5) | 0
141  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
142  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
143
144  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
145}
146
147const pick = <T>(list: readonly T[]): T => list[Math.floor(rnd() * list.length)]!
148const between = (lo: number, hi: number) => lo + rnd() * (hi - lo)
149const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
150
151type Layout = 'oval' | 'figure8' | 'outback' | 'helix' | 'twister' | 'zigzag'
152type Profile = 'coaster' | 'mouse' | 'flume'
153
154type TrackSpec = {
155  kind: string
156  isWood: boolean
157  hmax: [number, number]
158  loops: [number, number]
159  corkscrews: [number, number]
160  layouts: Layout[]
161  cars: number
162  profile: Profile
163  speed: number
164  base: number
165}
166
167const BIG: TrackSpec[] = [
168  { kind: 'Wooden Roller Coaster', isWood: true, hmax: [4.5, 6], loops: [0, 0], corkscrews: [0, 0], layouts: ['oval', 'figure8', 'outback', 'twister'], cars: 4, profile: 'coaster', speed: 1, base: 1.4 },
169  { kind: 'Steel Twister', isWood: false, hmax: [5, 7], loops: [1, 2], corkscrews: [1, 1], layouts: ['twister', 'helix', 'figure8'], cars: 4, profile: 'coaster', speed: 1.1, base: 1.2 },
170  { kind: 'Corkscrew Coaster', isWood: false, hmax: [4.5, 6], loops: [0, 1], corkscrews: [1, 2], layouts: ['twister', 'oval', 'helix'], cars: 4, profile: 'coaster', speed: 1, base: 1.2 },
171  { kind: 'Mine Train Coaster', isWood: true, hmax: [3, 4.2], loops: [0, 0], corkscrews: [0, 0], layouts: ['twister', 'helix', 'zigzag'], cars: 5, profile: 'coaster', speed: 0.9, base: 1.6 },
172  { kind: 'Looping Coaster', isWood: false, hmax: [5.5, 7], loops: [2, 2], corkscrews: [0, 1], layouts: ['oval', 'figure8', 'twister'], cars: 4, profile: 'coaster', speed: 1.1, base: 1.1 },
173  { kind: 'Hyper Coaster', isWood: false, hmax: [7.5, 9.2], loops: [0, 0], corkscrews: [0, 0], layouts: ['outback', 'oval', 'figure8', 'helix'], cars: 5, profile: 'coaster', speed: 1.2, base: 1.3 },
174]
175
176const MEDIUM_TRACK: TrackSpec[] = [
177  { kind: 'Wild Mouse', isWood: false, hmax: [2.6, 3.2], loops: [0, 0], corkscrews: [0, 0], layouts: ['zigzag'], cars: 1, profile: 'mouse', speed: 0.8, base: 2.4 },
178  { kind: 'Junior Coaster', isWood: false, hmax: [1.8, 2.6], loops: [0, 0], corkscrews: [0, 0], layouts: ['oval', 'twister', 'figure8'], cars: 3, profile: 'coaster', speed: 0.85, base: 2.1 },
179  { kind: 'Log Flume', isWood: true, hmax: [2.4, 3.2], loops: [0, 0], corkscrews: [0, 0], layouts: ['twister', 'oval', 'helix'], cars: 2, profile: 'flume', speed: 0.7, base: 2.6 },
180]
181
182type FlatSpec = { kind: string; e: number; i: number; n: number; capacity: number; size: number }
183
184const MEDIUM_FLAT: FlatSpec[] = [
185  { kind: 'Ferris Wheel', e: 2.3, i: 0.8, n: 0.5, capacity: 8, size: 16 },
186  { kind: 'Swinging Ship', e: 3.6, i: 4.1, n: 4.2, capacity: 12, size: 16 },
187  { kind: 'Observation Tower', e: 2.6, i: 0.7, n: 0.3, capacity: 8, size: 16 },
188]
189
190const SMALL_FLAT: FlatSpec[] = [
191  { kind: 'Merry-Go-Round', e: 1.4, i: 0.6, n: 0.4, capacity: 12, size: 10 },
192  { kind: 'Spiral Slide', e: 1.5, i: 1.4, n: 0.9, capacity: 4, size: 10 },
193  { kind: 'Dodgems', e: 2.4, i: 1.9, n: 0.7, capacity: 6, size: 10 },
194  { kind: 'Twist', e: 2.8, i: 4.1, n: 4.5, capacity: 9, size: 10 },
195]
196
197const NAMES: Record<Tier, string[]> = {
198  big: [
199    'Null Pointer Plunge', 'The Segfault', 'Merge Conflict', 'Stack Overflow',
200    'Race Condition', 'Infinite Loop', 'The Big O', 'Heap Drop', 'Recursion Rush',
201    'The Refactorer', 'Dependency Hell', 'Kernel Panic', 'Fork Bomb', 'Deadlock Drop',
202    'Undefined Behaviour', 'Production Push', 'The Monolith', 'Force Push',
203  ],
204  medium: [
205    'Off-By-One', 'Hotfix Express', 'Cache Miss', 'Git Blame', 'Callback Canyon',
206    'Tail Call', 'Rebase Rapids', 'Async Await', 'The Linter', 'Garbage Collector',
207    'Type Coercion', 'The Monad', 'Lazy Loader', 'Event Loop',
208  ],
209  small: [
210    'Little Lambda', 'Tiny Tuple', 'Hello World', 'The Semicolon', 'Bit Flip',
211    'Small Talk', 'The Null Check', 'Quick Sort', 'Byte Size', 'Tab Spinner',
212    'Pixel Pals', 'The Typo', 'Mini Map', 'Unit Test',
213  ],
214}
215
216const TRACK_COLORS = [0xe03030, 0xf0c020, 0x3a68e8, 0xe060c0, 0xf08020, 0xf4f4f4, 0x9a48e8, 0x20c8e8, 0x30c060]
217const CAR_COLORS = [0xf4f4f4, 0x2040c0, 0xe02020, 0xf0d020, 0x20a040, 0x101010, 0xf08020]
218export const SHIRTS = [0xe02828, 0x2858e0, 0xf0d028, 0x28b048, 0xe07818, 0xb030c8, 0xf4f4f4, 0x18c0c0, 0xf080a0]
219
220const LABELS = ['Low', 'Medium', 'High', 'Very High', 'Extreme', 'Ultra-Extreme']
221
222export function ratingLabel(v: number): string {
223  return LABELS[v < 2.5 ? 0 : v < 5 ? 1 : v < 6.5 ? 2 : v < 8 ? 3 : v < 9.5 ? 4 : 5]!
224}
225
226export function tierOf(model: string): Tier {
227  const m = model.toLowerCase()
228  if (m.includes('haiku')) return 'small'
229  if (m.includes('sonnet')) return 'medium'
230
231  return 'big'
232}
233
234export function crewName(model: string): string {
235  const family = /(opus|sonnet|haiku|fable|mythos)/i.exec(model)?.[1]
236
237  return family ? family[0]!.toUpperCase() + family.slice(1).toLowerCase() : model || 'Claude'
238}
239
240export function createWorld(): World {
241  const w: World = {
242    rides: [], guests: [], money: 10000, guestsTotal: 0, nextGuest: 1, rideCounter: 0,
243    trees: [], flowers: [], particles: [],
244    news: 'The gates are open! Give Claude some work and the crews start building.',
245    newsAt: 0, thought: '', time: 0, frame: 0, groundVer: 1,
246    spawnAcc: 0, thoughtAcc: 0, finishAcc: 0, isFinishing: false, isWorking: false,
247    toasts: [], milestone: 0, isDirty: true, camX: 9, camY: GY / 2,
248    cols: COLS_START, isShared: false, shareName: '',
249  }
250  plant(w, 0, gridW(w))
251
252  return w
253}
254
255// Trees and flowers on the land between columns x0 and x1.
256function plant(w: World, x0: number, x1: number) {
257  const area = (x1 - x0) * GY
258  for (let i = 0; i < area * 0.38; i++) {
259    const x = x0 + rnd() * (x1 - x0)
260    const y = rnd() * GY
261    if (Math.floor(y) === BLVD || (Math.floor(x) === 0 && Math.abs(y - BLVD - 0.5) < 1.5)) continue
262    if (w.rides.some(r => {
263      const f = footprint(r)
264      return x >= f.x0 - 0.2 && x < f.x0 + f.w + 0.2 && y >= f.y0 - 0.2 && y < f.y0 + f.h + 0.2
265    })) continue
266    w.trees.push({ x, y })
267  }
268  for (let x = Math.max(1, Math.floor(x0)); x < x1; x++) {
269    for (const y of [BLVD - 0.12, BLVD + 1.12]) {
270      if (rnd() < 0.5) w.flowers.push({ x: x + rnd(), y, c: pick([0xff4060, 0xffe040, 0xffffff, 0xc060ff]) })
271    }
272  }
273}
274
275// Buys the next strip of land at the end of the boulevard: two new plots.
276function expand(w: World) {
277  const before = gridW(w)
278  w.cols += 1
279  w.trees = w.trees.filter(t => t.x < before - 1)
280  plant(w, before - 1, gridW(w))
281  w.groundVer += 1
282  w.isDirty = true
283  say(w, `🌳 The park bought more land! ${w.cols * 2} plots now.`)
284  w.toasts.push(`🌳 The park expanded: ${w.cols * 2} plots`)
285}
286
287const SAVE_VERSION = 2
288
289export function saveWorld(w: World) {
290  return {
291    v: SAVE_VERSION,
292    money: Math.round(w.money),
293    guestsTotal: w.guestsTotal,
294    nextGuest: w.nextGuest,
295    rideCounter: w.rideCounter,
296    milestone: w.milestone,
297    cols: w.cols,
298    isShared: w.isShared,
299    shareName: w.shareName,
300    trees: w.trees.map(t => [+t.x.toFixed(2), +t.y.toFixed(2)]),
301    flowers: w.flowers.map(f => [+f.x.toFixed(2), +f.y.toFixed(2), f.c]),
302    rides: w.rides.map(({ train, queue, willCrash, ...rest }) => ({
303      ...rest,
304      nodes: rest.nodes.map(n => ({ ...n, x: +n.x.toFixed(3), y: +n.y.toFixed(3), h: +n.h.toFixed(3) })),
305      status: rest.status === 'testing' ? 'building' : rest.status,
306    })),
307  }
308}
309
310export function loadWorld(saved: unknown): World {
311  const w = createWorld()
312  const s = saved as ReturnType<typeof saveWorld> | undefined
313  if (!s || s.v !== SAVE_VERSION) return w
314  w.money = s.money
315  w.guestsTotal = s.guestsTotal
316  w.nextGuest = s.nextGuest
317  w.rideCounter = s.rideCounter
318  w.milestone = s.milestone ?? 0
319  w.cols = Math.min(COLS_MAX, Math.max(COLS_START, s.cols ?? COLS_START))
320  w.isShared = s.isShared ?? false
321  w.shareName = s.shareName ?? ''
322  w.trees = s.trees.map(([x, y]) => ({ x: x!, y: y! }))
323  w.flowers = s.flowers.map(([x, y, c]) => ({ x: x!, y: y!, c: c! }))
324  w.rides = s.rides.map(r => ({ ...(r as Ride), queue: [] }))
325  for (const r of w.rides) {
326    if (r.status === 'open') r.train = newTrain(false)
327  }
328  if (w.rides.length > 0) w.news = 'The park reopens its gates!'
329  w.isFinishing = w.rides.some(r => r.status === 'building')
330
331  return w
332}
333
334function newTrain(isTest: boolean): Train {
335  return { s: 0, mode: isTest ? 'run' : 'load', wait: 1, elapsed: 0, riders: [], isTest }
336}
337
338export function footprint(r: Ride): { x0: number; y0: number; w: number; h: number } {
339  const s = slotAt(r.slot)
340
341  return r.half < 0 ? s : { x0: s.x0 + r.half * 2, y0: s.y0, w: 2, h: s.h }
342}
343
344export function queueTile(r: Ride): { x: number; y: number } {
345  return { x: r.qx, y: r.qy }
346}
347
348// ---- Track layouts -------------------------------------------------------
349// Each is a closed path in the slot's own coordinates: u across (0..4), v
350// away from the boulevard (0..5). Every one starts along v = 0.5, where the
351// station sits next to the queue.
352
353type Pt = { u: number; v: number; isHelix?: true }
354
355function layoutPath(layout: Layout): Pt[] {
356  switch (layout) {
357    case 'oval':
358      return [{ u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 3.5, v: 4.5 }, { u: 0.5, v: 4.5 }]
359    case 'figure8':
360      return [
361        { u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 3.5, v: 1.8 }, { u: 0.5, v: 3.2 },
362        { u: 0.5, v: 4.5 }, { u: 3.5, v: 4.5 }, { u: 3.5, v: 3.2 }, { u: 0.5, v: 1.8 },
363      ]
364    case 'outback':
365      return [{ u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 2.7, v: 4.6 }, { u: 1.3, v: 4.6 }]
366    case 'helix': {
367      const path: Pt[] = [{ u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 3.5, v: 2.4 }]
368      const turns = 1.5
369      for (let k = 0; k <= 18; k++) {
370        const t = (k / 18) * Math.PI * 2 * turns
371        path.push({ u: 2 + Math.cos(t) * 1.1, v: 3.4 + Math.sin(t) * 1.05, isHelix: true })
372      }
373      path.push({ u: 0.5, v: 2.4 })
374
375      return path
376    }
377    case 'zigzag':
378      return [
379        { u: 0.5, v: 0.5 }, { u: 3.5, v: 0.5 }, { u: 3.5, v: 1.5 }, { u: 1.1, v: 1.5 },
380        { u: 1.1, v: 2.5 }, { u: 3.5, v: 2.5 }, { u: 3.5, v: 3.5 }, { u: 1.1, v: 3.5 },
381        { u: 1.1, v: 4.5 }, { u: 0.4, v: 4.5 }, { u: 0.4, v: 1.2 },
382      ]
383    case 'twister': {
384      // A rectangle with every corner pulled in by a random amount, then
385      // smoothed: no two come out alike.
386      const ring: Pt[] = []
387      const side = (a: Pt, b: Pt, k: number) => {
388        for (let i = 0; i < k; i++) ring.push({ u: a.u + ((b.u - a.u) * i) / k, v: a.v + ((b.v - a.v) * i) / k })
389      }
390      side({ u: 3.5, v: 0.5 }, { u: 3.5, v: 4.5 }, 4)
391      side({ u: 3.5, v: 4.5 }, { u: 0.5, v: 4.5 }, 3)
392      side({ u: 0.5, v: 4.5 }, { u: 0.5, v: 0.5 }, 4)
393      const wobbly = ring.map(p => {
394        const pull = rnd() * 0.85
395        return { u: p.u + (2 - p.u) * pull * 0.6, v: p.v + (2.6 - p.v) * pull * 0.45 }
396      })
397      let path: Pt[] = [{ u: 0.5, v: 0.5 }, { u: 2.5, v: 0.5 }, ...wobbly.slice(1), { u: 0.5, v: 1 }]
398      for (let pass = 0; pass < 2; pass++) {
399        const next: Pt[] = [path[0]!, path[1]!]
400        for (let i = 1; i < path.length - 1; i++) {
401          const a = path[i]!
402          const b = path[i + 1]!
403          next.push({ u: a.u * 0.75 + b.u * 0.25, v: a.v * 0.75 + b.v * 0.25 }, { u: a.u * 0.25 + b.u * 0.75, v: a.v * 0.25 + b.v * 0.75 })
404        }
405        next.push(path[path.length - 1]!)
406        path = next
407      }
408
409      return path
410    }
411  }
412}
413
414function resample(path: Pt[], step: number): Pt[] {
415  const out: Pt[] = []
416  let carry = 0
417  for (let i = 0; i < path.length; i++) {
418    const a = path[i]!
419    const b = path[(i + 1) % path.length]!
420    const len = Math.hypot(b.u - a.u, b.v - a.v)
421    let d = carry
422    while (d < len) {
423      const t = d / len
424      out.push({ u: a.u + (b.u - a.u) * t, v: a.v + (b.v - a.v) * t, ...(a.isHelix && b.isHelix ? { isHelix: true as const } : {}) })
425      d += step
426    }
427    carry = d - len
428  }
429
430  return out
431}
432
433// An inversion spliced in after node `at`: a vertical loop rises in the plane
434// of travel, a corkscrew rolls sideways across it.
435function inversion(nodes: Node[], at: number, isCorkscrew: boolean): Node[] {
436  const a = nodes[at]!
437  const b = nodes[(at + 1) % nodes.length]!
438  const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
439  const dx = (b.x - a.x) / len
440  const dy = (b.y - a.y) / len
441  const out: Node[] = []
442  for (let k = 1; k <= 12; k++) {
443    const t = (k / 13) * Math.PI * 2
444    if (isCorkscrew) {
445      const along = (k / 13) * 1.2
446      out.push({
447        x: a.x + dx * along - dy * Math.sin(t) * 0.45,
448        y: a.y + dy * along + dx * Math.sin(t) * 0.45,
449        h: a.h + (1 - Math.cos(t)) * 0.7,
450        isLoop: true,
451      })
452    } else {
453      const side = (k / 13 - 0.5) * 0.35
454      out.push({
455        x: a.x + dx * 0.75 * Math.sin(t) - dy * side,
456        y: a.y + dy * 0.75 * Math.sin(t) + dx * side,
457        h: a.h + (1 - Math.cos(t)) * 1.6,
458        isLoop: true,
459      })
460    }
461  }
462
463  return out
464}
465
466function makeTrack(slot: Slot, spec: TrackSpec) {
467  const layout = pick(spec.layouts)
468  const isMirror = rnd() < 0.5
469  const pts = resample(layoutPath(layout), 0.5)
470  const toWorld = (p: Pt): Node => {
471    const u = isMirror ? 4 - p.u : p.u
472    return { x: slot.x0 + u, y: slot.isTop ? slot.y0 + slot.h - p.v : slot.y0 + p.v, h: 0 }
473  }
474  const nodes = pts.map(toWorld)
475  const n = nodes.length
476  const stationLen = 4
477  const hmax = between(...spec.hmax)
478  const hs = 0.35
479  const lift = clamp(Math.round(n * 0.22), 5, 12)
480  const liftEnd = stationLen + lift
481  let splashAt = -1
482  const helix = pts.map(p => p.isHelix === true)
483  const hills = 1 + Math.floor(rnd() * 3)
484  for (let i = 0; i < n; i++) {
485    const node = nodes[i]!
486    if (i < stationLen) {
487      node.h = hs
488      continue
489    }
490    if (i < liftEnd) {
491      node.h = hs + ((hmax - hs) * (i - stationLen + 1)) / lift
492      continue
493    }
494    if (i >= n - 2) {
495      node.h = hs + 0.1
496      continue
497    }
498    const t = (i - liftEnd) / Math.max(1, n - 2 - liftEnd)
499    if (spec.profile === 'mouse') {
500      node.h = t < 0.65 ? hmax - t * 0.9 : (hmax - 0.6) * (1 - (t - 0.65) / 0.35) + 0.4 + (i % 3 === 0 ? 0.25 : 0)
501    } else if (spec.profile === 'flume') {
502      node.h = t < 0.6 ? hmax - t * 0.8 : t < 0.7 ? hmax * 0.55 * (1 - (t - 0.6) / 0.1) + 0.2 : 0.25
503      if (splashAt < 0 && t >= 0.7) splashAt = i
504    } else if (i === liftEnd) {
505      node.h = hmax * 0.55
506    } else if (i === liftEnd + 1) {
507      node.h = 0.6
508    } else {
509
510      node.h = 0.6 + Math.abs(Math.sin(t * Math.PI * hills)) * hmax * 0.55 * (1 - t * 0.6)
511    }
512    if (helix[i]) node.h = Math.max(0.5, node.h * 0.6)
513  }
514
515  let loops = Math.round(between(spec.loops[0], spec.loops[1]))
516  let screws = Math.round(between(spec.corkscrews[0], spec.corkscrews[1]))
517  const spots: number[] = []
518  for (let i = liftEnd + 1; i < nodes.length - 4; i++) {
519    if (!helix[i] && nodes[i]!.h < 1.3 && spots.every(s => Math.abs(s - i) > 5)) spots.push(i)
520  }
521  const inserts: { at: number; isCorkscrew: boolean }[] = []
522  for (const at of spots) {
523    if (loops > 0) {
524      inserts.push({ at, isCorkscrew: false })
525      loops -= 1
526    } else if (screws > 0) {
527      inserts.push({ at, isCorkscrew: true })
528      screws -= 1
529    }
530  }
531  inserts.sort((a, b) => b.at - a.at)
532  for (const ins of inserts) nodes.splice(ins.at + 1, 0, ...inversion(nodes, ins.at, ins.isCorkscrew))
533  const inversions = inserts.length
534  if (splashAt >= 0) splashAt = nodes.findIndex((nd, i) => i > liftEnd && nd.h <= 0.3)
535
536  const stationU = isMirror ? 4 - 1.25 : 1.25
537  const qx = slot.x0 + Math.floor(stationU)
538  const qy = slot.isTop ? BLVD - 1 : BLVD + 1
539
540  return { nodes, stationLen, liftEnd, splashAt, qx, qy, layout, hmax, inversions, hasHelix: helix.some(Boolean) }
541}
542
543// ---- Building ------------------------------------------------------------
544
545function say(w: World, text: string) {
546  w.news = text
547  w.newsAt = w.time
548}
549
550function building(w: World, tier: Tier) {
551  return w.rides.find(r => r.status === 'building' && r.tier === tier)
552}
553
554function isBusy(r: Ride) {
555  return r.status === 'building' || r.status === 'testing'
556}
557
558function demolish(w: World, gone: Ride[]) {
559  for (const r of gone) {
560    evict(w, r)
561    say(w, `${r.name} was demolished to make room for something new.`)
562    w.toasts.push(`🚧 Demolished ${r.name} to make room`)
563  }
564  const ids = new Set(gone.map(r => r.id))
565  w.rides = w.rides.filter(r => !ids.has(r.id))
566}
567
568// Finds room for a ride: half of a shared plot for a flat ride, else an
569// empty plot, else the park buys more land. Only a park at its full size
570// makes room by demolishing, and then only the crew's own weakest ride.
571function place(w: World, isHalf: boolean, tier: Tier): { slot: number; half: number } | undefined {
572  const inSlot = (s: number) => w.rides.filter(r => r.slot === s)
573  const score = (r: Ride) => (r.status === 'crashed' ? -1 : r.excitement)
574  const worstOf = (list: Ride[]) => list.reduce((a, b) => (score(b) < score(a) ? b : a))
575  if (isHalf) {
576    for (let s = 0; s < w.cols * 2; s++) {
577      const rides = inSlot(s)
578      if (rides.length === 1 && rides[0]!.half >= 0) return { slot: s, half: 1 - rides[0]!.half }
579    }
580  }
581  let empty = -1
582  for (let s = 0; s < w.cols * 2 && empty < 0; s++) if (inSlot(s).length === 0) empty = s
583  if (empty < 0 && w.cols < COLS_MAX) {
584    expand(w)
585    empty = (w.cols - 1) * 2
586  }
587  if (empty >= 0) return { slot: empty, half: isHalf ? 0 : -1 }
588  if (isHalf) {
589    const mine = w.rides.filter(r => r.half >= 0 && r.tier === tier && !isBusy(r))
590    if (mine.length === 0) return undefined
591    const worst = worstOf(mine)
592    demolish(w, [worst])
593
594    return { slot: worst.slot, half: worst.half }
595  }
596  const mine = w.rides.filter(r => r.half < 0).filter(r => r.tier === tier && !isBusy(r))
597  if (mine.length === 0) return undefined
598  const worst = worstOf(mine)
599  demolish(w, [worst])
600
601  return { slot: worst.slot, half: -1 }
602}
603
604function startRide(w: World, tier: Tier, who: string): Ride | undefined {
605  const broken = w.rides.find(r => r.status === 'crashed' && r.tier === tier)
606  if (broken) {
607    broken.status = 'building'
608    broken.built = Math.max(1, Math.min(broken.built, broken.size - 6))
609    broken.calls = 0
610    broken.fails = 0
611    broken.builtBy = who
612    say(w, `The ${who} crew is repairing ${broken.name}.`)
613    w.isDirty = true
614
615    return broken
616  }
617  let track = tier === 'big' ? pick(BIG) : tier === 'medium' && rnd() < 0.5 ? pick(MEDIUM_TRACK) : undefined
618  let flat = track ? undefined : pick(tier === 'medium' ? MEDIUM_FLAT : SMALL_FLAT)
619  let spot = place(w, flat !== undefined, tier)
620  if (!spot && tier === 'medium') {
621    // No plot for a family coaster: Sonnet's crew builds a flat ride instead,
622    // or the other way about.
623    ;[track, flat] = track ? [undefined, pick(MEDIUM_FLAT)] : [pick(MEDIUM_TRACK), undefined]
624    spot = place(w, flat !== undefined, tier)
625  }
626  if (!spot) return undefined
627  const slot = slotAt(spot.slot)
628  const used = new Set(w.rides.map(r => r.name))
629  const fresh = NAMES[tier].filter(n => !used.has(n))
630  w.rideCounter += 1
631  const kind = track?.kind ?? flat!.kind
632  const ride: Ride = {
633    id: w.rideCounter,
634    name: fresh.length > 0 ? pick(fresh) : `${kind} ${w.rideCounter}`,
635    kind, tier, cat: track ? 'track' : 'flat', builtBy: who,
636    slot: spot.slot, half: spot.half,
637    color: pick(TRACK_COLORS), carColor: pick(CAR_COLORS),
638    support: track?.isWood ? 0x8a5a2a : 0xb8b8c0, isWood: track?.isWood ?? false,
639    cars: track?.cars ?? 0, capacity: track ? track.cars * 2 : flat!.capacity,
640    nodes: [], built: 0, size: flat?.size ?? 0, stationLen: 0, liftEnd: 0, splashAt: -1,
641    cx: 0, cy: 0, qx: 0, qy: 0, status: 'building',
642    excitement: 0, intensity: 0, nausea: 0, price: 0, riders: 0,
643    calls: 0, fails: 0, crashAt: 0, queue: [],
644  }
645  if (track) {
646    const t = makeTrack(slot, track)
647    Object.assign(ride, { nodes: t.nodes, size: t.nodes.length, stationLen: t.stationLen, liftEnd: t.liftEnd, splashAt: t.splashAt, qx: t.qx, qy: t.qy })
648    ride.excitement = track.base + t.hmax * 0.5 + t.inversions * 0.9 + (t.hasHelix ? 0.6 : 0) + (t.layout === 'figure8' ? 0.4 : 0)
649    ride.intensity = 1 + t.hmax * 0.7 + t.inversions * 1.1 + (t.hasHelix ? 0.8 : 0)
650    ride.nausea = 0.4 + t.inversions * 1.1 + (t.hasHelix ? 1 : 0)
651    if (track.kind === 'Log Flume') ride.carColor = 0x8a5a2a
652    if (track.kind === 'Log Flume') ride.color = 0x3a90e0
653  } else {
654    const u = spot.half * 2 + 1
655    ride.cx = slot.x0 + u
656    ride.cy = slot.isTop ? slot.y0 + slot.h - 1.7 : slot.y0 + 1.7
657    ride.qx = slot.x0 + spot.half * 2 + (slot.isTop ? 1 : 0)
658    ride.qy = slot.isTop ? BLVD - 1 : BLVD + 1
659    ride.excitement = flat!.e
660    ride.intensity = flat!.i
661    ride.nausea = flat!.n
662  }
663  const f = footprint(ride)
664  w.trees = w.trees.filter(t => !(t.x >= f.x0 - 0.2 && t.x < f.x0 + f.w + 0.2 && t.y >= f.y0 - 0.2 && t.y < f.y0 + f.h + 0.2))
665  w.rides.push(ride)
666  w.groundVer += 1
667  w.isDirty = true
668  say(w, `The ${who} crew started building ${ride.name} (${ride.kind}).`)
669
670  return ride
671}
672
673function evict(w: World, r: Ride) {
674  const ids = new Set([...r.queue, ...(r.train?.riders ?? [])])
675  for (const g of w.guests) {
676    if (ids.has(g.id) || g.rideId === r.id) {
677      if (g.state === 'ride' || g.state === 'queue') {
678        g.x = r.qx + 0.5
679        g.y = BLVD + 0.5
680      }
681      g.state = 'walk'
682      g.rideId = -1
683      g.tx = g.x
684    }
685  }
686  r.queue = []
687  if (r.train) r.train.riders = []
688}
689
690const PIECES: Record<string, number> = {
691  Edit: 3, Write: 4, MultiEdit: 4, NotebookEdit: 3, Bash: 2, Agent: 4, Task: 4,
692}
693
694export function toolCalled(w: World, tool: string, model: string): string {
695  w.isWorking = true
696  const tier = tierOf(model)
697  const who = crewName(model)
698  const ride = building(w, tier) ?? startRide(w, tier, who)
699  if (!ride) return ''
700  const k = PIECES[tool] ?? 1
701  ride.built = Math.min(ride.size, ride.built + k)
702  ride.calls += 1
703  w.money -= k * (tier === 'big' ? 60 : tier === 'medium' ? 40 : 25)
704  w.isDirty = true
705  if (ride.built >= ride.size) startTest(w, ride)
706
707  return ride.name
708}
709
710export function toolFailed(w: World, rideName: string) {
711  const ride = w.rides.find(r => r.name === rideName)
712  if (ride) ride.fails += 1
713}
714
715export function turnEnded(w: World, reason: string) {
716  w.isWorking = false
717  const open = w.rides.filter(r => r.status === 'building')
718  if (open.length === 0) return
719  if (reason === 'aborted' || reason === 'error') {
720    for (const r of open) startTest(w, r)
721  } else {
722    w.isFinishing = true
723  }
724}
725
726export function turnStarted(w: World) {
727  w.isWorking = true
728}
729
730function startTest(w: World, r: Ride) {
731  r.status = 'testing'
732  r.train = newTrain(true)
733  const isShaky = r.fails >= 3 && r.fails / Math.max(1, r.calls) >= 0.35
734  r.willCrash = r.built < r.size || isShaky
735  if (r.cat === 'track') {
736    r.crashAt = r.built < r.size ? Math.max(1, r.built - 1) : Math.min(r.size - 2, r.liftEnd + 3)
737  }
738  say(w, r.built < r.size ? `Test run on ${r.name}... but it isn't finished!` : `Test run: ${r.name}`)
739  w.isDirty = true
740}
741
742function open(w: World, r: Ride) {
743  const jitter = () => rnd() * 0.9 - 0.3
744  const penalty = r.fails * 0.12
745  r.intensity = clamp(r.intensity + jitter(), 0.3, 11)
746  r.excitement = clamp(r.excitement + jitter() - penalty - Math.max(0, r.intensity - 9) * 0.6, 0.3, 9.9)
747  r.nausea = clamp(r.nausea + r.intensity * 0.15 + jitter(), 0.2, 10)
748  r.price = Math.max(1, Math.round(r.excitement * 1.3))
749  r.status = 'open'
750  r.train = newTrain(false)
751  say(w, `${r.name} is open! Excitement ${r.excitement.toFixed(2)} (${ratingLabel(r.excitement)}), intensity ${r.intensity.toFixed(2)}.`)
752  w.toasts.push(`${r.cat === 'track' ? '🎢' : '🎡'} ${r.name} (${r.kind}, built by ${r.builtBy}) is OPEN · Excitement ${r.excitement.toFixed(2)} (${ratingLabel(r.excitement)})`)
753  w.isDirty = true
754}
755
756function crash(w: World, r: Ride, at: Node, why: string) {
757  explode(w, at, r.cat === 'track' ? 46 : 20)
758  r.status = 'crashed'
759  r.train = undefined
760  r.willCrash = false
761  say(w, `💥 ${r.name} ${why}! The test was empty, luckily.`)
762  w.toasts.push(`💥 ${r.name} ${why}`)
763  w.isDirty = true
764}
765
766export function nodeAt(r: Ride, s: number): Node {
767  const n = r.nodes.length
768  const i = ((Math.floor(s) % n) + n) % n
769  const f = s - Math.floor(s)
770  const a = r.nodes[i]!
771  const b = r.nodes[(i + 1) % n]!
772
773  return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, h: a.h + (b.h - a.h) * f }
774}
775
776function speedAt(r: Ride, s: number): number {
777  const n = r.nodes.length
778  const i = Math.floor(s)
779  const k = r.kind === 'Log Flume' ? 0.7 : r.tier === 'medium' ? 0.85 : 1
780  if (i < r.liftEnd) return 2.2
781  if (i >= n - 3) return 2.8
782  const top = r.nodes[r.liftEnd - 1]!.h
783
784  return 2.2 + 3.4 * k * Math.sqrt(Math.max(0, top - nodeAt(r, s).h))
785}
786
787function explode(w: World, at: Node, count: number) {
788  for (let i = 0; i < count; i++) {
789    const t = rnd() * Math.PI * 2
790    const v = 0.4 + rnd() * 1.6
791    w.particles.push({
792      x: at.x, y: at.y, h: at.h,
793      vx: Math.cos(t) * v * 0.6, vy: Math.sin(t) * v * 0.6, vh: 1 + rnd() * 3,
794      life: 1 + rnd() * 1.4, c: pick([0xffe040, 0xff9020, 0xff3010, 0x808080, 0xffffff]),
795    })
796  }
797}
798
799function splash(w: World, at: Node) {
800  for (let i = 0; i < 10; i++) {
801    const t = rnd() * Math.PI * 2
802    w.particles.push({
803      x: at.x, y: at.y, h: 0.2, vx: Math.cos(t) * 0.5, vy: Math.sin(t) * 0.5, vh: 1.5 + rnd() * 2,
804      life: 0.6 + rnd() * 0.4, c: pick([0xffffff, 0xa0d8ff, 0x60b0f0]),
805    })
806  }
807}
808
809// Riders leave, the queue boards; the ride takes its fares.
810function board(w: World, r: Ride) {
811  const t = r.train!
812  for (const id of t.riders) {
813    const g = w.guests.find(one => one.id === id)
814    if (!g) continue
815    g.state = 'walk'
816    g.x = r.qx + 0.5
817    g.y = BLVD + 0.2 + rnd() * 0.6
818    g.rides += 1
819    g.lastRide = r.name
820    choose(w, g)
821  }
822  t.riders = r.queue.splice(0, r.capacity)
823  for (const id of t.riders) {
824    const g = w.guests.find(one => one.id === id)
825    if (g) g.state = 'ride'
826  }
827  w.money += t.riders.length * r.price
828  r.riders += t.riders.length
829}
830
831const FLAT_RUN = 7
832const FLAT_TEST = 4
833
834function stepFlat(w: World, r: Ride, dt: number) {
835  const t = r.train!
836  if (t.mode === 'load') {
837    t.wait -= dt
838    if (t.wait <= 0) {
839      t.mode = 'run'
840      t.elapsed = 0
841    }
842    return
843  }
844  t.elapsed += dt
845  t.s += dt
846  const center: Node = { x: r.cx, y: r.cy, h: 1 }
847  if (t.isTest) {
848    if (r.willCrash && t.elapsed >= FLAT_TEST / 2) {
849      crash(w, r, center, r.built < r.size ? 'collapsed: it was never finished' : 'broke down: too many failed inspections')
850    } else if (t.elapsed >= FLAT_TEST) {
851      open(w, r)
852    }
853    return
854  }
855  if (t.elapsed < FLAT_RUN) return
856  board(w, r)
857  t.mode = 'load'
858  t.wait = 2
859}
860
861function stepTrack(w: World, r: Ride, dt: number) {
862  const t = r.train!
863  const n = r.nodes.length
864  if (t.mode === 'load') {
865    t.wait -= dt
866    if (t.wait <= 0) t.mode = 'run'
867    return
868  }
869  const before = t.s
870  t.s += speedAt(r, t.s) * dt
871  if (r.splashAt > 0 && before < r.splashAt && t.s >= r.splashAt) splash(w, nodeAt(r, r.splashAt))
872  if (t.isTest && r.willCrash && t.s >= r.crashAt) {
873    const why = r.built < r.size ? 'flew off the unfinished track' : 'derailed: too many failed inspections'
874    crash(w, r, nodeAt(r, r.crashAt), why)
875    return
876  }
877  if (t.s < n) return
878  t.s = 0
879  if (t.isTest) {
880    open(w, r)
881    return
882  }
883  board(w, r)
884  t.mode = 'load'
885  t.wait = 1.8
886}
887
888function choose(w: World, g: Guest) {
889  const open = w.rides.filter(r => r.status === 'open' && r.queue.length < 22)
890  if (g.rides >= g.maxRides || (open.length === 0 && rnd() < 0.4)) {
891    g.state = 'leave'
892    g.tx = 0.25
893    g.rideId = -1
894    return
895  }
896  if (open.length === 0) {
897    g.state = 'walk'
898    g.tx = 1 + rnd() * (gridW(w) - 2)
899    g.rideId = -1
900    g.rides += 0.5
901    return
902  }
903  const weights = open.map(r => (r.excitement + 1.5) ** 1.6 * (r.intensity > 8.5 ? 0.35 : 1))
904  let roll = rnd() * weights.reduce((a, b) => a + b, 0)
905  let target = open[0]!
906  for (let i = 0; i < open.length; i++) {
907    roll -= weights[i]!
908    if (roll <= 0) {
909      target = open[i]!
910      break
911    }
912  }
913  g.state = 'walk'
914  g.rideId = target.id
915  g.tx = target.qx + 0.5
916}
917
918function stepGuests(w: World, dt: number) {
919  const open = w.rides.filter(r => r.status === 'open')
920  const pull = open.reduce((a, r) => a + r.excitement / 5, 0)
921  const rate = open.length === 0 ? 0.04 : Math.min(0.75 * w.cols, 0.2 + pull * 0.4)
922  w.spawnAcc += rate * dt
923  while (w.spawnAcc >= 1) {
924    w.spawnAcc -= 1
925    if (w.guests.length >= Math.min(400, 50 * w.cols)) continue
926    const g: Guest = {
927      id: w.nextGuest++, x: 0.2, y: BLVD + 0.15 + rnd() * 0.7, tx: 0,
928      state: 'walk', rideId: -1, rides: 0, maxRides: 2 + Math.floor(rnd() * 4),
929      shirt: pick(SHIRTS), speed: 1 + rnd() * 0.8,
930    }
931    choose(w, g)
932    w.guests.push(g)
933    w.guestsTotal += 1
934    w.money += 20
935  }
936  const byId = new Map(w.rides.map(r => [r.id, r]))
937  const gone = new Set<number>()
938  for (const g of w.guests) {
939    if (g.state === 'ride' || g.state === 'queue') {
940      const r = byId.get(g.rideId)
941      if (!r || r.status !== 'open') {
942        g.state = 'walk'
943        g.y = BLVD + 0.5
944        choose(w, g)
945      }
946      continue
947    }
948    if (g.state === 'toQueue') {
949      const r = byId.get(g.rideId)
950      if (!r || r.status !== 'open') {
951        choose(w, g)
952        continue
953      }
954      const dy = r.qy + 0.5 - g.y
955      g.y += Math.sign(dy) * Math.min(Math.abs(dy), g.speed * dt)
956      if (Math.abs(dy) < 0.05) {
957        g.state = 'queue'
958        r.queue.push(g.id)
959      }
960      continue
961    }
962    const dx = g.tx - g.x
963    g.x += Math.sign(dx) * Math.min(Math.abs(dx), g.speed * dt)
964    if (Math.abs(dx) > 0.04) continue
965    if (g.state === 'leave') {
966      gone.add(g.id)
967      continue
968    }
969    const r = byId.get(g.rideId)
970    if (r && r.status === 'open' && r.queue.length < 22) g.state = 'toQueue'
971    else choose(w, g)
972  }
973  if (gone.size > 0) w.guests = w.guests.filter(g => !gone.has(g.id))
974}
975
976const GENERAL = [
977  'This park is really clean and tidy',
978  "I'm hungry",
979  "I'm thirsty",
980  'I need to find a toilet',
981  'I want to go on something more thrilling',
982  'The scenery here is beautiful',
983  "I'm not paying that much to go on that",
984  'Great value for money!',
985]
986
987function think(w: World) {
988  const g = w.guests[Math.floor(rnd() * w.guests.length)]
989  if (!g) return
990  const r = w.rides.find(one => one.id === g.rideId)
991  let text = pick(GENERAL)
992  if (g.state === 'queue' && r && r.queue.length > 12) text = `The queue for ${r.name} is really long`
993  else if (g.state === 'ride' && r) text = r.intensity > 7.5 ? `${r.name} is terrifying!` : `Wheee! ${r.name}!`
994  else if (g.lastRide && rnd() < 0.6) {
995    const last = w.rides.find(one => one.name === g.lastRide)
996    text = last && last.nausea > 6 ? `I feel sick after ${g.lastRide}` : `${g.lastRide} was great!`
997  } else if (r && r.intensity > 8.5 && rnd() < 0.5) text = `${r.name} looks too intense for me`
998  w.thought = `Guest ${g.id}: "${text}"`
999}
1000
1001export function step(w: World, dt: number) {
1002  w.time += dt
1003  w.frame += 1
1004  if (w.isFinishing) {
1005    const left = w.rides.filter(r => r.status === 'building')
1006    if (left.length === 0) w.isFinishing = false
1007    w.finishAcc += dt
1008    while (w.finishAcc >= 0.12) {
1009      w.finishAcc -= 0.12
1010      for (const ride of left) {
1011        if (ride.status !== 'building') continue
1012        ride.built += 1
1013        w.money -= 40
1014        if (ride.built >= ride.size) startTest(w, ride)
1015      }
1016    }
1017  }
1018  for (const r of w.rides) {
1019    if (!r.train) continue
1020    if (r.cat === 'flat') stepFlat(w, r, dt)
1021    else stepTrack(w, r, dt)
1022  }
1023  stepCamera(w, dt)
1024  stepGuests(w, dt)
1025  for (const p of w.particles) {
1026    p.x += p.vx * dt
1027    p.y += p.vy * dt
1028    p.vh -= 6 * dt
1029    p.h = Math.max(0, p.h + p.vh * dt)
1030    p.life -= dt
1031  }
1032  w.particles = w.particles.filter(p => p.life > 0)
1033  w.thoughtAcc += dt
1034  if (w.thoughtAcc > 4.5) {
1035    w.thoughtAcc = 0
1036    think(w)
1037  }
1038  const marks = [25, 50, 100, 150, 200]
1039  const next = marks.find(m => m > w.milestone)
1040  if (next && w.guests.length >= next) {
1041    w.milestone = next
1042    w.toasts.push(`🎉 ${next} guests in the park!`)
1043  }
1044}
1045
1046export function parkRating(w: World): number {
1047  const open = w.rides.filter(r => r.status === 'open')
1048  const fun = open.reduce((a, r) => a + r.excitement, 0)
1049  const crashed = w.rides.filter(r => r.status === 'crashed').length
1050
1051  return Math.round(clamp(250 + fun * 20 + w.guests.length * 1.1 + w.trees.length - crashed * 150, 0, 999))
1052}
1053
1054export function buildingRides(w: World) {
1055  return w.rides.filter(r => r.status === 'building')
1056}
1057
1058// The camera drifts to the action: a test run, a build site, a wreck, else
1059// it tours the open rides.
1060export function stepCamera(w: World, dt: number) {
1061  const focus =
1062    w.rides.find(r => r.status === 'testing') ??
1063    w.rides.find(r => r.status === 'building') ??
1064    w.rides.find(r => r.status === 'crashed')
1065  const open = w.rides.filter(r => r.status === 'open')
1066  const ride = focus ?? open[Math.floor(w.time / 12) % Math.max(1, open.length)]
1067  let tx = gridW(w) / 2
1068  let ty = BLVD + 0.5
1069  if (ride) {
1070    const f = footprint(ride)
1071    const s = slotAt(ride.slot)
1072    tx = f.x0 + f.w / 2
1073    ty = f.y0 + f.h / 2 + (s.isTop ? 0.8 : -0.8)
1074  }
1075  const k = Math.min(1, dt * 1.2)
1076  w.camX += (tx - w.camX) * k
1077  w.camY += (ty - w.camY) * k
1078}
1079
types/index.d.ts 38 lines
1export type ParkTick = number
2
3export type ParkView = 'park' | 'leaderboard'
4
5export type LeaderboardEntry = {
6  rank: number
7  user: string
8  id: string
9  name: string
10  score: number
11  rating: number
12  guests: number
13  guestsTotal: number
14  ridesOpen: number
15  coasters: number
16  riders: number
17  best: string
18  updatedAt: string
19}
20
21export type Leaderboard = {
22  updatedAt: string
23  players: number
24  entries: LeaderboardEntry[]
25}
26
27export type ParkBoard = {
28  board: Leaderboard | null
29  fetchedAt: number
30  error: string
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'coaster-tycoon': { tick: ParkTick; view: ParkView; board: ParkBoard }
36  }
37}
38