A pixel-art aquarium above the prompt: Claude's work feeds the fish, the fish spit coins onto the sand, you pick them up and grow the tank.


A pixel-art aquarium that lives in a Claude Code pane. Claude's work feeds the fish: every turn's token usage becomes energy, the fish turn energy into coins, and successful edits, passing tests and git commits pay a little extra. You collect the coins, feed the fish, buy new species and decorations, and level the fish up. Nothing dies while you are away; the tank simply waits.
From a terminal session of Claude Code (y to add the marketplace, then pick a scope):
/plugin install token-tank --marketplace SheldonChangL/claude-mods
From a shell: claude plugin install token-tank --marketplace SheldonChangL/claude-mods. In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with SheldonChangL/claude-mods, then Add plugin.
The words follow Claude's language: Chinese when Claude Code's language setting (or the shell's LANG) is Chinese, English otherwise; the settings tab can pin either.
/fish opens the pane. /fish collect collects every pending coin and /fish feed feeds every fish without opening it.effective = input + 2×output + 0.02×cacheRead + 1.25×cacheWrite per main-loop turn (subagents are part of the same work and do not pay twice). Energy is (min(effective, 15,000) + 0.1 × the rest) / 2,500, so idle prompts are a poor way to farm. The tank shares each turn's energy by weighted square root: a fish weighs its rarity (common 1, rare 3, epic 10, legendary 32) times its level, hunger and mood, and gets energy × w / √Σw, so more fish earn more but with diminishing returns and rarer fish earn far more each. Every fish has a daily quota by rarity (120 / 360 / 960 / 1,920 coins); past it coins come at a fifth. Whole coins wait on the sand (at most 80 a fish) until you pick them up; fractions carry over.
The tank levels up on lifetime coins produced (18 levels, Lv.5 at 640, Lv.10 at 3,200, Lv.18 at 11,500); levels unlock rarer species and more fish slots, and extra slots can be bought. Edits, passing tests and commits pay a little extra into the wallet but never count toward the level. docs/economy-balance-report.md holds the 60-day simulations behind these numbers; npx tsx scripts/simulate-economy.ts reruns them.
The game is one JSON value in the plugin's $.store, shared by every session. A v2 save from an earlier build is migrated and the original kept under game.backup.v2.
Claude Code Desktop draws the tank as an SVG with native animation. The terminal shows a text tank with the same buttons; a pixel terminal tank (kitty graphics) is planned.
claude plugin validate .
claude plugin test .
node assets/tools/build-sprites.mjs # assets/sprites.json → render/sprites.ts
MIT licensed. All pixel art is original.
If the fish made you smile, you can buy them some food: ko-fi.com/sheldonchang. The mod stays free and open either way.
hooks/register.tsx 939 lines1// Token Tank: a pixel-art tank above the prompt. Claude's turns feed the
2// fish (token usage → energy → coins), successful edits, tests and commits pay
3// a little extra, and the person picks the coins up off the tank floor.
4//
5// Layers: game/* is pure (state in, state out); this file is the adapter to
6// `$` (store, turns, tool calls, the band, the pane); render/* draws the tank.
7//
8// The band shows while a long turn runs (as clawd-run did), or pinned by
9// `/fish`. The pane (right side) is the shop, the fish and the settings.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register, RenderElement } from 'claude-code'
13
14import type { ShopTab, View } from '../types'
15import type { DecorId, GameEvent, GameState, Outcome, RewardKind } from '../game/types'
16import { applyTurn, effectiveTokens, isOverQuota, levelBoost, levelUpCost, PENDING_CAP, reward, XP_THRESHOLDS } from '../game/economy'
17import { nextSlot, slotCapacity, tankProgress } from '../game/progression'
18import { collect, dropFood, feed, FULL_ENOUGH, levelUp, pendingTotal, releaseFish, rename, startle } from '../game/fish'
19import { DECOR, FOOD_PRICE, SPECIES, SPECIES_LIST } from '../game/species'
20import { buyDecoration, buyFish, buyFood, buySlot, MAX_DECOR, moveDecoration, shopList } from '../game/shop'
21import { appendLog, mergeStates, migrate } from '../game/state'
22import { BASE_W, decorPortraitSvg, fishCentreAt, fishLayout, fishPortraitSvg, FISH_SCALES, laneOrder, PELLET_SHOW_MS, piles, TANK_H, tankSvg } from '../render/svg'
23import { clock, decorName, describe, fmt, langOf, moodLine, refusalText, speciesName, tr } from './text'
24import type { Lang } from './text'
25
26const PANE = 'token-tank'
27const STORE_KEY = 'game'
28const BACKUP_KEY = 'game.backup.v2'
29/** The save as it was before the economy v0.3 conversion. */
30const BACKUP_KEY_V2_ECONOMY = 'game.backup.economy2'
31/** True when the stored save could not be converted: nothing is written back. */
32let isStoreReadOnly = false
33/** A turn this long shows the band by itself. */
34const LONG_TURN_MS = 20_000
35
36const game = atom({ plugin: 'token-tank', key: 'game' } as const, null as GameState | null)
37const view = atom({ plugin: 'token-tank', key: 'view' } as const, 'shop' as View)
38const shopTab = atom({ plugin: 'token-tank', key: 'shopTab' } as const, 'fish' as ShopTab)
39const selectedId = atom({ plugin: 'token-tank', key: 'selectedId' } as const, null as string | null)
40const isRenaming = atom({ plugin: 'token-tank', key: 'isRenaming' } as const, false)
41const notice = atom({ plugin: 'token-tank', key: 'notice' } as const, null as string | null)
42const turn = atom({ plugin: 'token-tank', key: 'turn' } as const, null as { turnId: string; isLong: boolean } | null)
43const isPinned = atom({ plugin: 'token-tank', key: 'isPinned' } as const, false)
44const lang = atom({ plugin: 'token-tank', key: 'lang' } as const, 'zh' as Lang)
45const confirmRelease = atom({ plugin: 'token-tank', key: 'confirmRelease' } as const, null as string | null)
46const movingDecor = atom({ plugin: 'token-tank', key: 'movingDecor' } as const, null as string | null)
47
48// ---------------------------------------------------------------- persistence
49
50/** The store, or nothing where there is none (the test kit). */
51async function storeGet($: EngineInterface): Promise<unknown> {
52 try {
53 return await $.store.get(STORE_KEY)
54 } catch {
55 return undefined
56 }
57}
58
59async function storeSet($: EngineInterface, key: string, value: unknown): Promise<void> {
60 if (isStoreReadOnly) return
61 try {
62 await $.store.set(key, value)
63 } catch {
64 // no store here
65 }
66}
67
68/** Claude's language: the `language` setting in ~/.claude/settings.json, else the shell's LANG. */
69async function detectLang($: EngineInterface): Promise<Lang> {
70 let setting: string | undefined
71 try {
72 // The row the /config menu shows as Language.
73 const row = (await $.config.list()).find(r => r.key === 'language')
74 if (row && typeof row.value === 'string' && row.value.trim() !== '') setting = row.value
75 } catch {
76 // no config rows here
77 }
78 try {
79 if (setting !== undefined) throw new Error('known')
80 const home = await $.env.get('HOME')
81 if (home) {
82 const text = await $.fs.read(`${home}/.claude/settings.json`)
83 const parsed = JSON.parse(text) as { language?: unknown }
84 if (typeof parsed.language === 'string') setting = parsed.language
85 }
86 } catch {
87 // no settings file to read here
88 }
89 let envLang: string | undefined
90 try {
91 envLang = (await $.env.get('LANG')) ?? (await $.env.get('LC_ALL'))
92 } catch {
93 // no environment here
94 }
95 return langOf(setting, envLang)
96}
97
98/** The language to draw in: the game's setting when pinned, else Claude's. */
99async function langNow($: EngineInterface, state: GameState | null): Promise<Lang> {
100 const pinned = state?.settings.language
101 if (pinned === 'zh' || pinned === 'en') return pinned
102 return read($, lang)
103}
104
105/** How often a session looks for another session's newer save. */
106const SYNC_MS = 10_000
107
108/** Takes the store's game when another session has written a newer revision. */
109async function syncFromStore($: EngineInterface): Promise<void> {
110 const mine = await read($, game)
111 if (!mine) return
112 const raw = await storeGet($)
113 if (raw === undefined) return
114 const now = await $.clock.now()
115 const stored = migrate(raw, now).state
116 if (stored.revision > mine.revision) await update($, game, () => stored)
117}
118
119async function load($: EngineInterface): Promise<GameState> {
120 const now = await $.clock.now()
121 const raw = await storeGet($)
122 const migrated = migrate(raw, now)
123 let { state } = migrated
124 const { migratedFrom, isReset, economyMigrated, isFailed } = migrated
125 if (isFailed) {
126 // The old save could not be converted: play on a fresh game in memory and never write over it.
127 isStoreReadOnly = true
128 await update($, game, () => state)
129 return state
130 }
131 // A fresh game in English names its first fish in English.
132 if ((raw === undefined || isReset) && (await read($, lang)) === 'en') {
133 state = { ...state, fish: state.fish.map(f => ({ ...f, name: SPECIES[f.species].en })) }
134 }
135 if (migratedFrom === 2) await storeSet($, BACKUP_KEY, raw)
136 if (economyMigrated) await storeSet($, BACKUP_KEY_V2_ECONOMY, raw)
137 if (migratedFrom !== null || isReset || economyMigrated || raw === undefined) await storeSet($, STORE_KEY, state)
138 await update($, game, () => state)
139 return state
140}
141
142/**
143 * Applies one pure change to the newest state any session wrote, logs its
144 * events, writes it back and redraws. The read-merge-write window is small
145 * but not zero: two sessions pressing at the same instant may lose one press.
146 */
147async function commit($: EngineInterface, change: (state: GameState, now: number) => Outcome): Promise<GameEvent[]> {
148 const now = await $.clock.now()
149 const mine = (await read($, game)) ?? (await load($))
150 const stored = migrate(await storeGet($), now).state
151 const base = stored.revision > mine.revision ? mergeStates(mine, stored) : mine
152 const { state, events } = change(base, now)
153 const L = await langNow($, base)
154 let next = state
155 for (const ev of events) {
156 const text = describe(next, ev, L)
157 if (text) next = appendLog(next, text, now)
158 }
159 const refused = events.find(ev => ev.kind === 'refused')
160 await update($, notice, () => (refused && refused.kind === 'refused' ? refusalText(refused.reason, L) : null))
161 if (next !== base) await storeSet($, STORE_KEY, next)
162 await update($, game, () => next)
163 return events
164}
165
166// ------------------------------------------------------------- tool rewards
167
168const TEST_COMMAND = /(^|[\s;&|])(pytest|vitest|jest|mocha|go test|cargo test|npm (run )?test|pnpm (run )?test|yarn test|bun test|node --test|claude plugin test|make test|dotnet test|mvn test|gradle test|phpunit|rspec)\b/
169const COMMIT_COMMAND = /(^|[\s;&|])git\s+(-\S+\s+)*commit\b/
170
171function rewardFor(tool: string, command: string | undefined, isError: boolean): RewardKind | null {
172 if (isError) return null
173 if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return 'edit'
174 if (tool === 'Bash' && command) {
175 if (COMMIT_COMMAND.test(command)) return 'commit'
176 if (TEST_COMMAND.test(command)) return 'test'
177 }
178 return null
179}
180
181// ------------------------------------------------------------------ drawing
182
183type Draw = {
184 el: ReturnType<EngineInterface['ui']['resolve']>
185 lang: Lang
186 surface: string
187 columns: number
188 /** Rows the site allows (the band's maxRows); 0 when unknown. */
189 maxRows: number
190 state: GameState
191 now: number
192}
193
194/**
195 * The tap grid's cell: TAP_W columns wide, a row tall. The label is em
196 * spaces (one em each in the Desktop's proportional font, so about 14 px):
197 * enough of them that a button overlaps the next cell, and a press anywhere
198 * lands on the top-most button whose cell starts at or before it.
199 */
200const TAP_W = 8
201const TAP_LABEL = '\u2003'.repeat(7)
202
203/**
204 * A tap on the tank at design pixel (X, Y), `renderedAt` being when the
205 * drawing started: on a fish it darts off; on open water a pellet drops.
206 */
207async function tapTank($: EngineInterface, X: number, Y: number, W: number, renderedAt: number): Promise<void> {
208 const state = await read($, game)
209 if (!state) return
210 const now = await $.clock.now()
211 // A decoration waiting to be placed takes the tap: the spot, on the 320-wide base.
212 const moving = await read($, movingDecor)
213 if (moving) {
214 await commit($, st => moveDecoration(st, moving, (X * BASE_W) / W, Y))
215 await update($, movingDecor, () => null)
216 return
217 }
218 const n = state.fish.length
219 const hit = laneOrder(state.fish).find((f, i) => {
220 const l = fishLayout(f, i, n, W, renderedAt, FISH_SCALES[state.settings.tankSize])
221 const c = fishCentreAt(l, now)
222 return Math.abs(X - c.x) <= l.w * 0.7 && Math.abs(Y - c.y) <= l.h * 0.8
223 })
224 if (hit) await commit($, (st, at) => startle(st, hit.id, at))
225 else await commit($, (st, at) => dropFood(st, X, Y, at))
226 // Once the pellet is gone, draw the lanes again (the drawing continues, nothing replays).
227 $.clock.after(PELLET_SHOW_MS + 150, () => {
228 void update($, game, st => (st ? { ...st } : st))
229 })
230}
231
232/** Rewards earned by tool calls in the running turn, paid at its end (module memory). */
233const pendingRewards: RewardKind[] = []
234
235/** The last tank drawing, reused while nothing of the tank changed (module memory; a reload starts over). */
236let tankCache: { key: string; source: string; renderedAt: number } | null = null
237
238/** The band's tank is as wide as the band; its size picks the aspect (width ÷ height). */
239const ASPECT: Record<GameState['settings']['tankSize'], number> = { s: 9, m: 6.5, l: 4.5 }
240/** Desktop cell estimates: one monospace column and one row in CSS pixels. */
241const PX_PER_COL = 8
242const PX_PER_ROW = 22
243
244function bar(d: Draw, ratio: number, color: string, widthPx: number): RenderElement {
245 const { Svg, Text } = d.el as { Svg?: (p: { source: string; alt: string; width: number; height: number }) => RenderElement; Text: (p: Record<string, unknown>) => RenderElement }
246 const r = Math.max(0, Math.min(1, ratio))
247 if (d.surface === 'desktop' && Svg) {
248 const w = widthPx
249 const src = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="8" viewBox="0 0 ${w} 8"><rect x="0" y="1" width="${w}" height="6" rx="3" fill="rgba(127,127,127,0.22)"/><rect x="0" y="1" width="${Math.max(0, w * r).toFixed(1)}" height="6" rx="3" fill="${color}"/></svg>`
250 return Svg({ source: src, alt: `${Math.round(r * 100)}%`, width: w, height: 8 })
251 }
252 const cells = Math.max(6, Math.floor(widthPx / 8))
253 const on = Math.round(cells * r)
254 return Text({ children: '━'.repeat(on) + '─'.repeat(cells - on), color, dimColor: false })
255}
256
257/** The terminal's tank: text fish on their lines, the coin piles as buttons on the floor. */
258function textTank($: EngineInterface, d: Draw, selected: string | null): RenderElement {
259 const { Box, Text, Button } = d.el
260 const s = d.state
261 const cols = Math.max(20, Math.min(d.columns - 4, 100))
262 const t = Math.floor(d.now / 1000)
263 const lines = s.fish.map((f, i) => {
264 const span = cols - 6
265 const phase = (t * (2 + (i % 3)) + i * 7) % (span * 2)
266 const x = phase < span ? phase : span * 2 - phase
267 const glyph = phase < span ? '><>' : '<><'
268 return <Text key={`l${i}`} color={f.id === selected ? 'yellow' : 'cyan'}>{' '.repeat(x)}{glyph}</Text>
269 })
270 const list = piles(s, 320)
271 let used = 0
272 return (
273 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} width={cols + 4}>
274 {lines.length === 0 ? <Text dimColor>{tr(d.lang, 'emptyTank')}</Text> : lines}
275 <Box flexDirection="row">
276 {list.length === 0 && <Text dimColor>{' '}</Text>}
277 {list.map(p => {
278 const at = Math.min(cols - 6, Math.max(used, Math.round((p.x / 320) * cols)))
279 const label = p.coins > 1 ? `🪙×${p.coins}` : '🪙'
280 const pad = at - used
281 used = at + label.length + 1
282 return (
283 <Box key={`pile-${p.fishId}`} paddingLeft={pad}>
284 <Button key={`coin-${p.fishId}`} plain onPress={() => void commit($, (st, now) => collect(st, p.fishId, now))}>{label}</Button>
285 </Box>
286 )
287 })}
288 </Box>
289 <Text dimColor>{'~'.repeat(cols)}</Text>
290 </Box>
291 )
292}
293
294/**
295 * The tank with its coins. On Desktop the SVG fills the whole width (no pixel
296 * width given, a wide aspect), the piles lie on the sand and a plain coin
297 * button sits over each one (absolute, anchored to the floor), so the person
298 * picks the coins up where they lie.
299 */
300function tankBlock($: EngineInterface, d: Draw, aspect: number, selected: string | null): RenderElement {
301 const { Box, Button } = d.el
302 const Svg = (d.el as { Svg?: (p: Record<string, unknown>) => RenderElement }).Svg
303 const s = d.state
304 if (d.surface === 'terminal' || !Svg) return textTank($, d, selected)
305 // Explicit pixels: a sized Svg box matches the drawn image, so the coins sit on the sand.
306 const widthPx = Math.max(240, Math.round(d.columns * PX_PER_COL))
307 const roomPx = d.maxRows > 0 ? Math.max(120, (d.maxRows - 3) * PX_PER_ROW) : 400
308 const heightPx = Math.round(Math.min(widthPx / aspect, roomPx))
309 const W = Math.round((TANK_H * widthPx) / heightPx)
310 // The band is drawn again whenever any plugin's band changes (pillbar samples
311 // every few seconds). The same drawing is handed back while nothing of the
312 // tank changed, and it is drawn as an interactive frame: the Desktop keeps a
313 // frame whose source is unchanged, while it reloads an image on every
314 // redraw (and flashes), whatever the size.
315 // Only what the drawing shows goes into the key: a turn that changed the
316 // coins or the stats alone must not make a new image (and a flash).
317 const fishKey = s.fish
318 .map(f => {
319 const spitting = f.state === 'spitCoin' && d.now - f.since < 15000
320 const startled = f.state === 'startled' && d.now - f.since < 1100
321 return `${f.id}:${f.species}:${f.name}:${f.hunger < 25 ? 'h' : ''}:${f.pending}:${spitting ? 's' : ''}${startled ? 'd' : ''}`
322 })
323 .join(',')
324 const decorKey = s.decorations.map(x => `${x.id}:${x.kind}@${x.x},${x.y}`).join(",")
325 const pelletKey = s.pellets.filter(p => d.now - p.at < PELLET_SHOW_MS).map(p => p.id).join(',')
326 const key = [W, widthPx, heightPx, selected ?? '', s.settings.theme, s.settings.showCoins, s.settings.showBubbles, s.settings.reduceMotion, s.settings.tankSize, fishKey, decorKey, pelletKey].join('|')
327 let drawing = tankCache
328 if (!drawing || drawing.key !== key) {
329 drawing = {
330 key,
331 renderedAt: d.now,
332 source: tankSvg(s, {
333 isMotion: !s.settings.reduceMotion,
334 showCoins: s.settings.showCoins,
335 showBubbles: s.settings.showBubbles,
336 selectedId: selected,
337 now: d.now,
338 width: W,
339 fishScale: FISH_SCALES[s.settings.tankSize],
340 }),
341 }
342 tankCache = drawing
343 }
344 const source = drawing.source
345 const renderedAt = drawing.renderedAt
346 const cols = Math.max(20, d.columns)
347 const rows = Math.max(3, Math.round(heightPx / PX_PER_ROW))
348 // The tap grid: invisible plain buttons over the water, TAP_W columns wide
349 // and a row tall each, so a press says roughly where (the Desktop sends no
350 // pointer events to a Client, and nothing crosses out of the Svg).
351 const taps: RenderElement[] = []
352 for (let r = 0; r <= rows; r++) {
353 for (let c = 0; c * TAP_W < cols; c++) {
354 const X = ((c + 0.5) * TAP_W) / cols * W
355 const Y = Math.min(TANK_H - 1, ((r + 0.5) / rows) * TANK_H)
356 taps.push(
357 <Box key={`tap-${c}-${r}`} position="absolute" left={c * TAP_W} top={r}>
358 <Button key={`tap-${c}-${r}`} plain onPress={() => void tapTank($, X, Y, W, renderedAt)}>{TAP_LABEL}</Button>
359 </Box>,
360 )
361 }
362 }
363 return (
364 <Box key="tank" flexDirection="column" width="100%">
365 <Box key="tank-image">
366 {Svg({ source, alt: tr(d.lang, 'tankAlt') + s.fish.map(f => f.name).join(d.lang === 'zh' ? '、' : ', '), width: widthPx, height: heightPx, isInteractive: true })}
367 </Box>
368 {taps}
369 {piles(s, W).map(p => (
370 <Box key={`pile-${p.fishId}`} position="absolute" left={Math.min(cols - 5, Math.round((p.x / W) * cols))} bottom={1}>
371 <Button key={`coin-${p.fishId}`} plain onPress={() => void commit($, (st, now) => collect(st, p.fishId, now))}>{p.coins > 1 ? `🪙×${p.coins}` : '🪙'}</Button>
372 </Box>
373 ))}
374 </Box>
375 )
376}
377
378/** The one-line status under the band's tank. */
379function bandRow($: EngineInterface, d: Draw, note: string | null, movingId: string | null): RenderElement {
380 const { Box, Text, Button } = d.el
381 const s = d.state
382 const moving = movingId ? s.decorations.find(x => x.id === movingId) ?? null : null
383 const pending = pendingTotal(s)
384 const hungry = s.fish.filter(f => f.hunger < 25).length
385 const last = s.log[s.log.length - 1]
386 return (
387 <Box key="token-tank-row" flexDirection="row" gap={2} alignItems="center" flexWrap="wrap">
388 <Text color="yellow">🪙 {fmt(s.coins)}</Text>
389 <Text dimColor={pending === 0}>{pending > 0 ? tr(d.lang, 'coinsOnFloor', { n: pending }) : tr(d.lang, 'accumulating')}</Text>
390 <Text dimColor>{s.fish.some(f => f.hunger < FULL_ENOUGH) ? tr(d.lang, 'tapToFeed', { n: s.food }) : tr(d.lang, 'allFull', { n: s.food })}</Text>
391 {note && <Text color="yellow">{note}</Text>}
392 {hungry > 0 && <Text color="yellow">{tr(d.lang, 'hungryCount', { n: hungry })}</Text>}
393 {s.fish.some(f => isOverQuota(f, d.now)) && <Text dimColor>⏳ {tr(d.lang, 'overQuotaBand')}</Text>}
394 {moving && <Text color="yellow">↔ {tr(d.lang, 'moving', { name: decorName(moving.kind, d.lang) })}</Text>}
395 {moving && link(d, 'band-cancel-move', tr(d.lang, 'cancelMove'), () => void update($, movingDecor, () => null))}
396 {hungry > 0 && <Button key="band-feed" plain onPress={() => void commit($, (st, now) => feed(st, null, now))}>{tr(d.lang, 'feedAll')}</Button>}
397 <Button key="band-open" plain onPress={() => void $.ui.open({ id: PANE, title: 'Token Tank', focus: true })}>{tr(d.lang, 'panel')}</Button>
398 {last && <Text dimColor wrap="truncate-end">{last.text}</Text>}
399
400 </Box>
401 )
402}
403
404// ------------------------------------------------------------------ the pane
405//
406// Airy, like the Desktop's own panels: fixed columns that line up, a blank
407// line between rows, plain text links instead of boxed buttons (the pointer
408// shows them), and the chosen tab or option marked with a dot.
409
410/** A link-like button: plain text, dim when it does nothing right now. */
411function link(d: Draw, key: string, label: string, onPress: () => void, isOff = false): RenderElement {
412 const { Button } = d.el
413 return (
414 <Button key={key} plain dimColor={isOff || undefined} onPress={onPress}>
415 {label}
416 </Button>
417 )
418}
419
420/** One choice in a row of options: the chosen one is a dot and bold text, the others links. */
421function choice(d: Draw, key: string, label: string, isChosen: boolean, onPress: () => void): RenderElement {
422 const { Text } = d.el
423 if (isChosen) return <Text weight="semibold">● {label}</Text>
424 return link(d, key, `○ ${label}`, onPress)
425}
426
427function paneHeader($: EngineInterface, d: Draw, current: View): RenderElement {
428 const { Box, Text } = d.el
429 const s = d.state
430 const progress = tankProgress(s)
431 const go = (v: View) => () => void update($, view, () => v)
432 const tab = (id: View, label: string) => (current === id ? <Text weight="semibold">{label}</Text> : link(d, `nav-${id}`, label, go(id)))
433 return (
434 <Box flexDirection="column" gap={1} marginBottom={1}>
435 <Box flexDirection="row" gap={2} flexWrap="wrap" alignItems="center">
436 <Text weight="bold" color="claude">{tr(d.lang, 'title')}</Text>
437 <Text dimColor>{tr(d.lang, 'tankLv', { n: progress.level })}</Text>
438 </Box>
439 <Box flexDirection="row" gap={1} alignItems="center">
440 {bar(d, progress.fraction, '#4cc9f0', Math.max(60, Math.min(160, (d.columns - 24) * 6)))}
441 <Text dimColor>{progress.nextAt === null ? tr(d.lang, 'tankMax') : tr(d.lang, 'tankNext', { have: fmt(progress.lifetime), need: fmt(progress.nextAt) })}</Text>
442 </Box>
443 <Box flexDirection="row" gap={3} flexWrap="wrap">
444 <Text color="yellow">🪙 {fmt(s.coins)}</Text>
445 <Text dimColor>🍤 {s.food}</Text>
446 <Text dimColor>🐟 {s.fish.length}/{slotCapacity(s)}</Text>
447 </Box>
448 <Box flexDirection="row" gap={3} flexWrap="wrap">
449 {tab('shop', tr(d.lang, 'tabShop'))}
450 {tab('fish', tr(d.lang, 'tabFish'))}
451 {tab('decor', tr(d.lang, 'tabDecor'))}
452 {tab('settings', tr(d.lang, 'tabSettings'))}
453 </Box>
454 </Box>
455 )
456}
457
458const COL_THUMB = 5
459const COL_NAME = 11
460const COL_PRICE = 8
461/** Locked species shown in the shop: the next few to unlock; the rest are one line. */
462const LOCKED_SHOWN = 2
463
464/** How wide one shop card is, in cells, and how many fit a row. */
465const CARD_W = 24
466const ECONOMY_MAX_SLOTS = 10
467function cardsPerRow(columns: number): number {
468 if (columns >= CARD_W * 4 + 6) return 4
469 if (columns >= CARD_W * 3 + 4) return 3
470 if (columns >= CARD_W * 2 + 2) return 2
471 return 1
472}
473
474const RARITY_KEY = { common: 'rarity0', rare: 'rarity1', epic: 'rarity2', legendary: 'rarity3' } as const
475
476type Card = {
477 key: string
478 /** The tile's SVG, or null for a mystery. */
479 art: ((px: number) => string) | null
480 name: string
481 sub: string
482 price: number
483 /** The action link, or the dim words that stand in for it. */
484 action: { key: string; label: string; onPress: () => void } | string
485}
486
487/** One product: a vertical card in a grid, or a horizontal strip when only one fits a row. */
488function productCard(d: Draw, c: Card, perRow: number): RenderElement {
489 const { Box, Text } = d.el
490 const Svg = (d.el as { Svg?: (p: Record<string, unknown>) => RenderElement }).Svg
491 const showArt = d.surface !== 'terminal' && Svg
492 const action = typeof c.action === 'string' ? <Text dimColor>{c.action}</Text> : link(d, c.action.key, `${c.action.label} ›`, c.action.onPress)
493 if (perRow === 1) {
494 const tile = 64
495 return (
496 <Box key={c.key} flexDirection="row" gap={2} alignItems="center" borderStyle="round" borderDimColor paddingX={1}>
497 <Box width={9} alignItems="center">
498 {c.art && showArt && Svg ? Svg({ source: c.art(tile), alt: c.name, width: tile, height: Math.round(tile * 0.72) }) : <Text dimColor>{c.art ? '🐟' : '?'}</Text>}
499 </Box>
500 <Box flexDirection="column" flexGrow={1}>
501 <Text weight="semibold" wrap="truncate-end">{c.name}</Text>
502 <Text dimColor wrap="truncate-end">{c.sub}</Text>
503 <Box flexDirection="row" gap={2} alignItems="center">
504 <Text color="yellow">🪙 {c.price}</Text>
505 {action}
506 </Box>
507 </Box>
508 </Box>
509 )
510 }
511 const tile = 120
512 return (
513 <Box key={c.key} flexDirection="column" alignItems="center" gap={0} width={CARD_W} borderStyle="round" borderDimColor paddingX={1} paddingY={0}>
514 {c.art && showArt && Svg ? Svg({ source: c.art(tile), alt: c.name, width: tile, height: Math.round(tile * 0.72) }) : <Text dimColor>{c.art ? '🐟' : '?'}</Text>}
515 <Text weight="semibold" wrap="truncate-end">{c.name}</Text>
516 <Text dimColor wrap="truncate-end">{c.sub}</Text>
517 <Text color="yellow" weight="semibold">🪙 {c.price}</Text>
518 {action}
519 </Box>
520 )
521}
522
523function shopView($: EngineInterface, d: Draw, tab: ShopTab): RenderElement {
524 const { Box, Text } = d.el
525 const s = d.state
526 const perRow = cardsPerRow(d.columns)
527 const tabs: [ShopTab, string][] = [['fish', tr(d.lang, 'shopFish')], ['decor', tr(d.lang, 'shopDecor')], ['food', tr(d.lang, 'shopFood')]]
528 let cards: Card[] = []
529 let footer: RenderElement | null = null
530 if (tab === 'fish') {
531 const list = shopList(s)
532 const lockedAll = list.filter(x => !x.isUnlocked).sort((a, b) => a.unlockLevel - b.unlockLevel || a.price - b.price)
533 const shown = list.filter(x => x.isUnlocked).concat(lockedAll.slice(0, LOCKED_SHOWN))
534 const hidden = lockedAll.length - Math.min(LOCKED_SHOWN, lockedAll.length)
535 cards = shown.map(({ species, rarity, price, unlockLevel, isUnlocked, reason }) => {
536 const sp = SPECIES[species]
537 return {
538 key: `shop-${species}`,
539 art: isUnlocked ? (px: number) => fishPortraitSvg(species, px, true) : null,
540 name: isUnlocked ? speciesName(sp, d.lang) : tr(d.lang, 'mystery'),
541 sub: `${tr(d.lang, RARITY_KEY[rarity])} · ${tr(d.lang, 'weightShort', { r: sp.rate })}`,
542 price,
543 action:
544 reason === 'locked-level'
545 ? tr(d.lang, 'lockedLv', { n: unlockLevel })
546 : reason === 'tank-full'
547 ? tr(d.lang, 'tankFull')
548 : reason === 'not-enough-coins'
549 ? tr(d.lang, 'notEnough')
550 : { key: `buy-${species}`, label: tr(d.lang, 'buy'), onPress: () => void commit($, (st, now) => buyFish(st, species, now, `${now}`, d.lang === 'en' ? sp.en : undefined)) },
551 }
552 })
553 // One more card: the next fish slot.
554 const slot = nextSlot(s)
555 cards.push({
556 key: 'shop-slot',
557 art: (px: number) => decorPortraitSvg('rock', px, true),
558 name: tr(d.lang, 'slotCard'),
559 sub: tr(d.lang, 'slotSub', { n: slotCapacity(s), max: ECONOMY_MAX_SLOTS }),
560 price: slot.price ?? 0,
561 action:
562 slot.reason === 'slots-max'
563 ? tr(d.lang, 'slotsMax')
564 : slot.reason === 'slot-locked-level'
565 ? tr(d.lang, 'slotLocked', { n: slot.unlockLevel ?? 0 })
566 : s.coins < (slot.price ?? 0)
567 ? tr(d.lang, 'notEnough')
568 : { key: 'buy-slot', label: tr(d.lang, 'buy'), onPress: () => void commit($, (st, now) => buySlot(st, now)) },
569 })
570 if (hidden > 0) footer = <Text dimColor wrap="wrap">{tr(d.lang, 'moreLocked', { n: hidden })}</Text>
571 } else if (tab === 'decor') {
572 const kinds = (Object.keys(DECOR) as DecorId[]).sort((a, b) => DECOR[a].price - DECOR[b].price)
573 cards = kinds.map(kind => {
574 const have = s.decorations.filter(x => x.kind === kind).length
575 const canAfford = s.coins >= DECOR[kind].price
576 return {
577 key: `shop-${kind}`,
578 art: (px: number) => decorPortraitSvg(kind, px, true),
579 name: decorName(kind, d.lang),
580 sub: tr(d.lang, 'owned', { n: have }),
581 price: DECOR[kind].price,
582 action: !canAfford ? tr(d.lang, 'notEnough') : { key: `buy-${kind}`, label: tr(d.lang, 'place'), onPress: () => void commit($, (st, now) => buyDecoration(st, kind, now, `${now}`)) },
583 }
584 })
585 footer = <Text dimColor>{tr(d.lang, 'decorCount', { n: s.decorations.length, max: MAX_DECOR })}</Text>
586 } else {
587 cards = [
588 {
589 key: 'shop-food-1',
590 art: (px: number) => decorPortraitSvg('pellet', px, true),
591 name: tr(d.lang, 'foodWord'),
592 sub: tr(d.lang, 'foodStock', { n: s.food }),
593 price: FOOD_PRICE,
594 action: s.coins < FOOD_PRICE ? tr(d.lang, 'notEnough') : { key: 'buy-food-1', label: tr(d.lang, 'buyFood1'), onPress: () => void commit($, st => buyFood(st, 1)) },
595 },
596 {
597 key: 'shop-food-10',
598 art: (px: number) => decorPortraitSvg('pellet', px, true),
599 name: `${tr(d.lang, 'foodWord')} ×10`,
600 sub: tr(d.lang, 'foodStock', { n: s.food }),
601 price: FOOD_PRICE * 10,
602 action: s.coins < FOOD_PRICE * 10 ? tr(d.lang, 'notEnough') : { key: 'buy-food-10', label: tr(d.lang, 'buyFood10'), onPress: () => void commit($, st => buyFood(st, 10)) },
603 },
604 ]
605 footer = <Text dimColor wrap="wrap">{tr(d.lang, 'foodHelp')}</Text>
606 }
607 return (
608 <Box flexDirection="column" gap={1}>
609 <Box flexDirection="row" gap={3} flexWrap="wrap">
610 {tabs.map(([id, label]) => (tab === id ? <Text weight="semibold">{label}</Text> : link(d, `tab-${id}`, label, () => void update($, shopTab, () => id))))}
611 </Box>
612 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
613 {cards.map(c => productCard(d, c, perRow))}
614 </Box>
615 {footer}
616 </Box>
617 )
618}
619
620function fishView($: EngineInterface, d: Draw, selected: string | null, renaming: boolean, releasing: string | null): RenderElement {
621 const { Box, Text } = d.el
622 const Input = (d.el as { Input?: (p: Record<string, unknown>) => RenderElement }).Input
623 const Svg = (d.el as { Svg?: (p: Record<string, unknown>) => RenderElement }).Svg
624 const s = d.state
625 const showArt = d.surface !== 'terminal' && Svg
626 const barW = Math.max(60, Math.min(160, (d.columns - 16) * 6))
627 if (s.fish.length === 0) return <Text dimColor>{tr(d.lang, 'noFishYet')}</Text>
628 return (
629 <Box flexDirection="column" gap={1}>
630 {s.fish.map(f => {
631 const sp = SPECIES[f.species]
632 const isOpen = f.id === selected
633 const nextXp = XP_THRESHOLDS[f.level] ?? null
634 const cost = levelUpCost(f)
635 const canLevel = nextXp !== null && f.xp >= nextXp && s.coins >= cost
636 const slowed = isOverQuota(f, d.now)
637 const xpRatio = nextXp === null ? 1 : (f.xp - (XP_THRESHOLDS[f.level - 1] ?? 0)) / Math.max(1, nextXp - (XP_THRESHOLDS[f.level - 1] ?? 0))
638 return (
639 <Box key={`fish-${f.id}`} flexDirection="column" gap={0}>
640 <Box flexDirection="row" gap={1} alignItems="center">
641 <Box width={COL_THUMB} alignItems="center">{showArt && Svg ? Svg({ source: fishPortraitSvg(f.species, 30), alt: speciesName(sp, d.lang), width: 30, height: 22 }) : <Text>🐟</Text>}</Box>
642 <Box width={COL_NAME}>{link(d, `pick-${f.id}`, `${f.name} ${isOpen ? '▾' : '▸'}`, () => void update($, selectedId, cur => (cur === f.id ? null : f.id)))}</Box>
643 <Text dimColor>Lv.{f.level}</Text>
644 <Text color={f.hunger < 25 ? 'yellow' : undefined} dimColor={f.hunger >= 25}>🍽 {Math.round(f.hunger)}%</Text>
645 {f.pending > 0 && <Text color="yellow">🪙 {f.pending}</Text>}
646 </Box>
647 {isOpen && (
648 <Box flexDirection="column" gap={0} paddingLeft={COL_THUMB + 1} marginTop={1}>
649 <Text dimColor>{speciesName(sp, d.lang)} · {tr(d.lang, RARITY_KEY[sp.rarity])} · {tr(d.lang, 'rateLine', { rate: (sp.rate * levelBoost(f.level)).toFixed(1), pending: f.pending, cap: PENDING_CAP })}</Text>
650 {slowed && <Text color="yellow">⏳ {tr(d.lang, 'overQuota')}</Text>}
651 <Box flexDirection="row" gap={1} alignItems="center">
652 <Text dimColor>XP</Text>
653 {bar(d, xpRatio, '#4cc9f0', barW)}
654 <Text dimColor>{nextXp === null ? 'MAX' : `${fmt(f.xp)}/${fmt(nextXp)}`}</Text>
655 </Box>
656 <Box flexDirection="row" gap={1} alignItems="center">
657 <Text dimColor>🙂</Text>
658 {bar(d, f.mood / 100, '#4cc9f0', barW)}
659 <Text dimColor>{Math.round(f.mood)}%</Text>
660 </Box>
661 <Box flexDirection="row" gap={1} alignItems="center">
662 <Text dimColor>🪙</Text>
663 {bar(d, f.carry, '#e9b949', barW)}
664 <Text dimColor>{tr(d.lang, 'nextCoin', { p: Math.round(f.carry * 100) })}</Text>
665 </Box>
666 <Text color="pink" wrap="wrap">❤ {moodLine(s, f.id, d.lang)}</Text>
667 <Box flexDirection="row" columnGap={3} rowGap={0} flexWrap="wrap" marginTop={1}>
668 {link(d, `feed-${f.id}`, `${tr(d.lang, 'feed')} ›`, () => void commit($, (st, now) => feed(st, f.id, now)), s.food <= 0)}
669 {link(d, `level-${f.id}`, `${tr(d.lang, 'levelUp')}${nextXp === null ? '' : ` ${cost}🪙`} ›`, () => void commit($, (st, now) => levelUp(st, f.id, now)), !canLevel)}
670 {link(d, `rename-${f.id}`, `${tr(d.lang, 'rename')} ›`, () => void update($, isRenaming, v => !v))}
671 {s.fish.length > 1 && link(d, `release-${f.id}`, `${tr(d.lang, 'release')} ›`, () => void update($, confirmRelease, () => f.id))}
672 </Box>
673 {releasing === f.id && (
674 <Box flexDirection="row" gap={2} alignItems="center" marginTop={1}>
675 <Text color="yellow">{tr(d.lang, 'releaseAsk', { name: f.name })}</Text>
676 {link(d, `release-yes-${f.id}`, tr(d.lang, 'yes'), () => {
677 void commit($, (st, now) => releaseFish(st, f.id, now)).then(() => update($, confirmRelease, () => null))
678 })}
679 {link(d, `release-no-${f.id}`, tr(d.lang, 'no'), () => void update($, confirmRelease, () => null))}
680 </Box>
681 )}
682 {renaming && Input && (
683 <Box flexDirection="column" marginTop={1}>
684 {Input({
685 key: `name-${f.id}`,
686 placeholder: tr(d.lang, 'renamePlaceholder'),
687 onSubmit: (value: string) => {
688 void commit($, st => rename(st, f.id, value)).then(() => update($, isRenaming, () => false))
689 },
690 })}
691 </Box>
692 )}
693 </Box>
694 )}
695 </Box>
696 )
697 })}
698 <Box marginTop={1}>{link(d, 'feed-all', `${tr(d.lang, 'feedAll')} ›`, () => void commit($, (st, now) => feed(st, null, now)), s.food <= 0)}</Box>
699 </Box>
700 )
701}
702
703/** How far one nudge arrow moves a decoration, in base design pixels. */
704const NUDGE = 8
705
706/** The decorations in the tank: each can be moved by a tap on the tank, or nudged by the arrows. */
707function decorView($: EngineInterface, d: Draw, movingId: string | null): RenderElement {
708 const { Box, Text } = d.el
709 const Svg = (d.el as { Svg?: (p: Record<string, unknown>) => RenderElement }).Svg
710 const s = d.state
711 const showArt = d.surface !== 'terminal' && Svg
712 if (s.decorations.length === 0) return <Text dimColor>{tr(d.lang, 'noDecor')}</Text>
713 const movingKind = s.decorations.find(x => x.id === movingId)?.kind
714 return (
715 <Box flexDirection="column" gap={1}>
716 {s.decorations.map(x => (
717 <Box key={`decor-${x.id}`} flexDirection="row" gap={1} alignItems="center">
718 <Box width={COL_THUMB} alignItems="center">{showArt && Svg ? Svg({ source: decorPortraitSvg(x.kind, 30), alt: decorName(x.kind, d.lang), width: 30, height: 30 }) : <Text>🪸</Text>}</Box>
719 <Box width={COL_NAME}><Text wrap="truncate-end">{decorName(x.kind, d.lang)}</Text></Box>
720 <Text dimColor>{tr(d.lang, 'decorAt', { x: x.x, y: x.y })}</Text>
721 {movingId === x.id
722 ? link(d, `move-cancel-${x.id}`, tr(d.lang, 'cancelMove'), () => void update($, movingDecor, () => null))
723 : link(d, `move-${x.id}`, `${tr(d.lang, 'move')} ›`, () => void update($, movingDecor, () => x.id))}
724 <Box flexDirection="row" gap={1}>
725 {link(d, `nudge-l-${x.id}`, '◀', () => void commit($, st => moveDecoration(st, x.id, x.x - NUDGE, x.y)))}
726 {link(d, `nudge-r-${x.id}`, '▶', () => void commit($, st => moveDecoration(st, x.id, x.x + NUDGE, x.y)))}
727 {link(d, `nudge-u-${x.id}`, '▲', () => void commit($, st => moveDecoration(st, x.id, x.x, x.y - NUDGE)))}
728 {link(d, `nudge-d-${x.id}`, '▼', () => void commit($, st => moveDecoration(st, x.id, x.x, x.y + NUDGE)))}
729 </Box>
730 </Box>
731 ))}
732 {movingKind && <Text color="yellow" wrap="wrap">↔ {tr(d.lang, 'moving', { name: decorName(movingKind, d.lang) })}</Text>}
733 <Text dimColor wrap="wrap">{tr(d.lang, 'decorCount', { n: s.decorations.length, max: MAX_DECOR })}</Text>
734 </Box>
735 )
736}
737
738function settingsView($: EngineInterface, d: Draw): RenderElement {
739 const { Box, Text } = d.el
740 const s = d.state
741 const st = s.stats
742 const set = (patch: Partial<GameState['settings']>) =>
743 void commit($, (state, now) => ({ state: { ...state, settings: { ...state.settings, ...patch }, revision: state.revision + 1, updatedAt: now }, events: [] }))
744 const toggle = (key: 'showCoins' | 'showBubbles' | 'reduceMotion' | 'showBand', label: string) => (
745 <Box key={`set-${key}`} flexDirection="row" gap={2} alignItems="center">
746 {link(d, `toggle-${key}`, s.settings[key] ? '● ' + tr(d.lang, 'on') : '○ ' + tr(d.lang, 'off'), () => set({ [key]: !s.settings[key] }))}
747 <Text dimColor={!s.settings[key]}>{label}</Text>
748 </Box>
749 )
750 const themes: [GameState['settings']['theme'], string][] = [['ocean', tr(d.lang, 'themeOcean')], ['wreck', tr(d.lang, 'themeWreck')], ['reef', tr(d.lang, 'themeReef')], ['abyss', tr(d.lang, 'themeAbyss')]]
751 const group = (label: string, body: RenderElement) => (
752 <Box flexDirection="column" gap={0}>
753 <Text dimColor>{label}</Text>
754 {body}
755 </Box>
756 )
757 return (
758 <Box flexDirection="column" gap={1}>
759 {group(tr(d.lang, 'theme'), (
760 <Box flexDirection="row" gap={2} flexWrap="wrap">
761 {themes.map(([id, label]) => choice(d, `theme-${id}`, label, s.settings.theme === id, () => set({ theme: id })))}
762 </Box>
763 ))}
764 {group(tr(d.lang, 'tankHeight'), (
765 <Box flexDirection="row" gap={2} flexWrap="wrap">
766 {(['s', 'm', 'l'] as const).map(size => choice(d, `size-${size}`, tr(d.lang, size === 's' ? 'sizeS' : size === 'm' ? 'sizeM' : 'sizeL'), s.settings.tankSize === size, () => set({ tankSize: size })))}
767 </Box>
768 ))}
769 {group(tr(d.lang, 'language'), (
770 <Box flexDirection="row" gap={2} flexWrap="wrap">
771 {(['auto', 'zh', 'en'] as const).map(l => choice(d, `lang-${l}`, tr(d.lang, l === 'auto' ? 'langAuto' : l === 'zh' ? 'langZh' : 'langEn'), s.settings.language === l, () => set({ language: l })))}
772 </Box>
773 ))}
774 <Box flexDirection="column" gap={0}>
775 {toggle('showBand', tr(d.lang, 'setBand'))}
776 {toggle('showCoins', tr(d.lang, 'setCoins'))}
777 {toggle('showBubbles', tr(d.lang, 'setBubbles'))}
778 {toggle('reduceMotion', tr(d.lang, 'setMotion'))}
779 </Box>
780 <Text dimColor wrap="wrap">{tr(d.lang, 'help')}</Text>
781 {group(tr(d.lang, 'stats'), (
782 <Box flexDirection="column" gap={0}>
783 <Text wrap="wrap">{tr(d.lang, 'statsToday', { t: fmt(st.todayEffectiveTokens), m: fmt(st.todayCoinsMinted), c: fmt(st.todayCoinsCollected), r: fmt(st.todayTurns) })}</Text>
784 <Text dimColor wrap="wrap">{tr(d.lang, 'statsTotal', { t: fmt(st.totalEffectiveTokens), m: fmt(st.totalCoinsMinted), c: fmt(st.totalCoinsCollected) })}</Text>
785 <Text dimColor wrap="wrap">{tr(d.lang, 'statsMore', { tests: fmt(st.testsPassed), commits: fmt(st.commits), edits: fmt(st.edits), feeds: fmt(st.feeds), dex: s.dex.length, all: SPECIES_LIST.length })}</Text>
786 </Box>
787 ))}
788 {s.log.length > 0 && group(tr(d.lang, 'log'), (
789 <Box flexDirection="column" gap={0}>
790 {s.log.slice(-5).reverse().map((e, i) => (
791 <Box key={`log-${i}`} flexDirection="row" gap={1}>
792 <Text dimColor>{clock(e.at)}</Text>
793 <Text wrap="truncate-end">{e.text}</Text>
794 </Box>
795 ))}
796 </Box>
797 ))}
798 </Box>
799 )
800}
801
802// ---------------------------------------------------------------- register
803
804export const register: Register = on => {
805 on('session.start', async ($, e, next) => {
806 const L = await detectLang($)
807 await update($, lang, () => L)
808 await $.command.register({ name: 'fish', description: tr(L, 'cmdDescription') })
809 await load($)
810 // Every session shares the save: pick up what another session wrote.
811 $.clock.every(SYNC_MS, () => {
812 void syncFromStore($)
813 })
814 return next(e)
815 })
816
817 on('command.run', { command: 'fish' }, async ($, e) => {
818 const arg = (e.args ?? '').trim()
819 const L = await langNow($, await read($, game))
820 if (arg === 'collect') {
821 const events = await commit($, (st, now) => collect(st, null, now))
822 const got = events.find(ev => ev.kind === 'collected')
823 return { text: got && got.kind === 'collected' ? tr(L, 'cmdCollected', { n: got.coins }) : tr(L, 'cmdNothing') }
824 }
825 if (arg === 'feed') {
826 await commit($, (st, now) => feed(st, null, now))
827 return { text: tr(L, 'cmdFed') }
828 }
829 if (arg === 'panel' || arg === 'shop') {
830 await $.ui.open({ id: PANE, title: 'Token Tank', focus: true })
831 return { text: tr(L, 'cmdPanel') }
832 }
833 const shown = await update($, isPinned, was => !was)
834 await $.ui.invalidate('ui.render')
835 return { text: shown ? tr(L, 'cmdShown') : tr(L, 'cmdHidden') }
836 })
837
838 on('turn.start', async ($, e, next) => {
839 if (e.agentId === undefined) {
840 const turnId = e.turnId
841 await update($, turn, () => ({ turnId, isLong: false }))
842 $.clock.after(LONG_TURN_MS, () => {
843 void update($, turn, cur => (cur?.turnId === turnId ? { turnId, isLong: true } : cur))
844 })
845 }
846 return next(e)
847 })
848
849 on('turn.complete', async ($, e, next) => {
850 const done = await next(e)
851 // Only the main loop pays: a subagent's usage is part of the same work.
852 if (e.agentId !== undefined) return done
853 // The band's "long turn" flag ends with the turn, so the next prompt does
854 // not show the tank for an instant before its own turn.start resets it.
855 await update($, turn, () => null)
856 const usage = e.usage
857 const effective = effectiveTokens(usage)
858 const rewards = pendingRewards.splice(0)
859 const events = await commit($, (st, now) => {
860 let out = applyTurn(st, { turnId: e.turnId, usage, now })
861 // The turn's rewards, each under its own cooldown, in one write.
862 for (const kind of rewards) {
863 const paid = reward(out.state, { kind, now })
864 out = { state: paid.state, events: [...out.events, ...paid.events] }
865 }
866 return out
867 })
868 const minted = events.filter(ev => ev.kind === 'minted').reduce((n, ev) => n + (ev.kind === 'minted' ? ev.coins : 0), 0)
869 const now = await $.clock.now()
870 const L = await langNow($, await read($, game))
871 if (effective > 0 && minted === 0 && !events.some(ev => ev.kind === 'refused')) {
872 await update($, game, st => (st ? appendLog(st, tr(L, 'turnNoCoins', { t: fmt(effective) }), now) : st))
873 } else if (minted > 0) {
874 await update($, game, st => (st ? appendLog(st, tr(L, 'turnCoins', { t: fmt(effective), n: minted }), now) : st))
875 }
876 return done
877 })
878
879 // Rewards wait for the end of the turn: one write and one redraw per turn,
880 // not one per tool call (every band redraw costs the Desktop a repaint).
881 on('tool.call', async ($, e, next) => {
882 const ran = await next(e)
883 if (e.agentId !== undefined) return ran
884 const command = e.tool === 'Bash' ? (e as { command?: string }).command : undefined
885 const kind = rewardFor(e.tool, command, Boolean((ran as { isError?: boolean }).isError) || 'deny' in ran)
886 if (kind) pendingRewards.push(kind)
887 return ran
888 })
889
890 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
891 const state = await read($, game)
892 const cur = await read($, turn)
893 const pinned = await read($, isPinned)
894 const note = await read($, notice)
895 const movingId = await read($, movingDecor)
896 const isLong = e.props.isWorking && cur?.isLong === true
897 const isShown = state?.settings.showBand !== false && (pinned || isLong)
898 if (e.props.hasSurvey || !state || !isShown || e.surface === 'mobile' || e.surface === 'vscode') return next(e)
899 const el = $.ui.resolve(e)
900 const { Box } = el
901 const now = await $.clock.now()
902 const columns = e.props.bodyColumns || e.viewport?.columns || 80
903 const d: Draw = { el, lang: await langNow($, state), surface: e.surface, columns, maxRows: e.props.maxRows ?? 0, state, now }
904 // The tank sits above whatever the plugins beneath draw (pillbar hugs the prompt).
905 const below = await next(e)
906 return (
907 <Box key="token-tank-stack" flexDirection="column">
908 {tankBlock($, d, ASPECT[state.settings.tankSize], null)}
909 {bandRow($, d, note, movingId)}
910 {below}
911 </Box>
912 )
913 })
914
915 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
916 const el = $.ui.resolve(e)
917 const { Box, Text } = el
918 const state = await read($, game)
919 const [current, tab, selected, note, renaming, releasing, movingId] = await Promise.all([read($, view), read($, shopTab), read($, selectedId), read($, notice), read($, isRenaming), read($, confirmRelease), read($, movingDecor)])
920 const now = await $.clock.now()
921 if (!state) {
922 return (
923 <Box padding={1}>
924 <Text dimColor>{tr(await read($, lang), 'loading')}</Text>
925 </Box>
926 )
927 }
928 const columns = e.props.bodyColumns || e.viewport?.columns || 60
929 const d: Draw = { el, lang: await langNow($, state), surface: e.surface, columns, maxRows: 0, state, now }
930 return (
931 <Box flexDirection="column" gap={0} paddingX={1} paddingTop={1}>
932 {paneHeader($, d, current)}
933 {note && <Text color="yellow" wrap="wrap">{note}</Text>}
934 {current === 'shop' ? shopView($, d, tab) : current === 'fish' ? fishView($, d, selected, renaming, releasing) : current === 'decor' ? decorView($, d, movingId) : settingsView($, d)}
935 </Box>
936 )
937 })
938}
939game/types.ts 7 lines1// The game's data lives in the plugin's state contract (types/index.d.ts),
2// which must be self-contained; the game modules import it from here.
3export type {
4 SpeciesId, SpeciesSize, Rarity, Species, FishState, Fish, DecorId, Decoration, Theme, TankSize,
5 Settings, Stats, LogEntry, Pellet, RewardKind, GameState, Usage, GameEvent, Outcome,
6} from '../types'
7game/economy.ts 241 lines1// Tokens → energy → coins (economy v0.3), fish levels, and the per-turn and reward transitions.
2// Energy is shared by a weighted square root (production_i = energy × w_i / √Σw), then each
3// fish's daily soft cap applies; tunables live in economy.config.ts.
4
5import { dayKey, rollDay } from './day'
6import { ECONOMY } from './economy.config'
7import { SPECIES } from './species'
8import type { Fish, FishState, GameEvent, GameState, Outcome, Rarity, RewardKind, SpeciesId, Usage } from './types'
9import { bump, clamp, num, refuse } from './util'
10
11export const PENDING_CAP = 80
12export const MAX_LEVEL = 10
13export const PROCESSED_TURNS_KEEP = 300
14/** How long a transient state (spitCoin / feeding / happy) shows before the fish swims again. */
15export const TRANSIENT_MS = 8000
16
17/** Cumulative xp to reach level n is XP_THRESHOLDS[n - 1]; so XP_THRESHOLDS[level] is the next level's. */
18export const XP_THRESHOLDS: readonly number[] = [0, 100, 300, 600, 1000, 1500, 2100, 2800, 3600, 4500]
19
20export const REWARD_COOLDOWN_MS: Record<RewardKind, number> = { edit: 60_000, test: 180_000, commit: 300_000 }
21export const REWARD_COINS: Record<RewardKind, number> = { edit: 2, test: 8, commit: 12 }
22
23/** Usage fields as finite non-negative numbers; missing, negative, NaN and Infinity count as 0. */
24function field(v: unknown): number {
25 return Math.max(0, num(v, 0))
26}
27
28/** input + output×2 + cacheRead×0.02 + cacheWrite×1.25 (weights in ECONOMY.tokenWeights). */
29export function effectiveTokens(u: Partial<Usage> | null | undefined): number {
30 if (!u || typeof u !== 'object') return 0
31 const w = ECONOMY.tokenWeights
32 return (
33 w.input * field(u.input_tokens) +
34 w.output * field(u.output_tokens) +
35 w.cacheRead * field(u.cache_read_input_tokens) +
36 w.cacheWrite * field(u.cache_creation_input_tokens)
37 )
38}
39
40/** (min(eff, 15000) + max(eff − 15000, 0) × 0.1) / 2500 — fractional, never negative. */
41export function energyOf(effective: number): number {
42 const e = Math.max(0, num(effective, 0))
43 const { knee, overKnee, tokensPerEnergy } = ECONOMY.energy
44 return (Math.min(e, knee) + overKnee * Math.max(e - knee, 0)) / tokensPerEnergy
45}
46
47export function rarityOf(species: SpeciesId): Rarity {
48 return SPECIES[species]?.rarity ?? 'common'
49}
50
51/** w = rarity weight × levelBoost × hungerFactor × moodFactor. */
52export function fishWeight(f: Pick<Fish, 'species' | 'level' | 'hunger' | 'mood'>): number {
53 const w = ECONOMY.rarityWeight[rarityOf(f.species)] * levelBoost(f.level) * hungerFactor(f.hunger) * moodFactor(f.mood)
54 return Number.isFinite(w) && w > 0 ? w : 0
55}
56
57/**
58 * Weighted square-root split: production_i = energy × w_i / √Σw. Adding a fish of positive
59 * weight never lowers the total (energy × √Σw); all zeros when there is nothing to share.
60 */
61export function allocate(energy: number, weights: readonly number[]): number[] {
62 const e = num(energy, 0)
63 const ws = weights.map((w) => (Number.isFinite(w) && w > 0 ? w : 0))
64 const total = ws.reduce((a, b) => a + b, 0)
65 if (!(e > 0) || !(total > 0) || !Number.isFinite(total)) return ws.map(() => 0)
66 const root = Math.sqrt(total)
67 return ws.map((w) => (e * w) / root)
68}
69
70/**
71 * The daily soft cap for one fish: the raw amount still under `quota` (after `used` raw today)
72 * pays in full, the rest pays `factor`. Splitting a turn into smaller ones pays the same.
73 */
74export function applyQuota(raw: number, used: number, quota: number, factor: number = ECONOMY.overQuotaFactor): { credited: number; used: number; isOver: boolean } {
75 const r = Math.max(0, num(raw, 0))
76 const u = Math.max(0, num(used, 0))
77 const room = Math.max(0, quota - u)
78 const full = Math.min(r, room)
79 return { credited: full + factor * (r - full), used: u + r, isOver: u + r > quota }
80}
81
82export function dailyQuotaOf(species: SpeciesId): number {
83 return ECONOMY.dailyQuota[rarityOf(species)]
84}
85
86/** The fish's daily counter for `now`'s local day (reset on a new day). */
87export function rollFishDay(f: Fish, now: number): Fish {
88 const key = dayKey(now)
89 return f.dailyKey === key ? f : { ...f, dailyKey: key, dailyRaw: 0 }
90}
91
92/** Whether the fish has used up today's full-rate quota. */
93export function isOverQuota(f: Fish, now: number): boolean {
94 const g = rollFishDay(f, now)
95 return g.dailyRaw >= dailyQuotaOf(g.species)
96}
97
98/** 1 + boostPerLevel × (level − 1): +5 % a level since v0.3 (was +15 %, which let a levelled common rival a rare). */
99export function levelBoost(level: number): number {
100 const lv = clamp(Math.floor(num(level, 1)), 1, MAX_LEVEL)
101 return 1 + ECONOMY.fishLevel.boostPerLevel * (lv - 1)
102}
103
104export function hungerFactor(hunger: number): number {
105 return 0.5 + (0.5 * clamp(hunger, 0, 100)) / 100
106}
107
108export function moodFactor(mood: number): number {
109 return 0.9 + (0.2 * clamp(mood, 0, 100)) / 100
110}
111
112export function levelForXp(xp: number): number {
113 const x = Math.max(0, num(xp, 0))
114 let lv = 1
115 for (let n = 1; n <= MAX_LEVEL; n++) if (x >= (XP_THRESHOLDS[n - 1] ?? Infinity)) lv = n
116 return lv
117}
118
119/** Xp still needed for the next level; 0 at the max level or when already enough. */
120export function xpToNext(fish: Pick<Fish, 'level' | 'xp'>): number {
121 if (fish.level >= MAX_LEVEL) return 0
122 return Math.max(0, (XP_THRESHOLDS[fish.level] ?? Infinity) - fish.xp)
123}
124
125/** Coins to raise a fish from `level`: costPerLevel × level × its rarity weight (the gain scales with the weight too). */
126export function levelUpCost(fish: Pick<Fish, 'species' | 'level'>): number {
127 return ECONOMY.fishLevel.costPerLevel * fish.level * ECONOMY.rarityWeight[rarityOf(fish.species)]
128}
129
130/** @deprecated The common-rarity cost only; use levelUpCost(fish). */
131export function LEVEL_UP_COST(level: number): number {
132 return ECONOMY.fishLevel.costPerLevel * level
133}
134
135const TRANSIENT: FishState[] = ['spitCoin', 'feeding', 'happy']
136
137export type TurnInput = { turnId: string; usage: Partial<Usage> | null | undefined; now: number }
138
139export function applyTurn(state: GameState, { turnId, usage, now }: TurnInput): Outcome {
140 if (state.processedTurns.includes(turnId)) return refuse(state, 'duplicate-turn')
141
142 const processedTurns = [...state.processedTurns, turnId].slice(-PROCESSED_TURNS_KEEP)
143 const rolled = rollDay(state, now)
144 const effective = effectiveTokens(usage)
145 if (effective <= 0) return { state: bump(rolled, { processedTurns }, now), events: [] }
146
147 const energy = energyOf(effective)
148 const mintedEvents: GameEvent[] = []
149 const hungryEvents: GameEvent[] = []
150 let minted = 0
151
152 const today = rolled.fish.map((f) => rollFishDay(f, now))
153 const raws = allocate(energy, today.map(fishWeight))
154
155 const fish = today.map((f, i): Fish => {
156 const { credited, used } = applyQuota(raws[i] ?? 0, f.dailyRaw, dailyQuotaOf(f.species))
157 const progress = f.carry + credited
158 const whole = Math.floor(progress)
159 const coins = Math.min(whole, Math.max(0, PENDING_CAP - f.pending))
160 const hunger = Math.max(0, f.hunger - Math.min(4, 1 + energy * 0.5))
161 const mood = f.mood > 70 ? Math.max(70, f.mood - 1) : Math.min(70, f.mood + 1)
162
163 let fishState = f.state
164 let since = f.since
165 if (coins > 0) {
166 fishState = 'spitCoin'
167 since = now
168 }
169 if (hunger < 25) {
170 if (fishState !== 'hungry') since = now
171 fishState = 'hungry'
172 hungryEvents.push({ kind: 'hungry', fishId: f.id })
173 } else if (coins === 0) {
174 if ((TRANSIENT.includes(f.state) && now - f.since > TRANSIENT_MS) || f.state === 'hungry') {
175 fishState = 'swim'
176 since = now
177 }
178 }
179
180 if (coins > 0) {
181 minted += coins
182 mintedEvents.push({ kind: 'minted', fishId: f.id, coins, effective })
183 }
184
185 return {
186 ...f,
187 // Coins past the pending cap are lost; the fraction below one coin is kept.
188 carry: progress - whole,
189 pending: f.pending + coins,
190 dailyRaw: used,
191 xp: f.xp + Math.round(energy * 10),
192 hunger,
193 mood,
194 state: fishState,
195 since,
196 }
197 })
198
199 const s = rolled.stats
200 const stats = {
201 ...s,
202 todayEffectiveTokens: s.todayEffectiveTokens + effective,
203 totalEffectiveTokens: s.totalEffectiveTokens + effective,
204 todayCoinsMinted: s.todayCoinsMinted + minted,
205 totalCoinsMinted: s.totalCoinsMinted + minted,
206 todayTurns: s.todayTurns + 1,
207 totalTurns: s.totalTurns + 1,
208 }
209
210 const lifetimeProducedCoins = rolled.lifetimeProducedCoins + minted
211 return {
212 state: bump(rolled, { fish, stats, processedTurns, lifetimeProducedCoins }, now),
213 events: [...mintedEvents, ...hungryEvents],
214 }
215}
216
217export function reward(state: GameState, { kind, now }: { kind: RewardKind; now: number }): Outcome {
218 const cooldown = REWARD_COOLDOWN_MS[kind]
219 if (cooldown === undefined) return refuse(state, 'unknown-reward')
220 const last = state.lastRewardAt[kind]
221 if (last !== undefined && now - last < cooldown) return refuse(state, 'cooldown')
222
223 const coins = REWARD_COINS[kind]
224 const stats = { ...state.stats }
225 if (kind === 'edit') stats.edits += 1
226 else if (kind === 'test') stats.testsPassed += 1
227 else stats.commits += 1
228
229 const fish =
230 kind === 'test'
231 ? state.fish.map((f): Fish => ({ ...f, mood: Math.min(100, f.mood + 15), state: 'happy', since: now }))
232 : state.fish
233
234 const next = bump(
235 state,
236 { coins: state.coins + coins, stats, fish, lastRewardAt: { ...state.lastRewardAt, [kind]: now } },
237 now,
238 )
239 return { state: next, events: [{ kind: 'reward', reward: kind, coins }] }
240}
241game/progression.ts 81 lines1// Tank level (from lifetime produced coins) and fish slots. Pure; numbers in economy.config.ts.
2
3import { ECONOMY } from './economy.config'
4import type { GameState } from './types'
5import { num } from './util'
6
7export const MAX_TANK_LEVEL = ECONOMY.tankLevels.length
8
9/** Tank level for a lifetime produced coin count: 1..MAX_TANK_LEVEL. Bad input counts as 0. */
10export function tankLevelFor(lifetime: number): number {
11 const c = Math.max(0, num(lifetime, 0))
12 let lv = 1
13 for (let n = 1; n <= MAX_TANK_LEVEL; n++) if (c >= (ECONOMY.tankLevels[n - 1] ?? Infinity)) lv = n
14 return lv
15}
16
17export function tankLevel(state: Pick<GameState, 'lifetimeProducedCoins'>): number {
18 return tankLevelFor(state.lifetimeProducedCoins)
19}
20
21export type TankProgress = {
22 level: number
23 lifetime: number
24 /** Lifetime coins at which this level started. */
25 levelStart: number
26 /** Lifetime coins the next level needs; null at the top level. */
27 nextAt: number | null
28 /** 0..1 through this level (1 at the top). */
29 fraction: number
30}
31
32export function tankProgress(state: Pick<GameState, 'lifetimeProducedCoins'>): TankProgress {
33 const lifetime = Math.max(0, num(state.lifetimeProducedCoins, 0))
34 const level = tankLevelFor(lifetime)
35 const levelStart = ECONOMY.tankLevels[level - 1] ?? 0
36 const next = ECONOMY.tankLevels[level]
37 const nextAt = next === undefined ? null : next
38 const fraction = nextAt === null ? 1 : (lifetime - levelStart) / (nextAt - levelStart)
39 return { level, lifetime, levelStart, nextAt, fraction }
40}
41
42/** Fish slots the tank level gives for free. */
43export function baseSlots(level: number): number {
44 const lv = Math.min(MAX_TANK_LEVEL, Math.max(1, Math.floor(num(level, 1))))
45 return Math.min(ECONOMY.maxSlots, ECONOMY.baseSlots[lv - 1] ?? ECONOMY.maxSlots)
46}
47
48/** How many slots may have been bought in total at this tank level. */
49export function slotsBuyable(level: number): number {
50 let n = 0
51 for (const [from, total] of ECONOMY.slotsBuyableFrom) if (level >= from) n = total
52 return Math.min(n, ECONOMY.slotPrices.length)
53}
54
55/**
56 * min(maxSlots, base + bought), but never fewer than the fish already in the tank
57 * (a save from before slots keeps every fish it had).
58 */
59export function slotCapacity(state: Pick<GameState, 'lifetimeProducedCoins' | 'slotsBought' | 'fish'>): number {
60 const bought = Math.max(0, Math.floor(num(state.slotsBought, 0)))
61 const cap = Math.min(ECONOMY.maxSlots, baseSlots(tankLevel(state)) + bought)
62 return Math.max(cap, state.fish.length)
63}
64
65export type SlotOffer =
66 | { reason: null; price: number }
67 | { reason: 'slots-max' | 'slot-locked-level'; price: number | null; unlockLevel: number | null }
68
69/** The next slot for sale and its price, or why none is. */
70export function nextSlot(state: Pick<GameState, 'lifetimeProducedCoins' | 'slotsBought' | 'fish'>): SlotOffer {
71 const level = tankLevel(state)
72 const bought = Math.max(0, Math.floor(num(state.slotsBought, 0)))
73 const price = ECONOMY.slotPrices[bought] ?? null
74 if (baseSlots(level) + bought >= ECONOMY.maxSlots || price === null) return { reason: 'slots-max', price: null, unlockLevel: null }
75 if (bought >= slotsBuyable(level)) {
76 const from = ECONOMY.slotsBuyableFrom.find(([, total]) => total > bought)
77 return { reason: 'slot-locked-level', price, unlockLevel: from ? from[0] : null }
78 }
79 return { reason: null, price }
80}
81game/fish.ts 189 lines1// Creating fish and the player's per-fish actions: feed, collect, rename, level up.
2
3import { dayKey } from './day'
4import { levelUpCost, MAX_LEVEL, XP_THRESHOLDS } from './economy'
5import { SPECIES } from './species'
6import type { Fish, GameState, Outcome, SpeciesId } from './types'
7import { bump, refuse } from './util'
8
9export const NAME_MAX = 12
10const FEED_HUNGER = 30
11const FEED_MOOD = 5
12
13export function newFish(species: SpeciesId, now: number, idSeed: string, name?: string): Fish {
14 return {
15 id: `${species}-${idSeed}`,
16 species,
17 name: name ?? SPECIES[species].zh,
18 level: 1,
19 xp: 0,
20 hunger: 80,
21 mood: 70,
22 carry: 0,
23 pending: 0,
24 dailyRaw: 0,
25 dailyKey: dayKey(now),
26 bornAt: now,
27 lastFedAt: now,
28 state: 'swim',
29 since: now,
30 }
31}
32
33/** How many fish rush to one pellet, hungriest first. */
34export const EATERS_PER_PELLET = 3
35/** A fish at or above this hunger ignores a pellet. */
36export const FULL_ENOUGH = 70
37export const PELLETS_KEEP = 6
38
39/**
40 * A click on open water: one portion of food drops there and the hungry fish
41 * (under FULL_ENOUGH, hungriest first, at most EATERS_PER_PELLET) rush to it
42 * and eat. With nobody hungry nothing drops and the food is kept.
43 */
44export function dropFood(state: GameState, x: number, y: number, now: number): Outcome {
45 if (state.fish.length === 0) return refuse(state, 'no-fish')
46 if (state.food <= 0) return refuse(state, 'no-food')
47 const eaters = [...state.fish]
48 .filter((f) => f.hunger < FULL_ENOUGH)
49 .sort((a, b) => a.hunger - b.hunger)
50 .slice(0, EATERS_PER_PELLET)
51 .map((f) => f.id)
52 // Nobody hungry: the food stays in the pantry.
53 if (eaters.length === 0) return refuse(state, 'all-full')
54 const fish = state.fish.map((f): Fish =>
55 eaters.includes(f.id)
56 ? { ...f, hunger: Math.min(100, f.hunger + FEED_HUNGER), mood: Math.min(100, f.mood + FEED_MOOD), state: 'feeding', since: now, lastFedAt: now }
57 : f,
58 )
59 const pellet = { id: `pellet-${now}`, x: Math.round(x), y: Math.round(y), at: now, eaters }
60 const next = bump(
61 state,
62 { fish, food: state.food - 1, pellets: [...state.pellets, pellet].slice(-PELLETS_KEEP), stats: { ...state.stats, feeds: state.stats.feeds + eaters.length } },
63 now,
64 )
65 return { state: next, events: [{ kind: 'dropped', x: pellet.x, y: pellet.y, eaters }] }
66}
67
68/** A click on a fish: it darts off (the drawing plays it) and cheers up a little. */
69export function startle(state: GameState, fishId: string, now: number): Outcome {
70 const target = state.fish.find((f) => f.id === fishId)
71 if (!target) return refuse(state, 'no-such-fish')
72 const fish = state.fish.map((f): Fish => (f.id === fishId ? { ...f, state: 'startled', since: now, mood: Math.min(100, f.mood + 1) } : f))
73 return { state: bump(state, { fish }, now), events: [{ kind: 'startled', fishId }] }
74}
75
76export function pendingTotal(state: GameState): number {
77 return state.fish.reduce((n, f) => n + f.pending, 0)
78}
79
80/** The fish with the lowest hunger, or null for an empty tank. */
81export function hungriest(state: GameState): Fish | null {
82 let best: Fish | null = null
83 for (const f of state.fish) if (!best || f.hunger < best.hunger) best = f
84 return best
85}
86
87/** Feed one fish, or all (`null`); short on food, the hungriest eat first. */
88export function feed(state: GameState, fishId: string | null, now: number): Outcome {
89 const targets = fishId === null ? state.fish : state.fish.filter((f) => f.id === fishId)
90 if (targets.length === 0) return refuse(state, fishId === null ? 'no-fish' : 'no-such-fish')
91 if (state.food <= 0) return refuse(state, 'no-food')
92
93 const count = Math.min(state.food, targets.length)
94 const fedIds = new Set(
95 [...targets].sort((a, b) => a.hunger - b.hunger).slice(0, count).map((f) => f.id),
96 )
97 const fish = state.fish.map((f): Fish =>
98 fedIds.has(f.id)
99 ? {
100 ...f,
101 hunger: Math.min(100, f.hunger + FEED_HUNGER),
102 mood: Math.min(100, f.mood + FEED_MOOD),
103 state: 'feeding',
104 since: now,
105 lastFedAt: now,
106 }
107 : f,
108 )
109 const next = bump(
110 state,
111 { fish, food: state.food - count, stats: { ...state.stats, feeds: state.stats.feeds + count } },
112 now,
113 )
114 return { state: next, events: [{ kind: 'fed', fishId, count }] }
115}
116
117/** Move pending coins of one fish, or all (`null`), into the wallet. */
118export function collect(state: GameState, fishId: string | null, now: number): Outcome {
119 if (fishId !== null && !state.fish.some((f) => f.id === fishId)) return refuse(state, 'no-such-fish')
120 let coins = 0
121 const fish = state.fish.map((f): Fish => {
122 if ((fishId !== null && f.id !== fishId) || f.pending <= 0) return f
123 coins += f.pending
124 return { ...f, pending: 0 }
125 })
126 if (coins <= 0) return refuse(state, 'nothing-to-collect')
127 const s = state.stats
128 const next = bump(
129 state,
130 {
131 fish,
132 coins: state.coins + coins,
133 stats: { ...s, todayCoinsCollected: s.todayCoinsCollected + coins, totalCoinsCollected: s.totalCoinsCollected + coins },
134 },
135 now,
136 )
137 return { state: next, events: [{ kind: 'collected', fishId, coins }] }
138}
139
140/** Trimmed, at most NAME_MAX code points; null when nothing is left. */
141export function cleanName(raw: unknown): string | null {
142 if (typeof raw !== 'string') return null
143 const name = Array.from(raw.trim()).slice(0, NAME_MAX).join('').trim()
144 return name.length > 0 ? name : null
145}
146
147export function rename(state: GameState, fishId: string, name: string): Outcome {
148 if (!state.fish.some((f) => f.id === fishId)) return refuse(state, 'no-such-fish')
149 const clean = cleanName(name)
150 if (clean === null) return refuse(state, 'empty-name')
151 const fish = state.fish.map((f) => (f.id === fishId ? { ...f, name: clean } : f))
152 return { state: bump(state, { fish }), events: [{ kind: 'renamed', fishId, name: clean }] }
153}
154
155export function levelUp(state: GameState, fishId: string, now: number): Outcome {
156 const target = state.fish.find((f) => f.id === fishId)
157 if (!target) return refuse(state, 'no-such-fish')
158 if (target.level >= MAX_LEVEL) return refuse(state, 'max-level')
159 if (target.xp < (XP_THRESHOLDS[target.level] ?? Infinity)) return refuse(state, 'not-enough-xp')
160 const cost = levelUpCost(target)
161 if (state.coins < cost) return refuse(state, 'not-enough-coins')
162 const level = target.level + 1
163 const fish = state.fish.map((f): Fish =>
164 f.id === fishId ? { ...f, level, state: 'happy', since: now } : f,
165 )
166 return { state: bump(state, { fish, coins: state.coins - cost }, now), events: [{ kind: 'levelUp', fishId, level }] }
167}
168
169/**
170 * Let a fish go to free its slot (no refund; its pending coins go to the wallet first).
171 * The last fish stays: an empty tank could never earn again.
172 */
173export function releaseFish(state: GameState, fishId: string, now: number): Outcome {
174 const target = state.fish.find((f) => f.id === fishId)
175 if (!target) return refuse(state, 'no-such-fish')
176 if (state.fish.length <= 1) return refuse(state, 'last-fish')
177 const s = state.stats
178 const next = bump(
179 state,
180 {
181 fish: state.fish.filter((f) => f.id !== fishId),
182 coins: state.coins + target.pending,
183 stats: { ...s, todayCoinsCollected: s.todayCoinsCollected + target.pending, totalCoinsCollected: s.totalCoinsCollected + target.pending },
184 },
185 now,
186 )
187 return { state: next, events: target.pending > 0 ? [{ kind: 'collected', fishId, coins: target.pending }] : [] }
188}
189game/species.ts 76 lines1// The catalogue: fish species, decorations and food prices. Pure data.
2
3import { ECONOMY } from './economy.config'
4import type { DecorId, Species, SpeciesId } from './types'
5
6/** What a species looks like; its rarity, price and unlock level come from ECONOMY.species. */
7const LOOKS: Record<SpeciesId, Pick<Species, 'zh' | 'en' | 'size' | 'speed'>> = {
8 guppy: { zh: '孔雀魚', en: 'Guppy', size: 's', speed: 3.2 },
9 clownfish: { zh: '小丑魚', en: 'Clownfish', size: 's', speed: 2.8 },
10 tang: { zh: '藍倒吊', en: 'Blue Tang', size: 's', speed: 3.0 },
11 puffer: { zh: '河豚', en: 'Puffer', size: 's', speed: 1.6 },
12 angelfish: { zh: '神仙魚', en: 'Angelfish', size: 'm', speed: 2.2 },
13 koi: { zh: '錦鯉', en: 'Koi', size: 'm', speed: 1.9 },
14 goldfish: { zh: '金魚', en: 'Goldfish', size: 's', speed: 2.0 },
15 seahorse: { zh: '海馬', en: 'Seahorse', size: 'm', speed: 1.2 },
16 neon: { zh: '霓虹燈魚', en: 'Neon Tetra', size: 's', speed: 3.6 },
17 betta: { zh: '鬥魚', en: 'Betta', size: 'm', speed: 2.0 },
18 butterflyfish: { zh: '蝴蝶魚', en: 'Butterflyfish', size: 's', speed: 2.6 },
19 lionfish: { zh: '獅子魚', en: 'Lionfish', size: 'm', speed: 1.4 },
20 arowana: { zh: '龍魚', en: 'Arowana', size: 'l', speed: 2.4 },
21 danio: { zh: '斑馬魚', en: 'Zebra Danio', size: 's', speed: 3.8 },
22 swordtail: { zh: '劍尾魚', en: 'Swordtail', size: 's', speed: 3.1 },
23 discus: { zh: '七彩神仙魚', en: 'Discus', size: 'm', speed: 1.8 },
24 mandarin: { zh: '麒麟魚', en: 'Mandarinfish', size: 's', speed: 1.5 },
25 oscar: { zh: '地圖魚', en: 'Oscar', size: 'm', speed: 2.1 },
26 moorishidol: { zh: '鐮魚', en: 'Moorish Idol', size: 'm', speed: 2.3 },
27 parrotfish: { zh: '鸚哥魚', en: 'Parrotfish', size: 'm', speed: 2.0 },
28 mola: { zh: '曼波魚', en: 'Ocean Sunfish', size: 'l', speed: 1.0 },
29}
30
31function speciesOf(id: SpeciesId): Species {
32 const eco = ECONOMY.species[id]
33 return {
34 id,
35 ...LOOKS[id],
36 price: eco.price,
37 rarity: eco.rarity,
38 rate: ECONOMY.rarityWeight[eco.rarity],
39 unlockLevel: eco.unlockLevel,
40 unlockAt: eco.unlockLevel,
41 }
42}
43
44export const SPECIES: Record<SpeciesId, Species> = Object.fromEntries(
45 (Object.keys(LOOKS) as SpeciesId[]).map((id) => [id, speciesOf(id)]),
46) as Record<SpeciesId, Species>
47
48/** In shop order: cheapest first (ties: lower unlock level first). */
49export const SPECIES_LIST: Species[] = Object.values(SPECIES).sort((a, b) => a.price - b.price || a.unlockLevel - b.unlockLevel)
50
51export const DECOR: Record<DecorId, { zh: string; en: string; price: number }> = {
52 seaweed: { zh: '海草', en: 'Seaweed', price: 30 },
53 shell: { zh: '貝殼', en: 'Shell', price: 40 },
54 rock: { zh: '岩石', en: 'Rock', price: 60 },
55 coral: { zh: '珊瑚', en: 'Coral', price: 90 },
56 anchor: { zh: '船錨', en: 'Anchor', price: 150 },
57 chest: { zh: '寶箱', en: 'Treasure Chest', price: 250 },
58 ruin: { zh: '遺跡', en: 'Ruin', price: 400 },
59 anemone: { zh: '海葵', en: 'Anemone', price: 120 },
60 clam: { zh: '珍珠貝', en: 'Giant Clam', price: 180 },
61 wheel: { zh: '船舵', en: "Ship's Wheel", price: 220 },
62 statue: { zh: '石像', en: 'Stone Statue', price: 350 },
63 shipwreck: { zh: '沉船', en: 'Shipwreck', price: 600 },
64}
65
66/** Price of one portion of food. */
67export const FOOD_PRICE = 5
68
69export function isSpeciesId(v: unknown): v is SpeciesId {
70 return typeof v === 'string' && Object.prototype.hasOwnProperty.call(SPECIES, v)
71}
72
73export function isDecorId(v: unknown): v is DecorId {
74 return typeof v === 'string' && Object.prototype.hasOwnProperty.call(DECOR, v)
75}
76game/shop.ts 126 lines1// The shop: buying fish, fish slots, decorations and food.
2
3import { ECONOMY } from './economy.config'
4import { newFish } from './fish'
5import { nextSlot, slotCapacity, tankLevel } from './progression'
6import { DECOR, FOOD_PRICE, isDecorId, isSpeciesId, SPECIES, SPECIES_LIST } from './species'
7import type { DecorId, Decoration, GameState, Outcome, Rarity, SpeciesId } from './types'
8import { bump, decorX, FLOOR_Y, refuse } from './util'
9
10/** The most fish any tank holds (ECONOMY.maxSlots); the current limit is slotCapacity(state). */
11export const MAX_FISH = ECONOMY.maxSlots
12export const MAX_DECOR = 8
13/** Largest single food purchase. */
14export const MAX_FOOD_BUY = 99
15
16/** A species is for sale from its unlock tank level (owning one before, e.g. from an old save, does not unlock it). */
17export function isUnlocked(state: GameState, species: SpeciesId): boolean {
18 return tankLevel(state) >= SPECIES[species].unlockLevel
19}
20
21export type BuyFishBlock = 'locked-level' | 'tank-full' | 'not-enough-coins'
22
23/** Why buying this species would be refused right now, or null when it would go through. */
24export function fishBlock(state: GameState, species: SpeciesId): BuyFishBlock | null {
25 const sp = SPECIES[species]
26 if (!isUnlocked(state, species)) return 'locked-level'
27 if (state.fish.length >= slotCapacity(state)) return 'tank-full'
28 if (!(sp.price > 0) || state.coins < sp.price) return 'not-enough-coins'
29 return null
30}
31
32export type ShopEntry = {
33 species: SpeciesId
34 rarity: Rarity
35 price: number
36 unlockLevel: number
37 isUnlocked: boolean
38 canAfford: boolean
39 /** The first reason a purchase would be refused, or null when it can be bought now. */
40 reason: BuyFishBlock | null
41}
42
43/** Every species in shop order (cheapest first) with what buyFish would decide. */
44export function shopList(state: GameState): ShopEntry[] {
45 return SPECIES_LIST.map((sp) => ({
46 species: sp.id,
47 rarity: sp.rarity,
48 price: sp.price,
49 unlockLevel: sp.unlockLevel,
50 isUnlocked: isUnlocked(state, sp.id),
51 canAfford: state.coins >= sp.price,
52 reason: fishBlock(state, sp.id),
53 }))
54}
55
56/** `base`, or `base-2`, `base-3`… whichever no existing id uses. */
57function uniqueId(base: string, taken: Set<string>): string {
58 if (!taken.has(base)) return base
59 let n = 2
60 while (taken.has(`${base}-${n}`)) n++
61 return `${base}-${n}`
62}
63
64export function buyFish(state: GameState, species: SpeciesId, now: number, idSeed: string, name?: string): Outcome {
65 if (!isSpeciesId(species)) return refuse(state, 'unknown-species')
66 const fish = newFish(species, now, idSeed, name)
67 // The same press delivered twice (same species and seed) buys once.
68 if (state.fish.some((f) => f.id === fish.id)) return refuse(state, 'duplicate-purchase')
69 const block = fishBlock(state, species)
70 if (block !== null) return refuse(state, block)
71 const price = SPECIES[species].price
72 const dex = state.dex.includes(species) ? state.dex : [...state.dex, species]
73 const next = bump(state, { coins: state.coins - price, fish: [...state.fish, fish], dex }, now)
74 return { state: next, events: [{ kind: 'bought', what: 'fish', id: fish.id, price }] }
75}
76
77/** Buy one more fish slot: refused at the slot maximum, before the tank level allows another, or short of coins. */
78export function buySlot(state: GameState, now: number): Outcome {
79 const offer = nextSlot(state)
80 if (offer.reason !== null) return refuse(state, offer.reason)
81 if (state.coins < offer.price) return refuse(state, 'not-enough-coins')
82 const slotsBought = state.slotsBought + 1
83 const next = bump(state, { coins: state.coins - offer.price, slotsBought }, now)
84 return { state: next, events: [{ kind: 'bought', what: 'slot', id: `slot-${slotsBought}`, price: offer.price }] }
85}
86
87export function buyDecoration(state: GameState, kind: DecorId, now: number, idSeed: string): Outcome {
88 if (!isDecorId(kind)) return refuse(state, 'unknown-decor')
89 if (state.decorations.length >= MAX_DECOR) return refuse(state, 'decor-full')
90 const price = DECOR[kind].price
91 if (state.coins < price) return refuse(state, 'not-enough-coins')
92
93 const decor: Decoration = {
94 id: uniqueId(`${kind}-${idSeed}`, new Set(state.decorations.map((d) => d.id))),
95 kind,
96 x: decorX(state.decorations.length, idSeed),
97 y: FLOOR_Y,
98 }
99 const next = bump(state, { coins: state.coins - price, decorations: [...state.decorations, decor] }, now)
100 return { state: next, events: [{ kind: 'bought', what: 'decor', id: decor.id, price }] }
101}
102
103/** Bounds a decoration's baseline: never below the sand, never above the middle of the water. */
104export const DECOR_Y_MIN = 60
105export const DECOR_Y_MAX = FLOOR_Y
106
107/** Moves a decoration to design coordinates on the 320 × 180 base (clamped); the spot is kept in the save. */
108export function moveDecoration(state: GameState, id: string, x: number, y: number): Outcome {
109 const target = state.decorations.find((d) => d.id === id)
110 if (!target) return refuse(state, 'unknown-decor')
111 if (!Number.isFinite(x) || !Number.isFinite(y)) return refuse(state, 'bad-position')
112 const nx = Math.round(Math.max(8, Math.min(312, x)))
113 const ny = Math.round(Math.max(DECOR_Y_MIN, Math.min(DECOR_Y_MAX, y)))
114 if (nx === target.x && ny === target.y) return { state, events: [] }
115 const decorations = state.decorations.map((d) => (d.id === id ? { ...d, x: nx, y: ny } : d))
116 return { state: bump(state, { decorations }), events: [{ kind: 'moved', id, x: nx, y: ny }] }
117}
118
119export function buyFood(state: GameState, count: number): Outcome {
120 if (!Number.isInteger(count) || count < 1 || count > MAX_FOOD_BUY) return refuse(state, 'bad-count')
121 const price = count * FOOD_PRICE
122 if (state.coins < price) return refuse(state, 'not-enough-coins')
123 const next = bump(state, { coins: state.coins - price, food: state.food + count })
124 return { state: next, events: [{ kind: 'bought', what: 'food', id: 'food', price }] }
125}
126game/state.ts 323 lines1// A new game, loading (migrate + sanitize) whatever `$.store` held, the log, and merging sessions.
2
3import { dayKey, rollDay } from './day'
4import { levelForXp, MAX_LEVEL, PENDING_CAP, PROCESSED_TURNS_KEEP } from './economy'
5import { ECONOMY, ECONOMY_CONFIG_VERSION } from './economy.config'
6import { cleanName, newFish } from './fish'
7import { MAX_DECOR, MAX_FISH } from './shop'
8import { isDecorId, isSpeciesId, SPECIES } from './species'
9import type {
10 Decoration, Fish, FishState, GameState, LogEntry, RewardKind, Settings, SpeciesId, Stats, TankSize, Theme,
11} from './types'
12import { clamp, decorX, FLOOR_Y, num } from './util'
13
14export { dayKey, rollDay }
15
16export const LOG_KEEP = 50
17
18const FISH_STATES: FishState[] = ['idle', 'swim', 'spitCoin', 'feeding', 'happy', 'hungry', 'startled']
19const THEMES: Theme[] = ['ocean', 'wreck', 'reef', 'abyss']
20const TANK_SIZES: TankSize[] = ['s', 'm', 'l']
21const REWARD_KINDS: RewardKind[] = ['edit', 'test', 'commit']
22
23const DEFAULT_SETTINGS: Settings = {
24 theme: 'ocean', tankSize: 'm', showCoins: true, showBubbles: true, reduceMotion: false, showBand: true, language: 'auto',
25}
26
27function emptyStats(now: number): Stats {
28 return {
29 todayKey: dayKey(now),
30 todayEffectiveTokens: 0, todayCoinsMinted: 0, todayCoinsCollected: 0, todayTurns: 0,
31 totalEffectiveTokens: 0, totalCoinsMinted: 0, totalCoinsCollected: 0, totalTurns: 0,
32 testsPassed: 0, commits: 0, edits: 0, feeds: 0,
33 }
34}
35
36export function newGame(now: number): GameState {
37 return {
38 schemaVersion: 3,
39 economyVersion: 3,
40 economyConfigVersion: ECONOMY_CONFIG_VERSION,
41 lifetimeProducedCoins: 0,
42 slotsBought: 0,
43 revision: 1,
44 coins: 150,
45 food: 10,
46 fish: [newFish('guppy', now, 'a'), newFish('clownfish', now, 'b')],
47 decorations: [
48 { id: 'seaweed-1', kind: 'seaweed', x: 40, y: FLOOR_Y },
49 { id: 'rock-1', kind: 'rock', x: 150, y: FLOOR_Y },
50 { id: 'seaweed-2', kind: 'seaweed', x: 260, y: FLOOR_Y },
51 ],
52 dex: ['guppy', 'clownfish'],
53 stats: emptyStats(now),
54 settings: { ...DEFAULT_SETTINGS },
55 processedTurns: [],
56 log: [],
57 pellets: [],
58 lastRewardAt: {},
59 createdAt: now,
60 updatedAt: now,
61 }
62}
63
64// ---------- sanitize ----------
65
66type Obj = Record<string, unknown>
67const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
68const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
69const nonNeg = (v: unknown, d: number) => Math.max(0, num(v, d))
70const count = (v: unknown, d: number) => Math.floor(nonNeg(v, d))
71const oneOf = <T extends string>(v: unknown, list: readonly T[], d: T): T =>
72 list.includes(v as T) ? (v as T) : d
73const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
74const isDayKey = (v: unknown): v is string => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)
75
76function sanitizeFish(raw: unknown, i: number, now: number, taken: Set<string>): Fish | null {
77 if (!isObj(raw)) return null
78 const species: SpeciesId = isSpeciesId(raw.species) ? raw.species : 'guppy'
79 let id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : `${species}-m${i}`
80 while (taken.has(id)) id = `${id}-x`
81 taken.add(id)
82 const xp = nonNeg(raw.xp, 0)
83 const carry = nonNeg(raw.carry, 0)
84 return {
85 id,
86 species,
87 name: cleanName(raw.name) ?? SPECIES[species].zh,
88 level: clamp(Math.floor(num(raw.level, levelForXp(xp))), 1, MAX_LEVEL),
89 xp,
90 hunger: clamp(num(raw.hunger, 80), 0, 100),
91 mood: clamp(num(raw.mood, 70), 0, 100),
92 carry: carry - Math.floor(carry),
93 pending: clamp(Math.floor(num(raw.pending, 0)), 0, PENDING_CAP),
94 dailyRaw: nonNeg(raw.dailyRaw, 0),
95 dailyKey: isDayKey(raw.dailyKey) ? raw.dailyKey : dayKey(now),
96 bornAt: num(raw.bornAt, now),
97 lastFedAt: num(raw.lastFedAt, now),
98 state: oneOf(raw.state, FISH_STATES, 'swim'),
99 since: num(raw.since, now),
100 }
101}
102
103function sanitizeDecor(raw: unknown, i: number, taken: Set<string>): Decoration | null {
104 if (!isObj(raw) || !isDecorId(raw.kind)) return null
105 let id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : `${raw.kind}-m${i}`
106 while (taken.has(id)) id = `${id}-x`
107 taken.add(id)
108 return {
109 id,
110 kind: raw.kind,
111 x: clamp(num(raw.x, decorX(i, id)), 0, 320),
112 y: clamp(num(raw.y, FLOOR_Y), 0, 180),
113 }
114}
115
116/** Any object → a valid GameState: bad numbers fall back to defaults, ranges are clamped, lists capped. */
117export function sanitize(raw: Obj, now: number): GameState {
118 const fishIds = new Set<string>()
119 const fish = arr(raw.fish)
120 .map((f, i) => sanitizeFish(f, i, now, fishIds))
121 .filter((f): f is Fish => f !== null)
122 .slice(0, MAX_FISH)
123
124 const decorIds = new Set<string>()
125 const decorations = arr(raw.decorations)
126 .map((d, i) => sanitizeDecor(d, i, decorIds))
127 .filter((d): d is Decoration => d !== null)
128 .slice(0, MAX_DECOR)
129
130 const dex: SpeciesId[] = []
131 for (const s of [...arr(raw.dex), ...fish.map((f) => f.species)]) {
132 if (isSpeciesId(s) && !dex.includes(s)) dex.push(s)
133 }
134
135 const rs = isObj(raw.stats) ? raw.stats : {}
136 const base = emptyStats(now)
137 const stats: Stats = {
138 todayKey: isDayKey(rs.todayKey) ? rs.todayKey : base.todayKey,
139 todayEffectiveTokens: nonNeg(rs.todayEffectiveTokens, 0),
140 todayCoinsMinted: count(rs.todayCoinsMinted, 0),
141 todayCoinsCollected: count(rs.todayCoinsCollected, 0),
142 todayTurns: count(rs.todayTurns, 0),
143 totalEffectiveTokens: nonNeg(rs.totalEffectiveTokens, 0),
144 totalCoinsMinted: count(rs.totalCoinsMinted, 0),
145 totalCoinsCollected: count(rs.totalCoinsCollected, 0),
146 totalTurns: count(rs.totalTurns, 0),
147 testsPassed: count(rs.testsPassed, 0),
148 commits: count(rs.commits, 0),
149 edits: count(rs.edits, 0),
150 feeds: count(rs.feeds, 0),
151 }
152
153 const st = isObj(raw.settings) ? raw.settings : {}
154 const settings: Settings = {
155 theme: oneOf(st.theme, THEMES, DEFAULT_SETTINGS.theme),
156 tankSize: oneOf(st.tankSize, TANK_SIZES, DEFAULT_SETTINGS.tankSize),
157 showCoins: bool(st.showCoins, DEFAULT_SETTINGS.showCoins),
158 showBubbles: bool(st.showBubbles, DEFAULT_SETTINGS.showBubbles),
159 reduceMotion: bool(st.reduceMotion, DEFAULT_SETTINGS.reduceMotion),
160 showBand: bool(st.showBand, DEFAULT_SETTINGS.showBand),
161 language: oneOf(st.language, ['auto', 'zh', 'en'] as const, DEFAULT_SETTINGS.language),
162 }
163
164 const processedTurns = arr(raw.processedTurns)
165 .filter((t): t is string => typeof t === 'string')
166 .slice(-PROCESSED_TURNS_KEEP)
167
168 const log: LogEntry[] = arr(raw.log)
169 .filter((e): e is Obj => isObj(e) && typeof e.text === 'string')
170 .map((e) => ({ at: num(e.at, now), text: e.text as string }))
171 .slice(-LOG_KEEP)
172
173 const lr = isObj(raw.lastRewardAt) ? raw.lastRewardAt : {}
174 const lastRewardAt: GameState['lastRewardAt'] = {}
175 for (const k of REWARD_KINDS) {
176 const v = lr[k]
177 if (typeof v === 'number' && Number.isFinite(v)) lastRewardAt[k] = v
178 }
179
180 // Economy v0.3: a save without lifetimeProducedCoins takes the coins its fish ever minted
181 // (stats.totalCoinsMinted never included rewards); one that has it keeps it, never lower than that.
182 const lifetimeProducedCoins = Math.max(nonNeg(raw.lifetimeProducedCoins, 0), stats.totalCoinsMinted)
183
184 return {
185 schemaVersion: 3,
186 economyVersion: 3,
187 economyConfigVersion: count(raw.economyConfigVersion, ECONOMY_CONFIG_VERSION),
188 lifetimeProducedCoins,
189 slotsBought: Math.min(count(raw.slotsBought, 0), ECONOMY.slotPrices.length),
190 revision: Math.max(1, Math.floor(num(raw.revision, 1))),
191 coins: count(raw.coins, 0),
192 food: count(raw.food, 0),
193 fish,
194 decorations,
195 dex,
196 stats,
197 settings,
198 processedTurns,
199 log,
200 pellets: arr(raw.pellets).filter(isObj).map((p, i) => ({ id: typeof p.id === 'string' ? p.id : `pellet-${i}`, x: num(p.x, 160), y: num(p.y, 60), at: num(p.at, 0), eaters: arr(p.eaters).filter((e): e is string => typeof e === 'string') })).slice(-6),
201 lastRewardAt,
202 createdAt: num(raw.createdAt, now),
203 updatedAt: num(raw.updatedAt, now),
204 }
205}
206
207// ---------- migrate ----------
208
209export type Migrated = {
210 state: GameState
211 migratedFrom: number | null
212 isReset: boolean
213 /** The save predates economy v0.3 and was converted (back up the raw save before writing). */
214 economyMigrated: boolean
215 /** Converting threw: `state` is a fresh game that must NOT be written over the raw save. */
216 isFailed: boolean
217}
218
219/** v2: `{version:2, coins, fish:[{id,species,name,xp,hunger,carry,pending}], decorations:[string], totalEffectiveTokens}`. */
220function fromV2(raw: Obj, now: number): GameState {
221 const fish = arr(raw.fish).filter(isObj).map((f) => {
222 const xp = nonNeg(f.xp, 0)
223 // Unknown species become guppies; the name and xp stay.
224 return { ...f, species: isSpeciesId(f.species) ? f.species : 'guppy', xp, level: levelForXp(xp), mood: 70 }
225 })
226 const decorations = arr(raw.decorations)
227 .filter(isDecorId)
228 .map((kind, i) => ({ id: `${kind}-v2-${i}`, kind, x: decorX(i, `${kind}-v2-${i}`), y: FLOOR_Y }))
229 const fresh = newGame(now)
230 return sanitize(
231 {
232 revision: 1,
233 coins: raw.coins,
234 food: fresh.food,
235 fish,
236 decorations,
237 stats: { totalEffectiveTokens: raw.totalEffectiveTokens },
238 createdAt: now,
239 updatedAt: now,
240 },
241 now,
242 )
243}
244
245function migrateRaw(raw: unknown, now: number): Omit<Migrated, 'isFailed'> {
246 if (raw === undefined || raw === null) return { state: newGame(now), migratedFrom: null, isReset: false, economyMigrated: false }
247 if (isObj(raw) && raw.schemaVersion === 3) {
248 const economyMigrated = raw.economyVersion !== 3
249 const state = rollDay(sanitize(raw, now), now)
250 return { state: { ...state, economyConfigVersion: ECONOMY_CONFIG_VERSION }, migratedFrom: null, isReset: false, economyMigrated }
251 }
252 if (isObj(raw) && raw.version === 2) return { state: fromV2(raw, now), migratedFrom: 2, isReset: false, economyMigrated: true }
253 return { state: newGame(now), migratedFrom: null, isReset: true, economyMigrated: false }
254}
255
256/**
257 * Whatever the store held → a current GameState. Idempotent: migrating its own output
258 * changes nothing but the day roll. Never throws; on a failure `isFailed` is set.
259 */
260export function migrate(raw: unknown, now: number): Migrated {
261 try {
262 return { ...migrateRaw(raw, now), isFailed: false }
263 } catch {
264 return { state: newGame(now), migratedFrom: null, isReset: false, economyMigrated: false, isFailed: true }
265 }
266}
267
268// ---------- log ----------
269
270export function appendLog(state: GameState, text: string, now: number): GameState {
271 return { ...state, log: [...state.log, { at: now, text }].slice(-LOG_KEEP) }
272}
273
274// ---------- merge ----------
275
276/**
277 * Merge two sessions' copies. The higher revision is the base (ties: `mine`); processedTurns are
278 * unioned; coins take the max; fish merge by id (fields from the side updated later, pending the max).
279 *
280 * Limitations — this is a heuristic, not a CRDT:
281 * - coins = max: a spend on one side and an earn on the other loses one of them.
282 * - pending = max: a collect on one side plus a mint on the other can pay the same coins twice.
283 * - fish only on one side are kept (a fish released on one side comes back), capped at MAX_FISH.
284 * - lifetimeProducedCoins and slotsBought take the max (the tank level never drops).
285 * - food, decorations, stats, settings, lastRewardAt come from the base alone.
286 */
287export function mergeStates(mine: GameState, theirs: GameState): GameState {
288 const [base, other] = theirs.revision > mine.revision ? [theirs, mine] : [mine, theirs]
289 const otherNewer = other.updatedAt > base.updatedAt
290
291 const seen = new Set(base.processedTurns)
292 const processedTurns = [...base.processedTurns, ...other.processedTurns.filter((t) => !seen.has(t))]
293 .slice(-PROCESSED_TURNS_KEEP)
294
295 const otherById = new Map(other.fish.map((f) => [f.id, f]))
296 const baseIds = new Set(base.fish.map((f) => f.id))
297 const fish = [
298 ...base.fish.map((b) => {
299 const o = otherById.get(b.id)
300 if (!o) return b
301 const winner = otherNewer ? o : b
302 // The daily soft cap is spent on both sides: keep the larger use of the same day.
303 const dailyRaw = b.dailyKey === o.dailyKey ? Math.max(b.dailyRaw, o.dailyRaw) : winner.dailyRaw
304 return { ...winner, pending: Math.max(b.pending, o.pending), dailyRaw }
305 }),
306 ...other.fish.filter((f) => !baseIds.has(f.id)),
307 ].slice(0, MAX_FISH)
308
309 const dex = [...base.dex, ...other.dex.filter((s) => !base.dex.includes(s))]
310
311 return {
312 ...base,
313 revision: Math.max(mine.revision, theirs.revision) + 1,
314 coins: Math.max(mine.coins, theirs.coins),
315 lifetimeProducedCoins: Math.max(mine.lifetimeProducedCoins, theirs.lifetimeProducedCoins),
316 slotsBought: Math.max(mine.slotsBought, theirs.slotsBought),
317 fish,
318 dex,
319 processedTurns,
320 updatedAt: Math.max(mine.updatedAt, theirs.updatedAt),
321 }
322}
323render/svg.ts 399 lines1// Draws the tank as one SVG document (320 × 180 design pixels). On Desktop the
2// Svg element shows it in a sandboxed frame where SMIL runs, so the fish swim,
3// the coins spin and the bubbles rise without the plugin redrawing anything.
4// The document is rebuilt only when the game state changes.
5
6import type { Decoration, Fish, GameState, Theme } from '../game/types'
7import { SPECIES } from '../game/species'
8import { SPRITES } from './sprites'
9import type { Sprite, SpriteFrame } from './sprites'
10
11/** The design width the game's coordinates (decorations) are stored in. */
12export const BASE_W = 320
13export const TANK_H = 180
14const FLOOR_Y = 150
15/** How much larger than their pixels the fish are drawn, per tank height. */
16export const FISH_SCALES = { s: 1.3, m: 1.6, l: 1.9 } as const
17let FISH_SCALE = 1.6
18/** The width of the tank being drawn, in design pixels: set by `tankSvg` from the room it has. */
19let W = BASE_W
20
21const THEMES: Record<Theme, { top: string; bottom: string; sand: string; sandDark: string; ray: string }> = {
22 ocean: { top: '#0b2a4a', bottom: '#14456e', sand: '#d9b979', sandDark: '#b8964f', ray: '#8fd3ff' },
23 wreck: { top: '#061a2b', bottom: '#0e3550', sand: '#8d7b5e', sandDark: '#5f5240', ray: '#6fb0d8' },
24 reef: { top: '#0a3e5c', bottom: '#1c7a8e', sand: '#f0d9a0', sandDark: '#cbb06a', ray: '#b8f0ff' },
25 abyss: { top: '#03070f', bottom: '#0a1a33', sand: '#3a3f55', sandDark: '#25293a', ray: '#5a7fcf' },
26}
27
28/** A deterministic 0..1 from a string and a lane, so a redraw keeps each fish's path. */
29export function hash01(seed: string, salt = 0): number {
30 let h = 2166136261 ^ salt
31 for (let i = 0; i < seed.length; i++) {
32 h ^= seed.charCodeAt(i)
33 h = Math.imul(h, 16777619)
34 }
35 return ((h >>> 0) % 10000) / 10000
36}
37
38const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
39
40function framePaths(frame: SpriteFrame): string {
41 return frame.map(p => `<path fill="${p.fill}" d="${p.d}"/>`).join('')
42}
43
44/** One <symbol> per frame of one animation, ids `${name}-${anim}-${i}`. */
45function symbols(name: string, anim: string): string {
46 const sprite = SPRITES[name]
47 if (!sprite) return ''
48 const frames = sprite.frames[anim] ?? sprite.frames[Object.keys(sprite.frames)[0] ?? '']
49 if (!frames) return ''
50 // Paths, not embedded PNGs: the Desktop's sandboxed frame strips <image>.
51 return frames
52 .map((f, i) => `<symbol id="${name}-${anim}-${i}" viewBox="0 0 ${sprite.w} ${sprite.h}" width="${sprite.w}" height="${sprite.h}">${framePaths(f)}</symbol>`)
53 .join('')
54}
55
56/** A <use> whose href cycles over the animation's frames. */
57function animatedUse(name: string, anim: string, dur: number, x: number, y: number, isMotion: boolean, extra = ''): string {
58 const sprite = SPRITES[name]
59 if (!sprite) return ''
60 const n = (sprite.frames[anim] ?? sprite.frames[Object.keys(sprite.frames)[0] ?? ''])?.length ?? 0
61 if (n === 0) return ''
62 const first = `#${name}-${anim}-0`
63 if (!isMotion || n === 1) return `<use href="${first}" x="${x}" y="${y}"/>`
64 const values = Array.from({ length: n }, (_, i) => `#${name}-${anim}-${i}`).join(';')
65 return `<use href="${first}" x="${x}" y="${y}"><animate attributeName="href" values="${values}" dur="${dur}s" ${extra} calcMode="discrete" repeatCount="indefinite"/></use>`
66}
67
68export type TankOptions = {
69 isMotion: boolean
70 showCoins: boolean
71 showBubbles: boolean
72 selectedId: string | null
73 /** Milliseconds now, for one-shot effects (a fish that just spat). */
74 now: number
75 /** The tank's width in design pixels (height is always TANK_H); the element fills its slot. */
76 width?: number
77 /** The fish's size over their pixels (FISH_SCALES); the hit test must use the same. */
78 fishScale?: number
79}
80
81/** Where a fish swims: its lane and the span it patrols, in design pixels. */
82export type FishLayout = {
83 /** The lane's centre line. */
84 y: number
85 x1: number
86 x2: number
87 /** One full trip there and back, seconds. */
88 dur: number
89 /** Where in the trip it starts, seconds (negative SMIL begin), for the drawing made at `now`. */
90 begin: number
91 /** The trip's own offset into wall-clock time, seconds: the phase is a function of the clock alone. */
92 offset: number
93 /** The drawn size (sprite × FISH_SCALE). */
94 w: number
95 h: number
96 isHungry: boolean
97}
98
99export function fishLayout(fish: Fish, index: number, count: number, width = W, now = 0, scale = FISH_SCALE): FishLayout {
100 const sp = SPECIES[fish.species]
101 const sprite = SPRITES[fish.species]
102 const w = (sprite?.w ?? 32) * scale
103 const h = (sprite?.h ?? 24) * scale
104 const laneTop = 18
105 const laneBottom = FLOOR_Y - h - 6
106 const y = laneTop + ((index + 0.5) / Math.max(count, 1)) * (laneBottom - laneTop) + (hash01(fish.id, 1) - 0.5) * 10
107 const span = Math.min(width - 24 - w, (90 + hash01(fish.id, 2) * 130) * Math.max(1, width / BASE_W))
108 const x1 = 8 + hash01(fish.id, 3) * Math.max(0, width - 16 - span - w)
109 const x2 = x1 + span
110 const isHungry = fish.state === 'hungry' || fish.hunger < 25
111 const speed = sp.speed * (isHungry ? 0.55 : 1) * (0.9 + fish.mood / 500)
112 const dur = Math.max(4, span / (speed * 8))
113 // The phase is set by the clock, so a drawing made at any moment continues
114 // the previous one's motion instead of starting the trip over.
115 const offset = hash01(fish.id, 6) * dur
116 const begin = -(((now / 1000 + offset) % dur + dur) % dur)
117 return { y, x1, x2, dur, begin, offset, w, h, isHungry }
118}
119
120/** The fish's centre at wall-clock `nowMs`, on any drawing (the phase follows the clock). */
121export function fishCentreAt(l: FishLayout, nowMs: number): { x: number; y: number } {
122 const t = nowMs / 1000 + l.offset
123 const phase = ((t % l.dur) + l.dur) % l.dur / l.dur
124 const x = phase < 0.5 ? l.x1 + (l.x2 - l.x1) * phase * 2 : l.x2 - (l.x2 - l.x1) * (phase - 0.5) * 2
125 // The sprite is centred on the moving point: half its width ahead of x1/x2.
126 return { x: x + l.w / 2, y: l.y }
127}
128
129/** The fish in lane order, small species on top and large ones near the sand. */
130export function laneOrder(fish: readonly Fish[]): Fish[] {
131 const rank = { s: 0, m: 1, l: 2 } as const
132 return [...fish].sort((a, b) => rank[SPECIES[a.species].size] - rank[SPECIES[b.species].size] || a.id.localeCompare(b.id))
133}
134
135/** The pellets still worth drawing: dropped in the last six seconds. */
136export const PELLET_SHOW_MS = 4500
137
138function fishMarkup(fish: Fish, index: number, count: number, o: TankOptions, state: GameState): string {
139 const sprite: Sprite | undefined = SPRITES[fish.species]
140 if (!sprite) return ''
141 const l = fishLayout(fish, index, count, W, o.now)
142 const { w, h, x1, x2, dur, isHungry } = l
143 const y = l.y
144 const bob = 2 + hash01(fish.id, 4) * 3
145 const bobDur = 1.6 + hash01(fish.id, 5) * 1.2
146 const bobBegin = (-((o.now / 1000) % bobDur)).toFixed(2)
147 const frameDur = isHungry ? 0.9 : 0.45
148 const justSpat = fish.state === 'spitCoin' && o.now - fish.since < 15000
149 const anim = justSpat && sprite.frames.spit ? 'spit' : 'swim'
150 const isSelected = o.selectedId === fish.id
151 const begin = l.begin.toFixed(2)
152 const body = `<g transform="scale(${FISH_SCALE})">${animatedUse(fish.species, anim, frameDur, -sprite.w / 2, -sprite.h / 2, o.isMotion)}</g>`
153 const ring = isSelected ? `<rect x="${-w / 2 - 2}" y="${-h / 2 - 2}" width="${w + 4}" height="${h + 4}" fill="none" stroke="#ffe066" stroke-width="1" stroke-dasharray="2 2" rx="2"/>` : ''
154 const hungryMark = isHungry ? `<text x="${w / 2 - 2}" y="${-h / 2 - 2}" font-family="monospace" font-size="9" fill="#ffd166" font-weight="bold">!</text>` : ''
155 // A fish with coins waiting glows softly gold.
156 const glow = fish.pending > 0 ? `<ellipse cx="0" cy="0" rx="${(w * 0.7).toFixed(0)}" ry="${(h * 0.75).toFixed(0)}" fill="url(#glow)">${o.isMotion ? '<animate attributeName="opacity" values="0.5;1;0.5" dur="2.4s" repeatCount="indefinite"/>' : ''}</ellipse>` : ''
157 if (!o.isMotion) {
158 const xm = (x1 + x2) / 2 + w / 2
159 return `<g transform="translate(${xm.toFixed(1)} ${y.toFixed(1)})">${glow}${body}${ring}${hungryMark}</g>`
160 }
161 const gid = `fish-${fish.id.replace(/[^a-zA-Z0-9_-]/g, '')}`
162 const dashX = (hash01(fish.id, 17) < 0.5 ? -1 : 1) * (40 + hash01(fish.id, 18) * 30)
163 // A click on the fish (or a startle the plugin recorded): it darts off and up, then settles back.
164 const startled = fish.state === 'startled' && o.now - fish.since < 1100
165 // A redraw mid-dash continues it: the begin is however long ago the tap was.
166 const dashBegin = startled ? `${(-(o.now - fish.since) / 1000).toFixed(2)}s;` : ''
167 const dash =
168 `<animateTransform attributeName="transform" type="translate" additive="sum" values="0 0;${dashX.toFixed(0)} -14;${(dashX * 0.6).toFixed(0)} -6;0 0" keyTimes="0;0.25;0.6;1" dur="1.1s" begin="${dashBegin}${gid}.click" calcMode="spline" keySplines="0.1 0.8 0.3 1;0.4 0 0.6 1;0.4 0 0.6 1" fill="remove"/>` +
169 `<animate attributeName="opacity" values="1;0.6;1" dur="0.3s" begin="${dashBegin}${gid}.click"/>`
170 // A pellet this fish rushed to: it leaves its lane for the food, fast, and nibbles there.
171 const pellet = [...state.pellets].reverse().find(p => p.eaters.includes(fish.id) && o.now - p.at < PELLET_SHOW_MS)
172 let rush = ''
173 if (pellet) {
174 // The eaters gather around the food, each on its own side, noses in.
175 const k = pellet.eaters.indexOf(fish.id)
176 const side = k % 2 === 0 ? -1 : 1
177 const depth = Math.ceil((k + 1) / 2)
178 const gx = pellet.x + side * (depth * w * 0.55 + 4)
179 const px = Math.max(w / 2, Math.min(W - w / 2, gx)) - w / 2
180 const py = Math.max(h / 2 + 4, Math.min(FLOOR_Y - h / 2, pellet.y + 10 + (depth - 1) * 6)) - y
181 const faces = side < 0 ? '1 1' : '-1 1'
182 // How long ago the food dropped: a redraw continues the rush, or finds the fish already there.
183 const ago = (o.now - pellet.at) / 1000
184 const RUSH_AT = 0.5
185 const RUSH_DUR = 0.6
186 const isThere = ago >= RUSH_AT + RUSH_DUR
187 const nibble = `<animateTransform attributeName="transform" type="translate" additive="sum" values="0 0;0 -2;0 0" begin="${(RUSH_AT + RUSH_DUR - ago).toFixed(2)}s" dur="0.4s" repeatCount="6"/>`
188 const lane = isThere
189 ? `<g transform="translate(${px.toFixed(1)} ${py.toFixed(1)})">`
190 : `<g transform="translate(${x1.toFixed(1)} 0)">` +
191 `<animateTransform attributeName="transform" type="translate" values="${x1.toFixed(1)} 0;${x2.toFixed(1)} 0;${x1.toFixed(1)} 0" keyTimes="0;0.5;1" dur="${dur.toFixed(2)}s" begin="${begin}s" repeatCount="indefinite" calcMode="linear"/>` +
192 `<animateTransform attributeName="transform" type="translate" to="${px.toFixed(1)} ${py.toFixed(1)}" begin="${(RUSH_AT - ago).toFixed(2)}s" dur="${RUSH_DUR}s" fill="freeze" calcMode="spline" keySplines="0.2 0 0.4 1"/>`
193 rush = nibble
194 return (
195 `<g transform="translate(0 ${y.toFixed(1)})">` +
196 lane +
197 rush +
198 `${glow}<g id="${gid}" style="cursor:pointer">${dash}<g transform="scale(${faces})">${body}</g></g>` +
199 `${ring}${hungryMark}</g></g>`
200 )
201 }
202 return (
203 `<g transform="translate(0 ${y.toFixed(1)})">` +
204 `<animateTransform attributeName="transform" type="translate" additive="sum" values="0 0;0 ${bob};0 0" dur="${bobDur.toFixed(2)}s" begin="${bobBegin}s" repeatCount="indefinite"/>` +
205 `<g transform="translate(${x1.toFixed(1)} 0)">` +
206 `<animateTransform attributeName="transform" type="translate" values="${x1.toFixed(1)} 0;${x2.toFixed(1)} 0;${x1.toFixed(1)} 0" keyTimes="0;0.5;1" dur="${dur.toFixed(2)}s" begin="${begin}s" repeatCount="indefinite" calcMode="linear"/>` +
207 `${glow}<g id="${gid}" style="cursor:pointer">${dash}<g><animateTransform attributeName="transform" type="scale" values="1 1;-1 1;1 1" keyTimes="0;0.5;1" dur="${dur.toFixed(2)}s" begin="${begin}s" repeatCount="indefinite" calcMode="discrete"/>${body}</g></g>` +
208 // the ring and the mark ride along inside the moving group, unflipped
209 `${ring}${hungryMark}</g></g>`
210 )
211}
212
213/** The animation a decoration plays: its sway where it has one, else its idle or closed frame. */
214function decorAnim(kind: string): string {
215 const frames = SPRITES[kind]?.frames ?? {}
216 return ['sway', 'idle', 'closed'].find(a => a in frames) ?? Object.keys(frames)[0] ?? 'idle'
217}
218
219/** Food falling from where the person clicked, then sinking to the sand. */
220function pelletMarkup(state: GameState, o: TankOptions): string {
221 let out = ''
222 for (const p of state.pellets) {
223 if (o.now - p.at >= PELLET_SHOW_MS) continue
224 const x = Math.max(4, Math.min(W - 4, p.x))
225 const y = Math.max(6, Math.min(FLOOR_Y - 4, p.y))
226 const ago = ((o.now - p.at) / 1000).toFixed(2)
227 const fall = o.isMotion
228 ? `<animateTransform attributeName="transform" type="translate" values="${x} 2;${x} ${y};${x} ${FLOOR_Y - 4}" keyTimes="0;0.2;1" dur="4s" begin="-${ago}s" fill="freeze" calcMode="spline" keySplines="0.3 0 0.7 1;0.2 0 0.8 1"/>` +
229 `<animate attributeName="opacity" values="1;1;0" keyTimes="0;0.35;1" dur="4s" begin="-${ago}s" fill="freeze"/>`
230 : ''
231 out += `<g transform="translate(${x} ${y})">${fall}<g transform="scale(2)">${animatedUse('pellet', 'idle', 1, -2, -2, false)}</g></g>`
232 }
233 return out
234}
235
236/** Where a fish's coin pile lies on the floor, in design pixels (its left edge). */
237export function pileX(fish: Fish, index: number, count: number, width = W): number {
238 const slot = (width - 60) / Math.max(count, 1)
239 return Math.round(24 + index * slot + hash01(fish.id, 16) * Math.max(0, slot - 52))
240}
241
242export type Pile = { fishId: string; name: string; x: number; coins: number }
243
244/** The coin piles on the floor, one per fish that has coins waiting, left to right. */
245export function piles(state: GameState, width: number): Pile[] {
246 const n = state.fish.length
247 return state.fish
248 .map((f, i) => ({ fishId: f.id, name: f.name, x: pileX(f, i, n, width), coins: f.pending }))
249 .filter(p => p.coins > 0)
250 .sort((a, b) => a.x - b.x)
251}
252
253/** The piles on the sand, and a coin falling from any fish that just spat. */
254function floorCoins(state: GameState, o: TankOptions): string {
255 if (!o.showCoins) return ''
256 let out = ''
257 const n = state.fish.length
258 state.fish.forEach((f, i) => {
259 if (f.pending <= 0) return
260 const x = pileX(f, i, n)
261 const shown = f.pending >= 30 ? 3 : f.pending >= 10 ? 2 : 1
262 for (let k = 0; k < shown; k++) {
263 const cx = x + k * 9
264 const cy = FLOOR_Y - 11 - (k === 1 ? 5 : 0)
265 const begin = -(hash01(f.id + k, 7) * 0.8).toFixed(2)
266 out += `<g transform="translate(${cx} ${cy})">${animatedUse('coin', 'spin', 0.9, 0, 0, o.isMotion, `begin="${begin}s"`)}</g>`
267 }
268 if (f.pending > 1) out += `<text x="${x + shown * 9 + 3}" y="${FLOOR_Y - 2}" font-family="monospace" font-size="8" fill="#ffe066" stroke="#1a1a2e" stroke-width="2" paint-order="stroke">×${f.pending}</text>`
269 // A coin that just left the fish drops to the pile.
270 if (f.state === 'spitCoin' && o.now - f.since < 15000 && o.isMotion) {
271 const laneTop = 18
272 const sprite = SPRITES[f.species]
273 const h = sprite?.h ?? 24
274 const laneBottom = FLOOR_Y - h - 6
275 const y = laneTop + ((i + 0.5) / Math.max(n, 1)) * (laneBottom - laneTop)
276 out += `<g transform="translate(${x} ${y.toFixed(1)})"><animateTransform attributeName="transform" type="translate" values="${x} ${y.toFixed(1)};${x} ${FLOOR_Y - 11}" dur="1.6s" begin="0.3s" fill="freeze" calcMode="spline" keySplines="0.3 0 0.7 1"/>${animatedUse('coin', 'spin', 0.5, 0, 0, true)}</g>`
277 }
278 })
279 return out
280}
281
282function decorMarkup(d: Decoration, o: TankOptions): string {
283 const sprite = SPRITES[d.kind]
284 if (!sprite) return ''
285 const anim = decorAnim(d.kind)
286 // Decorations are placed on the base width; they spread over a wider tank.
287 const x = Math.max(0, Math.min(W - sprite.w, (d.x * W) / BASE_W - sprite.w / 2))
288 // `y` is the baseline the sprite stands on (the sand at 150, or higher up the water).
289 const y = Math.max(0, Math.min(FLOOR_Y, d.y)) - sprite.h + 4
290 const dur = 1.2 + hash01(d.id, 8) * 0.8
291 return animatedUse(d.kind, anim, dur, x, y, o.isMotion)
292}
293
294function bubbles(o: TankOptions, seed: string): string {
295 if (!o.showBubbles) return ''
296 let out = ''
297 const count = Math.max(5, Math.round((7 * W) / BASE_W))
298 for (let i = 0; i < count; i++) {
299 const x = 20 + hash01(seed + i, 9) * (W - 40)
300 const r = 1 + hash01(seed + i, 10) * 2
301 const dur = 5 + hash01(seed + i, 11) * 5
302 const begin = (-(((o.now / 1000 + hash01(seed + i, 12) * dur) % dur + dur) % dur)).toFixed(2)
303 if (!o.isMotion) {
304 out += `<circle cx="${x.toFixed(1)}" cy="${(40 + hash01(seed + i, 13) * 100).toFixed(1)}" r="${r.toFixed(1)}" fill="none" stroke="#bfe9ff" stroke-opacity="0.6"/>`
305 continue
306 }
307 out += `<circle cx="${x.toFixed(1)}" cy="${FLOOR_Y}" r="${r.toFixed(1)}" fill="none" stroke="#bfe9ff" stroke-opacity="0.7"><animate attributeName="cy" values="${FLOOR_Y};6" dur="${dur.toFixed(1)}s" begin="${begin}s" repeatCount="indefinite"/><animate attributeName="cx" values="${x.toFixed(1)};${(x + 4).toFixed(1)};${x.toFixed(1)}" dur="2s" begin="${begin}s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.7;0.7;0" keyTimes="0;0.8;1" dur="${dur.toFixed(1)}s" begin="${begin}s" repeatCount="indefinite"/></circle>`
308 }
309 return out
310}
311
312function rays(theme: Theme, o: TankOptions): string {
313 const t = THEMES[theme]
314 let out = ''
315 const count = Math.max(2, Math.round(W / 80))
316 for (let i = 0; i < count; i++) {
317 const x = 40 + i * 70
318 const anim = o.isMotion ? `<animate attributeName="opacity" values="0.05;0.16;0.05" dur="${(4 + i).toFixed(0)}s" repeatCount="indefinite"/>` : ''
319 out += `<polygon points="${x},0 ${x + 26},0 ${x + 60},${FLOOR_Y} ${x + 10},${FLOOR_Y}" fill="${t.ray}" opacity="0.1">${anim}</polygon>`
320 }
321 return out
322}
323
324function sand(theme: Theme): string {
325 const t = THEMES[theme]
326 let dots = ''
327 const count = Math.round((10 * W) / BASE_W)
328 for (let i = 0; i < count; i++) {
329 const x = hash01('sand' + i, 14) * W
330 const y = FLOOR_Y + 4 + hash01('sand' + i, 15) * 22
331 dots += `<rect x="${x.toFixed(0)}" y="${y.toFixed(0)}" width="2" height="1" fill="${t.sandDark}"/>`
332 }
333 return `<rect x="0" y="${FLOOR_Y}" width="${W}" height="${TANK_H - FLOOR_Y}" fill="${t.sand}"/><rect x="0" y="${FLOOR_Y}" width="${W}" height="2" fill="${t.sandDark}" opacity="0.5"/>${dots}`
334}
335
336/** The whole tank. `widthPx` sizes the element; the drawing scales with it. */
337export function tankSvg(state: GameState, o: TankOptions): string {
338 W = Math.max(200, Math.round(o.width ?? BASE_W))
339 FISH_SCALE = o.fishScale ?? FISH_SCALES.m
340 const theme = state.settings.theme
341 const t = THEMES[theme]
342 const speciesShown = [...new Set(state.fish.map(f => f.species))]
343 const decorShown = [...new Set(state.decorations.map(d => d.kind))]
344 const defs =
345 speciesShown.map(s => symbols(s, 'swim') + (state.fish.some(f => f.species === s && f.state === 'spitCoin') ? symbols(s, 'spit') : '')).join('') +
346 decorShown.map(k => symbols(k, decorAnim(k))).join('') +
347 (o.showCoins && state.fish.some(f => f.pending > 0) ? symbols('coin', 'spin') : '') +
348 (state.pellets.some(p => o.now - p.at < PELLET_SHOW_MS) ? symbols('pellet', 'idle') : '')
349 const fish = laneOrder(state.fish).map((f, i) => fishMarkup(f, i, state.fish.length, o, state)).join('')
350 const decor = state.decorations.map(d => decorMarkup(d, o)).join('')
351 return (
352 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${TANK_H}" width="${W * 4}" height="${TANK_H * 4}" shape-rendering="crispEdges" style="image-rendering:pixelated">` +
353 `<defs><linearGradient id="water" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${t.top}"/><stop offset="1" stop-color="${t.bottom}"/></linearGradient><radialGradient id="glow"><stop offset="0" stop-color="#ffd166" stop-opacity="0.45"/><stop offset="1" stop-color="#ffd166" stop-opacity="0"/></radialGradient>${defs}</defs>` +
354 `<rect width="${W}" height="${TANK_H}" fill="url(#water)"/>` +
355 rays(theme, o) +
356 sand(theme) +
357 decor +
358 floorCoins(state, o) +
359 pelletMarkup(state, o) +
360 bubbles(o, state.createdAt.toString()) +
361 fish +
362 `<rect x="0.5" y="0.5" width="${W - 1}" height="${TANK_H - 1}" fill="none" stroke="#9ad9ff" stroke-opacity="0.35"/>` +
363 `</svg>`
364 )
365}
366
367/** One decoration, first frame, for the shop. */
368export function decorPortraitSvg(kind: string, px: number, tile = false): string {
369 const sprite = SPRITES[kind]
370 if (!sprite) return `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}"/>`
371 const anim = decorAnim(kind)
372 const frame = sprite.frames[anim]?.[0] ?? []
373 if (tile) return tileSvg(frame, sprite.w, sprite.h, px, Math.round(px * 0.72))
374 const scale = Math.min(px / sprite.w, px / sprite.h)
375 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${sprite.w} ${sprite.h}" width="${Math.round(sprite.w * scale)}" height="${Math.round(sprite.h * scale)}" shape-rendering="crispEdges">${framePaths(frame)}</svg>`
376}
377
378/** A sprite centred on a light rounded tile of `w` × `h` CSS pixels, for a shop card. */
379function tileSvg(frame: SpriteFrame, sw: number, sh: number, w: number, h: number): string {
380 const pad = 6
381 const scale = Math.min((w - pad * 2) / sw, (h - pad * 2) / sh)
382 const dw = sw * scale
383 const dh = sh * scale
384 const x = (w - dw) / 2
385 const y = (h - dh) / 2
386 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}"><rect width="${w}" height="${h}" rx="10" fill="#f3f1ec"/><g transform="translate(${x.toFixed(1)} ${y.toFixed(1)}) scale(${scale.toFixed(3)})" shape-rendering="crispEdges">${framePaths(frame)}</g></svg>`
387}
388
389/** One fish, first swim frame, for a card or a list row. */
390export function fishPortraitSvg(species: string, px: number, tile = false): string {
391 const sprite = SPRITES[species]
392 if (!sprite) return `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}"/>`
393 const frame = sprite.frames.swim?.[0] ?? []
394 if (tile) return tileSvg(frame, sprite.w, sprite.h, px, Math.round(px * 0.72))
395 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${sprite.w} ${sprite.h}" width="${px}" height="${Math.round((px * sprite.h) / sprite.w)}" shape-rendering="crispEdges">${framePaths(frame)}</svg>`
396}
397
398export const escapeSvgText = esc
399hooks/text.ts 245 lines1// Every word the person reads, in Traditional Chinese and English. The
2// language follows Claude's (the `language` setting, else the shell's LANG):
3// Chinese stays Chinese, anything else is English; the settings can pin one.
4import type { DecorId, GameEvent, GameState, RewardKind, Species } from '../game/types'
5import { DECOR, SPECIES } from '../game/species'
6
7export type Lang = 'zh' | 'en'
8
9type Pair = { zh: string; en: string }
10
11export const S = {
12 title: { zh: '🐠 水族箱', en: '🐠 Token Tank' },
13 loading: { zh: '水族箱載入中……', en: 'Loading the tank…' },
14 emptyTank: { zh: '(空魚缸)', en: '(an empty tank)' },
15 tankAlt: { zh: '魚缸:', en: 'Tank: ' },
16 accumulating: { zh: '魚還在累積能量', en: 'The fish are gathering energy' },
17 coinsOnFloor: { zh: '缸底有 {n} 枚金幣,點它收取', en: '{n} coins lie on the sand, tap them to collect' },
18 tapToFeed: { zh: '點空水處撒飼料(🍤 {n})', en: 'Tap open water to drop food (🍤 {n})' },
19 hungryCount: { zh: '❗ {n} 條魚餓了', en: '❗ {n} fish are hungry' },
20 feedAll: { zh: '🍤 餵全部', en: '🍤 Feed all' },
21 panel: { zh: '🐠 面板', en: '🐠 Panel' },
22 tabShop: { zh: '🛒 商店', en: '🛒 Shop' },
23 tabFish: { zh: '🐟 魚', en: '🐟 Fish' },
24 tabSettings: { zh: '⚙ 設定', en: '⚙ Settings' },
25 tabDecor: { zh: '🪸 裝飾', en: '🪸 Decor' },
26 move: { zh: '↔ 移動', en: '↔ Move' },
27 moving: { zh: '點魚缸任一處放置{name}', en: 'Tap the tank to place {name}' },
28 cancelMove: { zh: '取消', en: 'Cancel' },
29 noDecor: { zh: '魚缸裡還沒有裝飾,到商店買一些。', en: 'No decorations yet; the shop has some.' },
30 decorAt: { zh: '位置 {x}, {y}', en: 'at {x}, {y}' },
31 shopFish: { zh: '魚種', en: 'Fish' },
32 shopDecor: { zh: '裝飾', en: 'Decor' },
33 shopFood: { zh: '飼料', en: 'Food' },
34 buy: { zh: '購買', en: 'Buy' },
35 place: { zh: '放入', en: 'Place' },
36 lockedAt: { zh: '🔒 Lv.{n}', en: '🔒 Lv.{n}' },
37 moreLocked: { zh: '還有 {n} 種魚未解鎖,魚缸升級後出現。', en: '{n} more species unlock as the tank levels up.' },
38 mystery: { zh: '???', en: '???' },
39 rarity0: { zh: '普通', en: 'Common' },
40 slotCard: { zh: '魚槽 +1', en: 'Fish slot +1' },
41 slotSub: { zh: '魚槽 {n}/{max}', en: 'slots {n}/{max}' },
42 slotLocked: { zh: '🔒 Lv.{n} 開放', en: '🔒 at Lv.{n}' },
43 slotsMax: { zh: '已達上限', en: 'At the maximum' },
44 tankFull: { zh: '魚槽已滿', en: 'Slots full' },
45 lockedLv: { zh: '🔒 Lv.{n}', en: '🔒 Lv.{n}' },
46 release: { zh: '🕊 放生', en: '🕊 Release' },
47 releaseAsk: { zh: '確定放生{name}?不退款。', en: 'Release {name}? No refund.' },
48 yes: { zh: '確定', en: 'Yes' },
49 no: { zh: '取消', en: 'No' },
50 overQuota: { zh: '今日產幣已減速', en: 'coin rate reduced today' },
51 overQuotaBand: { zh: '有魚今日產幣額度用完,效率降低', en: 'some fish are past their daily quota; coins slow down' },
52 tankLv: { zh: '魚缸 Lv.{n}', en: 'Tank Lv.{n}' },
53 tankNext: { zh: '{have} / {need} 累計產幣', en: '{have} / {need} lifetime coins' },
54 tankMax: { zh: '最高等級', en: 'top level' },
55 weightShort: { zh: '權重 {r}', en: 'weight {r}' },
56 rarity1: { zh: '稀有', en: 'Rare' },
57 rarity2: { zh: '史詩', en: 'Epic' },
58 rarity3: { zh: '傳說', en: 'Legendary' },
59 rateShort: { zh: '產幣 {r}×', en: 'coins {r}×' },
60 notEnough: { zh: '金幣不足', en: 'Not enough coins' },
61 owned: { zh: '已放 {n} 個', en: '{n} placed' },
62 foodStock: { zh: '庫存 {n} 份', en: '{n} in stock' },
63 placed: { zh: '已放 {n}', en: '{n} placed' },
64 decorCount: { zh: '裝飾 {n}/{max}', en: 'Decorations {n}/{max}' },
65 foodLine: { zh: ' / 份 · 庫存 {n}', en: ' each · {n} in stock' },
66 foodWord: { zh: '飼料', en: 'Food' },
67 buyFood1: { zh: '買 1 份', en: 'Buy 1' },
68 buyFood10: { zh: '買 10 份', en: 'Buy 10' },
69 foodHelp: { zh: '餵食:飽食度 +30、心情 +5。飽食度越高,吐的金幣越多。', en: 'Feeding: hunger +30, mood +5. A fuller fish spits more coins.' },
70 noFishYet: { zh: '魚缸是空的,到商店買一條魚吧。', en: 'The tank is empty. Buy a fish in the shop.' },
71 rateLine: { zh: '產幣 ×{rate} · 缸底 {pending}/{cap}', en: 'coin rate ×{rate} · on the sand {pending}/{cap}' },
72 feed: { zh: '🍤 餵食', en: '🍤 Feed' },
73 nextCoin: { zh: '下一枚 {p}%', en: 'next coin {p}%' },
74 allFull: { zh: '魚都吃飽了(🍤 {n})', en: 'The fish are all full (🍤 {n})' },
75 levelUp: { zh: '⬆ 升級', en: '⬆ Level up' },
76 rename: { zh: '✏️ 改名', en: '✏️ Rename' },
77 renamePlaceholder: { zh: '新名字,Enter 確定(最多 12 字)', en: 'New name, Enter to confirm (12 characters max)' },
78 on: { zh: '開', en: 'On' },
79 off: { zh: '關', en: 'Off' },
80 theme: { zh: '主題', en: 'Theme' },
81 themeOcean: { zh: '經典海洋', en: 'Classic ocean' },
82 themeWreck: { zh: '沉船遺跡', en: 'Shipwreck' },
83 themeReef: { zh: '珊瑚花園', en: 'Coral garden' },
84 themeAbyss: { zh: '夜光深海', en: 'Glowing abyss' },
85 tankHeight: { zh: '魚缸高度', en: 'Tank height' },
86 sizeS: { zh: '矮', en: 'Short' },
87 sizeM: { zh: '中', en: 'Medium' },
88 sizeL: { zh: '高', en: 'Tall' },
89 language: { zh: '語言', en: 'Language' },
90 langAuto: { zh: '跟著 Claude', en: 'Follow Claude' },
91 langZh: { zh: '中文', en: '中文' },
92 langEn: { zh: 'English', en: 'English' },
93 setBand: { zh: '長任務時在輸入框上方出現魚缸', en: 'Show the tank above the prompt during long turns' },
94 setCoins: { zh: '顯示缸底金幣', en: 'Show coins on the sand' },
95 setBubbles: { zh: '泡泡效果', en: 'Bubbles' },
96 setMotion: { zh: '簡化動畫(低效能模式)', en: 'Reduced motion (low-power mode)' },
97 help: { zh: '點魚缸的空水處撒飼料,餓的魚會衝過去吃;點魚牠會逃開。/fish 顯示或收起魚缸;/fish collect 收全部金幣;/fish feed 餵全部。', en: 'Tap open water to drop food and the hungry fish rush to it; tap a fish and it darts off. /fish shows or hides the tank; /fish collect takes every coin; /fish feed feeds every fish.' },
98 stats: { zh: '📊 統計', en: '📊 Stats' },
99 statsToday: { zh: '今日 Token {t} · 產幣 {m} · 收集 {c} · 回合 {r}', en: 'Today: tokens {t} · minted {m} · collected {c} · turns {r}' },
100 statsTotal: { zh: '累計 Token {t} · 產幣 {m} · 收集 {c}', en: 'All time: tokens {t} · minted {m} · collected {c}' },
101 statsMore: { zh: '測試通過 {tests} · Commit {commits} · 修改 {edits} · 餵食 {feeds} · 魚冊 {dex}/{all}', en: 'Tests passed {tests} · commits {commits} · edits {edits} · feeds {feeds} · species {dex}/{all}' },
102 log: { zh: '📜 紀錄', en: '📜 Log' },
103 cmdDescription: { zh: '顯示或收起代幣魚缸;/fish panel 開面板、/fish collect 收金幣、/fish feed 餵魚', en: 'Show or hide the Token Tank; /fish panel opens the panel, /fish collect takes the coins, /fish feed feeds the fish' },
104 cmdCollected: { zh: '收了 {n} 枚金幣。', en: 'Collected {n} coins.' },
105 cmdNothing: { zh: '沒有金幣可以收。', en: 'No coins to collect.' },
106 cmdFed: { zh: '餵過了。', en: 'Fed.' },
107 cmdPanel: { zh: '面板打開了。', en: 'Panel opened.' },
108 cmdShown: { zh: '魚缸顯示在輸入框上方。再打一次 /fish 收起。', en: 'The tank is above the prompt. /fish again hides it.' },
109 cmdHidden: { zh: '魚缸收起了(長任務時會自己出現)。', en: 'The tank is hidden (it shows by itself during long turns).' },
110 turnNoCoins: { zh: 'Claude 用了 {t} 有效 Token,魚還在累積能量。', en: 'Claude used {t} effective tokens; the fish are gathering energy.' },
111 turnCoins: { zh: 'Claude 用了 {t} 有效 Token,魚吐出了 {n} 枚金幣!', en: 'Claude used {t} effective tokens and the fish spat {n} coins!' },
112 aFish: { zh: '魚', en: 'a fish' },
113 allFish: { zh: '全部的魚', en: 'all the fish' },
114 decorWord: { zh: '裝飾', en: 'a decoration' },
115} satisfies Record<string, Pair>
116
117export type Key = keyof typeof S
118
119/** The words for `key`, with `{name}` fields filled from `vars`. */
120export function tr(lang: Lang, key: Key, vars: Record<string, string | number> = {}): string {
121 let out: string = S[key][lang]
122 for (const [k, v] of Object.entries(vars)) out = out.split(`{${k}}`).join(String(v))
123 return out
124}
125
126export const speciesName = (sp: Species, lang: Lang) => (lang === 'zh' ? sp.zh : sp.en)
127export const decorName = (kind: DecorId, lang: Lang) => (lang === 'zh' ? DECOR[kind].zh : DECOR[kind].en)
128
129const fishName = (state: GameState, id: string | null, lang: Lang) =>
130 id ? state.fish.find(f => f.id === id)?.name ?? tr(lang, 'aFish') : tr(lang, 'allFish')
131
132export function describe(state: GameState, e: GameEvent, lang: Lang): string | null {
133 const zh = lang === 'zh'
134 switch (e.kind) {
135 case 'minted':
136 return zh ? `${fishName(state, e.fishId, lang)}吐出了 ${e.coins} 枚金幣!` : `${fishName(state, e.fishId, lang)} spat ${e.coins} coins!`
137 case 'collected':
138 return zh ? `你收集了 ${e.coins} 枚金幣。` : `You collected ${e.coins} coins.`
139 case 'dropped': {
140 const names = e.eaters.map(id => fishName(state, id, lang)).join(zh ? '、' : ', ')
141 if (e.eaters.length === 0) return zh ? '你撒了飼料,但沒有魚餓到想吃。' : 'You dropped food, but no fish was hungry enough.'
142 return zh ? `你撒了飼料,${names}衝過去吃了。` : `You dropped food and ${names} rushed to it.`
143 }
144 case 'startled':
145 return null
146 case 'moved': {
147 const kind = state.decorations.find(d => d.id === e.id)?.kind
148 const name = kind ? decorName(kind, lang) : tr(lang, 'decorWord')
149 return zh ? `你把${name}移到了新位置。` : `You moved ${name}.`
150 }
151 case 'fed':
152 if (e.fishId) return zh ? `你餵了${fishName(state, e.fishId, lang)}。` : `You fed ${fishName(state, e.fishId, lang)}.`
153 return zh ? `你餵了 ${e.count} 條魚。` : `You fed ${e.count} fish.`
154 case 'bought': {
155 if (e.what === 'fish') {
156 const sp = SPECIES[e.id as keyof typeof SPECIES]
157 const name = sp ? speciesName(sp, lang) : e.id
158 return zh ? `你買了一條${name},花了 ${e.price} 金幣。` : `You bought a ${name} for ${e.price} coins.`
159 }
160 if (e.what === 'decor') {
161 const kind = (e.id in DECOR ? e.id : state.decorations.find(d => d.id === e.id)?.kind) as DecorId | undefined
162 const name = kind ? decorName(kind, lang) : tr(lang, 'decorWord')
163 return zh ? `你放了${name}進魚缸,花了 ${e.price} 金幣。` : `You placed ${name} in the tank for ${e.price} coins.`
164 }
165 if (e.what === 'slot') return zh ? `你擴充了一格魚槽,花了 ${e.price} 金幣。` : `You added a fish slot for ${e.price} coins.`
166 return zh ? `你買了飼料,花了 ${e.price} 金幣。` : `You bought food for ${e.price} coins.`
167 }
168 case 'levelUp':
169 return zh ? `${fishName(state, e.fishId, lang)}升到 Lv.${e.level}!` : `${fishName(state, e.fishId, lang)} reached Lv.${e.level}!`
170 case 'renamed':
171 return zh ? `魚改名為「${e.name}」。` : `A fish is now called “${e.name}”.`
172 case 'reward':
173 return REWARD_TEXT[e.reward][lang](e.coins)
174 case 'hungry':
175 return zh ? `${fishName(state, e.fishId, lang)}餓了,游得慢了。` : `${fishName(state, e.fishId, lang)} is hungry and swims slowly.`
176 case 'refused':
177 return null
178 }
179}
180
181const REWARD_TEXT: Record<RewardKind, Record<Lang, (coins: number) => string>> = {
182 edit: { zh: coins => `Claude 改好了程式,魚缸多了 ${coins} 枚金幣。`, en: coins => `Claude edited the code: ${coins} coins for the tank.` },
183 test: { zh: coins => `測試通過!魚心情上升,魚缸多了 ${coins} 枚金幣。`, en: coins => `Tests passed! The fish cheer up and the tank earns ${coins} coins.` },
184 commit: { zh: coins => `Git commit 完成,魚缸多了 ${coins} 枚金幣。`, en: coins => `Git commit made: ${coins} coins for the tank.` },
185}
186
187const REFUSALS: Record<string, Pair> = {
188 'duplicate-turn': { zh: '這個回合已經算過了。', en: 'This turn was already counted.' },
189 'no-food': { zh: '沒有飼料了,到商店買一些。', en: 'No food left; buy some in the shop.' },
190 'nothing-to-collect': { zh: '現在沒有金幣可以收。', en: 'No coins to collect right now.' },
191 'not-enough-coins': { zh: '金幣不夠。', en: 'Not enough coins.' },
192 'tank-full': { zh: '魚槽滿了,升級魚缸或購買魚槽。', en: 'The slots are full; level the tank up or buy a slot.' },
193 'decor-full': { zh: '裝飾太多了(最多 8 個)。', en: 'Too many decorations (8 at most).' },
194 'locked': { zh: '這種魚還沒解鎖。', en: 'This species is locked.' },
195 'locked-level': { zh: '魚缸等級不夠,還不能買這種魚。', en: 'The tank level is too low for this species.' },
196 'slot-locked-level': { zh: '這個等級還不能再買魚槽。', en: 'No more slots can be bought at this level.' },
197 'slots-max': { zh: '魚槽已經是上限了。', en: 'The slots are at their maximum.' },
198 'duplicate-purchase': { zh: '剛剛已經買過了。', en: 'That was just bought.' },
199 'last-fish': { zh: '最後一條魚不能放生。', en: 'The last fish cannot be released.' },
200 'max-level': { zh: '已經是最高等級了。', en: 'Already at the top level.' },
201 'not-enough-xp': { zh: '經驗值還不夠升級。', en: 'Not enough XP to level up.' },
202 'no-such-fish': { zh: '找不到這條魚。', en: 'No such fish.' },
203 'bad-name': { zh: '名字要 1 到 12 個字。', en: 'A name is 1 to 12 characters.' },
204 'cooldown': { zh: '獎勵冷卻中。', en: 'The reward is cooling down.' },
205 'no-fish': { zh: '魚缸裡沒有魚。', en: 'There are no fish in the tank.' },
206 'empty-name': { zh: '名字不能是空的(最多 12 個字)。', en: 'The name cannot be empty (12 characters max).' },
207 'unknown-species': { zh: '沒有這種魚。', en: 'No such species.' },
208 'unknown-decor': { zh: '沒有這種裝飾。', en: 'No such decoration.' },
209 'unknown-reward': { zh: '沒有這種獎勵。', en: 'No such reward.' },
210 'bad-count': { zh: '數量不對。', en: 'Bad count.' },
211 'bad-position': { zh: '位置不對。', en: 'Bad position.' },
212 'all-full': { zh: '魚都吃飽了,飼料留著。', en: 'The fish are all full; the food is kept.' },
213}
214
215export function refusalText(reason: string, lang: Lang): string {
216 return REFUSALS[reason]?.[lang] ?? (lang === 'zh' ? `做不到:${reason}` : `Cannot: ${reason}`)
217}
218
219export function clock(at: number): string {
220 const d = new Date(at)
221 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
222}
223
224export function moodLine(state: GameState, fishId: string, lang: Lang): string {
225 const f = state.fish.find(x => x.id === fishId)
226 if (!f) return ''
227 const zh = lang === 'zh'
228 if (f.hunger < 25) return zh ? '肚子好餓,游不太動……餵我一點吧。' : 'So hungry I can hardly swim… feed me a little.'
229 if (f.state === 'happy') return zh ? '今天很有精神!剛剛因為測試通過特別開心。' : 'Full of energy today! The tests passing made me happy.'
230 if (f.state === 'spitCoin') return zh ? '剛剛吐出了金幣,記得收走喔。' : 'I just spat a coin; remember to pick it up.'
231 if (f.mood >= 85) return zh ? '心情超好,游來游去停不下來。' : 'In a great mood, swimming all over the place.'
232 if (f.mood >= 60) return zh ? '心情不錯,正在悠哉地巡視魚缸。' : 'Feeling fine, patrolling the tank at leisure.'
233 return zh ? '有點無聊,Claude 多做點事吧。' : 'A bit bored; Claude, do some more work.'
234}
235
236/** Claude's language: Chinese when the `language` setting or LANG says so, else English. */
237export function langOf(setting: string | undefined, envLang: string | undefined): Lang {
238 const s = (setting ?? '').toLowerCase()
239 if (/中文|chinese|^zh|zh[-_]/.test(s)) return 'zh'
240 if (s.trim() !== '') return 'en'
241 return /^zh/i.test(envLang ?? '') ? 'zh' : 'en'
242}
243
244export const fmt = (n: number) => Math.round(n).toLocaleString('en-US')
245game/day.ts 28 lines1// The local calendar day, and rolling the `today*` counters over when it changes.
2
3import type { GameState } from './types'
4
5/** YYYY-MM-DD in the local time zone. */
6export function dayKey(now: number): string {
7 const d = new Date(now)
8 const p = (n: number) => (n < 10 ? '0' + n : String(n))
9 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
10}
11
12/** Zero the `today*` counters when `now` falls on another day than `stats.todayKey`. */
13export function rollDay(state: GameState, now: number): GameState {
14 const key = dayKey(now)
15 if (state.stats.todayKey === key) return state
16 return {
17 ...state,
18 stats: {
19 ...state.stats,
20 todayKey: key,
21 todayEffectiveTokens: 0,
22 todayCoinsMinted: 0,
23 todayCoinsCollected: 0,
24 todayTurns: 0,
25 },
26 }
27}
28game/economy.config.ts 65 lines1// Economy v0.3 tuning: every number the balance depends on, in one place.
2// The shop, the purchase checks, the engine and scripts/simulate-economy.ts all
3// read this object; change a value here and re-run the simulator.
4
5import type { Rarity, SpeciesId } from './types'
6
7/** Bump when a value below changes; saves record the version they last ran under. */
8export const ECONOMY_CONFIG_VERSION = 1
9
10export type SpeciesEconomy = { rarity: Rarity; price: number; unlockLevel: number }
11
12export const ECONOMY = {
13 /** effectiveTokens = Σ field × weight (turn.complete usage; the fields do not overlap). */
14 tokenWeights: { input: 1, output: 2, cacheRead: 0.02, cacheWrite: 1.25 },
15 /** energy = (min(eff, knee) + max(eff − knee, 0) × overKnee) / tokensPerEnergy */
16 energy: { knee: 15_000, overKnee: 0.1, tokensPerEnergy: 2_500 },
17
18 /** Base share weight of one fish; production_i = energy × w_i / √Σw. */
19 rarityWeight: { common: 1, rare: 3, epic: 10, legendary: 32 } as Record<Rarity, number>,
20 /** Raw coins a fish produces per local day at the full rate; past it, overQuotaFactor applies. */
21 dailyQuota: { common: 120, rare: 360, epic: 960, legendary: 1_920 } as Record<Rarity, number>,
22 overQuotaFactor: 0.2,
23 /** Fish levels (xp-gated, bought with coins): production ×(1 + boostPerLevel·(lv − 1)); cost costPerLevel × lv × rarity weight. */
24 fishLevel: { boostPerLevel: 0.05, costPerLevel: 50 },
25 /** Allowed price band per rarity (checked by the tests). */
26 priceBand: { common: [80, 300], rare: [500, 1_500], epic: [2_000, 5_000], legendary: [8_000, 15_000] } as Record<Rarity, readonly [number, number]>,
27
28 species: {
29 guppy: { rarity: 'common', price: 80, unlockLevel: 1 },
30 neon: { rarity: 'common', price: 100, unlockLevel: 1 },
31 clownfish: { rarity: 'common', price: 120, unlockLevel: 1 },
32 danio: { rarity: 'common', price: 150, unlockLevel: 2 },
33 swordtail: { rarity: 'common', price: 180, unlockLevel: 2 },
34 goldfish: { rarity: 'common', price: 220, unlockLevel: 3 },
35 betta: { rarity: 'common', price: 280, unlockLevel: 4 },
36 tang: { rarity: 'rare', price: 500, unlockLevel: 5 },
37 puffer: { rarity: 'rare', price: 550, unlockLevel: 5 },
38 butterflyfish: { rarity: 'rare', price: 650, unlockLevel: 6 },
39 angelfish: { rarity: 'rare', price: 750, unlockLevel: 7 },
40 koi: { rarity: 'rare', price: 900, unlockLevel: 8 },
41 seahorse: { rarity: 'rare', price: 1_050, unlockLevel: 8 },
42 oscar: { rarity: 'rare', price: 1_200, unlockLevel: 9 },
43 discus: { rarity: 'epic', price: 2_000, unlockLevel: 10 },
44 mandarin: { rarity: 'epic', price: 2_400, unlockLevel: 11 },
45 lionfish: { rarity: 'epic', price: 2_900, unlockLevel: 12 },
46 moorishidol: { rarity: 'epic', price: 3_500, unlockLevel: 14 },
47 parrotfish: { rarity: 'epic', price: 4_200, unlockLevel: 15 },
48 arowana: { rarity: 'legendary', price: 8_000, unlockLevel: 18 },
49 mola: { rarity: 'legendary', price: 12_000, unlockLevel: 18 },
50 } as Record<SpeciesId, SpeciesEconomy>,
51
52 /**
53 * Lifetime produced coins to reach tank level n is tankLevels[n − 1].
54 * Anchors: Lv.1 0, Lv.5 640, Lv.10 3,200, Lv.18 11,500; the rest interpolated, strictly increasing.
55 */
56 tankLevels: [0, 100, 250, 430, 640, 950, 1_350, 1_850, 2_450, 3_200, 4_000, 4_900, 5_900, 7_000, 8_150, 9_350, 10_400, 11_500] as readonly number[],
57 /** Base fish slots at tank level n is baseSlots[n − 1] (Lv.1 3, Lv.5 5, Lv.10 7, Lv.18 10). */
58 baseSlots: [3, 3, 4, 4, 5, 5, 5, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 10] as readonly number[],
59 maxSlots: 10,
60 /** Price of the n-th bought slot is slotPrices[n − 1]. */
61 slotPrices: [300, 1_200, 3_000] as readonly number[],
62 /** How many slots may be bought by tank level: [fromLevel, total buyable] pairs, ascending. */
63 slotsBuyableFrom: [[1, 1], [5, 2], [10, 3]] as readonly (readonly [number, number])[],
64} as const
65