SLOPSHOPPER

token-tank

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.

newpanebandguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-tank
│ ┃ token-tank ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ 🐠 Token Tank Tank Lv.1 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ━─────────────────── 6 / 100 lifetime coins ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 🪙 152 🍤 10 🐟 2/3 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 🛒 Shop 🐟 Fish 🪸 Decor ⚙ Settings │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ The reward is cooling down. │ ┃ Fish Decor Food ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭──────────────────────╮ ╭───────────────── › /fish │ ┃ │ 🐟 │ │ 🐟 ⎿ token-tank: The tank is above the prompt. /fish again hides it. │ ┃ │ Guppy │ │ Neon Tetra │ ┃ │ Common · weight 1 │ │ Common · weight │ ┃ │ 🪙 80 │ │ 🪙 100 │ ┃ │ Buy › │ │ Buy › │ ┃ ╰──────────────────────╯ ╰───────────────── │ ┃ │ ┃ 16 more species unlock as the tank levels │ ┃ up. │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ <>< │ │ ><> │ │ 🪙×3 🪙×3 │ │ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 🪙 152 6 coins lie on the sand, tap them to collect The fish are all full (🍤 10) The reward is cooling down. 🐠 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ <>< │ │ ><> │ │ 🪙×3 🪙×3 │ │ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 🪙 152 6 coins lie on the sand, tap them to collect The fish are all full (🍤 10) The reward is co ⟨Claude Code's own drawing⟩
Pane · token-tank
🐠 Token Tank Tank Lv.1 ━─────────────────── 6 / 100 lifetime coins 🪙 152 🍤 10 🐟 2/3 🛒 Shop 🐟 Fish 🪸 Decor ⚙ Settings The reward is cooling down. Fish Decor Food ╭──────────────────────╮ ╭──────────────────────╮ ╭──────── │ 🐟 │ │ 🐟 │ │ │ Guppy │ │ Neon Tetra │ │ Cl │ Common · weight 1 │ │ Common · weight 1 │ │ Common │ 🪙 80 │ │ 🪙 100 │ │ │ Buy › │ │ Buy › │ │ ╰──────────────────────╯ ╰──────────────────────╯ ╰──────── 16 more species unlock as the tank levels up.
README

Token Tank 代幣魚缸

The tank above the prompt while Claude works, pillbar beneath it

Buy the fish some food on Ko-fi

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.

Install

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.

Use

  • /fish opens the pane. /fish collect collects every pending coin and /fish feed feeds every fish without opening it.
  • In the pane: pick a fish by name to see its card (hunger, mood, XP), then feed, collect, rename or level it up. 收全部 / 餵全部 act on the whole tank.
  • 裝飾 lists the decorations in the tank: press 移動 on one, then tap the tank where it should stand; the spot is kept in the save.
  • 商店 sells fish (more unlock as the tank grows), decorations and food. 魚冊 is the species you have owned, 統計 the token and coin figures, 設定 the theme and the animation switches (turn on 簡化動畫 on a slow machine).

How coins are made

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.

Where it is stored

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.

Surfaces

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.

Develop

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.

Support

If the fish made you smile, you can buy them some food: ko-fi.com/sheldonchang. The mod stays free and open either way.

Source 15 files
hooks/register.tsx 939 lines
1// 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}
939
game/types.ts 7 lines
1// 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'
7
game/economy.ts 241 lines
1// 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}
241
game/progression.ts 81 lines
1// 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}
81
game/fish.ts 189 lines
1// 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}
189
game/species.ts 76 lines
1// 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}
76
game/shop.ts 126 lines
1// 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}
126
game/state.ts 323 lines
1// 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}
323
render/svg.ts 399 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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
399
hooks/text.ts 245 lines
1// 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')
245
game/day.ts 28 lines
1// 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}
28
game/economy.config.ts 65 lines
1// 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