Catch Modsters while you wait for Claude.

Catch Modsters while you wait for Claude.
Modster Hunter is a Claude Code mod: an idle collecting game that lives in your terminal. Each session drops you into a random biome. While Claude works on your request, wild Modsters appear just above the prompt. Press 1 to throw, and you have a few tries to catch each one before it gets away. Type /modsters to browse your collection.
You can keep the built-in biomes and Modsters, switch them off, or make your own, sprites included.
Status: in planning. Nothing is playable yet. See the roadmap for where things stand.
claude plugin marketplace add danielpg95/modster-hunter
claude plugin install modster-hunter@modster-hunter
Requires Claude Code v2.1.287 or later, in a terminal. Desktop app support is planned.
Contributions are welcome, especially pixel art. Start with CONTRIBUTING.md. If you use Claude Code, open the repo and it will pick up CLAUDE.md and the contributor skills; start with the start-session skill.
Code is under the MIT License. Built-in art and sounds are under CC BY 4.0, credited per asset in plugin/content/CREDITS.md (decision 0011).
hooks/register.tsx 428 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { BAND } from './constants'
3import { formatIssue, loadContent, type ContentReader, type ContentRegistry, type LoadedModster } from './content'
4import {
5 pickBiome,
6 startEncounters,
7 stepEncounter,
8 type EncounterContext,
9 type EncounterInput,
10 type EncounterState,
11} from './game'
12import { bandView, spriteCells, type BandEncounter, type BandLine } from './render'
13import { recordEncounterEvents, recordStat, type StorePort } from './store'
14
15// Rebuilt at every session start; cheap, so it isn't kept in $.state (ARCHITECTURE.md)
16let content: ContentRegistry | undefined
17// The session's biome (decision 0006). /clear, /resume and /branch don't start a
18// new process or reload the module, so it survives them as a module variable.
19let biomeId: string | undefined
20
21// The encounter loop (decisions 0005, 0014). Not persisted: a reload or a new
22// session drops an encounter in progress without counting it (0005).
23let machine: EncounterState | undefined
24let machineContext: EncounterContext | undefined
25let tickTimer: Timer | undefined
26
27// Collection and stats (decision 0009). Writes run one after another so this
28// session never races itself; each one re-reads its key first.
29let sessionId = 'unknown'
30let pendingWrites: Promise<void> = Promise.resolve()
31
32// Sprite animation: each mounted Raster is repainted in place with $.ui.blit (0012).
33// The band and the encounter pane (0015) are "sites"; each frame is blitted to every
34// site drawing the sprite right now.
35const SPRITE_KEY = 'sprite'
36const cellsByModster = new Map<string, string[]>()
37const spriteSites = new Set<string>() // requestIds
38let frameTimer: Timer | undefined
39let frameModster: LoadedModster | undefined
40let frameIndex = 0
41let isBlitting = false
42
43// The opt-in encounter pane (decision 0015), opened with `/modsters hunt`
44const PANE_ID = 'modster-hunt'
45
46export const register: Register = (on, options) => {
47 const idleTimeoutSec = typeof options.encounterIdleTimeoutSec === 'number' ? options.encounterIdleTimeoutSec : 90
48 const showIdleLine = options.showIdleLine !== false
49
50 on('session.start', async ($, e, next) => {
51 content = await loadBuiltInContent($)
52 sessionId = await $.session.id().catch(() => 'unknown')
53 // Keep the biome if session.start ever repeats in this process; pick only when there's none yet
54 if (biomeId === undefined || !content.biomes.has(biomeId)) biomeId = pickBiome(content.biomes.keys(), Math.random)
55 startMachine($, idleTimeoutSec)
56 // Register last: a taken name throws and would skip the rest of this hook
57 await $.command.register({ name: 'modsters', description: 'Open your Modster collection' })
58 return next(e)
59 })
60
61 on('turn.start', async ($, e, next) => {
62 await advanceMachine($, 'turnStart')
63 const at = await $.clock.now()
64 queueWrite($, (store) => recordStat(store, sessionId, { kind: 'turn', at }))
65 return next(e)
66 })
67
68 on('turn.complete', async ($, e, next) => {
69 await advanceMachine($, 'turnEnd')
70 return next(e)
71 })
72
73 on('session.end', async ($, e, next) => {
74 stopTimers()
75 // Let queued writes land, within the 1.5 s session.end budget (ARCHITECTURE.md)
76 await Promise.race([pendingWrites, $.clock.sleep(1000)])
77 return next(e)
78 })
79
80 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
81 // A survey owns the band while it's up
82 if (e.props.hasSurvey) return next(e)
83 // While the encounter pane is on screen the band steps aside, so the same
84 // encounter isn't drawn twice (0015)
85 if (await isPaneShown($)) {
86 removeSite(e.requestId)
87 return next(e)
88 }
89 const encounter = machine?.encounter
90 const modster = encounter && content?.modsters.get(encounter.modsterId)
91 const biome = biomeId === undefined ? undefined : content?.biomes.get(biomeId)?.biome
92
93 const band: BandEncounter | undefined =
94 encounter && modster
95 ? {
96 phase: encounter.phase,
97 name: modster.modster.name,
98 tier: encounter.tier,
99 attemptsLeft: encounter.attemptsLeft,
100 spriteWidth: modster.sprite.width,
101 spriteHeight: modster.sprite.height,
102 ...(encounter.outcome ? { outcome: encounter.outcome } : {}),
103 ...(encounter.fledBecause ? { fledBecause: encounter.fledBecause } : {}),
104 }
105 : undefined
106 const view = bandView({
107 maxRows: e.props.maxRows,
108 columns: e.props.bodyColumns,
109 // Raster is terminal only (0012; Svg for the desktop app is P5-06)
110 canDrawSprite: e.surface === 'terminal',
111 showIdleLine,
112 ...(band ? { encounter: band } : {}),
113 ...(biome ? { biome: biome.accentColor ? { name: biome.name, accentColor: biome.accentColor } : { name: biome.name } } : {}),
114 })
115
116 if (view.kind !== 'full') removeSite(e.requestId)
117 if (view.kind === 'none') return next(e)
118
119 const { Box, Text, Button } = $.ui.resolve(e)
120 const line = (segments: BandLine, index: number) => (
121 <Box key={`line-${index}`} flexDirection="row">
122 {segments.map((segment, at) =>
123 'button' in segment ? (
124 <Button
125 key="throw"
126 label="Throw"
127 hotkey="1"
128 plain
129 onPress={() => {
130 void advanceMachine($, 'throw')
131 }}
132 />
133 ) : (
134 <Text
135 key={`text-${at}`}
136 wrap="truncate"
137 {...(segment.bold ? { bold: true } : {})}
138 {...(segment.dim ? { dimColor: true } : {})}
139 {...(segment.color ? { color: segment.color } : {})}
140 >
141 {segment.text}
142 </Text>
143 ),
144 )}
145 </Box>
146 )
147
148 if (view.kind === 'idle') return line(view.line, 0)
149 if (view.kind === 'compact') return <Box flexDirection="column">{view.lines.map(line)}</Box>
150
151 // Full layout: the sprite, then the text block beside it. Only chosen on the
152 // terminal (canDrawSprite); the check narrows the surface so Raster resolves
153 if (e.surface !== 'terminal') return next(e)
154 const { Raster } = $.ui.resolve(e)
155 const cells = modster ? cellsFor(modster) : []
156 if (modster) addSite($, e.requestId, modster)
157 return (
158 <Box flexDirection="row" columnGap={BAND.gapColumns}>
159 <Raster key={SPRITE_KEY} columns={view.spriteColumns} rows={view.spriteRows} cells={cells[frameIndex] ?? cells[0] ?? ''} />
160 <Box flexDirection="column">{view.lines.map(line)}</Box>
161 </Box>
162 )
163 })
164
165 // The same encounter in a pane the person opens (0015). It docks beside the
166 // transcript in fullscreen from 110 columns, else sits inline above the prompt.
167 on('ui.render', { component: 'Pane' }, ($, e, next) => {
168 if (e.requestId !== PANE_ID) return next(e)
169 const { Box, Text, Button } = $.ui.resolve(e)
170 const encounter = machine?.encounter
171 const modster = encounter && content?.modsters.get(encounter.modsterId)
172 const biome = biomeId === undefined ? undefined : content?.biomes.get(biomeId)?.biome
173 const rows = Math.max(0, e.props.scroll.bodyRows - 2) // header and a blank line
174 const columns = e.props.bodyColumns
175
176 const header = (
177 <Box key="header" flexDirection="row">
178 <Text bold {...(biome?.accentColor ? { color: biome.accentColor } : {})}>
179 {biome ? biome.name : 'No biome'}
180 </Text>
181 <Text dimColor>{machine?.turnRunning ? ' · Claude is working' : ' · waiting for work'}</Text>
182 </Box>
183 )
184 if (!encounter || !modster) {
185 removeSite(e.requestId)
186 return (
187 <Box flexDirection="column">
188 {header}
189 <Text key="gap"> </Text>
190 <Text key="idle" dimColor>
191 Listening for Modsters… they appear while Claude works.
192 </Text>
193 </Box>
194 )
195 }
196
197 // The sprite's normal size, as in the band: 2x looked far too big in a real terminal (0015)
198 const fits = () => Math.ceil(modster.sprite.height / 2) <= rows && modster.sprite.width + BAND.gapColumns + BAND.textColumns <= columns
199 const view = bandView({
200 maxRows: rows,
201 columns,
202 canDrawSprite: e.surface === 'terminal' && fits(),
203 showIdleLine: false,
204 encounter: {
205 phase: encounter.phase,
206 name: modster.modster.name,
207 tier: encounter.tier,
208 attemptsLeft: encounter.attemptsLeft,
209 spriteWidth: modster.sprite.width,
210 spriteHeight: modster.sprite.height,
211 ...(encounter.outcome ? { outcome: encounter.outcome } : {}),
212 ...(encounter.fledBecause ? { fledBecause: encounter.fledBecause } : {}),
213 },
214 })
215 const line = (segments: BandLine, index: number) => (
216 <Box key={`line-${index}`} flexDirection="row">
217 {segments.map((segment, at) =>
218 'button' in segment ? (
219 <Button
220 key="throw"
221 label="Throw"
222 hotkey="1"
223 onPress={() => {
224 void advanceMachine($, 'throw')
225 }}
226 />
227 ) : (
228 <Text
229 key={`text-${at}`}
230 wrap="truncate"
231 {...(segment.bold ? { bold: true } : {})}
232 {...(segment.dim ? { dimColor: true } : {})}
233 {...(segment.color ? { color: segment.color } : {})}
234 >
235 {segment.text}
236 </Text>
237 ),
238 )}
239 </Box>
240 )
241 if (view.kind !== 'full' || e.surface !== 'terminal') {
242 removeSite(e.requestId)
243 const lines = view.kind === 'compact' ? view.lines : []
244 return (
245 <Box flexDirection="column">
246 {header}
247 {lines.map(line)}
248 </Box>
249 )
250 }
251 const { Raster } = $.ui.resolve(e)
252 const cells = cellsFor(modster)
253 addSite($, e.requestId, modster)
254 return (
255 <Box flexDirection="column">
256 {header}
257 <Text key="gap"> </Text>
258 <Box key="encounter" flexDirection="row" columnGap={BAND.gapColumns}>
259 <Raster key={SPRITE_KEY} columns={view.spriteColumns} rows={view.spriteRows} cells={cells[frameIndex] ?? cells[0] ?? ''} />
260 <Box flexDirection="column">{view.lines.map(line)}</Box>
261 </Box>
262 </Box>
263 )
264 })
265
266 // When the pane closes, the band takes the encounter back (0015)
267 on('ui.close', async ($, e, next) => {
268 const result = await next(e)
269 $.ui.invalidate('ui.render')
270 return result
271 })
272
273 on('command.run', { command: 'modsters' }, async ($, e) => {
274 // `/modsters hunt` opens the encounter pane; the mod never opens it by itself (0002, 0015)
275 const verb = typeof e.args === 'string' ? e.args.trim() : ''
276 if (verb === 'hunt') {
277 const opened = await $.ui.open({ id: PANE_ID, title: 'Modster Hunter' })
278 // The band redraws without the encounter now that the pane shows it
279 $.ui.invalidate('ui.render')
280 return opened.isPlaced ? {} : { text: 'Modster Hunter: the pane is waiting for more room (widen the terminal)' }
281 }
282 // Until the collection pane (P3-01), the command says where you are
283 const biome = biomeId === undefined ? undefined : content?.biomes.get(biomeId)?.biome
284 return { text: biome ? `Modster Hunter is loaded · You're in ${biome.name}` : 'Modster Hunter is loaded · No biomes yet' }
285 })
286}
287
288function cellsFor(loaded: LoadedModster): string[] {
289 let cells = cellsByModster.get(loaded.modster.id)
290 if (!cells) {
291 cells = spriteCells(loaded.sprite)
292 cellsByModster.set(loaded.modster.id, cells)
293 }
294 return cells
295}
296
297// Functions that take `$` live in this file: the engine follows `$` only into
298// functions declared in the same file, never across an import (decision 0013).
299
300/** Sets up the encounter machine for the session's biome and starts its tick. */
301function startMachine($: EngineInterface, idleTimeoutSec: number): void {
302 stopTimers()
303 machine = undefined
304 machineContext = undefined
305 cellsByModster.clear()
306 const biome = biomeId === undefined ? undefined : content?.biomes.get(biomeId)?.biome
307 if (!content || !biome) return
308 machineContext = {
309 biome,
310 modsters: new Map([...content.modsters].map(([id, loaded]) => [id, loaded.modster])),
311 random: Math.random,
312 idleTimeoutMs: idleTimeoutSec * 1000,
313 }
314 machine = startEncounters(machineContext)
315 // Timers start in session.start, never at module top level (mod-code rule)
316 tickTimer = $.clock.every(BAND.tickMs, () => {
317 void advanceMachine($, 'tick')
318 })
319}
320
321/** Steps the machine at the clock's now and redraws the band when anything changed. */
322async function advanceMachine($: EngineInterface, type: EncounterInput['type']): Promise<void> {
323 if (!machine || !machineContext) return
324 const now = await $.clock.now()
325 const step = stepEncounter(machine, { type, now }, machineContext)
326 if (step.state === machine) return
327 machine = step.state
328 $.ui.invalidate('ui.render')
329 const where = { sessionId, biomeId: machineContext.biome.id }
330 if (step.events.length > 0) queueWrite($, (store) => recordEncounterEvents(store, where, step.events, now))
331}
332
333/** Runs a store write after the ones before it; a failure is logged, never thrown at the game. */
334function queueWrite($: EngineInterface, write: (store: StorePort) => Promise<void>): void {
335 const store: StorePort = {
336 get: (key) => $.store.get(key),
337 set: (key, value) => $.store.set(key, value),
338 }
339 pendingWrites = pendingWrites
340 .then(() => write(store))
341 .catch((error: unknown) => {
342 $.ui.log(`store write failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
343 })
344}
345
346/** Notes that `requestId` draws the sprite, and keeps the frame loop running for it. */
347function addSite($: EngineInterface, requestId: string, loaded: LoadedModster): void {
348 spriteSites.add(requestId)
349 if (frameModster?.modster.id !== loaded.modster.id) {
350 frameIndex = 0
351 frameModster = loaded
352 frameTimer?.cancel()
353 frameTimer = undefined
354 }
355 if (frameTimer || loaded.sprite.frames.length < 2) return
356 frameTimer = $.clock.every(Math.max(1, Math.round(1000 / (loaded.modster.sprite.fps ?? 6))), () => {
357 const current = frameModster
358 // Skip a tick while the last blits are in flight (P1-03)
359 if (!current || isBlitting || spriteSites.size === 0) return
360 isBlitting = true
361 frameIndex = (frameIndex + 1) % current.sprite.frames.length
362 const blits = [...spriteSites].map((site) =>
363 $.ui.blit({ requestId: site, key: SPRITE_KEY, cells: cellsFor(current)[frameIndex] ?? '' }).catch(() => undefined),
364 )
365 void Promise.all(blits).finally(() => {
366 isBlitting = false
367 })
368 })
369}
370
371/** Whether our encounter pane is open, placed and the shown tab. Unknown counts as no. */
372async function isPaneShown($: EngineInterface): Promise<boolean> {
373 try {
374 return (await $.ui.panes()).some((pane) => pane.id === PANE_ID && pane.isShown && pane.isPlaced)
375 } catch {
376 return false
377 }
378}
379
380function removeSite(requestId: string): void {
381 spriteSites.delete(requestId)
382 if (spriteSites.size === 0) stopFrames()
383}
384
385function stopFrames(): void {
386 frameTimer?.cancel()
387 frameTimer = undefined
388 frameModster = undefined
389 spriteSites.clear()
390}
391
392function stopTimers(): void {
393 tickTimer?.cancel()
394 tickTimer = undefined
395 stopFrames()
396}
397
398/**
399 * Loads `plugin/content/` (P2-03; user content joins in P4-01). Problems go to
400 * the debug log, not the transcript; the Settings tab will list them (P4-02).
401 */
402async function loadBuiltInContent($: EngineInterface): Promise<ContentRegistry> {
403 const started = performance.now()
404 const registry = await loadContent(fsReader($), `${$.plugin.root}/content`)
405 const ms = Math.round(performance.now() - started)
406 for (const issue of registry.issues) $.ui.log(formatIssue(issue), { to: 'debug' })
407 const summary = `${registry.biomes.size} biomes, ${registry.modsters.size} Modsters, ${registry.issues.length} issues`
408 $.ui.log(`content: ${summary} in ${ms} ms`, { to: 'debug' })
409 return registry
410}
411
412/** `$.fs` behind the loader's reader: a missing folder or file is absent, not an error. */
413function fsReader($: EngineInterface): ContentReader {
414 return {
415 async listFolders(path) {
416 if (!(await $.fs.exists(path))) return []
417 const entries = await $.fs.list(path)
418 return entries.filter((entry) => entry.kind === 'dir').map((entry) => entry.name)
419 },
420 async readText(path) {
421 if (!(await $.fs.exists(path))) return undefined
422 const text = await $.fs.read(path)
423 // Without `{ as: 'bytes' }` a read is always text
424 return typeof text === 'string' ? text : undefined
425 },
426 }
427}
428hooks/constants.ts 73 lines1// Tunable numbers, in one place. Each group names the decision or doc it comes from.
2
3/**
4 * Rarity tiers, decision 0004 point 1, checked in this order. A Modster whose
5 * share of its biome's total weight is at least `minPercent` gets the tier;
6 * the defaults apply unless the biome entry or the Modster overrides them.
7 */
8export const TIERS = [
9 { tier: 'common', minPercent: 20, maxAttempts: 3, catchRate: 0.5 },
10 { tier: 'uncommon', minPercent: 5, maxAttempts: 3, catchRate: 0.35 },
11 { tier: 'rare', minPercent: 1, maxAttempts: 4, catchRate: 0.2 },
12 { tier: 'legendary', minPercent: 0, maxAttempts: 5, catchRate: 0.08 },
13] as const
14
15/** Encounter phase lengths, decision 0014. The idle timeout is the `encounterIdleTimeoutSec` user option. */
16export const ENCOUNTER = {
17 appearingMs: 1000,
18 throwingMs: 1500,
19 resultMs: 4000,
20} as const
21
22/** Band layout, decision 0012: the full layout needs sprite + gap + text block in the band's width. */
23export const BAND = {
24 gapColumns: 2,
25 textColumns: 24,
26 /** How often the encounter machine is stepped (timers resolve to within this) */
27 tickMs: 250,
28} as const
29
30/** Content file bounds: docs/CONTENT_FORMAT.md (schema version 1), decisions 0004, 0007 and 0012. */
31export const CONTENT = {
32 schemaVersion: 1,
33 idPattern: /^[a-z][a-z0-9-]{1,31}$/,
34 /** A file name with no folder part, ending in `.sprite.json` (CONTENT_FORMAT "in this folder"). */
35 spriteFilePattern: /^[^/\\]+\.sprite\.json$/,
36 rgbColorPattern: /^#[0-9a-fA-F]{6}$/,
37 rgbaColorPattern: /^#[0-9a-fA-F]{8}$/,
38 descriptionMaxChars: 120,
39 biome: {
40 nameMaxChars: 32,
41 encounterEverySecMin: 3,
42 encounterEverySecMax: 600,
43 defaultEncounterEverySec: [10, 30] as const,
44 modstersMin: 1,
45 modstersMax: 50,
46 weightMin: 1,
47 weightMax: 10_000,
48 },
49 modster: {
50 // Has to fit in the band
51 nameMaxChars: 24,
52 fpsMin: 1,
53 fpsMax: 12,
54 defaultFps: 6,
55 },
56 // Decision 0004 point 5
57 maxAttemptsMin: 1,
58 maxAttemptsMax: 10,
59 catchRateMin: 0.01,
60 catchRateMax: 1,
61 sprite: {
62 // Decision 0012 point 5: fits an 80×24 band with a row to spare
63 widthMin: 8,
64 widthMax: 24,
65 heightMin: 8,
66 heightMax: 12,
67 paletteMin: 1,
68 paletteMax: 64,
69 framesMin: 1,
70 framesMax: 8,
71 },
72} as const
73hooks/content/index.ts 9 lines1export { checkBiomeReferences } from './check-biome-references'
2export { formatIssue } from './issue-list'
3export { loadContent, type ContentReader, type ContentRegistry, type LoadedBiome, type LoadedModster } from './load-content'
4export { spriteFromSheet, type RgbaImage } from './sprite-from-sheet'
5export type { Biome, BiomeModsterEntry, ContentIssue, Modster, Rarity, Sprite, Validation } from './types'
6export { validateBiome } from './validate-biome'
7export { validateModster } from './validate-modster'
8export { validateSprite } from './validate-sprite'
9hooks/game/index.ts 19 lines1export {
2 startEncounters,
3 stepEncounter,
4 workTime,
5 type Encounter,
6 type EncounterContext,
7 type EncounterEvent,
8 type EncounterInput,
9 type EncounterPhase,
10 type EncounterState,
11 type EncounterStep,
12} from './encounter-machine'
13export { formatOddsTable, oddsTable, type OddsRow } from './odds-table'
14export { pickBiome } from './pick-biome'
15export { pickWeighted } from './pick-weighted'
16export type { RandomSource } from './random-source'
17export { rarityTier } from './rarity-tier'
18export { resolveCatchOdds, type CatchOdds } from './resolve-catch-odds'
19hooks/render/index.ts 4 lines1export { bandRows, bandView, type BandEncounter, type BandInput, type BandLine, type BandSegment, type BandView } from './band-view'
2export { DEFAULT_COLOR, pixelsToCells, type Frame } from './pixels-to-cells'
3export { spriteCells } from './sprite-cells'
4hooks/store/index.ts 5 lines1export { addCatch, caughtKey, readCaughtRecord, type Catch, type CaughtRecord } from './caught-record'
2export { recordEncounterEvents, recordStat } from './record-encounter-events'
3export { addToStats, readSessionStats, statsKey, type BiomeStats, type SessionStats, type StatsEvent } from './session-stats'
4export type { StorePort } from './store-port'
5hooks/content/check-biome-references.ts 20 lines1import { fieldPath } from './fields'
2import { IssueList } from './issue-list'
3import type { Biome, Validation } from './types'
4
5/**
6 * Runs after merging (decision 0007): drops biome entries whose Modster
7 * doesn't exist, an error for that entry only (CONTENT_FORMAT.md). The biome
8 * itself is dropped only when no entries are left.
9 */
10export function checkBiomeReferences(biome: Biome, modsterIds: ReadonlySet<string>, file: string): Validation<Biome> {
11 const issues = new IssueList(file)
12 const modsters = biome.modsters.filter((entry, index) => {
13 if (modsterIds.has(entry.id)) return true
14 issues.error(fieldPath(fieldPath('modsters', index), 'id'), `names a Modster that doesn't exist: "${entry.id}"`)
15 return false
16 })
17 if (modsters.length === 0) return issues.fail('modsters', 'has no Modsters left that exist')
18 return { ok: true, value: { ...biome, modsters }, issues: issues.items }
19}
20hooks/content/issue-list.ts 43 lines1import type { ContentIssue, Validation } from './types'
2
3/** Collects the issues for one file while a validator walks it. */
4export class IssueList {
5 readonly items: ContentIssue[] = []
6 readonly file: string
7
8 // A plain field, not a parameter property: tools/ load this file with Node's type stripping, which has no parameter properties
9 constructor(file: string) {
10 this.file = file
11 }
12
13 error(field: string, problem: string): void {
14 this.items.push({ severity: 'error', file: this.file, field, problem })
15 }
16
17 warning(field: string, problem: string): void {
18 this.items.push({ severity: 'warning', file: this.file, field, problem })
19 }
20
21 get hasErrors(): boolean {
22 return this.items.some((issue) => issue.severity === 'error')
23 }
24
25 /** Not `ok`, for input too broken to walk any further. */
26 fail(field: string, problem: string): Validation<never> {
27 this.error(field, problem)
28 return { ok: false, issues: this.items }
29 }
30
31 /** `ok` with `value` when nothing is an error; the issues come along either way. */
32 result<T>(value: T): Validation<T> {
33 return this.hasErrors ? { ok: false, issues: this.items } : { ok: true, value, issues: this.items }
34 }
35}
36
37/** One line for logs and the Settings tab: `biomes/forest/biome.json: modsters[1].weight must be …` */
38export function formatIssue(issue: ContentIssue): string {
39 const where = issue.field ? `${issue.file}: ${issue.field}` : issue.file
40 const tag = issue.severity === 'warning' ? ' (warning)' : ''
41 return `${where} ${issue.problem}${tag}`
42}
43hooks/content/load-content.ts 153 lines1import { checkBiomeReferences } from './check-biome-references'
2import type { Biome, ContentIssue, Modster, Sprite, Validation } from './types'
3import { validateBiome } from './validate-biome'
4import { validateModster } from './validate-modster'
5import { validateSprite } from './validate-sprite'
6
7/** The file access the loader needs; the adapter backs it with `$.fs`, tests with a map. */
8export interface ContentReader {
9 /** Names of the folders inside `path`, or [] when `path` doesn't exist */
10 listFolders(path: string): Promise<string[]>
11 /** The file's text, or undefined when it doesn't exist. May reject (e.g. over 4 MiB). */
12 readText(path: string): Promise<string | undefined>
13}
14
15export interface LoadedModster {
16 modster: Modster
17 sprite: Sprite
18}
19
20export interface LoadedBiome {
21 /** Entries naming a Modster that didn't load are already dropped */
22 biome: Biome
23 background?: Sprite
24}
25
26/** Everything that loaded, by id, and every problem found on the way. */
27export interface ContentRegistry {
28 biomes: Map<string, LoadedBiome>
29 modsters: Map<string, LoadedModster>
30 issues: ContentIssue[]
31}
32
33/**
34 * Loads a content folder (docs/CONTENT_FORMAT.md layout): Modsters with their
35 * sprites first, then biomes, whose entries must name a loaded Modster. A bad
36 * file is skipped with its issues listed; this never rejects (decision 0007).
37 */
38export async function loadContent(reader: ContentReader, root: string): Promise<ContentRegistry> {
39 const issues: ContentIssue[] = []
40
41 const modsterFolders = await listFolders(reader, root, 'modsters', issues)
42 const modsters = new Map<string, LoadedModster>()
43 for (const loaded of await Promise.all(modsterFolders.map((folder) => loadModster(reader, root, folder, issues)))) {
44 if (loaded) modsters.set(loaded.modster.id, loaded)
45 }
46
47 const biomeFolders = await listFolders(reader, root, 'biomes', issues)
48 const biomes = new Map<string, LoadedBiome>()
49 const modsterIds = new Set(modsters.keys())
50 for (const loaded of await Promise.all(biomeFolders.map((folder) => loadBiome(reader, root, folder, modsterIds, issues)))) {
51 if (loaded) biomes.set(loaded.biome.id, loaded)
52 }
53
54 return { biomes, modsters, issues }
55}
56
57async function loadModster(
58 reader: ContentReader,
59 root: string,
60 folder: string,
61 issues: ContentIssue[],
62): Promise<LoadedModster | undefined> {
63 const file = `modsters/${folder}/modster.json`
64 const json = await readJson(reader, root, file, issues)
65 const modster = json && keep(validateModster(json.value, { file, folder }), issues)
66 if (!modster) return undefined
67
68 const spriteFile = `modsters/${folder}/${modster.sprite.file}`
69 const spriteJson = await readJson(reader, root, spriteFile, issues)
70 const sprite = spriteJson && keep(validateSprite(spriteJson.value, { file: spriteFile }), issues)
71 if (!sprite) {
72 // A Modster can't appear without its sprite, so it's skipped too
73 issues.push(issueFor(file, 'sprite.file', `points to a sprite that didn't load: ${spriteFile}`))
74 return undefined
75 }
76 return { modster, sprite }
77}
78
79async function loadBiome(
80 reader: ContentReader,
81 root: string,
82 folder: string,
83 modsterIds: ReadonlySet<string>,
84 issues: ContentIssue[],
85): Promise<LoadedBiome | undefined> {
86 const file = `biomes/${folder}/biome.json`
87 const json = await readJson(reader, root, file, issues)
88 const validated = json && keep(validateBiome(json.value, { file, folder }), issues)
89 if (!validated) return undefined
90 const biome = keep(checkBiomeReferences(validated, modsterIds, file), issues)
91 if (!biome) return undefined
92
93 if (biome.background === undefined) return { biome }
94 const backgroundFile = `biomes/${folder}/${biome.background}`
95 const backgroundJson = await readJson(reader, root, backgroundFile, issues)
96 const background = backgroundJson && keep(validateSprite(backgroundJson.value, { file: backgroundFile, singleFrame: true }), issues)
97 if (background) return { biome, background }
98 // The background is optional: the biome still loads, drawn without it
99 const { background: _dropped, ...withoutBackground } = biome
100 return { biome: withoutBackground }
101}
102
103/** The parsed JSON, or undefined after reporting why the file can't be used. */
104async function readJson(
105 reader: ContentReader,
106 root: string,
107 file: string,
108 issues: ContentIssue[],
109): Promise<{ value: unknown } | undefined> {
110 let text: string | undefined
111 try {
112 text = await reader.readText(`${root}/${file}`)
113 } catch (error) {
114 issues.push(issueFor(file, '', `could not be read: ${errorMessage(error)}`))
115 return undefined
116 }
117 if (text === undefined) {
118 issues.push(issueFor(file, '', 'is missing'))
119 return undefined
120 }
121 try {
122 return { value: JSON.parse(text) as unknown }
123 } catch (error) {
124 issues.push(issueFor(file, '', `is not valid JSON: ${errorMessage(error)}`))
125 return undefined
126 }
127}
128
129async function listFolders(reader: ContentReader, root: string, kind: string, issues: ContentIssue[]): Promise<string[]> {
130 try {
131 const names = await reader.listFolders(`${root}/${kind}`)
132 // Hidden folders (.git, editor state) aren't content; sorted so the load order never depends on the disk
133 return names.filter((name) => !name.startsWith('.')).sort()
134 } catch (error) {
135 issues.push(issueFor(`${kind}/`, '', `could not be listed: ${errorMessage(error)}`))
136 return []
137 }
138}
139
140/** Collects a validator's issues and returns its value when it passed. */
141function keep<T>(result: Validation<T>, issues: ContentIssue[]): T | undefined {
142 issues.push(...result.issues)
143 return result.ok ? result.value : undefined
144}
145
146function issueFor(file: string, field: string, problem: string): ContentIssue {
147 return { severity: 'error', file, field, problem }
148}
149
150function errorMessage(error: unknown): string {
151 return error instanceof Error ? error.message : String(error)
152}
153hooks/content/sprite-from-sheet.ts 91 lines1import { CONTENT } from '../constants'
2import { IssueList } from './issue-list'
3import type { Sprite, Validation } from './types'
4import { validateSprite } from './validate-sprite'
5
6/** Decoded image pixels: RGBA, 4 bytes per pixel, row by row. */
7export interface RgbaImage {
8 width: number
9 height: number
10 data: ArrayLike<number>
11}
12
13// Terminal cells can't blend, so alpha is cut in two (decision 0012, CONTENT_FORMAT "Alpha")
14const ALPHA_CUTOFF = 128
15const TRANSPARENT = '#00000000'
16
17/**
18 * Turns a sprite sheet (frames side by side, equal width) into a `.sprite.json`
19 * value (decision 0008). Index 0 is transparent; opaque colors follow in the
20 * order they first appear. The result is checked with `validateSprite`, so an
21 * `ok` result is always a valid file. Problems come back as issues with a hint.
22 */
23export function spriteFromSheet(sheet: RgbaImage, frameCount: number, file: string): Validation<Sprite> {
24 const issues = new IssueList(file)
25 const { sprite: bounds } = CONTENT
26
27 if (!Number.isInteger(frameCount) || frameCount < bounds.framesMin || frameCount > bounds.framesMax) {
28 return issues.fail('frames', `must be ${bounds.framesMin}–${bounds.framesMax} frames (asked for ${frameCount})`)
29 }
30 if (sheet.width % frameCount !== 0) {
31 return issues.fail(
32 'frames',
33 `can't split a ${sheet.width} px wide sheet into ${frameCount} equal frames; frames sit side by side, so the width must be a multiple of the frame count`,
34 )
35 }
36 const width = sheet.width / frameCount
37 const height = sheet.height
38 if (width < bounds.widthMin || width > bounds.widthMax || height < bounds.heightMin || height > bounds.heightMax || height % 2 !== 0) {
39 return issues.fail(
40 '',
41 `frames are ${width}×${height} px; they must be ${bounds.widthMin}–${bounds.widthMax} wide and ${bounds.heightMin}–${bounds.heightMax} tall with an even height (decision 0012). Resize the art or check --frames`,
42 )
43 }
44
45 const palette = [TRANSPARENT]
46 const indexOf = new Map<string, number>([[TRANSPARENT, 0]])
47 const frames: string[] = []
48 for (let frame = 0; frame < frameCount; frame++) {
49 const indexes = new Uint8Array(width * height)
50 for (let y = 0; y < height; y++) {
51 for (let x = 0; x < width; x++) {
52 const at = (y * sheet.width + frame * width + x) * 4
53 const color = colorAt(sheet.data, at)
54 let index = indexOf.get(color)
55 if (index === undefined) {
56 index = palette.length
57 palette.push(color)
58 indexOf.set(color, index)
59 }
60 // Past the limit: keep counting colors for the message, but don't write a bad index
61 indexes[y * width + x] = index < 256 ? index : 0
62 }
63 }
64 frames.push(encodeBase64(indexes))
65 }
66
67 if (palette.length > bounds.paletteMax) {
68 return issues.fail(
69 'palette',
70 `the art uses ${palette.length - 1} opaque colors; at most ${bounds.paletteMax - 1} fit (plus transparent). Reduce the colors (built-in Modsters use 16 or fewer)`,
71 )
72 }
73
74 const sprite: Sprite = { schemaVersion: 1, width, height, palette, frames }
75 return validateSprite(sprite, { file })
76}
77
78/** `#rrggbbaa` of the pixel at byte offset `at`, with alpha snapped to 00 or ff. */
79function colorAt(data: ArrayLike<number>, at: number): string {
80 const alpha = data[at + 3] ?? 0
81 if (alpha < ALPHA_CUTOFF) return TRANSPARENT
82 const hex = (value: number | undefined): string => (value ?? 0).toString(16).padStart(2, '0')
83 return `#${hex(data[at])}${hex(data[at + 1])}${hex(data[at + 2])}ff`
84}
85
86function encodeBase64(bytes: Uint8Array): string {
87 let binary = ''
88 for (const byte of bytes) binary += String.fromCharCode(byte)
89 return btoa(binary)
90}
91hooks/content/types.ts 66 lines1// Shapes of the content files in docs/CONTENT_FORMAT.md (schema version 1).
2// A value of these types has passed its validator; fields keep the file's own
3// shape, and defaults (e.g. `encounterEverySec`) are applied by the code that reads them.
4
5export type Rarity = 'common' | 'uncommon' | 'rare' | 'legendary'
6
7export interface BiomeModsterEntry {
8 id: string
9 weight: number
10 maxAttempts?: number
11 catchRate?: number
12}
13
14export interface Biome {
15 schemaVersion: 1
16 id: string
17 name: string
18 description?: string
19 accentColor?: string
20 background?: string
21 encounterEverySec?: [number, number]
22 modsters: BiomeModsterEntry[]
23}
24
25export interface Modster {
26 schemaVersion: 1
27 id: string
28 name: string
29 description?: string
30 rarity?: Rarity | null
31 maxAttempts?: number | null
32 catchRate?: number | null
33 shinyChance?: number | null
34 sprite: { file: string; fps?: number }
35}
36
37export interface Sprite {
38 schemaVersion: 1
39 width: number
40 height: number
41 /** `#rrggbbaa` colors; frames index into this */
42 palette: string[]
43 shinyPalette?: string[]
44 /** base64 of width × height palette indexes, one byte each, row by row */
45 frames: string[]
46}
47
48/**
49 * One problem found in a content file. An `error` drops what it names (the
50 * whole file, or one biome entry for a missing Modster); a `warning` is
51 * reported but the content still loads.
52 */
53export interface ContentIssue {
54 severity: 'error' | 'warning'
55 /** The file's path as the loader names it, e.g. `modsters/sproutling/modster.json` */
56 file: string
57 /** Path to the field, e.g. `modsters[2].weight`; empty for the file as a whole */
58 field: string
59 problem: string
60}
61
62/** A validator's answer: never thrown, always a list of issues (possibly empty). */
63export type Validation<T> =
64 | { ok: true; value: T; issues: ContentIssue[] }
65 | { ok: false; issues: ContentIssue[] }
66hooks/content/validate-biome.ts 88 lines1import { CONTENT } from '../constants'
2import {
3 checkKeys,
4 checkNumber,
5 checkNumberValue,
6 checkPattern,
7 checkSchemaVersion,
8 checkString,
9 fieldPath,
10 isObject,
11} from './fields'
12import { IssueList } from './issue-list'
13import type { Biome, Validation } from './types'
14
15const KEYS = ['schemaVersion', 'id', 'name', 'description', 'accentColor', 'background', 'encounterEverySec', 'modsters']
16const REQUIRED = ['schemaVersion', 'id', 'name', 'modsters']
17const ENTRY_KEYS = ['id', 'weight', 'maxAttempts', 'catchRate']
18const ENTRY_REQUIRED = ['id', 'weight']
19
20/**
21 * Checks a parsed `biome.json` against CONTENT_FORMAT.md. `folder` is the name
22 * of the folder it was read from, which must equal its `id`. Whether each
23 * listed Modster exists is checked after merging, by `checkBiomeReferences`.
24 */
25export function validateBiome(input: unknown, where: { file: string; folder: string }): Validation<Biome> {
26 const issues = new IssueList(where.file)
27 if (!isObject(input)) return issues.fail('', 'must be a JSON object')
28
29 checkKeys(input, KEYS, REQUIRED, issues)
30 checkSchemaVersion(input, CONTENT.schemaVersion, issues)
31 const id = checkPattern(input, 'id', CONTENT.idPattern, 'a lowercase id like "whispering-forest"', issues)
32 if (id !== undefined && id !== where.folder) {
33 issues.error('id', `must match its folder name "${where.folder}" (it is "${id}")`)
34 }
35 checkString(input, 'name', { min: 1, max: CONTENT.biome.nameMaxChars }, issues)
36 checkString(input, 'description', { min: 0, max: CONTENT.descriptionMaxChars }, issues)
37 checkPattern(input, 'accentColor', CONTENT.rgbColorPattern, 'a color like "#4caf50"', issues)
38 checkPattern(input, 'background', CONTENT.spriteFilePattern, 'a .sprite.json file in this folder', issues)
39 checkEncounterEverySec(input.encounterEverySec, issues)
40 checkEntries(input.modsters, issues)
41
42 return issues.result(input as unknown as Biome)
43}
44
45function checkEncounterEverySec(value: unknown, issues: IssueList): void {
46 if (value === undefined) return
47 const { encounterEverySecMin: low, encounterEverySecMax: high } = CONTENT.biome
48 const expected = `[min, max] seconds with ${low} ≤ min ≤ max ≤ ${high}`
49 if (!Array.isArray(value) || value.length !== 2) {
50 issues.error('encounterEverySec', `must be ${expected}`)
51 return
52 }
53 const rule = { min: low, max: high }
54 const min = checkNumberValue(value[0], rule, 'encounterEverySec[0]', issues)
55 const max = checkNumberValue(value[1], rule, 'encounterEverySec[1]', issues)
56 if (typeof min === 'number' && typeof max === 'number' && min > max) {
57 issues.error('encounterEverySec', `min must not be above max (it is [${min}, ${max}])`)
58 }
59}
60
61function checkEntries(value: unknown, issues: IssueList): void {
62 if (value === undefined) return
63 const { modstersMin, modstersMax } = CONTENT.biome
64 if (!Array.isArray(value) || value.length < modstersMin || value.length > modstersMax) {
65 issues.error('modsters', `must be a list of ${modstersMin}–${modstersMax} Modsters`)
66 return
67 }
68 const seen = new Map<string, number>()
69 value.forEach((entry: unknown, index) => {
70 const prefix = fieldPath('modsters', index)
71 if (!isObject(entry)) {
72 issues.error(prefix, 'must be an object like { "id": "sproutling", "weight": 60 }')
73 return
74 }
75 checkKeys(entry, ENTRY_KEYS, ENTRY_REQUIRED, issues, prefix)
76 const id = checkPattern(entry, 'id', CONTENT.idPattern, 'a lowercase Modster id', issues, prefix)
77 if (id !== undefined) {
78 const first = seen.get(id)
79 if (first === undefined) seen.set(id, index)
80 else issues.error(fieldPath(prefix, 'id'), `repeats "${id}" (already at modsters[${first}])`)
81 }
82 const { weightMin, weightMax } = CONTENT.biome
83 checkNumber(entry, 'weight', { min: weightMin, max: weightMax, integer: true }, issues, prefix)
84 checkNumber(entry, 'maxAttempts', { min: CONTENT.maxAttemptsMin, max: CONTENT.maxAttemptsMax, integer: true }, issues, prefix)
85 checkNumber(entry, 'catchRate', { min: CONTENT.catchRateMin, max: CONTENT.catchRateMax }, issues, prefix)
86 })
87}
88