SLOPSHOPPER

codyssey

Claude Code, played as an adventure: generative music that follows the agent's mood (/miod), a pixel knight who fights a monster for every edit and command…

newbandrowsguardcommandtoast
v0.1.0MITupdated 2026-10-09delexw/codyssey
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codyssey
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ YOU │ │ │ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⏺ Read(src/auth.ts) ⎿ Read 6 lines ✓ ⚒ FORGE Edit · …/app/src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Done. I made refresh reject expired claims and added an audit call. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM › /knightod ⎿ codyssey: knightod on. Lv 1 · ♥♥♥♥♡ · 5 slain · 21 gold · 1 quests · played 0m · 0 of 5 bosses slain · fell 0 times. Use ⌂ Lv 1 · ♥♥♥♥♡ · 5 slain · 21 gold · progress saved ▣ client module ./strip.tsx ♪ fixed ▣ client module ./wave.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⌂ Lv 1 · ♥♥♥♥♡ · 5 slain · 21 gold · progress saved ▣ client module ./strip.tsx ♪ fixed ▣ client module ./wave.tsx ⟨Claude Code's own drawing⟩
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ YOU │ │ │ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Claude's reply
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Done. I made refresh reject expired claims and added an audit call. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Tool row
✓ ⚒ FORGE Edit · …/app/src/auth.ts
README

<img src="docs/codyssey.png" alt="codyssey logo: an amber shield with a sword whose crossguard is a pair of code brackets, a scroll and a music note" width="200">

<h1 align="center">codyssey</h1>

[!IMPORTANT] miod's music only works on macOS.

I'm an engineer, but these days I solve the boring problems entirely by vibe coding. Somewhere along the way I felt I had lost the creativity and curiosity I used to have when writing code myself.

So I started thinking about how to make vibe coding fun again. codyssey turns a Claude Code session into a little adventure: while the agent does the work, you hear it as music, watch a pixel knight fight through it, and read it back as a quest log.

It's three mods in one plugin, and each one turns on and off on its own.

What it is

Three Claude Code mods in one plugin.

miod: the soundtrack

Generative music written on the spot while Claude works. No music files, no library, no AI model.

  • Every task picks its own flavour (lo-fi, chiptune, ambient, jazzy and 8 more), key, chord progression, tempo, sound and theme.
  • The mood follows what the agent is doing: reading sounds airy, editing bright, testing focused, shipping triumphant, failing tense until a command passes.
  • The faster it spends tokens, the busier the music.
  • Above the prompt, one line shows the mood, flavour, key, tempo and energy, with a wave under it that scrolls with the music:

miod's status line and moving wave

knightod: the hero

A pixel knight walks a narrow strip above the prompt while Claude works.

  • Every tool call is an encounter: reading finds a scroll, editing a file fights its monster (slime, goblin, skeleton, orc, spider), web searches raise bats and ghosts, and subagents send a skeleton.
  • A failed command costs a heart. A test that passes after a failure summons the Bug Dragon.
  • Committing, pushing or opening a PR opens a treasure chest.
  • Every two minutes of play a boss comes: King Slime, Ogre, Lich, Demon, then the Shadow King.
  • Monsters and bosses grow tougher as the knight levels up.
  • Kills bring gold and xp, and xp brings levels. Progress carries from task to task within a session; a new session starts a new knight. The knight makes camp while you're away.

The knight fighting through a task

What you can meet on the road:

When Claude…The knight meetsWhat it brings
Reads or searches files (Read, Grep, Glob)A scrollHeals 1 heart
Edits or writes a fileA Slime, Goblin, Skeleton, Orc or Spider (the file name picks which)1 xp and 1 to 3 gold, plus 1 xp and 1 gold for each extra heart
Searches or fetches the webA Bat or GhostSame as any monster
Runs a commandA Goblin or WolfSame as any monster
Hands work to a subagentA SkeletonSame as any monster
Calls an MCP toolA SlimeSame as any monster
Runs a command that failsA hitLoses 1 heart
Runs a passing test or check after a failureThe Bug Dragon, 4 hearts2 xp and 5 gold or more per heart
Commits, pushes, merges, tags, or opens or merges a PRA treasure chest5 to 24 gold
Has been playing for 2, 4, 6, 8, then 10 minutesKing Slime (3 hearts), Ogre (4), Lich (5), Demon (6), Shadow King (7)2 xp and 5 gold or more per heart

The knight picks a weapon at random for every strike:

WeaponRangePowerHow oftenHow it hits
⚔️ SwordClose1 heart, or 2 with a specialOftenA slash. Now and then it becomes a glowing yellow special
🪓 AxeClose2 heartsSometimesA heavy chop
🔱 LanceA little further1 heartSometimesReaches the monster a few steps early
🗡️ DaggerFar1 heartSometimesThrown early, flies to the monster
🔥 FireballFurthest2 heartsRarelyCast from far off, flies to the monster

Thrown weapons only open a fight. Once a monster has been hit and is coming in close, the knight switches to the sword, axe or lance. Each weapon is one file in hooks/knightod/game/weapons/, with its range, power and how often it's picked.

How the game works:

MechanismHow it works
❤️ HeartsThe knight starts with 5. Every monster and boss shows one heart and the number it has left beside it, like ♥3. Past 5 hearts, the knight's line shows the count too, like ♥7/12.
📈 Monsters level upMonsters start with 1 heart and gain one every 3 knight levels. Bosses gain one every 2 knight levels, on top of their own.
⭐ LevelsEach level needs 8 more xp than the last. A new level adds a heart and heals the knight fully.
👹 Boss fightsA boss swings back and can wound the knight, but never lands the last blow. Some of the knight's strikes are specials that hit for 2.
💀 FallingLosing the last heart to a failed command brings the knight back with full hearts and half the gold.
⚡ SpeedThe knight walks faster the quicker Claude spends tokens: normal, then double from 30k tokens a minute, then triple from 80k.
📜 ProgressLevel, hearts, kills, gold, quests, play time, bosses slain and falls add up from task to task, for the rest of the session. A new session starts a new knight. Between tasks the knight sits by a campfire.
⏸️ PausingIf Claude ends its turn while a background command or subagent is still running, the knight shows ⏸ paused and stays on the road. When the last one finishes, the knight makes camp.

scrollod: the quest log

Restyles the transcript so the story is easier to follow.

  • Tool calls become one-liners, and their results show only when they fail.
  • Claude's replies stand out from the tool noise.
  • Two styles. game (the default): each tool call wears a badge (☰ SCOUT, ⚒ FORGE, ⚔ FIGHT, ☄ MAGIC, ♞ ALLY, ⚗ POTION), Claude speaks from a nameplate, your prompts sit in a ◆ YOU box, and finished background tasks arrive as ⚑ QUEST rows. plain: the same quiet transcript without the badges.

The transcript in the game style

Install

Needs Claude Code 2.1.290 or newer.

claude plugin marketplace add delexw/codyssey
claude plugin install codyssey@codyssey

Then start a new session. All three mods start on.

If you loaded miod, knightod or scrollod on their own before (from delexw/miod, or a folder in CLAUDE_CODE_PLUGIN_DIRS), remove those first, or their commands are registered twice.

Pick your mods

All three come in the one plugin, and each turns on and off with its own command:

  • Turn one off: /miod off, /knightod off or /scrollod off, and on to bring it back. The choice is remembered in later sessions.
  • Game style: scrollod starts in the game style. Switch to the plain look with /scrollod style plain; it stays plain in later sessions until you pick /scrollod style game.

Usage

/miod                    is the music on or off?
/miod off                stop the music
/miod on                 play again from the next task
/miod wave               list the wave looks: bars, line, mirror, dots, pulse
/miod wave dots          switch the wave to the dots look

/knightod                this session's game: level, hearts, kills, gold, bosses
/knightod off            put the knight away
/knightod on             send the knight out again
/knightod reset          start over with a new knight

/scrollod                on or off, and which style
/scrollod off            show the transcript as Claude Code draws it
/scrollod on             restyle it again
/scrollod style plain    the quieter plain look
/scrollod style game     back to badges, nameplates and quest rows

Add your own

Each mod lives in its own folder under hooks/, and hooks/register.tsx turns all three on.

  • miod: each mood is one row in hooks/miod/moods.ts, each flavour one row in hooks/miod/flavours.ts, each sound style one entry in hooks/miod/styles.ts, and each wave look one file in hooks/miod/waves/.
  • knightod: each weapon is one file in hooks/knightod/game/weapons/; monsters and bosses are sprites in hooks/knightod/sprites/; which tool raises which encounter is in hooks/knightod/game/cues.ts, and the boss order and timing in hooks/knightod/game/bosses.ts.
  • scrollod: the game style's badges are in hooks/scrollod/badge.ts, and the styles in hooks/scrollod/style.ts.

Then check it:

claude plugin validate .
claude plugin test .

Run a checkout without installing it with claude --plugin-dir <path to this repo>.

Limitations

  • miod's music only plays on macOS, where Claude Code plays it through afplay.
  • miod's sound is simple chiptune-style tones.
  • codyssey only sees the session it's loaded in.
Source 87 files
hooks/register.tsx 12 lines
1import type { Register } from 'claude-code'
2
3import { registerKnightod } from './knightod/register'
4import { registerMiod } from './miod/register'
5import { registerScrollod } from './scrollod/register'
6
7export const register: Register = (on, options) => {
8  registerKnightod(on, options)
9  registerMiod(on, options)
10  registerScrollod(on, options)
11}
12
hooks/knightod/register.tsx 209 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { drawBand, STRIP_KEY } from './band'
5import { BOSS_TIERS, dueBoss, withBoss } from './game/bosses'
6import type { Cue } from './game/cues'
7import { cueForCommand, cueForTool, isTestCommand } from './game/cues'
8import { hashText } from './game/random'
9import { finishQuest, NEW_SAVE, playMinutes } from './game/save'
10import { overflowIds, restScene, startScene, unsettledIdsExceptBosses, withCue, withoutUnfoughtBosses } from './game/scene'
11import { settledIdsOf, settleEvents, woundEvents, woundsOf } from './game/settle'
12import { speedFromRate } from './game/speed'
13import type { Spend } from '../shared/tokenRate'
14import { RATE_WINDOW_MS, tokensPerMinute } from '../shared/tokenRate'
15import { hearts } from './game/label'
16import { levelledCue } from './game/level'
17
18const STORE_KEY = 'knightod.isEnabled'
19const isEnabled = atom({ plugin: 'codyssey', key: 'knightod.isEnabled' } as const, true)
20const save = atom({ plugin: 'codyssey', key: 'knightod.save' } as const, NEW_SAVE)
21const scene = atom({ plugin: 'codyssey', key: 'knightod.scene' } as const, null)
22
23const CAMP_MS = 12_000
24
25type Session = { turnId: string | null; spends: Spend[]; wasFailing: boolean; campTimer: Timer | null; backgroundTasks: number }
26
27const session: Session = { turnId: null, spends: [], wasFailing: false, campTimer: null, backgroundTasks: 0 }
28
29async function startQuest($: EngineInterface, prompt: string, turnId: string) {
30  session.campTimer?.cancel()
31  session.campTimer = null
32  session.turnId = turnId
33  session.spends = []
34  session.wasFailing = false
35  const now = await $.clock.now()
36  await update($, scene, previous => startScene(hashText(`${now}:${prompt}`), previous, now))
37}
38
39async function settle($: EngineInterface, ids: readonly number[]) {
40  const current = await read($, scene)
41  if (current === null || ids.length === 0) return
42  const settled = settleEvents(await read($, save), current, ids)
43  await update($, save, () => settled.save)
44  await update($, scene, () => settled.scene)
45}
46
47async function wound($: EngineInterface, wounds: Readonly<Record<string, number>>) {
48  const current = await read($, scene)
49  if (current === null || Object.keys(wounds).length === 0) return
50  const wounded = woundEvents(await read($, save), current, wounds)
51  await update($, save, () => wounded.save)
52  await update($, scene, () => wounded.scene)
53}
54
55async function recordCue($: EngineInterface, cue: Cue) {
56  const current = await read($, scene)
57  if (current === null || !current.isRunning) return
58  const next = withCue(current, levelledCue(cue, (await read($, save)).level))
59  await update($, scene, () => next)
60  await settle($, overflowIds(next))
61}
62
63async function summonDueBoss($: EngineInterface) {
64  const current = await read($, scene)
65  if (current === null) return
66  const tier = dueBoss(await read($, save), current, await $.clock.now())
67  if (tier === null) return
68  const next = withBoss(current, tier, (await read($, save)).level)
69  await update($, scene, () => next)
70  await settle($, overflowIds(next))
71}
72
73async function endQuest($: EngineInterface) {
74  session.turnId = null
75  const current = await read($, scene)
76  if (current === null || !current.isRunning) return
77  await settle($, unsettledIdsExceptBosses(current))
78  const endedAt = await $.clock.now()
79  await update($, save, previous => finishQuest(previous, endedAt - current.startedAt))
80  const isPaused = session.backgroundTasks > 0
81  await update($, scene, now => (now === null ? now : restScene(withoutUnfoughtBosses(now), isPaused)))
82  if (!isPaused) makeCamp($)
83}
84
85function makeCamp($: EngineInterface) {
86  session.campTimer = $.clock.after(CAMP_MS, () => {
87    session.campTimer = null
88    update($, scene, now => (now !== null && !now.isRunning ? null : now)).catch(() => undefined)
89  })
90}
91
92function startsInBackground(input: unknown): boolean {
93  return typeof input === 'object' && input !== null && (input as { run_in_background?: unknown }).run_in_background === true
94}
95
96async function finishBackgroundTask($: EngineInterface) {
97  if (session.backgroundTasks === 0) return
98  session.backgroundTasks -= 1
99  if (session.backgroundTasks > 0 || session.turnId !== null) return
100  const current = await read($, scene)
101  if (current === null || current.isPaused !== true) return
102  await update($, scene, now => (now === null || now.isRunning ? now : restScene(now)))
103  makeCamp($)
104}
105
106export const registerKnightod: Register = on => {
107  on('session.start', { cwd: /^/ }, async ($, e, next) => {
108    session.backgroundTasks = 0
109    const saved = await $.store.get(STORE_KEY)
110    if (typeof saved === 'boolean') await update($, isEnabled, () => saved)
111    await $.command.register({
112      name: 'knightod',
113      description: 'knightod, the pixel knight: show the saved game, or turn it on, off, or start over.',
114      argumentHint: '[on|off|reset]',
115      immediate: true,
116    })
117    return next(e)
118  })
119
120  on('command.run', { command: 'knightod' }, async ($, e) => {
121    const choice = e.args.trim().toLowerCase()
122    if (choice === 'on' || choice === 'off') {
123      await update($, isEnabled, () => choice === 'on')
124      await $.store.set(STORE_KEY, choice === 'on')
125      if (choice === 'off') await update($, scene, () => null)
126      return { text: `knightod ${choice}.` }
127    }
128    if (choice === 'reset') {
129      await update($, save, () => NEW_SAVE)
130      return { text: 'A new knight sets out.' }
131    }
132    const now = await read($, save)
133    const state = (await read($, isEnabled)) ? 'on' : 'off'
134    return {
135      text: `knightod ${state}. Lv ${now.level} · ${hearts(now)} · ${now.kills} slain · ${now.gold} gold · ${now.quests} quests · played ${playMinutes(now)}m · ${now.bossesSlain} of ${BOSS_TIERS.length} bosses slain · fell ${now.deaths} times. Use /knightod on, off or reset.`,
136    }
137  })
138
139  on('turn.start', { turnId: /^/ }, async ($, e, next) => {
140    const result = await next(e)
141    if (session.turnId === null && (await read($, isEnabled))) await startQuest($, e.text, e.turnId)
142    return result
143  })
144
145  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
146    const ran = await next(e)
147    if (startsInBackground(e) && ran.deny === undefined && ran.isError !== true) session.backgroundTasks += 1
148    const hasFailed = ran.deny === undefined && ran.isError === true
149    await recordCue($, cueForCommand(e.command, hasFailed, session.wasFailing))
150    await summonDueBoss($)
151    if (hasFailed) session.wasFailing = true
152    else if (isTestCommand(e.command)) session.wasFailing = false
153    return ran
154  }).catch(($, e, next) => next(e))
155
156  on('tool.call', { tool: /^/ }, async ($, e, next) => {
157    if (e.tool === 'Bash') return next(e)
158    await recordCue($, cueForTool(e.tool, e as unknown as Record<string, unknown>))
159    await summonDueBoss($)
160    const ran = await next(e)
161    if (startsInBackground(e) && ran.deny === undefined && ran.isError !== true) session.backgroundTasks += 1
162    return ran
163  }).catch(($, e, next) => next(e))
164
165  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
166    const result = await next(e)
167    await finishBackgroundTask($)
168    return result
169  }).catch(($, e, next) => next(e))
170
171  on('turn.step', { turnId: /^/ }, async function* ($, e, next) {
172    const result = yield* next(e)
173    if (session.turnId !== null && result.usage) {
174      const { input_tokens, output_tokens, cache_creation_input_tokens } = result.usage
175      const now = await $.clock.now()
176      session.spends = [...session.spends.filter(spend => now - spend.at < RATE_WINDOW_MS), { at: now, tokens: input_tokens + output_tokens + cache_creation_input_tokens }]
177      const speed = speedFromRate(tokensPerMinute(session.spends, now))
178      await update($, scene, current => (current === null || current.speed === speed ? current : { ...current, speed }))
179      await summonDueBoss($)
180    }
181    return result
182  })
183
184  on('turn.complete', { turnId: /^/ }, async ($, e, next) => {
185    const result = await next(e)
186    if (e.turnId === session.turnId) await endQuest($)
187    return result
188  })
189
190  on('ui.message', async ($, e, next) => {
191    if (e.element !== STRIP_KEY) return next(e)
192    await wound($, woundsOf(e.data))
193    await settle($, settledIdsOf(e.data))
194    return {}
195  })
196
197  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
198    const below = await next(e)
199    const current = await read($, scene)
200    if (current === null || e.props.hasSurvey) return below
201    return drawBand($.ui.resolve(e), await read($, save), current, e.props.bodyColumns, below)
202  })
203
204  on('session.end', { sessionId: /^/ }, async ($, e, next) => {
205    session.campTimer?.cancel()
206    return next(e)
207  })
208}
209
hooks/miod/register.tsx 316 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { activityForCommand, activityForTool, strongestActivity } from './activity'
5import type { Activity } from './activity'
6import { drawBand } from './band'
7import { composeFinish, composeFixed, composePhrase, energyFromRate, soundingScale } from './compose'
8import type { Moment, Note } from './compose'
9import { nextMood } from './moods'
10import type { MoodChoice } from './moods'
11import { seededRandom } from './random'
12import { renderClip, toBase64, WAVE_POINTS_PER_SECOND } from './synth'
13import type { Fade } from './synth'
14import { describeTask, phraseSeconds, startTask } from './task'
15import { DEFAULT_WAVE, isWaveName, WAVE_NAMES } from './waves'
16import type { Task } from './task'
17import type { ScaleName } from './theory'
18import type { NowPlaying } from '../../types'
19import type { Spend } from '../shared/tokenRate'
20import { RATE_WINDOW_MS, tokensPerMinute } from '../shared/tokenRate'
21
22const STORE_KEY = 'miod.isEnabled'
23const isEnabled = atom({ plugin: 'codyssey', key: 'miod.isEnabled' } as const, true)
24const nowPlaying = atom({ plugin: 'codyssey', key: 'miod.nowPlaying' } as const, null)
25const chosenWave = atom({ plugin: 'codyssey', key: 'miod.wave' } as const, DEFAULT_WAVE)
26
27const GAIN = 0.1
28const FINISH_LOOK: Look = { label: 'finished', color: '#F5A623' }
29const FIXED_LOOK: Look = { label: 'fixed', color: '#81C784' }
30
31type Look = { label: string; color: string }
32
33type PlayOptions = { fade?: Fade }
34
35type Session = {
36  task: Task | null
37  turnId: string | null
38  phraseNumber: number
39  ticker: Timer | null
40  playing: Set<AbortController>
41  spends: Spend[]
42  toolCalls: number
43  contextFill: number
44  seenThisPhrase: Activity[]
45  isFailing: boolean
46  helpers: number
47  clipCount: number
48  lastTask: Task | null
49  moodChoice: MoodChoice | null
50  phraseEndsAt: number
51  finishTimer: Timer | null
52}
53
54const session: Session = {
55  task: null,
56  turnId: null,
57  phraseNumber: 0,
58  ticker: null,
59  playing: new Set(),
60  spends: [],
61  toolCalls: 0,
62  contextFill: 0,
63  seenThisPhrase: [],
64  isFailing: false,
65  helpers: 0,
66  clipCount: 0,
67  lastTask: null,
68  moodChoice: null,
69  phraseEndsAt: 0,
70  finishTimer: null,
71}
72
73function play($: EngineInterface, task: Task, notes: Note[], seconds: number, look: Look, options: PlayOptions = {}) {
74  const controller = new AbortController()
75  session.playing.add(controller)
76  const clip = renderClip(task.style, notes, seconds, task.seed, options.fade)
77  session.clipCount += 1
78  showWave($, { clipId: session.clipCount, ...look, levels: clip.levels, pointsPerSecond: WAVE_POINTS_PER_SECOND })
79  $.audio
80    .play({ base64: toBase64(clip.wav), mime: 'audio/wav' }, { gain: GAIN, signal: controller.signal })
81    .catch(() => $.ui.toast('miod: could not play audio'))
82    .finally(() => session.playing.delete(controller))
83}
84
85function silence() {
86  session.playing.forEach(controller => controller.abort())
87  session.playing.clear()
88}
89
90function showWave($: EngineInterface, clip: NowPlaying | null) {
91  update($, nowPlaying, () => clip).catch(() => undefined)
92}
93
94async function readMoment($: EngineInterface, now: number): Promise<Moment> {
95  session.spends = session.spends.filter(spend => now - spend.at < RATE_WINDOW_MS)
96  const seen = session.isFailing ? ['failing' as const] : session.seenThisPhrase
97
98  const task = session.task
99  const random = seededRandom((task?.seed ?? 0) + session.phraseNumber * 7919)
100  session.moodChoice = nextMood(session.moodChoice, strongestActivity(seen), random)
101  const moment: Moment = {
102    mood: session.moodChoice.mood,
103    scale: session.moodChoice.scale,
104    energy: energyFromRate(tokensPerMinute(session.spends, now)),
105    contextFill: session.contextFill,
106    toolCalls: session.toolCalls,
107    helpers: session.helpers,
108  }
109  session.toolCalls = 0
110  session.seenThisPhrase = []
111  return moment
112}
113
114async function playNextPhrase($: EngineInterface) {
115  const task = session.task
116  if (!task) {
117    return
118  }
119  const now = await $.clock.now()
120  const moment = await readMoment($, now)
121  const mood = moment.mood
122  const seconds = phraseSeconds(task)
123  const isFirst = session.phraseNumber === 0
124  const look = { label: `${mood.feel} · ${describeTask(task, soundingScale(task, moment.scale))} · energy ${Math.round(moment.energy * 100)}%`, color: mood.color }
125  play($, task, composePhrase(task, session.phraseNumber, moment), seconds, look, {
126    fade: isFirst ? { inSeconds: seconds / 2 } : undefined,
127  })
128  session.phraseNumber += 1
129  session.phraseEndsAt = now + seconds * 1000
130}
131
132async function startMusic($: EngineInterface, prompt: string, turnId: string) {
133  session.finishTimer?.cancel()
134  session.finishTimer = null
135  const task = startTask(prompt, await $.clock.now(), session.lastTask ?? undefined)
136  Object.assign(session, {
137    task,
138    turnId,
139    phraseNumber: 0,
140    spends: [],
141    toolCalls: 0,
142    seenThisPhrase: [],
143    isFailing: false,
144    helpers: 0,
145    moodChoice: null,
146  })
147  await playNextPhrase($)
148  session.ticker = $.clock.every(Math.round(phraseSeconds(task) * 1000), () => {
149    playNextPhrase($).catch(() => undefined)
150  })
151}
152
153async function stopMusic($: EngineInterface, shouldPlayFinish: boolean) {
154  session.ticker?.cancel()
155  session.ticker = null
156  session.finishTimer?.cancel()
157  session.finishTimer = null
158  const task = session.task
159  const scale = session.moodChoice?.scale
160  if (task && scale && shouldPlayFinish) {
161    session.lastTask = task
162    const wait = Math.max(0, session.phraseEndsAt - (await $.clock.now()))
163    session.finishTimer = $.clock.after(Math.round(wait), () => playFinish($, task, scale))
164  } else {
165    silence()
166    showWave($, null)
167  }
168  session.task = null
169  session.turnId = null
170}
171
172function playFinish($: EngineInterface, task: Task, scale: ScaleName) {
173  session.finishTimer = null
174  if (session.task) {
175    return
176  }
177  const seconds = phraseSeconds(task)
178  play($, task, composeFinish(task, soundingScale(task, scale)), seconds, FINISH_LOOK, { fade: { outSeconds: seconds / 2 } })
179  session.finishTimer = $.clock.after(Math.round(seconds * 1000), () => {
180    session.finishTimer = null
181    if (!session.task) {
182      showWave($, null)
183    }
184  })
185}
186
187function noteActivity(activity: Activity) {
188  if (session.task) {
189    session.seenThisPhrase.push(activity)
190  }
191}
192
193function noteCommandResult($: EngineInterface, hasFailed: boolean) {
194  const task = session.task
195  if (!task) {
196    return
197  }
198  if (hasFailed) {
199    session.isFailing = true
200    return
201  }
202  if (session.isFailing) {
203    session.isFailing = false
204    play($, task, composeFixed(task), phraseSeconds(task) / 2, FIXED_LOOK)
205  }
206}
207
208export const registerMiod: Register = on => {
209  on('session.start', async ($, e, next) => {
210    const saved = await $.store.get(STORE_KEY)
211    if (typeof saved === 'boolean') await update($, isEnabled, () => saved)
212    await $.command.register({
213      name: 'miod',
214      description: 'Turn the task music on or off.',
215      argumentHint: '[on|off|wave <name>]',
216      immediate: true,
217    })
218    return next(e)
219  })
220
221  on('command.run', { command: 'miod' }, async ($, e) => {
222    const [choice = '', name = ''] = e.args.trim().toLowerCase().split(/\s+/)
223    if (choice === 'wave') {
224      if (!isWaveName(name)) {
225        return { text: `waves: ${WAVE_NAMES.join(', ')}. Now: ${await read($, chosenWave)}. Use /miod wave <name>.` }
226      }
227      await update($, chosenWave, () => name)
228      return { text: `wave is ${name}.` }
229    }
230    if (choice === 'on' || choice === 'off') {
231      await update($, isEnabled, () => choice === 'on')
232      await $.store.set(STORE_KEY, choice === 'on')
233      if (choice === 'off') {
234        await stopMusic($, false)
235      }
236      return { text: `${choice}.` }
237    }
238    const state = (await read($, isEnabled)) ? 'on' : 'off'
239    return { text: `${state}. Use /miod on, /miod off, or /miod wave <name>.` }
240  })
241
242  on('turn.start', async ($, e, next) => {
243    const result = await next(e)
244    if (!session.task && (await read($, isEnabled))) {
245      await startMusic($, e.text, e.turnId)
246    }
247    return result
248  })
249
250  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
251    noteActivity(activityForCommand(e.command))
252    const ran = await next(e)
253    noteCommandResult($, ran.deny === undefined && ran.isError === true)
254    return ran
255  }).catch(($, e, next) => next(e))
256
257  on('tool.call', async ($, e, next) => {
258    if (e.tool === 'Bash') {
259      return next(e)
260    }
261    const activity = activityForTool(e.tool)
262    noteActivity(activity)
263    if (activity !== 'delegating') {
264      return next(e)
265    }
266    session.helpers += 1
267    try {
268      return await next(e)
269    } finally {
270      session.helpers = Math.max(0, session.helpers - 1)
271    }
272  }).catch(($, e, next) => next(e))
273
274  on('turn.step', async function* ($, e, next) {
275    const result = yield* next(e)
276    if (session.task && result.usage) {
277      const { input_tokens, output_tokens, cache_creation_input_tokens } = result.usage
278      session.spends.push({
279        at: await $.clock.now(),
280        tokens: input_tokens + output_tokens + cache_creation_input_tokens,
281      })
282      session.toolCalls += result.toolUses.length
283    }
284    return result
285  })
286
287  on('session.measure', ($, e, next) => {
288    if (e.context.percent !== undefined) {
289      session.contextFill = e.context.percent / 100
290    }
291    return next(e)
292  })
293
294  on('turn.complete', async ($, e, next) => {
295    const result = await next(e)
296    if (session.task && e.turnId === session.turnId) {
297      await stopMusic($, e.reason === 'answer')
298    }
299    return result
300  })
301
302  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
303    const below = await next(e)
304    const clip = await read($, nowPlaying)
305    if (!clip || e.props.hasSurvey) {
306      return below
307    }
308    return drawBand($.ui.resolve(e), clip, await read($, chosenWave), e.props.bodyColumns, below)
309  })
310
311  on('session.end', async ($, e, next) => {
312    await stopMusic($, false)
313    return next(e)
314  })
315}
316
hooks/scrollod/register.tsx 96 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { drawNoticeRow } from './notice-row'
5import { drawPromptBox } from './prompt-box'
6import { drawReply } from './reply'
7import { markRunStarts, MAX_RUN_STARTS } from './runs'
8import { DEFAULT_STYLE, isStyle, STYLES } from './style'
9import { drawToolRow } from './tool-row'
10
11const STORE_KEY = 'scrollod.isOn'
12const STYLE_STORE_KEY = 'scrollod.style'
13const isOn = atom({ plugin: 'codyssey', key: 'scrollod.isOn' } as const, true)
14const style = atom({ plugin: 'codyssey', key: 'scrollod.style' } as const, DEFAULT_STYLE)
15const runStarts = atom({ plugin: 'codyssey', key: 'scrollod.runStarts' } as const, [])
16const lastBlock = atom({ plugin: 'codyssey', key: 'scrollod.lastBlock' } as const, null)
17
18export const registerScrollod: Register = on => {
19  on('session.start', { cwd: /^/ }, async ($, e, next) => {
20    const saved = await $.store.get(STORE_KEY)
21    if (typeof saved === 'boolean') await update($, isOn, () => saved)
22    const savedStyle = await $.store.get(STYLE_STORE_KEY)
23    if (isStyle(savedStyle)) await update($, style, () => savedStyle)
24    await $.command.register({
25      name: 'scrollod',
26      description: 'Quiet transcript: show its state, turn it on or off, or pick the plain or game style.',
27      argumentHint: '[on|off|style <plain|game>]',
28      immediate: true,
29    })
30    return next(e)
31  })
32
33  on('command.run', { command: 'scrollod' }, async ($, e) => {
34    const [choice = '', name = ''] = e.args.trim().toLowerCase().split(/\s+/)
35    if (choice === 'style') {
36      if (!isStyle(name)) return { text: `styles: ${STYLES.join(', ')}. Now: ${await read($, style)}. Use /scrollod style <name>.` }
37      await update($, style, () => name)
38      await $.store.set(STYLE_STORE_KEY, name)
39      return { text: `scrollod style is ${name}.` }
40    }
41    if (choice === 'on' || choice === 'off') {
42      await update($, isOn, () => choice === 'on')
43      await $.store.set(STORE_KEY, choice === 'on')
44      return { text: `scrollod ${choice}.` }
45    }
46    return { text: `scrollod is ${(await read($, isOn)) ? 'on' : 'off'}, style ${await read($, style)}. Use /scrollod on, off or style <plain|game>.` }
47  })
48
49  on('session.append', async ($, e, next) => {
50    const result = await next(e)
51    if (e.agentId !== undefined) return result
52    if (e.door === 'prompt') {
53      await update($, lastBlock, () => 'prose')
54    } else if (e.door === 'response') {
55      const marked = markRunStarts(await read($, lastBlock), e.message.content as readonly { type: string; id?: unknown }[])
56      await update($, lastBlock, () => marked.last)
57      if (marked.starts.length > 0) await update($, runStarts, ids => [...ids, ...marked.starts].slice(-MAX_RUN_STARTS))
58    }
59    return result
60  }).catch(($, e, next) => next(e))
61
62  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
63    if (e.surface !== 'terminal' || !(await read($, isOn))) return next(e)
64    return drawToolRow($.ui.resolve(e), {
65      id: e.props.tool_use_id,
66      tool: e.props.tool,
67      input: e.props.input,
68      isRunning: e.props.isRunning,
69      isErrored: e.props.isErrored,
70      isInterrupted: e.props.isInterrupted,
71      style: await read($, style),
72      startsRun: (await read($, runStarts)).includes(e.props.tool_use_id),
73    })
74  })
75
76  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
77    if (e.surface !== 'terminal' || e.props.isErrored || !(await read($, isOn))) return next(e)
78    const { Box } = $.ui.resolve(e)
79    return <Box />
80  })
81
82  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
83    if (e.surface !== 'terminal' || !(await read($, isOn)) || (await read($, style)) !== 'game') return next(e)
84    if (e.props.origin.kind === 'composer') return drawPromptBox($.ui.resolve(e), e.props.text)
85    if (e.props.origin.kind === 'task-notification') {
86      return drawNoticeRow($.ui.resolve(e), { text: e.props.text, status: e.props.task?.status, durationMs: e.props.task?.durationMs })
87    }
88    return next(e)
89  })
90
91  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
92    if (e.surface !== 'terminal' || e.props.isSummary || !(await read($, isOn))) return next(e)
93    return drawReply($.ui.resolve(e), { text: e.props.text, isFirstOfReply: e.props.isFirstOfReply, style: await read($, style) })
94  })
95}
96
hooks/knightod/band.tsx 29 lines
1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { Save, Scene } from '../../types'
4import { statusLine } from './game/label'
5import { STRIP_PIXEL_ROWS } from './world/world'
6
7export const STRIP_MAX_COLUMNS = 64
8export const STRIP_MIN_COLUMNS = 16
9export const STRIP_KEY = 'knightod-strip'
10
11export function drawBand(elements: ElementTable, save: Save, scene: Scene, columns: number, below: RenderElement): RenderElement {
12  const { Box, Text } = elements
13  const stripColumns = Math.min(STRIP_MAX_COLUMNS, columns)
14  const strip =
15    'Client' in elements && stripColumns >= STRIP_MIN_COLUMNS ? (
16      <elements.Client key={STRIP_KEY} module="./strip.tsx" props={{ scene }} width={stripColumns} height={STRIP_PIXEL_ROWS / 2} />
17    ) : null
18
19  return (
20    <Box flexDirection="column">
21      <Text color={scene.isRunning ? '#FFC107' : 'subtle'} wrap="truncate-end">
22        {statusLine(save, scene)}
23      </Text>
24      {strip}
25      {below}
26    </Box>
27  )
28}
29
hooks/knightod/game/bosses.ts 34 lines
1import type { BossSpecies, Save, Scene, Species } from '../../../types'
2import { levelledCue } from './level'
3import { withCue } from './scene'
4
5export type BossTier = { species: BossSpecies; hearts: number; atMs: number }
6
7const MINUTE_MS = 60_000
8
9export const BOSS_TIERS: readonly BossTier[] = [
10  { species: 'king-slime', hearts: 3, atMs: 2 * MINUTE_MS },
11  { species: 'ogre', hearts: 4, atMs: 4 * MINUTE_MS },
12  { species: 'lich', hearts: 5, atMs: 6 * MINUTE_MS },
13  { species: 'demon', hearts: 6, atMs: 8 * MINUTE_MS },
14  { species: 'shadow-king', hearts: 7, atMs: 10 * MINUTE_MS },
15]
16
17export function playedMs(save: Save, scene: Scene, now: number): number {
18  return save.playMs + (scene.isRunning ? Math.max(0, now - scene.startedAt) : 0)
19}
20
21export function isTimedBoss(species: Species | null): boolean {
22  return BOSS_TIERS.some(tier => tier.species === species)
23}
24
25export function dueBoss(save: Save, scene: Scene, now: number): BossTier | null {
26  const tier = BOSS_TIERS[save.bossesSlain]
27  if (!scene.isRunning || tier === undefined || playedMs(save, scene, now) < tier.atMs) return null
28  return scene.events.some(event => event.kind === 'boss' && !event.isSettled) ? null : tier
29}
30
31export function withBoss(scene: Scene, tier: BossTier, level = 1): Scene {
32  return withCue(scene, levelledCue({ kind: 'boss', species: tier.species, target: '', hearts: tier.hearts }, level))
33}
34
hooks/knightod/game/cues.ts 118 lines
1import type { EventKind, GameEvent, Species } from '../../../types'
2import { heartCount } from './label'
3import { hashText } from './random'
4
5export type Cue = { kind: EventKind; species: Species | null; target: string; hearts?: number }
6
7const READING_TOOLS = ['Read', 'Grep', 'Glob', 'LS', 'NotebookRead']
8const EXPLORING_TOOLS = ['WebFetch', 'WebSearch']
9const EDITING_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
10const DELEGATING_TOOLS = ['Agent', 'Task']
11const EDIT_SPECIES: readonly Species[] = ['slime', 'goblin', 'skeleton', 'orc', 'spider']
12const WEB_SPECIES: readonly Species[] = ['bat', 'ghost']
13const SHELL_SPECIES: readonly Species[] = ['goblin', 'wolf']
14const BUG_DRAGON_HEARTS = 4
15
16const SHIPPING_COMMAND = /\bgit (commit|push|merge|tag)\b|\bgh pr (create|merge)\b/
17const TEST_COMMAND = /\b(test|tests|spec|jest|vitest|pytest|rspec|mocha|check|lint|tsc)\b/
18
19export function isTestCommand(command: string): boolean {
20  return TEST_COMMAND.test(command)
21}
22
23function baseName(path: string): string {
24  return path.split('/').filter(Boolean).at(-1) ?? path
25}
26
27function textField(input: Record<string, unknown>, ...names: string[]): string {
28  for (const name of names) {
29    const value = input[name]
30    if (typeof value === 'string' && value.trim() !== '') return value.trim()
31  }
32  return ''
33}
34
35function pickSpecies(species: readonly Species[], target: string): Species {
36  return species[hashText(target) % species.length] ?? 'slime'
37}
38
39function hostOf(url: string): string {
40  const match = /^[a-z]+:\/\/([^/]+)/i.exec(url)
41  return match?.[1] ?? url
42}
43
44export function cueForTool(tool: string, input: Record<string, unknown>): Cue {
45  if (READING_TOOLS.includes(tool)) {
46    return { kind: 'scroll', species: null, target: baseName(textField(input, 'file_path', 'notebook_path', 'path', 'pattern')) || tool }
47  }
48  if (EXPLORING_TOOLS.includes(tool)) {
49    const target = hostOf(textField(input, 'url', 'query')) || 'the web'
50    return { kind: 'monster', species: pickSpecies(WEB_SPECIES, target), target }
51  }
52  if (EDITING_TOOLS.includes(tool)) {
53    const target = baseName(textField(input, 'file_path', 'notebook_path')) || tool
54    return { kind: 'monster', species: pickSpecies(EDIT_SPECIES, target), target }
55  }
56  if (DELEGATING_TOOLS.includes(tool)) {
57    return { kind: 'monster', species: 'skeleton', target: textField(input, 'description') || 'the crypt' }
58  }
59  if (tool.startsWith('mcp__')) {
60    return { kind: 'monster', species: 'slime', target: tool.split('__')[1] ?? tool }
61  }
62  return { kind: 'monster', species: 'slime', target: tool }
63}
64
65export function cueForCommand(command: string, hasFailed: boolean, wasFailing: boolean): Cue {
66  const target = command.trim().split(/\s+/).slice(0, 2).join(' ') || 'the shell'
67  if (hasFailed) return { kind: 'hurt', species: null, target }
68  if (SHIPPING_COMMAND.test(command)) return { kind: 'chest', species: null, target }
69  if (isTestCommand(command) && wasFailing) return { kind: 'boss', species: 'dragon', target, hearts: BUG_DRAGON_HEARTS }
70  return { kind: 'monster', species: pickSpecies(SHELL_SPECIES, target), target }
71}
72
73const SPECIES_NAMES: Record<Species, string> = {
74  slime: 'Slime',
75  goblin: 'Goblin',
76  bat: 'Bat',
77  skeleton: 'Skeleton',
78  wolf: 'Wolf',
79  spider: 'Spider',
80  ghost: 'Ghost',
81  orc: 'Orc',
82  'king-slime': 'King Slime',
83  ogre: 'Ogre',
84  lich: 'Lich',
85  demon: 'Demon',
86  'shadow-king': 'Shadow King',
87  dragon: 'Bug Dragon',
88}
89
90export function cueName(cue: Cue): string {
91  switch (cue.kind) {
92    case 'monster':
93      return `the ${SPECIES_NAMES[cue.species ?? 'slime']} of "${cue.target}"`
94    case 'boss':
95      return `the ${SPECIES_NAMES[cue.species ?? 'dragon']} ${heartCount(cue.hearts ?? 1)}`
96    case 'chest':
97      return 'a treasure chest'
98    case 'scroll':
99      return `a scroll of "${cue.target}"`
100    case 'hurt':
101      return `hit by "${cue.target}"`
102  }
103}
104
105export function settledName(event: GameEvent): string {
106  switch (event.kind) {
107    case 'monster':
108    case 'boss':
109      return `slew ${event.name}`
110    case 'chest':
111      return `opened ${event.name}`
112    case 'scroll':
113      return `picked up ${event.name}`
114    case 'hurt':
115      return event.name
116  }
117}
118
hooks/knightod/game/random.ts 20 lines
1export function hashText(text: string): number {
2  let hash = 2166136261
3  for (let index = 0; index < text.length; index += 1) {
4    hash ^= text.charCodeAt(index)
5    hash = Math.imul(hash, 16777619)
6  }
7  return hash >>> 0
8}
9
10export function seededRandom(seed: number): () => number {
11  let state = seed >>> 0
12  return () => {
13    state = (state + 0x6d2b79f5) >>> 0
14    let mixed = state
15    mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1)
16    mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61)
17    return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296
18  }
19}
20
hooks/knightod/game/save.ts 44 lines
1import type { EventKind, Save } from '../../../types'
2
3export const NEW_SAVE: Save = { level: 1, xp: 0, hp: 5, maxHp: 5, kills: 0, gold: 0, deaths: 0, quests: 0, playMs: 0, bossesSlain: 0 }
4
5export function playMinutes(save: Save): number {
6  return Math.floor(save.playMs / 60_000)
7}
8
9export function xpForNextLevel(level: number): number {
10  return level * 8
11}
12
13function gainXp(save: Save, xp: number): Save {
14  let next = { ...save, xp: save.xp + xp }
15  while (next.xp >= xpForNextLevel(next.level)) {
16    next = { ...next, xp: next.xp - xpForNextLevel(next.level), level: next.level + 1, maxHp: next.maxHp + 1 }
17    next = { ...next, hp: next.maxHp }
18  }
19  return next
20}
21
22export function applyEvent(save: Save, kind: EventKind, random: () => number, hearts = 1): Save {
23  switch (kind) {
24    case 'monster':
25      return gainXp({ ...save, kills: save.kills + 1, gold: save.gold + hearts + Math.floor(random() * 3) }, hearts)
26    case 'boss':
27      return gainXp({ ...save, kills: save.kills + 1, gold: save.gold + 5 * hearts + Math.floor(random() * 10) }, 2 * hearts)
28    case 'chest':
29      return { ...save, gold: save.gold + 5 + Math.floor(random() * 20) }
30    case 'scroll':
31      return { ...save, hp: Math.min(save.maxHp, save.hp + 1) }
32    case 'hurt':
33      return save.hp > 1 ? { ...save, hp: save.hp - 1 } : { ...save, hp: save.maxHp, deaths: save.deaths + 1, gold: Math.floor(save.gold / 2) }
34  }
35}
36
37export function woundByBoss(save: Save, hits: number): Save {
38  return { ...save, hp: Math.max(Math.min(save.hp, 1), save.hp - hits) }
39}
40
41export function finishQuest(save: Save, playedMs: number): Save {
42  return { ...save, quests: save.quests + 1, playMs: save.playMs + Math.max(0, playedMs) }
43}
44
hooks/knightod/game/scene.ts 46 lines
1import type { GameEvent, Scene } from '../../../types'
2import type { Cue } from './cues'
3import { cueName } from './cues'
4
5export const MAX_EVENTS = 12
6export const MAX_UNSETTLED = 24
7
8export function startScene(seed: number, previous: Scene | null, now: number): Scene {
9  return {
10    isRunning: true,
11    seed,
12    speed: 1,
13    events: [],
14    nextId: previous?.nextId ?? 1,
15    latest: previous ? 'back on the road' : 'sets out',
16    startedAt: now,
17  }
18}
19
20export function withCue(scene: Scene, cue: Cue): Scene {
21  const event: GameEvent = { id: scene.nextId, kind: cue.kind, species: cue.species, name: cueName(cue), isSettled: false }
22  if (cue.hearts !== undefined) event.hearts = cue.hearts
23  const events = [...scene.events, event]
24  while (events.length > MAX_EVENTS && events[0]?.isSettled === true) events.shift()
25  return { ...scene, events, nextId: scene.nextId + 1, latest: event.name }
26}
27
28export function unsettledIdsExceptBosses(scene: Scene): number[] {
29  return scene.events.filter(event => !event.isSettled && event.kind !== 'boss').map(event => event.id)
30}
31
32export function withoutUnfoughtBosses(scene: Scene): Scene {
33  return { ...scene, events: scene.events.filter(event => event.isSettled || event.kind !== 'boss') }
34}
35
36export function overflowIds(scene: Scene): number[] {
37  const waiting = unsettledIdsExceptBosses(scene)
38  return waiting.slice(0, Math.max(0, waiting.length - MAX_UNSETTLED))
39}
40
41export const PAUSED_TEXT = 'paused, waiting on background work'
42
43export function restScene(scene: Scene, isPaused = false): Scene {
44  return { ...scene, isRunning: false, isPaused, latest: isPaused ? PAUSED_TEXT : 'progress saved' }
45}
46
hooks/knightod/game/settle.ts 48 lines
1import type { Save, Scene } from '../../../types'
2import { settledName } from './cues'
3import { seededRandom } from './random'
4import { isTimedBoss } from './bosses'
5import { counterHits } from './fight'
6import { applyEvent, woundByBoss } from './save'
7
8export function settleEvents(save: Save, scene: Scene, ids: readonly number[]): { save: Save; scene: Scene } {
9  let next = save
10  let latest = scene.latest
11  const events = scene.events.map(event => {
12    if (event.isSettled || !ids.includes(event.id)) return event
13    if (event.kind === 'boss') next = woundByBoss(next, counterHits(scene.seed, event.id, event.hearts ?? 1) - (event.woundsTaken ?? 0))
14    if (event.kind === 'boss' && isTimedBoss(event.species)) next = { ...next, bossesSlain: next.bossesSlain + 1 }
15    next = applyEvent(next, event.kind, seededRandom(scene.seed + event.id * 7919), event.hearts)
16    latest = settledName(event)
17    return { ...event, isSettled: true }
18  })
19  return { save: next, scene: { ...scene, events, latest } }
20}
21
22export function woundEvents(save: Save, scene: Scene, wounds: Readonly<Record<string, number>>): { save: Save; scene: Scene } {
23  let next = save
24  const events = scene.events.map(event => {
25    const landed = wounds[String(event.id)]
26    if (event.kind !== 'boss' || event.isSettled || landed === undefined) return event
27    const total = Math.min(landed, counterHits(scene.seed, event.id, event.hearts ?? 1))
28    const fresh = total - (event.woundsTaken ?? 0)
29    if (fresh <= 0) return event
30    next = woundByBoss(next, fresh)
31    return { ...event, woundsTaken: total }
32  })
33  return { save: next, scene: { ...scene, events } }
34}
35
36export function woundsOf(data: unknown): Record<string, number> {
37  if (typeof data !== 'object' || data === null || !('wounds' in data)) return {}
38  const wounds = (data as { wounds: unknown }).wounds
39  if (typeof wounds !== 'object' || wounds === null) return {}
40  return Object.fromEntries(Object.entries(wounds).filter((entry): entry is [string, number] => typeof entry[1] === 'number'))
41}
42
43export function settledIdsOf(data: unknown): number[] {
44  if (typeof data !== 'object' || data === null || !('settled' in data)) return []
45  const settled = (data as { settled: unknown }).settled
46  return Array.isArray(settled) ? settled.filter((id): id is number => typeof id === 'number') : []
47}
48
hooks/knightod/game/speed.ts 6 lines
1export function speedFromRate(perMinute: number): number {
2  if (perMinute >= 80_000) return 3
3  if (perMinute >= 30_000) return 2
4  return 1
5}
6