SLOPSHOPPER

Modster Hunter

Catch Modsters while you wait for Claude.

newpanebandcommandtimer
★ 1v0.0.1MITupdated 2026-10-09danielpg95/modster-hunter/plugin
A shopper browsing a rack in a slop shop
README

Modster Hunter

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.

Install (after the first release)

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.

Contributing

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.

License

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).

Source 29 files
hooks/register.tsx 428 lines
1import 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}
428
hooks/constants.ts 73 lines
1// 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
73
hooks/content/index.ts 9 lines
1export { 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'
9
hooks/game/index.ts 19 lines
1export {
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'
19
hooks/render/index.ts 4 lines
1export { 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'
4
hooks/store/index.ts 5 lines
1export { 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'
5
hooks/content/check-biome-references.ts 20 lines
1import { 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}
20
hooks/content/issue-list.ts 43 lines
1import 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}
43
hooks/content/load-content.ts 153 lines
1import { 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}
153
hooks/content/sprite-from-sheet.ts 91 lines
1import { 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}
91
hooks/content/types.ts 66 lines
1// 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[] }
66
hooks/content/validate-biome.ts 88 lines
1import { 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