SLOPSHOPPER

road-trip

A top-down driving game above the prompt. Task coins rain in as Claude works. /roadtrip to play.

newpaneguardcommandtoasttimer
v0.1.0MITupdated 2026-10-06Alyan-khattak/Claude-Code-Mods/road-trip
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · road-trip
│ ┃ Road Trip ✕ › fix the failing auth test and add an audit log call │ ┃ ROAD TRIP 0 m · Best 0 m · ◉ 0 │ ┃ LV 1 ▱▱▱▱▱▱▱▱▱▱ Starter Hatch ⏺ 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 │ ┃ │ ┃ › /roadtrip │ ┃ ⎿ road-trip: Road Trip opened. Controls: A/D lanes P pause G gar │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Road Trip
ROAD TRIP 0 m · Best 0 m · ◉ 0 LV 1 ▱▱▱▱▱▱▱▱▱▱ Starter Hatch ▌ ▐ ▌ ▐ ▌ ┃ [ A ] [ D ] [ P ] [ G ] [ B ]
Source 5 files
hooks/register.tsx 7 lines
1import type { Register } from 'claude-code'
2import { registerRoadTrip } from './road-trip'
3
4export const register: Register = on => {
5  registerRoadTrip(on)
6}
7
hooks/road-trip.tsx 253 lines
1import type { On } from 'claude-code'
2import {
3  createGame, resizeGame, tickGame, renderFrame, handleKey, garageBuy,
4  triggerArrived, resumeFromArrived, parseProfile,
5  type GameState, type Profile,
6} from './road-trip-game'
7
8const PANE = 'road-trip'
9
10// Module-level game state (reset each session start)
11let game: GameState | null = null
12let rasterCols = 0
13let rasterRows = 0
14let gameLoopCancel: (() => void) | undefined
15let disabled = false
16let globalRetro = false
17
18// ─── Helpers ──────────────────────────────────────────────────────────────────
19
20async function loadProfile($: Parameters<Parameters<On>[2]>[0]): Promise<Profile> {
21  try {
22    const v = await $.store.get({ key: 'road-trip-profile' })
23    return parseProfile(v?.value)
24  } catch { return parseProfile(null) }
25}
26
27async function saveProfile($: Parameters<Parameters<On>[2]>[0], g: GameState): Promise<void> {
28  try {
29    await $.store.set({
30      key: 'road-trip-profile',
31      value: {
32        bank: g.profile.bank,
33        ownedCars: g.profile.ownedCars,
34        currentCar: g.profile.currentCar,
35        xp: g.profile.xp,
36        bestDistance: g.profile.bestDistance,
37      },
38    })
39  } catch { /* save failure must never stop the game */ }
40}
41
42// ─── Register ─────────────────────────────────────────────────────────────────
43
44export function registerRoadTrip(on: On): void {
45
46  // ── Session start ──────────────────────────────────────────────────────────
47  on('session.start', async ($, e, next) => {
48    game = null
49    rasterCols = 0
50    rasterRows = 0
51    gameLoopCancel?.()
52    gameLoopCancel = undefined
53
54    // Restore disabled/retro from store
55    try {
56      const v = await $.store.get({ key: 'road-trip-meta' })
57      if (v?.value && typeof v.value === 'object') {
58        const m = v.value as Record<string, unknown>
59        if (m['disabled'] === true) disabled = true
60        if (m['retro'] === true) globalRetro = true
61      }
62    } catch {}
63
64    await $.command.register({ name: 'roadtrip', description: 'Open Road Trip game. /roadtrip on|off|retro on|off' })
65
66    return next(e)
67  })
68
69  // ── Command ────────────────────────────────────────────────────────────────
70  on('command.run', { command: 'roadtrip' }, async ($, e) => {
71    const args = (e.args ?? '').trim().toLowerCase()
72
73    if (args === 'off') {
74      disabled = true
75      try { await $.store.set({ key: 'road-trip-meta', value: { disabled: true, retro: globalRetro } }) } catch {}
76      gameLoopCancel?.()
77      gameLoopCancel = undefined
78      return { text: 'Road Trip hidden. /roadtrip on to re-enable.' }
79    }
80    if (args === 'on') {
81      disabled = false
82      try { await $.store.set({ key: 'road-trip-meta', value: { disabled: false, retro: globalRetro } }) } catch {}
83      return { text: 'Road Trip enabled. Type /roadtrip to open.' }
84    }
85    if (args === 'retro on') {
86      globalRetro = true
87      if (game) { game.retro = true }
88      try { await $.store.set({ key: 'road-trip-meta', value: { disabled, retro: true } }) } catch {}
89      return { text: 'Retro mode on.' }
90    }
91    if (args === 'retro off') {
92      globalRetro = false
93      if (game) { game.retro = false }
94      try { await $.store.set({ key: 'road-trip-meta', value: { disabled, retro: false } }) } catch {}
95      return { text: 'Retro mode off.' }
96    }
97
98    if (disabled) {
99      return { text: 'Road Trip is off. Type /roadtrip on to re-enable.' }
100    }
101
102    // Open pane (responds instantly — answer before opening)
103    const openResult = await $.ui.open({ id: PANE, title: 'Road Trip', focus: true, closeOnEscape: true, rows: 30, columns: 110 })
104    if (!openResult.isPlaced) {
105      $.ui.toast('Road Trip needs a little more room: widen the window')
106      return { text: 'Pane not placed (window too small).' }
107    }
108
109    // Start game loop if not running
110    if (!gameLoopCancel) {
111      gameLoopCancel = $.clock.every(70, () => {
112        if (!game || !rasterCols) return
113        tickGame(game)
114        void $.ui.blit({ requestId: PANE, key: 'game', cells: renderFrame(game) })
115      })
116    }
117
118    return { text: 'Road Trip opened. Controls: A/D lanes  P pause  G garage' }
119  })
120
121  // ── Pane render ────────────────────────────────────────────────────────────
122  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
123    if (e.surface !== 'terminal') {
124      const { Text } = $.ui.resolve(e)
125      return <Text>Road Trip plays in the terminal.</Text>
126    }
127
128    const cols = e.props.bodyColumns
129    const rows = e.props.scroll.bodyRows
130
131    if (cols < 36 || rows < 5) {
132      const { Text } = $.ui.resolve(e)
133      return <Text>Drag this pane taller to play  (cols={cols} rows={rows})</Text>
134    }
135
136    const newRC = cols
137    const newRR = Math.max(4, rows - 1)  // leave 1 row for buttons
138
139    let needInit = !game
140    if (newRC !== rasterCols || newRR !== rasterRows) {
141      rasterCols = newRC
142      rasterRows = newRR
143      needInit = needInit || true
144    }
145
146    if (needInit && !game) {
147      const profile = await loadProfile($)
148      game = createGame(rasterCols, rasterRows, profile, globalRetro)
149    } else if (game && (newRC !== game.cols || newRR !== game.rows)) {
150      game = resizeGame(game, newRC, newRR)
151      rasterCols = newRC
152      rasterRows = newRR
153    }
154
155    if (!game) {
156      const { Text } = $.ui.resolve(e)
157      return <Text>Loading...</Text>
158    }
159
160    const { Box, Button } = $.ui.resolve(e)
161    const elements = $.ui.resolve(e) as unknown as { Raster: (p: { key: string; columns: number; rows: number; cells: string }) => unknown }
162    const g = game
163
164    const onKeyA = (): void => handleKey(g, 'a')
165    const onKeyD = (): void => handleKey(g, 'd')
166    const onKeyP = (): void => handleKey(g, 'p')
167    const onKeyG = (): void => handleKey(g, 'g')
168    const onKeyB = (): void => {
169      if (g.phase === 'arrived') void $.ui.close({ id: PANE })
170      else garageBuy(g)
171    }
172    return (
173      <Box flexDirection="column">
174        {h(elements.Raster, { key: 'game', columns: rasterCols, rows: rasterRows, cells: renderFrame(g) })}
175        <Box flexDirection="row" gap={2}>
176          <Button key="btn-a" label="A" hotkey="a" onPress={onKeyA} />
177          <Button key="btn-d" label="D" hotkey="d" onPress={onKeyD} />
178          <Button key="btn-p" label="P" hotkey="p" onPress={onKeyP} />
179          <Button key="btn-g" label="G" hotkey="g" onPress={onKeyG} />
180          <Button key="btn-b" label="B" hotkey="b" onPress={onKeyB} />
181        </Box>
182      </Box>
183    )
184  })
185
186  // ── Pane close ────────────────────────────────────────────────────────────
187  on('ui.close', { id: PANE }, async ($, e, next) => {
188    gameLoopCancel?.()
189    gameLoopCancel = undefined
190    if (game) await saveProfile($, game)
191    return next(e)
192  })
193
194  // ── Turn complete: show Arrived card ──────────────────────────────────────
195  on('turn.complete', async ($, e, next) => {
196    // Only main agent (not subagents)
197    if ((e as unknown as Record<string, unknown>)['agentId'] !== undefined) return next(e)
198    if (game && (game.phase === 'driving' || game.phase === 'countdown')) {
199      triggerArrived(game)
200      // Trigger a blit so the Arrived card is shown immediately
201      if (rasterCols) void $.ui.blit({ requestId: PANE, key: 'game', cells: renderFrame(game) })
202    }
203    return next(e)
204  })
205
206  // ── Turn start: resume driving ────────────────────────────────────────────
207  on('turn.start', async ($, e, next) => {
208    if (game && game.phase === 'arrived') {
209      resumeFromArrived(game)
210    }
211    return next(e)
212  })
213
214  // ── Task coins from completed Clean View steps ────────────────────────────
215  on('tool.call', { tool: 'mcp__clean-view__report_progress' as 'mcp__clean-view__report_progress' }, async ($, e, next) => {
216    const result = await next(e)
217    const pct = (e as unknown as Record<string, unknown>)['percent']
218    if (pct === 100 && game && game.phase === 'driving') {
219      game.taskCoinQueue = Math.min(3, game.taskCoinQueue + 1)
220    }
221    return result
222  })
223
224  // ── Task coins from TodoWrite (fallback when Clean View not installed) ─────
225  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
226    const todos: unknown[] = (e as unknown as Record<string, unknown[]>)['todos'] ?? []
227    const beforeCompleted = game?.seenCompleted ?? 0
228    const result = await next(e)
229    if (!result.isError && game) {
230      const nowCompleted = todos.filter((t: unknown) => {
231        const todo = t as Record<string, unknown>
232        return todo['status'] === 'completed'
233      }).length
234      if (nowCompleted > beforeCompleted) {
235        const delta = Math.min(3, nowCompleted - beforeCompleted)
236        game.taskCoinQueue = Math.min(3, game.taskCoinQueue + delta)
237        game.seenCompleted = nowCompleted
238      }
239    }
240    return result
241  })
242
243  // ── Task coins from TaskUpdate ────────────────────────────────────────────
244  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
245    const result = await next(e)
246    const status = (e as unknown as Record<string, unknown>)['status']
247    if (!result.isError && status === 'completed' && game && game.phase === 'driving') {
248      game.taskCoinQueue = Math.min(3, game.taskCoinQueue + 1)
249    }
250    return result
251  })
252}
253
hooks/road-trip-game.ts 741 lines
1// Pure game logic + Raster renderer. No $ references.
2
3import { CARS, QUIZ, LEVEL_XP, levelFromXp, xpProgress, type CarDef, type Perk, type QuizQ } from './road-trip-art'
4
5// ─── Colors ───────────────────────────────────────────────────────────────────
6
7const T = 0x01000000  // terminal default
8
9const D = {
10  road: 0x1C1B24, grass: 0x0F2218, edge: 0x6E7CF6, lane: 0x2E2D3A,
11  hud: 0xFFF6E8, car: 0x6E7CF6, carBody: 0x9AA5FF, coin: 0xFFD700,
12  task: 0xFF3D97, obs: 0xFF5533, obsStripe: 0xCC2200,
13  nitro: 0x00FFFF, right: 0x44FF88, wrong: 0xFF3D97,
14  tree: 0x2A5C38, dot: 0x334433, overlay: 0x1A1928,
15  gateA: 0x3A3060, gateB: 0x603030, gateC: 0x305030,
16  shield: 0xFFDD00, quizHdr: 0xFF8C00, dimRoad: 0x252430,
17}
18const R = {
19  road: 0x000000, grass: 0x001400, edge: 0xFF00FF, lane: 0x006666,
20  hud: 0x00FF00, car: 0x00FFFF, carBody: 0x00DDDD, coin: 0xFFAA00,
21  task: 0xFF00FF, obs: 0xFF2200, obsStripe: 0xAA0000,
22  nitro: 0xFF00FF, right: 0x00FF00, wrong: 0xFF0000,
23  tree: 0x004400, dot: 0x002200, overlay: 0x0A0A0A,
24  gateA: 0x220066, gateB: 0x660022, gateC: 0x006622,
25  shield: 0xFFCC00, quizHdr: 0xFF6600, dimRoad: 0x0A0A0A,
26}
27
28// ─── Base64 ───────────────────────────────────────────────────────────────────
29
30const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
31
32function encodeB64(bytes: Uint8Array): string {
33  const len = bytes.length
34  const out: string[] = new Array(Math.ceil(len / 3) * 4)
35  let o = 0
36  for (let i = 0; i < len; i += 3) {
37    const b0 = bytes[i]!, b1 = bytes[i + 1] ?? 0, b2 = bytes[i + 2] ?? 0
38    out[o++] = B64[b0 >> 2]!
39    out[o++] = B64[((b0 & 3) << 4) | (b1 >> 4)]!
40    out[o++] = B64[((b1 & 0xf) << 2) | (b2 >> 6)]!
41    out[o++] = B64[b2 & 0x3f]!
42  }
43  const rem = len % 3
44  if (rem === 1) { out[o - 2] = '='; out[o - 1] = '=' }
45  else if (rem === 2) { out[o - 1] = '=' }
46  return out.join('')
47}
48
49// ─── Types ────────────────────────────────────────────────────────────────────
50
51export type Phase = 'countdown' | 'driving' | 'paused' | 'flattire' | 'arrived' | 'garage'
52export type ObjKind = 'coin' | 'taskcoin' | 'barrier' | 'cone' | 'pothole' | 'gate'
53
54export interface RoadObj {
55  id: number
56  kind: ObjKind
57  row: number         // current screen row in road area (2 = just below HUD)
58  lane: 0 | 1 | 2    // for gate: the "correct" lane; use lanes[] for barriers
59  lanes?: (0 | 1 | 2)[]  // barrier: which lanes are blocked
60  quizIdx?: number    // gate only
61  collected: boolean
62}
63
64export interface Profile {
65  bank: number
66  ownedCars: string[]
67  currentCar: string
68  xp: number
69  bestDistance: number
70}
71
72export interface GameState {
73  // Layout
74  cols: number
75  rows: number       // total Raster rows
76  laneW: number      // lane width in columns
77  roadL: number      // column of left road edge (▌)
78  // Profile
79  profile: Profile
80  // Per-run
81  phase: Phase
82  tick: number
83  countdown: number
84  cdTimer: number
85  carX: number
86  carTargetLane: 0 | 1 | 2
87  carLane: 0 | 1 | 2
88  speed: number
89  scrollAcc: number
90  scrollPhase: number  // 0..7, drives lane-dash scroll
91  distance: number
92  objects: RoadObj[]
93  nextId: number
94  spawnAcc: number
95  spawnNext: number
96  coinsThisRun: number
97  quizRightThisRun: number
98  nitroTicks: number
99  shieldActive: boolean
100  flatTireTimer: number
101  shakeTicks: number
102  seenCompleted: number
103  taskCoinQueue: number
104  lastQuizTick: number
105  nextQuizTicks: number
106  activeQuiz: QuizQ | null
107  quizAnswered: boolean
108  garagePage: number
109  retro: boolean
110  arrivedData: { distance: number; coins: number; quizRight: number } | null
111}
112
113// ─── Layout helpers ──────────────────────────────────────────────────────────
114
115function calcLayout(cols: number): { laneW: number; roadL: number } {
116  const laneW = Math.max(5, Math.floor((cols - 16) / 3))
117  const roadW = 3 * laneW + 4  // 2 edges + 2 dividers
118  const roadL = Math.max(2, Math.floor((cols - roadW) / 2))
119  return { laneW, roadL }
120}
121
122export function laneCenter(g: Pick<GameState, 'roadL' | 'laneW'>, lane: 0 | 1 | 2): number {
123  return g.roadL + 1 + lane * (g.laneW + 1) + Math.floor(g.laneW / 2)
124}
125
126function roadRight(g: Pick<GameState, 'roadL' | 'laneW'>): number {
127  return g.roadL + 1 + 3 * g.laneW + 2  // after lane 2, the ▐ edge
128}
129
130// ─── Profile validation ───────────────────────────────────────────────────────
131
132export function parseProfile(raw: unknown): Profile {
133  const def: Profile = { bank: 0, ownedCars: ['hatch'], currentCar: 'hatch', xp: 0, bestDistance: 0 }
134  if (!raw || typeof raw !== 'object') return def
135  const r = raw as Record<string, unknown>
136  const validIds = CARS.map(c => c.id)
137  if (typeof r['bank'] === 'number' && r['bank'] >= 0) def.bank = Math.floor(r['bank'])
138  if (Array.isArray(r['ownedCars'])) {
139    def.ownedCars = r['ownedCars'].filter((x: unknown) => typeof x === 'string' && validIds.includes(x)) as string[]
140    if (!def.ownedCars.includes('hatch')) def.ownedCars.unshift('hatch')
141  }
142  if (typeof r['currentCar'] === 'string' && def.ownedCars.includes(r['currentCar'])) {
143    def.currentCar = r['currentCar']
144  }
145  if (typeof r['xp'] === 'number' && r['xp'] >= 0) def.xp = Math.floor(r['xp'])
146  if (typeof r['bestDistance'] === 'number' && r['bestDistance'] >= 0) def.bestDistance = Math.floor(r['bestDistance'])
147  return def
148}
149
150// ─── Game creation ────────────────────────────────────────────────────────────
151
152export function createGame(cols: number, rows: number, profile: Profile, retro: boolean): GameState {
153  const { laneW, roadL } = calcLayout(cols)
154  const car = CARS.find(c => c.id === profile.currentCar) ?? CARS[0]!
155  return {
156    cols, rows, laneW, roadL,
157    profile: { ...profile },
158    phase: 'countdown',
159    tick: 0,
160    countdown: 3,
161    cdTimer: 0,
162    carX: laneCenter({ laneW, roadL }, 1),
163    carTargetLane: 1,
164    carLane: 1,
165    speed: car.perk === 'all' ? 0.8 : 0.5,
166    scrollAcc: 0,
167    scrollPhase: 0,
168    distance: 0,
169    objects: [],
170    nextId: 1,
171    spawnAcc: 0,
172    spawnNext: 20,
173    coinsThisRun: 0,
174    quizRightThisRun: 0,
175    nitroTicks: car.perk === 'all' ? 90 : 0,
176    shieldActive: car.perk === 'shield' || car.perk === 'all',
177    flatTireTimer: 0,
178    shakeTicks: 0,
179    seenCompleted: 0,
180    taskCoinQueue: 0,
181    lastQuizTick: 0,
182    nextQuizTicks: pickQuizInterval(),
183    activeQuiz: null,
184    quizAnswered: false,
185    garagePage: CARS.findIndex(c => c.id === profile.currentCar),
186    retro,
187    arrivedData: null,
188  }
189}
190
191export function resizeGame(g: GameState, cols: number, rows: number): GameState {
192  const { laneW, roadL } = calcLayout(cols)
193  const newG = { ...g, cols, rows, laneW, roadL }
194  // Reposition car to same lane
195  newG.carX = laneCenter(newG, newG.carLane)
196  // Reposition objects horizontally (they're row-based anyway, no x stored)
197  return newG
198}
199
200function pickQuizInterval(): number {
201  // 25–30 seconds at 70ms/tick = 357–428 ticks
202  return 357 + Math.floor(Math.random() * 72)
203}
204
205// ─── Input ────────────────────────────────────────────────────────────────────
206
207export function handleKey(g: GameState, key: 'a' | 'd' | 'p' | 'g' | 'b'): void {
208  switch (g.phase) {
209    case 'countdown':
210    case 'driving':
211      if (key === 'a') { const t = Math.max(0, g.carTargetLane - 1) as 0|1|2; g.carTargetLane = t }
212      else if (key === 'd') { const t = Math.min(2, g.carTargetLane + 1) as 0|1|2; g.carTargetLane = t }
213      else if (key === 'p') g.phase = 'paused'
214      else if (key === 'g') { g.phase = 'garage'; g.garagePage = CARS.findIndex(c => c.id === g.profile.currentCar) }
215      break
216    case 'paused':
217      if (key === 'p' || key === 'g') g.phase = 'driving'
218      else if (key === 'a') { const t = Math.max(0, g.carTargetLane - 1) as 0|1|2; g.carTargetLane = t }
219      else if (key === 'd') { const t = Math.min(2, g.carTargetLane + 1) as 0|1|2; g.carTargetLane = t }
220      break
221    case 'garage':
222      if (key === 'a') g.garagePage = Math.max(0, g.garagePage - 1)
223      else if (key === 'd') g.garagePage = Math.min(CARS.length - 1, g.garagePage + 1)
224      else if (key === 'b') garageBuy(g)
225      else if (key === 'p' || key === 'g') g.phase = g.arrivedData ? 'arrived' : 'driving'
226      break
227    case 'arrived':
228      if (key === 'a') startNewRun(g)           // Keep Driving
229      else if (key === 'g') { g.phase = 'garage'; g.garagePage = CARS.findIndex(c => c.id === g.profile.currentCar) }
230      // 'b' = Close (handled by caller via $.ui.close)
231      break
232    case 'flattire':
233      break
234  }
235}
236
237export function garageBuy(g: GameState): 'ok' | 'locked' | 'poor' | 'driving' {
238  const car = CARS[g.garagePage]
239  if (!car) return 'poor'
240  const level = levelFromXp(g.profile.xp)
241  if (level < car.levelReq) return 'locked'
242  if (g.profile.currentCar === car.id && g.profile.ownedCars.includes(car.id)) return 'driving'
243  if (!g.profile.ownedCars.includes(car.id)) {
244    if (g.profile.bank < car.price) return 'poor'
245    g.profile.bank -= car.price
246    g.profile.ownedCars = [...g.profile.ownedCars, car.id]
247  }
248  g.profile.currentCar = car.id
249  if (g.arrivedData) {
250    g.phase = 'countdown'
251    g.arrivedData = null
252    startNewRun(g)
253  } else {
254    startNewRun(g)
255  }
256  return 'ok'
257}
258
259function startNewRun(g: GameState): void {
260  const { laneW, roadL } = calcLayout(g.cols)
261  Object.assign(g, {
262    phase: 'countdown', tick: 0, countdown: 3, cdTimer: 0,
263    laneW, roadL,
264    carX: laneCenter({ laneW, roadL }, 1), carTargetLane: 1, carLane: 1,
265    speed: 0.5, scrollAcc: 0, scrollPhase: 0, distance: 0,
266    objects: [], nextId: g.nextId, spawnAcc: 0, spawnNext: 20,
267    coinsThisRun: 0, quizRightThisRun: 0,
268    flatTireTimer: 0, shakeTicks: 0, arrivedData: null,
269  })
270  const car = CARS.find(c => c.id === g.profile.currentCar) ?? CARS[0]!
271  if (car.perk === 'all') { g.speed = 0.8; g.nitroTicks = 90 } else { g.nitroTicks = 0 }
272  g.shieldActive = car.perk === 'shield' || car.perk === 'all'
273  g.lastQuizTick = 0
274  g.nextQuizTicks = pickQuizInterval()
275  g.activeQuiz = null
276  g.quizAnswered = false
277}
278
279// ─── Game tick ────────────────────────────────────────────────────────────────
280
281export function tickGame(g: GameState): void {
282  if (g.phase === 'paused' || g.phase === 'arrived' || g.phase === 'garage') return
283
284  if (g.phase === 'countdown') {
285    g.cdTimer++
286    if (g.cdTimer >= 18) {  // ~1.3s per count
287      g.cdTimer = 0
288      g.countdown--
289      if (g.countdown <= 0) g.phase = 'driving'
290    }
291    return
292  }
293
294  if (g.phase === 'flattire') {
295    g.flatTireTimer--
296    if (g.flatTireTimer <= 0) startNewRun(g)
297    return
298  }
299
300  // Driving
301  g.tick++
302
303  // Smooth car movement (ease toward target lane center)
304  const targetX = laneCenter(g, g.carTargetLane)
305  const dx = targetX - g.carX
306  g.carX += dx * 0.18
307  if (Math.abs(dx) < 0.6) { g.carX = targetX; g.carLane = g.carTargetLane }
308
309  // Shake decay
310  if (g.shakeTicks > 0) g.shakeTicks--
311
312  // Nitro decay
313  if (g.nitroTicks > 0) g.nitroTicks--
314
315  // Speed ramp-up (max ~2.5 rows/tick)
316  const baseSpeed = 0.5 + g.distance / 20000
317  g.speed = Math.min(2.5, (g.nitroTicks > 0 ? 2.5 : baseSpeed))
318
319  // Scroll
320  g.scrollAcc += g.speed
321  const scrolled = Math.floor(g.scrollAcc)
322  g.scrollAcc -= scrolled
323  g.scrollPhase = (g.scrollPhase + g.speed) % 8
324
325  // Update distance
326  g.distance += g.speed * 1.5  // meters (approx)
327
328  // XP from distance (1 XP per ~50 distance)
329  if (Math.floor(g.distance / 50) > Math.floor((g.distance - g.speed * 1.5) / 50)) {
330    g.profile.xp += 1
331  }
332
333  // Move objects down the screen (they scroll toward car, which is at bottom)
334  for (const obj of g.objects) {
335    obj.row += scrolled
336  }
337
338  // Spawn task coins from queue
339  if (g.taskCoinQueue > 0 && g.tick % 8 === 0) {
340    const lane = (Math.floor(Math.random() * 3)) as 0 | 1 | 2
341    g.objects.push({ id: g.nextId++, kind: 'taskcoin', row: 2, lane, collected: false })
342    g.taskCoinQueue--
343  }
344
345  // Spawn new objects
346  g.spawnAcc += g.speed
347  if (g.spawnAcc >= g.spawnNext) {
348    g.spawnAcc = 0
349    g.spawnNext = 15 + Math.floor(Math.random() * 12)
350    spawnObject(g)
351  }
352
353  // Quiz timer
354  if (g.tick - g.lastQuizTick >= g.nextQuizTicks && !g.activeQuiz) {
355    const q = QUIZ[Math.floor(Math.random() * QUIZ.length)]!
356    g.activeQuiz = q
357    g.quizAnswered = false
358    const correctLane = Math.floor(Math.random() * 3) as 0 | 1 | 2
359    // Build gate permutation so correct answer lands in correctLane
360    const perm: [0|1|2, 0|1|2, 0|1|2] = [0, 1, 2]
361    // swap correct answer index into correctLane
362    const correctOptIdx = q.correct
363    const tmp = perm[correctLane]!
364    perm[correctLane] = correctOptIdx as 0|1|2
365    perm[correctOptIdx] = tmp as 0|1|2
366    g.objects.push({
367      id: g.nextId++, kind: 'gate', row: 2,
368      lane: correctLane,
369      quizIdx: QUIZ.indexOf(q),
370      collected: false,
371    })
372    ;(g.objects[g.objects.length - 1] as RoadObj & { perm?: number[] })['perm'] = perm
373    g.lastQuizTick = g.tick
374    g.nextQuizTicks = pickQuizInterval()
375  }
376
377  // Collision detection
378  const carTopRow = g.rows - 3  // top of car in Raster coords
379  const carBotRow = g.rows - 1
380  const carEffLane = closestLane(g, g.carX)
381  const car = CARS.find(c => c.id === g.profile.currentCar) ?? CARS[0]!
382  const hasMagnet = car.perk === 'magnet' || car.perk === 'all'
383
384  for (const obj of g.objects) {
385    if (obj.collected) continue
386    if (obj.row < carTopRow || obj.row > carBotRow + 1) continue
387
388    // Lane match
389    const inLane = obj.lane === carEffLane
390    const adjacentCoin = hasMagnet && (obj.kind === 'coin' || obj.kind === 'taskcoin') &&
391      Math.abs(obj.lane - carEffLane) === 1
392
393    if (!inLane && !adjacentCoin) {
394      // Check barrier: it blocks specific lanes
395      if (obj.kind === 'barrier') {
396        const blocked = (obj as RoadObj & { lanes?: (0|1|2)[] }).lanes ?? [obj.lane]
397        if (!blocked.includes(carEffLane)) continue
398      } else continue
399    }
400
401    if (obj.kind === 'coin') {
402      obj.collected = true
403      const mult = car.perk === 'doublecoin' || car.perk === 'all' ? 2 : 1
404      g.profile.bank += mult
405      g.coinsThisRun += mult
406    } else if (obj.kind === 'taskcoin') {
407      obj.collected = true
408      const mult = car.perk === 'doublecoin' || car.perk === 'all' ? 2 : 1
409      g.profile.bank += 10 * mult
410      g.coinsThisRun += 10 * mult
411    } else if (obj.kind === 'barrier' || obj.kind === 'cone' || obj.kind === 'pothole') {
412      if (g.nitroTicks > 0) {
413        // Nitro smashes through: collect 2 coins
414        obj.collected = true
415        g.profile.bank += 2
416        g.coinsThisRun += 2
417      } else if (g.shieldActive) {
418        obj.collected = true
419        g.shieldActive = false
420      } else {
421        obj.collected = true
422        hitObstacle(g)
423        return
424      }
425    } else if (obj.kind === 'gate' && !g.quizAnswered) {
426      obj.collected = true
427      g.quizAnswered = true
428      const correctLane = obj.lane
429      if (carEffLane === correctLane) {
430        // Correct lane
431        g.activeQuiz = null
432        const mult = car.perk === 'doublecoin' || car.perk === 'all' ? 2 : 1
433        g.profile.bank += 15 * mult
434        g.coinsThisRun += 15 * mult
435        g.profile.xp += 50
436        g.nitroTicks = Math.max(g.nitroTicks, 60 + Math.floor(60 * Math.random()))
437        g.quizRightThisRun++
438      } else {
439        g.activeQuiz = null
440      }
441    }
442  }
443
444  // Remove off-screen or collected objects
445  g.objects = g.objects.filter(o => !o.collected && o.row <= g.rows + 2)
446}
447
448export function hitObstacle(g: GameState): void {
449  g.shakeTicks = 8
450  g.phase = 'flattire'
451  g.flatTireTimer = 28  // ~2s
452}
453
454function spawnObject(g: GameState): void {
455  const r = Math.random()
456  if (r < 0.45) {
457    // Coins: 2-4 in a line, random lane
458    const lane = (Math.floor(Math.random() * 3)) as 0 | 1 | 2
459    const count = 2 + Math.floor(Math.random() * 3)
460    for (let i = 0; i < count; i++) {
461      g.objects.push({ id: g.nextId++, kind: 'coin', row: 2 + i * 3, lane, collected: false })
462    }
463  } else if (r < 0.70) {
464    // Obstacle: barrier, cone or pothole
465    const kind: ObjKind = r < 0.55 ? 'barrier' : r < 0.62 ? 'cone' : 'pothole'
466    // Pick which lanes to block (never all 3)
467    const blockedCount = Math.random() < 0.4 ? 2 : 1
468    const lanes: (0 | 1 | 2)[] = [0, 1, 2]
469    for (let i = lanes.length - 1; i > 0; i--) {
470      const j = Math.floor(Math.random() * (i + 1))
471      ;[lanes[i], lanes[j]] = [lanes[j]!, lanes[i]!]
472    }
473    const blocked = lanes.slice(0, blockedCount) as (0 | 1 | 2)[]
474    const primaryLane = blocked[0]!
475    g.objects.push({ id: g.nextId++, kind, row: 2, lane: primaryLane, lanes: blocked, collected: false })
476  }
477  // else: nothing this cycle
478}
479
480function closestLane(g: GameState, x: number): 0 | 1 | 2 {
481  let best: 0 | 1 | 2 = 0
482  let bestDist = Infinity
483  for (const lane of [0, 1, 2] as const) {
484    const d = Math.abs(laneCenter(g, lane) - x)
485    if (d < bestDist) { bestDist = d; best = lane }
486  }
487  return best
488}
489
490// ─── Arrived / Resume ─────────────────────────────────────────────────────────
491
492export function triggerArrived(g: GameState): void {
493  if (g.phase !== 'driving' && g.phase !== 'countdown') return
494  if (g.distance > g.profile.bestDistance) g.profile.bestDistance = Math.floor(g.distance)
495  g.arrivedData = { distance: Math.floor(g.distance), coins: g.coinsThisRun, quizRight: g.quizRightThisRun }
496  g.phase = 'arrived'
497}
498
499export function resumeFromArrived(g: GameState): void {
500  if (g.phase !== 'arrived') return
501  startNewRun(g)
502}
503
504// ─── Raster renderer ──────────────────────────────────────────────────────────
505
506let frameBuffer = new Uint32Array(0)
507
508function ensureBuf(cols: number, rows: number): Uint32Array {
509  const need = cols * rows * 3
510  if (frameBuffer.length !== need) frameBuffer = new Uint32Array(need)
511  return frameBuffer
512}
513
514function sc(buf: Uint32Array, col: number, row: number, cols: number, rows: number, cp: number, fg: number, bg: number): void {
515  if (col < 0 || col >= cols || row < 0 || row >= rows) return
516  const i = (row * cols + col) * 3
517  buf[i] = cp; buf[i + 1] = fg; buf[i + 2] = bg
518}
519
520function sw(buf: Uint32Array, col: number, row: number, cols: number, rows: number, str: string, fg: number, bg: number): void {
521  let c = col
522  for (let i = 0; i < str.length; ) {
523    const cp = str.codePointAt(i)!
524    sc(buf, c++, row, cols, rows, cp, fg, bg)
525    i += cp > 0xFFFF ? 2 : 1
526  }
527}
528
529function fr(buf: Uint32Array, r1: number, c1: number, r2: number, c2: number, cols: number, rows: number, cp: number, fg: number, bg: number): void {
530  for (let row = r1; row <= r2; row++) {
531    for (let col = c1; col <= c2; col++) {
532      sc(buf, col, row, cols, rows, cp, fg, bg)
533    }
534  }
535}
536
537export function renderFrame(g: GameState): string {
538  const { cols, rows, laneW, roadL } = g
539  const buf = ensureBuf(cols, rows)
540  const c = g.retro ? R : D
541  const roadR = roadRight(g)
542  const sp = Math.floor(g.scrollPhase)
543
544  // ── Background ─────────────────────────────────────────────────────────────
545  // Grass
546  fr(buf, 0, 0, rows - 1, cols - 1, cols, rows, 0x20, c.dot, c.grass)
547  // Road
548  fr(buf, 0, roadL, rows - 1, roadR, cols, rows, 0x20, T, c.road)
549
550  // ── Trees in grass ─────────────────────────────────────────────────────────
551  const treePositions = [1, 4, 7, 10, 13, 16, 19, 22]
552  for (const tp of treePositions) {
553    const rr = ((tp * 3 + sp * 2) % (rows - 2)) + 2
554    if (rr >= 2 && rr < rows - 1) {
555      // left tree
556      sc(buf, Math.max(0, roadL - 2), rr, cols, rows, 0x2663, c.tree, c.grass)  // ♣
557      // right tree
558      sc(buf, Math.min(cols - 1, roadR + 2), rr, cols, rows, 0x2663, c.tree, c.grass)
559    }
560  }
561
562  // ── Road edges ─────────────────────────────────────────────────────────────
563  for (let row = 2; row < rows; row++) {
564    sc(buf, roadL, row, cols, rows, 0x258C, c.edge, c.grass)   // ▌
565    sc(buf, roadR, row, cols, rows, 0x2590, c.edge, c.grass)   // ▐
566  }
567
568  // ── Lane dividers (dashed, scroll) ─────────────────────────────────────────
569  const div1 = roadL + 1 + laneW
570  const div2 = roadL + 2 + laneW * 2
571  for (let row = 2; row < rows; row++) {
572    if ((row + sp) % 4 === 0) {
573      sc(buf, div1, row, cols, rows, 0x2503, c.lane, c.road)  // ┃
574      sc(buf, div2, row, cols, rows, 0x2503, c.lane, c.road)
575    }
576  }
577
578  // ── Objects ────────────────────────────────────────────────────────────────
579  for (const obj of g.objects) {
580    if (obj.collected) continue
581    const row = obj.row
582    if (row < 2 || row >= rows) continue
583    const cx = laneCenter(g, obj.lane)
584
585    if (obj.kind === 'coin') {
586      sc(buf, cx, row, cols, rows, 0x24, c.coin, c.road)           // $
587    } else if (obj.kind === 'taskcoin') {
588      sc(buf, cx - 1, row, cols, rows, 0x28, c.task, c.road)       // (
589      sc(buf, cx,     row, cols, rows, 0x24, c.task, c.road)       // $
590      sc(buf, cx + 1, row, cols, rows, 0x29, c.task, c.road)       // )
591    } else if (obj.kind === 'barrier') {
592      const lanes = (obj as RoadObj & { lanes?: (0|1|2)[] }).lanes ?? [obj.lane]
593      for (const lane of lanes) {
594        const x = laneCenter(g, lane)
595        for (let dc = -Math.floor(laneW / 2); dc <= Math.floor(laneW / 2); dc++) {
596          const c2 = ((x + dc) + Math.abs(dc)) % 2 === 0 ? c.obs : c.obsStripe
597          sc(buf, x + dc, row, cols, rows, 0x259E, T, c2)          // ▞
598        }
599      }
600    } else if (obj.kind === 'cone') {
601      sc(buf, cx, row, cols, rows, 0x25B2, c.obs, c.road)          // ▲
602    } else if (obj.kind === 'pothole') {
603      sc(buf, cx - 1, row, cols, rows, 0x28, c.obs, c.road)
604      sc(buf, cx,     row, cols, rows, 0x25C9, c.obs, c.road)      // ◉
605      sc(buf, cx + 1, row, cols, rows, 0x29, c.obs, c.road)
606    } else if (obj.kind === 'gate') {
607      const perm = (obj as RoadObj & { perm?: number[] })['perm'] ?? [0, 1, 2]
608      const gateColors = [c.gateA, c.gateB, c.gateC]
609      for (const lane of [0, 1, 2] as const) {
610        const optIdx = perm[lane] ?? lane
611        const lx = laneCenter(g, lane)
612        const lbl = ['A', 'B', 'C'][optIdx] ?? 'A'
613        const q = g.activeQuiz
614        const opt = q?.opts[optIdx] ?? ''
615        const bg = gateColors[lane]!
616        // Fill lane with gate color
617        for (let dc = -Math.floor(laneW / 2); dc <= Math.floor(laneW / 2); dc++) {
618          sc(buf, lx + dc, row, cols, rows, 0x20, T, bg)
619        }
620        // Write label
621        const labelStr = lbl + ' ' + opt.slice(0, Math.min(opt.length, laneW - 3))
622        sw(buf, lx - Math.floor(laneW / 2), row, cols, rows, labelStr, 0xFFFFFF, bg)
623      }
624      // Show question 3 rows above gate
625      if (g.activeQuiz && row > 5) {
626        const q = g.activeQuiz
627        sw(buf, roadL + 1, row - 3, cols, rows, 'QUIZ  ' + q.q.slice(0, Math.min(q.q.length, roadR - roadL - 8)), c.quizHdr, c.dimRoad)
628        sw(buf, roadL + 1, row - 2, cols, rows, '  A ' + q.opts[0] + '   B ' + q.opts[1] + '   C ' + q.opts[2], c.hud, c.dimRoad)
629      }
630    }
631  }
632
633  // ── Car ────────────────────────────────────────────────────────────────────
634  const carDef = CARS.find(cd => cd.id === g.profile.currentCar) ?? CARS[0]!
635  const carCol = Math.round(g.carX) - 2
636  const carRow = rows - 3
637  const shakeX = g.shakeTicks > 0 ? (g.tick % 2 === 0 ? 1 : -1) : 0
638  const carFg = g.nitroTicks > 0 ? c.nitro : c.car
639  for (let r = 0; r < 3; r++) {
640    sw(buf, carCol + shakeX, carRow + r, cols, rows, carDef.art[r]!, carFg, c.road)
641  }
642  // Shield indicator
643  if (g.shieldActive) {
644    sc(buf, carCol - 1, carRow + 1, cols, rows, 0x25C6, c.shield, c.road)  // ◆
645
646  }
647
648  // ── HUD ────────────────────────────────────────────────────────────────────
649  fr(buf, 0, 0, 1, cols - 1, cols, rows, 0x20, T, c.overlay)
650  const { level, frac } = xpProgress(g.profile.xp)
651  const dist = Math.floor(g.distance)
652  const best = g.profile.bestDistance
653  const bank = g.profile.bank
654
655  // Row 0: title, distance, best, bank
656  const r0 = ' ROAD TRIP' + '  ' + dist + ' m  · Best ' + best + ' m  · ' + String.fromCodePoint(0x25C9) + ' ' + bank
657  sw(buf, 0, 0, cols, rows, r0.slice(0, cols), c.hud, c.overlay)
658  // Nitro indicator
659  if (g.nitroTicks > 0) sw(buf, cols - 8, 0, cols, rows, ' NITRO! ', c.nitro, c.overlay)
660
661  // Row 1: level, XP bar, perk, car name
662  const xpBar = Array.from({ length: 10 }, (_, i) =>
663    i < Math.floor(frac * 10) ? String.fromCodePoint(0x25B0) : String.fromCodePoint(0x25B1)
664  ).join('')  // ▰▱
665  const carPerkStr = carDef.perk === 'none' ? '' : '  ' + String.fromCodePoint(0x25C6) + ' ' + capPerk(carDef.perk)
666  const r1 = ' LV ' + level + ' ' + xpBar + carPerkStr + '  ' + carDef.name
667  sw(buf, 0, 1, cols, rows, r1.slice(0, cols), c.hud, c.overlay)
668
669  // ── Overlays ───────────────────────────────────────────────────────────────
670  if (g.phase === 'countdown' && g.countdown > 0) {
671    drawCentered(buf, Math.floor(rows / 2), g.countdown === 3 ? 'Get ready...' : g.countdown === 2 ? 'Get ready. 2' : 'Get ready. 1', cols, rows, c.nitro, c.overlay)
672  }
673
674  if (g.phase === 'flattire') {
675    drawCentered(buf, Math.floor(rows / 2), 'Flat tire!  Respawning...', cols, rows, c.wrong, c.overlay)
676  }
677
678  if (g.phase === 'paused') {
679    drawCentered(buf, Math.floor(rows / 2) - 1, '  PAUSED  ', cols, rows, c.hud, c.overlay)
680    drawCentered(buf, Math.floor(rows / 2),      '  P resume  G garage  ', cols, rows, c.hud, c.overlay)
681  }
682
683  if (g.phase === 'arrived' && g.arrivedData) {
684    const ad = g.arrivedData
685    const mid = Math.floor(rows / 2)
686    fr(buf, mid - 4, Math.floor(cols * 0.1), mid + 4, Math.floor(cols * 0.9), cols, rows, 0x20, T, c.overlay)
687    drawCentered(buf, mid - 3, 'Arrived! Your AI employee finished the job', cols, rows, c.right, c.overlay)
688    drawCentered(buf, mid - 1, dist + ' m   ' + String.fromCodePoint(0x25C9) + ' ' + ad.coins + ' coins   ' + ad.quizRight + ' quiz', cols, rows, c.hud, c.overlay)
689    drawCentered(buf, mid,     'LV ' + level + '  Bank: ' + String.fromCodePoint(0x25C9) + ' ' + bank, cols, rows, c.hud, c.overlay)
690    drawCentered(buf, mid + 2, '  A Keep Driving   G Garage   B Close  ', cols, rows, c.coin, c.overlay)
691  }
692
693  if (g.phase === 'garage') {
694    drawGarage(buf, g, c, cols, rows)
695  }
696
697  return encodeB64(new Uint8Array(buf.buffer))
698}
699
700function drawCentered(buf: Uint32Array, row: number, text: string, cols: number, rows: number, fg: number, bg: number): void {
701  const col = Math.max(0, Math.floor((cols - text.length) / 2))
702  fr(buf, row, Math.max(0, col - 1), row, Math.min(cols - 1, col + text.length), cols, rows, 0x20, T, bg)
703  sw(buf, col, row, cols, rows, text, fg, bg)
704}
705
706function drawGarage(buf: Uint32Array, g: GameState, c: typeof D | typeof R, cols: number, rows: number): void {
707  const car = CARS[g.garagePage]!
708  const mid = Math.floor(rows / 2)
709  const level = levelFromXp(g.profile.xp)
710  const owned = g.profile.ownedCars.includes(car.id)
711  const driving = g.profile.currentCar === car.id
712  const locked = level < car.levelReq
713  const canAfford = g.profile.bank >= car.price
714
715  fr(buf, mid - 5, 2, mid + 5, cols - 3, cols, rows, 0x20, T, c.overlay)
716  drawCentered(buf, mid - 5, '  GARAGE  ' + (g.garagePage + 1) + '/' + CARS.length, cols, rows, c.coin, c.overlay)
717
718  // Car art centered
719  const artCol = Math.floor((cols - 5) / 2)
720  for (let r = 0; r < 3; r++) {
721    sw(buf, artCol, mid - 3 + r, cols, rows, car.art[r]!, c.car, c.overlay)
722  }
723
724  drawCentered(buf, mid, car.name, cols, rows, c.hud, c.overlay)
725  drawCentered(buf, mid + 1, car.flavor.slice(0, cols - 4), cols, rows, c.hud, c.overlay)
726
727  let status: string
728  if (driving) status = 'Driving this one'
729  else if (owned) status = 'Owned  B to drive'
730  else if (locked) status = 'Locked  reach LV ' + car.levelReq
731  else if (!canAfford) status = String.fromCodePoint(0x25C9) + ' ' + car.price + '  you have ' + g.profile.bank
732  else status = 'B to buy for ' + String.fromCodePoint(0x25C9) + ' ' + car.price
733  drawCentered(buf, mid + 2, status, cols, rows, locked ? c.wrong : (owned || driving) ? c.right : c.coin, c.overlay)
734  drawCentered(buf, mid + 4, '  A prev   D next   B select   P/G back  ', cols, rows, c.hud, c.overlay)
735}
736
737function capPerk(p: Perk): string {
738  const map: Record<Perk, string> = { none: '', magnet: 'Magnet', shield: 'Shield', doublecoin: '2x Coins', all: 'All' }
739  return map[p]
740}
741
hooks/road-trip-art.ts 104 lines
1// Cars, quiz questions, XP levels. No $ references.
2
3export type Perk = 'none' | 'magnet' | 'shield' | 'doublecoin' | 'all'
4
5export interface CarDef {
6  id: string
7  name: string
8  flavor: string
9  price: number
10  levelReq: number
11  perk: Perk
12  art: readonly [string, string, string]  // 5-char wide, 3 rows
13}
14
15export const CARS: readonly CarDef[] = [
16  {
17    id: 'hatch', name: 'Starter Hatch',
18    flavor: 'Every empire starts somewhere',
19    price: 0, levelReq: 1, perk: 'none',
20    art: [' ,_, ', '[   ]', "'o o'"],
21  },
22  {
23    id: 'coupe', name: 'Side Hustle Coupe',
24    flavor: 'Coin magnet: grabs coins in adjacent lane',
25    price: 60, levelReq: 2, perk: 'magnet',
26    art: ['▗▄▄▄▖', '▐▀▀▀▌', '▝o o▘'],
27  },
28  {
29    id: 'wagon', name: 'Agency Wagon',
30    flavor: 'Shield: survive one hit per run',
31    price: 180, levelReq: 3, perk: 'shield',
32    art: ['▗▟█▙▖', '▐█▀█▌', '▝▀ ▀▘'],
33  },
34  {
35    id: 'gt', name: 'Founder GT',
36    flavor: 'Double coins on everything',
37    price: 450, levelReq: 5, perk: 'doublecoin',
38    art: ['▗▃▃▃▖', '▐███▌', '▝▀▀▀▘'],
39  },
40  {
41    id: 'hyper', name: 'Unicorn Hyper',
42    flavor: 'All perks + nitro start',
43    price: 1000, levelReq: 8, perk: 'all',
44    art: ['+▀█▀+', '▐███▌', '+▄█▄+'],
45  },
46]
47
48// Cumulative XP needed to reach each level boundary (index 0 = XP for level 2)
49export const LEVEL_XP = [500, 1500, 3000, 5000, 8000, 12000, 18000, 25000, 35000] as const
50
51export function levelFromXp(xp: number): number {
52  let lv = 1
53  for (const t of LEVEL_XP) { if (xp >= t) lv++; else break }
54  return lv
55}
56
57export function xpProgress(xp: number): { level: number; frac: number } {
58  let level = 1
59  let prev = 0
60  for (const t of LEVEL_XP) {
61    if (xp >= t) { level++; prev = t } else {
62      return { level, frac: (xp - prev) / (t - prev) }
63    }
64  }
65  return { level, frac: 1 }
66}
67
68export interface QuizQ {
69  q: string                             // question, max ~36 chars
70  opts: readonly [string, string, string] // A/B/C, each max ~12 chars
71  correct: 0 | 1 | 2
72}
73
74export const QUIZ: readonly QuizQ[] = [
75  { q: 'Stop Claude mid-task?',         opts: ['Esc', 'F5', 'Tab'],                correct: 0 },
76  { q: '/compact does what?',           opts: ['Delete files', 'Compress context', 'Exit'], correct: 1 },
77  { q: 'That meeting should be...',     opts: ['A slide deck', 'An email', 'A call'],      correct: 1 },
78  { q: '/clear resets?',                opts: ['Files', 'Context window', 'Plugins'],      correct: 1 },
79  { q: 'MCP stands for?',               opts: ['Multi-Client', 'Model Context', 'Managed'], correct: 1 },
80  { q: 'Best deploy day?',              opts: ['Friday 5pm', 'Monday AM', 'Wednesday AM'], correct: 2 },
81  { q: 'Haiku vs Sonnet: speed?',       opts: ['Same', 'Haiku faster', 'Sonnet faster'],   correct: 1 },
82  { q: 'Debug or rewrite?',             opts: ['Always debug', 'Always rewrite', 'Depends'], correct: 2 },
83  { q: 'Biggest startup risk?',         opts: ['Office space', 'Not starting', 'The logo'], correct: 1 },
84  { q: 'Spec or code first?',           opts: ['Code', 'Spec', 'Both at once'],            correct: 1 },
85  { q: 'Force push main branch?',       opts: ['Sure', 'Never on Friday', 'Ask first'],    correct: 2 },
86  { q: 'Fix scope creep how?',          opts: ['Say no', 'Sprint faster', 'Add devs'],     correct: 0 },
87  { q: 'Context window full?',          opts: ['/compact', '/clear', 'Restart'],           correct: 0 },
88  { q: 'YAGNI stands for?',             opts: ["You Ain't Gonna", 'Yet Another Go', 'Your Agent'], correct: 0 },
89  { q: 'Claude API style?',             opts: ['REST only', 'Messages API', 'GraphQL'],    correct: 1 },
90  { q: 'Prompt caching saves?',         opts: ['Speed only', 'Cost only', 'Both'],         correct: 2 },
91  { q: 'Best code comment?',            opts: ['Why not what', 'What not why', 'No comments'], correct: 0 },
92  { q: 'Ship at 80% or wait?',          opts: ['Wait for 100', 'Ship at 80', 'Ship at 60'], correct: 1 },
93  { q: 'Cancel running tool?',          opts: ['Esc', 'Ctrl+C', 'q'],                     correct: 0 },
94  { q: 'Fastest bug fix?',              opts: ['Add logs', 'Step debugger', 'Ask Claude'], correct: 2 },
95  { q: 'Subagents run how?',            opts: ['In parallel', 'In sequence', 'Either'],    correct: 2 },
96  { q: 'Daily standup duration?',       opts: ['15 minutes', '1 hour', '30 min'],          correct: 0 },
97  { q: 'Tech debt interest rate?',      opts: ['Compound', 'Fixed 5%', 'No interest'],     correct: 0 },
98  { q: 'Zero inbox: real or myth?',     opts: ['Real goal', 'Productivity myth', 'App feature'], correct: 1 },
99  { q: 'Best first hire?',              opts: ['Generalist', 'Specialist', 'No hire yet'], correct: 0 },
100  { q: 'Three similar lines need?',     opts: ['An abstraction', 'A helper', 'Nothing yet'], correct: 2 },
101  { q: 'Async/await vs callbacks?',     opts: ['Always async', 'Depends', 'Always callbacks'], correct: 1 },
102  { q: 'One-liner or helper?',          opts: ['One-liner always', 'Helper always', 'One-liner first'], correct: 2 },
103]
104
types/index.d.ts 18 lines
1export type RoadTripSave = {
2  bank: number
3  ownedCars: string[]
4  currentCar: string
5  xp: number
6  bestDistance: number
7  retro: boolean
8  disabled: boolean
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'road-trip': {
14      tick: number
15    }
16  }
17}
18