SLOPSHOPPER

context-dungeon

A roguelike pane played by your real session: context is HP, errors spawn monsters, green tests slay them, commits open chests, PRs are floor bosses

panebandguardcommandtoast
v0.2.0MITupdated 2026-10-02ccdwyer/context-dungeon
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-dungeon
│ ┃ Context Dungeon ✕ › fix the failing auth test and add an audit log call │ ┃ Floor 1 · Lv 1 (8/20 XP) · 0 gil · streak 0 │ ┃ HP ███████████████░░░░░░░░░░░░░░░ 51% contex ⏺ Read(src/auth.ts) │ ┃ ############################################ ⎿ Read 6 lines │ ┃ # # ⏺ Update(src/auth.ts) │ ┃ # @ B # ⎿ Added 2 lines, removed 1 line │ ┃ # # ⏺ Bash(bun test) │ ┃ ############################################ ⎿ 3 pass, 1 fail │ ┃ ⚔ Red Test Behemoth ██████████ 3/3 │ ┃ Loot: nothing yet ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ──────────────────────────────────────────── │ ┃ ✧ Terra casts Libra on auth.ts. ✻ Worked for 42s · done 4:20 PM │ ┃ ✧ Ramza casts Scan on audit.ts. │ ┃ ✧ Cloud casts Refactor on cache.ts. › /dungeon │ ┃ ! A wild Red Test Behemoth appears! ⎿ context-dungeon: Context Dungeon: you descend into the dungeon. │ ┃ [ hall of fame ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context Dungeon
Floor 1 · Lv 1 (8/20 XP) · 0 gil · streak 0 HP ███████████████░░░░░░░░░░░░░░░ 51% context left ############################################ # # # @ B # # # ############################################ ⚔ Red Test Behemoth ██████████ 3/3 Loot: nothing yet ──────────────────────────────────────────── ✧ Terra casts Libra on auth.ts. ✧ Ramza casts Scan on audit.ts. ✧ Cloud casts Refactor on cache.ts. ! A wild Red Test Behemoth appears! [ hall of fame ]
README

Context Dungeon

Context Dungeon demo

A roguelike that plays itself from your real Claude Code session. Open it with /dungeon and watch your work turn into a dungeon crawl.

In your sessionIn the dungeon
Context window remainingThe party's HP
A failing command or test runA monster spawns, named after the error: TypeError Wraith, ENOENT Imp, Red Test Tonberry. A search that finds nothing (rg, grep, diff) doesn't count
Another failure while it's aliveIt feeds on the error and grows stronger, up to a cap
A passing test run, typecheck or lintAn attack. A clean run is a LIMIT BREAK that slays the foe and drops loot
A real commit (from Claude Code's git metadata, not the command text)A treasure chest
A pull request openedThe floor boss appears
The pull request mergedThe boss falls and the stairs to the next floor open
Each finished turnXP; levels grow the party
A compactionResting at a bonfire: HP restored, kill streak spent
Running out of contextA wipe. The run goes into the Hall of Fame and a new party gathers; it can't wipe again until context frees up

Runs, best floor, best level and the top five runs are kept across sessions in the Hall of Fame. When the pane is closed, a one-line ticker above the prompt shows HP and the current foe. Press × to hide it, or run /dungeon ticker to toggle it.

The mod only watches. It never blocks or rewrites a tool call, and it ignores subagents and backgrounded commands. HP updates when each turn finishes. The battle log never shows command lines, which can contain secrets.

Screenshots

A failing test spawns a monsterA clean run is a LIMIT BREAK, the commit opens a chest
monsterchest

Commands

  • /dungeon opens the dungeon pane.
  • /dungeon fame opens the Hall of Fame.
  • /dungeon ticker shows or hides the ticker.

Install

/plugin marketplace add ccdwyer/claude-mods
/plugin install context-dungeon@ccdwyer-mods
/reload-plugins

Develop

claude plugin validate .
claude plugin test .

What it hooks

Events this mod hooks, as claude plugin validate reads the module:

  • session.start
  • command.run{command=dungeon}
  • ui.close{id=context-dungeon}
  • tool.call
  • turn.complete
  • session.compact
  • session.end
  • ui.render{component=Pane
  • requestId=context-dungeon}
  • ui.render{component=AbovePrompt}

Engine calls it makes: $.clock.now, $.command.register, $.session.usage, $.state.get, $.state.set, $.store.get, $.store.set (via remember), $.ui.open (via openPane), $.ui.resolve, $.ui.toast (via openPane).

A tool.call hook sits in the middle of every tool call: it can see the call, refuse it, or add context to its result. This mod uses that only for the behaviour described above.

Privacy

It runs entirely on your machine. It sends nothing over the network. Game stats are kept in Claude Code's local plugin store.

The mod collects no analytics or telemetry, and its author receives no data from it.

Full policy: PRIVACY.md.

License

MIT

Source 3 files
hooks/register.tsx 340 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Fame, Lifetime, Run } from '../types'
5import {
6  fameOf, freshLifetime, freshRun, hasProgress, isCheckCommand, isExcusedFailure, onBonfire, onBossAppears,
7  onCommit, onContext, onEdit, onFailure, onFloorCleared, onPass, onTurn, tally, xpToNext,
8} from './game'
9import type { Step } from './game'
10
11const PANE = 'context-dungeon'
12// The old single-key tally, read but never written again.
13const LIFETIME_KEY = 'lifetime'
14// Each finished run is stored under its own key with this prefix.
15const RUN_PREFIX = 'run:'
16const LOG_KEEP = 60
17
18const run = atom({ plugin: 'context-dungeon', key: 'run' } as const, null)
19const log = atom({ plugin: 'context-dungeon', key: 'log' } as const, [])
20const isPaneOpen = atom({ plugin: 'context-dungeon', key: 'isPaneOpen' } as const, false)
21const isTickerHidden = atom({ plugin: 'context-dungeon', key: 'isTickerHidden' } as const, false)
22const view = atom({ plugin: 'context-dungeon', key: 'view' } as const, 'room')
23const lifetime = atom({ plugin: 'context-dungeon', key: 'lifetime' } as const, null)
24
25type Engine = EngineInterface
26type BashOutcome = {
27  backgroundTaskId?: string
28  gitOperation?: { commit?: { kind?: string }; pr?: { action?: string } }
29}
30
31// Every change to the run is computed inside one atomic update, so overlapping
32// tool calls each land on the latest run instead of overwriting each other.
33// `now` is only needed when a run may start or end; the hot path skips the clock.
34async function apply($: Engine, transition: (r: Run) => Step, now = 0) {
35  let lines: string[] = []
36  let fame: Fame | undefined
37  await update($, run, cur => {
38    const step = transition(cur ?? freshRun(now))
39    lines = step.lines
40    fame = step.fame
41    return step.run
42  })
43  if (lines.length > 0) await update($, log, all => [...all, ...lines].slice(-LOG_KEEP))
44  if (fame !== undefined) await remember($, fame)
45}
46
47// A stored run counts only when every field the tally sums is there.
48const isFame = (v: unknown): v is Fame => {
49  if (typeof v !== 'object' || v === null) return false
50  const f = v as Record<string, unknown>
51  return ['floor', 'level', 'kills', 'chests', 'bosses', 'at'].every(k => typeof f[k] === 'number' && Number.isFinite(f[k])) && typeof f.fate === 'string'
52}
53
54// The lifetime tally: the old single-key tally plus every stored run.
55async function loadLifetime($: Engine): Promise<Lifetime> {
56  const base = (await $.store.get(LIFETIME_KEY)) as Lifetime | undefined
57  const runs: Fame[] = []
58  for (const key of await $.store.keys()) {
59    if (!key.startsWith(RUN_PREFIX)) continue
60    const value = await $.store.get(key)
61    if (isFame(value)) runs.push(value)
62  }
63  return tally(base, runs)
64}
65
66// Only a finished run touches the store, and only by adding a key of its own:
67// sessions ending at the same moment each add theirs, so no run is lost.
68async function remember($: Engine, fame: Fame): Promise<void> {
69  try {
70    const id = `${RUN_PREFIX}${fame.at}-${Math.random().toString(36).slice(2, 10)}`
71    await $.store.set(id, fame)
72    const life = await loadLifetime($)
73    await update($, lifetime, () => life)
74  } catch {
75    // The hall of fame is optional.
76  }
77}
78
79async function openPane($: Engine) {
80  try {
81    const opened = await $.ui.open({ id: PANE, title: 'Context Dungeon' })
82    await update($, isPaneOpen, () => opened.isPlaced)
83    if (!opened.isPlaced) $.ui.toast('Context Dungeon: widen the terminal to enter the dungeon')
84  } catch {
85    await update($, isPaneOpen, () => false)
86  }
87}
88
89const bar = (pct: number, width: number) => {
90  const full = Math.round((Math.max(0, Math.min(100, pct)) / 100) * width)
91  return '█'.repeat(full) + '░'.repeat(width - full)
92}
93const hpColor = (hp: number) => (hp > 50 ? 'green' : hp > 20 ? 'yellow' : 'red')
94const fit = (text: string, room: number) => (text.length > room ? `${text.slice(0, Math.max(0, room - 1))}…` : text)
95
96// The room: walls, the party on the left, the foe (or the stairs) on the right.
97function room(r: Run, width: number): { text: string; color?: string; dim?: boolean }[][] {
98  const w = Math.max(16, Math.min(width, 44))
99  const inner = w - 2
100  const party = r.level >= 10 ? '@@@@' : r.level >= 5 ? '@@@' : r.level >= 2 ? '@@' : '@'
101  const right = r.foe ? r.foe.glyph : '>'
102  const gap = Math.max(1, inner - 2 - party.length - 2 - right.length)
103  const wall = [{ text: '#'.repeat(w), dim: true }]
104  const empty = [{ text: '#', dim: true }, { text: ' '.repeat(inner) }, { text: '#', dim: true }]
105  const middle = [
106    { text: '#', dim: true },
107    { text: '  ' },
108    { text: party, color: 'cyan' },
109    { text: ' '.repeat(gap) },
110    { text: right, color: r.foe ? 'red' : 'yellow' },
111    { text: '  ' },
112    { text: '#', dim: true },
113  ]
114  return [wall, empty, middle, empty, wall]
115}
116
117export const register: Register = on => {
118  on('session.start', async ($, e, next) => {
119    try {
120      await $.command.register({
121        name: 'dungeon',
122        description: 'Context Dungeon: open the dungeon pane (args: fame, ticker)',
123        immediate: true,
124      })
125      const life = await loadLifetime($)
126      await update($, lifetime, () => life)
127      const now = await $.clock.now()
128      await update($, run, cur => cur ?? freshRun(now))
129    } catch {
130      // The dungeon is optional; the session is not.
131    }
132    return next(e)
133  })
134
135  on('command.run', { command: 'dungeon' }, async ($, e) => {
136    const arg = e.args.trim().toLowerCase()
137    if (arg === 'ticker') {
138      const hidden = await update($, isTickerHidden, was => !was)
139      return { text: `Context Dungeon: ticker ${hidden ? 'hidden' : 'shown'}.` }
140    }
141    await update($, view, () => (arg === 'fame' ? 'fame' : 'room'))
142    await openPane($)
143    return { text: 'Context Dungeon: you descend into the dungeon.' }
144  })
145
146  on('ui.close', { id: PANE }, async ($, e, next) => {
147    await update($, isPaneOpen, () => false)
148    return next(e)
149  })
150
151  // Pure observation: every call runs exactly as it would have; the dungeon only watches.
152  on('tool.call', async ($, e, next) => {
153    const ran = await next(e)
154    if (ran.deny !== undefined || e.agentId !== undefined) return ran
155    try {
156      const seed = e.tool_use_id ?? 'call'
157      if (e.tool === 'Bash') {
158        const command = String(e.command ?? '')
159        // Only a successful result carries Bash's own record; an errored one is just text.
160        const result = (ran.isError === true ? {} : (ran.result ?? {})) as BashOutcome
161        // A backgrounded command has not finished: it is neither a pass nor a fail yet.
162        if (result.backgroundTaskId !== undefined) return ran
163        const text = String(ran.text ?? '')
164        const failed = ran.isError === true
165        const commit = result.gitOperation?.commit?.kind
166        const pr = result.gitOperation?.pr?.action
167        const isCheck = !failed && isCheckCommand(command)
168        const isFoe = failed && !isExcusedFailure(command, text)
169        const isChest = commit === 'committed' || commit === 'amended' || commit === 'cherry-picked'
170        const isPr = pr === 'created' || pr === 'merged'
171        // Nothing for the dungeon: hand the result straight back.
172        if (!isCheck && !isFoe && !isChest && !isPr) return ran
173        await apply($, r => {
174          let step: Step = { run: r, lines: [] }
175          const then = (f: (x: Run) => Step) => {
176            const s = f(step.run)
177            step = { run: s.run, lines: [...step.lines, ...s.lines] }
178          }
179          if (isChest) then(x => onCommit(x, seed))
180          if (pr === 'created') then(x => onBossAppears(x, seed))
181          if (pr === 'merged') then(x => onFloorCleared(x, seed))
182          if (isCheck) then(x => onPass(x, text, seed))
183          if (isFoe) then(x => onFailure(x, text))
184          return step
185        })
186      } else if ((e.tool === 'Edit' || e.tool === 'Write') && ran.isError !== true) {
187        const staged = (ran.result as { staged?: boolean } | undefined)?.staged === true
188        if (!staged) await apply($, r => onEdit(r, String(e.file_path ?? ''), seed))
189      }
190    } catch {
191      // The dungeon never gets in the way of real work.
192    }
193    return ran
194  })
195
196  on('turn.complete', async ($, e, next) => {
197    const done = await next(e)
198    if (e.agentId !== undefined) return done
199    try {
200      const used = (await $.session.usage()).context.percent
201      const now = await $.clock.now()
202      await apply($, r => {
203        const turn = onTurn(r)
204        if (used === undefined) return turn
205        const after = onContext(turn.run, 100 - used, now)
206        return { run: after.run, lines: [...turn.lines, ...after.lines], fame: after.fame }
207      }, now)
208    } catch {
209      // Ignore: usage may be unavailable mid-shutdown.
210    }
211    return done
212  })
213
214  on('session.compact', async ($, e, next) => {
215    const done = await next(e)
216    if (e.agentId !== undefined || e.trigger === 'precompute' || done.messages === undefined) return done
217    try {
218      await apply($, onBonfire)
219    } catch {
220      // Ignore.
221    }
222    return done
223  })
224
225  // Leaving the session ends the run with honour: it goes in the hall of fame.
226  // /clear scatters the party: the next session's dungeon starts fresh.
227  on('session.end', async ($, e, next) => {
228    try {
229      // Take the run and clear it in one step, so a tool call finishing meanwhile cannot be lost or revived.
230      let ended: Run | null = null
231      await update($, run, cur => {
232        ended = cur
233        return null
234      })
235      await update($, log, () => [])
236      const r = ended as Run | null
237      if (r !== null && hasProgress(r)) {
238        await remember($, fameOf(r, e.reason === 'clear' ? 'scattered by /clear' : 'retired to the inn', await $.clock.now()))
239      }
240    } catch {
241      // Ignore.
242    }
243    return next(e)
244  })
245
246  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
247    const { Box, Text, Button } = $.ui.resolve(e)
248    const r = (await read($, run)) ?? freshRun(0)
249    const lines = await read($, log)
250    const life = (await read($, lifetime)) ?? freshLifetime()
251    const which = await read($, view)
252    const cols = Math.max(20, e.props.bodyColumns)
253    const rows = e.viewport?.rows ?? 30
254    const toggle = (
255      <Button
256        key="view"
257        label={which === 'fame' ? 'room' : 'hall of fame'}
258        onPress={() => update($, view, v => (v === 'fame' ? 'room' : 'fame'))}
259      />
260    )
261
262    if (which === 'fame') {
263      return (
264        <Box flexDirection="column">
265          <Text bold color="yellow">Hall of Fame</Text>
266          <Text dimColor>
267            {fit(`${life.runs} runs · ${life.kills} foes slain · ${life.bosses} bosses · ${life.wipes} wipes · best floor ${life.bestFloor} · best Lv ${life.bestLevel}`, cols)}
268          </Text>
269          {life.fame.length === 0 && <Text dimColor>No legends yet. Finish a run to be remembered.</Text>}
270          {life.fame.map((f, i) => (
271            <Text>{fit(`${i + 1}. Floor ${f.floor} · Lv ${f.level} · ${f.kills} slain · ${f.bosses} bosses · ${f.fate}`, cols)}</Text>
272          ))}
273          <Box>{toggle}</Box>
274        </Box>
275      )
276    }
277
278    const barWidth = Math.max(8, Math.min(30, cols - 22))
279    const logRoom = Math.max(3, rows - 14)
280    return (
281      <Box flexDirection="column">
282        <Text>
283          {fit(`Floor ${r.floor} · Lv ${r.level} (${r.xp}/${xpToNext(r.level)} XP) · ${r.gil} gil · streak ${r.streak}`, cols)}
284        </Text>
285        <Box>
286          <Text>HP </Text>
287          <Text color={hpColor(r.hp)}>{bar(r.hp, barWidth)}</Text>
288          <Text> {r.hp}% context left</Text>
289        </Box>
290        {room(r, cols).map(row => (
291          <Box>
292            {row.map(cell => (
293              <Text color={cell.color} dimColor={cell.dim}>{cell.text}</Text>
294            ))}
295          </Box>
296        ))}
297        {r.foe !== null ? (
298          <Box>
299            <Text color="red">{r.foe.isBoss === true ? '☠' : '⚔'} {fit(r.foe.name, cols - 24)} </Text>
300            <Text color="red">{bar((100 * r.foe.hp) / r.foe.maxHp, 10)}</Text>
301            <Text dimColor> {r.foe.hp}/{r.foe.maxHp}</Text>
302          </Box>
303        ) : (
304          <Text dimColor>The room is quiet. Stairs lead down ( &gt; ).</Text>
305        )}
306        <Text dimColor>{fit(`Loot: ${r.loot.slice(-4).join(', ') || 'nothing yet'}`, cols)}</Text>
307        <Text dimColor>{'─'.repeat(Math.min(cols, 44))}</Text>
308        {lines.length === 0 && <Text dimColor>Your adventure begins. Run a test, make a commit…</Text>}
309        {lines.slice(-logRoom).map(line => (
310          <Text>{fit(line, cols)}</Text>
311        ))}
312        <Box>{toggle}</Box>
313      </Box>
314    )
315  })
316
317  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
318    if (e.props.hasSurvey || (await read($, isPaneOpen)) || (await read($, isTickerHidden))) return next(e)
319    const r = await read($, run)
320    if (r === null) return next(e)
321    const { Box, Text, Button } = $.ui.resolve(e)
322    const cols = e.props.bodyColumns ?? 80
323    const foe = r.foe !== null ? ` · ⚔ ${r.foe.name}` : ''
324    // The band is shared: draw our line above whatever the other plugins draw.
325    const below = await next(e)
326    return (
327      <Box flexDirection="column">
328        <Box>
329          <Text dimColor>Lv{r.level} F{r.floor} HP </Text>
330          <Text color={hpColor(r.hp)}>{bar(r.hp, 10)}</Text>
331          <Text dimColor>{fit(` ${r.hp}%${foe} `, Math.max(10, cols - 40))}</Text>
332          <Button key="enter" label="enter" onPress={() => openPane($)} />
333          <Button key="hide" label="×" plain onPress={() => update($, isTickerHidden, () => true)} />
334        </Box>
335        {below}
336      </Box>
337    )
338  })
339}
340
hooks/game.ts 500 lines
1// The rules of the dungeon: pure functions from a run and an event to the next run
2// and the battle-log lines it earned. No `$` here, so the rules test on their own.
3import type { Fame, Foe, Lifetime, Run } from '../types'
4
5export type Step = { run: Run; lines: string[]; fame?: Fame }
6
7const CREATURES = ['Wraith', 'Imp', 'Golem', 'Drake', 'Lich', 'Tonberry', 'Behemoth', 'Malboro', 'Cactuar', 'Ghoul', 'Wyvern', 'Mimic']
8const GLYPHS: Record<string, string> = {
9  Wraith: 'W', Imp: 'i', Golem: 'G', Drake: 'D', Lich: 'L', Tonberry: 't',
10  Behemoth: 'B', Malboro: 'M', Cactuar: 'c', Ghoul: 'g', Wyvern: 'V', Mimic: 'm',
11}
12const BOSSES = ['Review Behemoth', 'CI Ultima Weapon', 'Merge Conflict Malboro', 'Gatekeeper Margit', 'Ragnaros of the Main Branch']
13const LOOT = [
14  'Potion', 'Hi-Potion', 'Ether', 'Phoenix Down', 'Elixir', 'Megalixir', 'Ribbon', 'Genji Glove',
15  'Flask of Crimson Tears', 'Golden Rune', 'Smithing Stone', 'Hearthstone', 'Thunderfury (shard)',
16  'Mythril Sword', 'Masamune', 'Ultima Weapon', 'Rusty Key', 'Stale Bread',
17]
18const HEROES = ['Cloud', 'Tifa', 'Squall', 'Zidane', 'Terra', 'Ramza', 'the Tarnished']
19
20export const XP_PER_TURN = 5
21export const xpToNext = (level: number) => level * 20
22// At or below this much context left (percent) the party falls.
23export const WIPE_AT = 2
24// A foe stops growing here, however many errors it eats.
25export const MAX_FOE_HP = 12
26// Context samples ignored after a compaction, until a fresh response reports.
27export const REST_SKIPS = 1
28
29export const freshRun = (now: number, hp = 100): Run => ({
30  floor: 1, level: 1, xp: 0, gil: 0, hp, streak: 0, kills: 0, chests: 0, bosses: 0,
31  loot: [], foe: null, startedAt: now, isExhausted: false, restSkips: 0,
32})
33
34export const freshLifetime = (): Lifetime => ({
35  runs: 0, kills: 0, chests: 0, bosses: 0, wipes: 0, bestFloor: 1, bestLevel: 1, fame: [],
36})
37
38// Whether a run did anything worth remembering. Passive turn XP is not an adventure.
39export const hasProgress = (r: Run) => r.kills > 0 || r.chests > 0 || r.bosses > 0 || r.floor > 1 || r.foe?.isBoss === true
40
41// A small stable hash, so the same error always summons the same creature.
42export function pick<T>(list: readonly T[], seed: string): T {
43  let h = 2166136261
44  for (let i = 0; i < seed.length; i += 1) h = Math.imul(h ^ seed.charCodeAt(i), 16777619)
45  return list[(h >>> 0) % list.length] as T
46}
47
48// The monster's first name comes from the error itself, never from the command
49// (a command line can carry tokens and passwords).
50export function errorKind(text: string): string {
51  const named = /\b([A-Z][A-Za-z]*(?:Error|Exception))\b/.exec(text)
52  if (named) return named[1] as string
53  const code = /\b(E[A-Z]{3,}[A-Z0-9]*)\b/.exec(text)
54  if (code) return code[1] as string
55  if (/\b(fail(ed|ing|ures?)?)\b/i.test(text)) return 'Red Test'
56  if (/command not found/i.test(text)) return 'Missing Binary'
57  const exit = /exit (?:code )?(\d+)/i.exec(text)
58  if (exit) return `Exit ${exit[1]}`
59  return 'Nameless'
60}
61
62export function summon(kind: string, floor: number, seed: string): Foe {
63  const creature = pick(CREATURES, seed)
64  const maxHp = 2 + Math.min(floor, 4)
65  return { name: `${kind} ${creature}`, glyph: GLYPHS[creature] ?? '?', hp: maxHp, maxHp }
66}
67
68const hero = (run: Run, seed: string) => (run.level >= 10 ? 'the party' : pick(HEROES, seed))
69
70function gainXp(run: Run, xp: number, lines: string[]): Run {
71  let next = { ...run, xp: run.xp + xp }
72  while (next.xp >= xpToNext(next.level)) {
73    next = { ...next, xp: next.xp - xpToNext(next.level), level: next.level + 1 }
74    lines.push(`★ Level up! The party reaches Lv ${next.level}.`)
75  }
76  return next
77}
78
79// A tool call failed: a monster appears, or the one already here grows stronger.
80export function onFailure(run: Run, text: string): Step {
81  const lines: string[] = []
82  const kind = errorKind(text)
83  if (run.foe === null) {
84    const foe = summon(kind, run.floor, `${kind}:${run.kills}`)
85    lines.push(`! A wild ${foe.name} appears!`)
86    return { run: { ...run, foe }, lines }
87  }
88  if (run.foe.maxHp >= MAX_FOE_HP) {
89    lines.push(`! ${run.foe.name} shrugs off the ${kind}.`)
90    return { run, lines }
91  }
92  const foe = { ...run.foe, maxHp: run.foe.maxHp + 1, hp: run.foe.hp + 1 }
93  lines.push(`! ${foe.name} feeds on the ${kind}. It grows stronger.`)
94  return { run: { ...run, foe }, lines }
95}
96
97// Output that still reports failures, even from a zero exit: a runner's summary
98// with a failure count, a FAIL/FAILED line, or a failed-test mark. Test titles
99// that merely mention failures do not count.
100const ANSI = /\x1b\[[0-9;]*m/g
101export function reportsFailures(raw: string): boolean {
102  const text = raw.replace(ANSI, '')
103  if (/^\s*(FAIL(ED)?\b|--- FAIL)/m.test(text)) return true
104  if (/^\s*[✕✗×] /m.test(text)) return true
105  if (/^\s*[1-9]\d* failing\b/m.test(text)) return true
106  if (/^FAILED \((failures|errors)=\d+/m.test(text)) return true
107  return text.split('\n').some(line => {
108    if (!/\b[1-9]\d* (failed|failures?|errors?)\b/i.test(line)) return false
109    return /^\s*(Tests?|Test Suites|Test Files|Examples?|Specs?):/i.test(line) || /\b\d+ passed\b/.test(line) || /={3,}/.test(line)
110  })
111}
112
113// A check passed. A clean run is a finishing blow on a common foe; a boss takes two hits.
114export function onPass(run: Run, text: string, seed: string): Step {
115  const lines: string[] = []
116  const isClean = !reportsFailures(text)
117  if (run.foe === null) {
118    if (!isClean) return { run, lines: [`… The checks report failures. Something stirs in the dark.`] }
119    const next = gainXp(run, 2, lines)
120    lines.unshift(`✓ ${hero(run, seed)} keeps watch. The checks are green.`)
121    return { run: next, lines }
122  }
123  // A boss already held at 1 HP waits for the merge; another green run changes nothing.
124  if (run.foe.isBoss === true && run.foe.hp <= 1) return { run, lines }
125  const damage = !isClean ? 1 : run.foe.isBoss === true ? Math.ceil(run.foe.maxHp / 2) : run.foe.hp
126  const foe = { ...run.foe, hp: run.foe.hp - damage }
127  if (foe.hp > 0) {
128    lines.push(`⚔ ${hero(run, seed)} attacks! ${damage} damage to ${foe.name}.`)
129    return { run: { ...run, foe }, lines }
130  }
131  if (foe.isBoss === true) {
132    lines.push(`⚔ ${foe.name} reels, but a boss falls only when the PR is merged.`)
133    return { run: { ...run, foe: { ...foe, hp: 1 } }, lines }
134  }
135  const drop = pick(LOOT, `${seed}:${run.kills}`)
136  lines.push(isClean ? `✦ LIMIT BREAK! ${hero(run, seed)} slays ${foe.name}!` : `⚔ ${foe.name} falls.`)
137  lines.push(`  It drops: ${drop}.`)
138  let next: Run = {
139    ...run, foe: null, kills: run.kills + 1, streak: run.streak + 1,
140    loot: [...run.loot, drop].slice(-12), gil: run.gil + 10 * run.floor,
141  }
142  next = gainXp(next, 5 + foe.maxHp * 2, lines)
143  return { run: next, lines }
144}
145
146export function onCommit(run: Run, seed: string): Step {
147  const lines: string[] = []
148  const drop = pick(LOOT, `chest:${seed}:${run.chests}`)
149  const gil = 25 * run.floor
150  lines.push(`▣ A treasure chest! Inside: ${drop} and ${gil} gil.`)
151  const next = gainXp({ ...run, chests: run.chests + 1, gil: run.gil + gil, loot: [...run.loot, drop].slice(-12) }, 8, lines)
152  return { run: next, lines }
153}
154
155// A pull request opened: the floor boss steps out. A lesser foe flees before it.
156export function onBossAppears(run: Run, seed: string): Step {
157  if (run.foe?.isBoss === true) return { run, lines: [`☠ ${run.foe.name} already blocks the stairs.`] }
158  const name = pick(BOSSES, `${seed}:${run.floor}`)
159  const maxHp = 6 + 2 * Math.min(run.floor, 5)
160  const lines = [`☠ FLOOR BOSS: ${name}! The pull request is raised.`]
161  if (run.foe !== null) lines.push(`  ${run.foe.name} flees before it.`)
162  return { run: { ...run, foe: { name, glyph: 'Ω', hp: maxHp, maxHp, isBoss: true } }, lines }
163}
164
165// A pull request merged: the floor is cleared and the stairs open.
166export function onFloorCleared(run: Run, seed: string): Step {
167  const lines: string[] = []
168  const name = run.foe?.isBoss === true ? run.foe.name : pick(BOSSES, `${seed}:${run.floor}`)
169  lines.push(`☠ The PR is merged. ${name} is vanquished!`)
170  lines.push(`  The stairs open. Floor ${run.floor + 1}.`)
171  const next = gainXp(
172    { ...run, bosses: run.bosses + 1, floor: run.floor + 1, foe: null, gil: run.gil + 100 * run.floor },
173    25 * run.floor,
174    lines,
175  )
176  return { run: next, lines }
177}
178
179export function onEdit(run: Run, file: string, seed: string): Step {
180  const lines: string[] = []
181  const name = file.split('/').pop() ?? file
182  const spell = pick(['Refactor', 'Haste', 'Protect', 'Cure', 'Libra', 'Scan', 'Esuna'], seed)
183  lines.push(`✧ ${hero(run, seed)} casts ${spell} on ${name}.`)
184  return { run: gainXp(run, 1, lines), lines }
185}
186
187// Context changed: HP follows it. The first time it runs out the party falls; a
188// new party starts at the real HP left and cannot fall again until context frees up.
189export function onContext(run: Run, remaining: number, now: number): Step {
190  const left = Math.max(0, Math.min(100, remaining))
191  const hp = Math.round(left)
192  // Right after a bonfire the reading may predate the compaction: never wipe on it.
193  if (run.restSkips > 0) return { run: { ...run, restSkips: run.restSkips - 1 }, lines: [] }
194  if (left > WIPE_AT) return { run: { ...run, hp, isExhausted: false }, lines: [] }
195  if (run.isExhausted) return { run: { ...run, hp }, lines: [] }
196  return {
197    run: { ...freshRun(now, hp), isExhausted: true },
198    lines: [`✝ The party has fallen on floor ${run.floor}. Context exhausted.`, `  A new party gathers at the entrance.`],
199    // Like leaving, a wipe joins the hall of fame only if the run did something.
200    fame: hasProgress(run) ? fameOf(run, 'wiped out (context exhausted)', now) : undefined,
201  }
202}
203
204export function onTurn(run: Run): Step {
205  const lines: string[] = []
206  return { run: gainXp(run, XP_PER_TURN, lines), lines }
207}
208
209// A compaction is a rest at a bonfire: HP restored, but the streak is spent.
210export function onBonfire(run: Run): Step {
211  const lines = [`🔥 The party rests at a bonfire. HP restored.`]
212  if (run.streak > 0) lines.push(`  The ${run.streak}-kill streak fades into the embers.`)
213  return { run: { ...run, hp: 100, streak: 0, isExhausted: false, restSkips: REST_SKIPS }, lines }
214}
215
216export function fameOf(run: Run, fate: string, now: number): Fame {
217  return { floor: run.floor, level: run.level, kills: run.kills, chests: run.chests, bosses: run.bosses, fate, at: now }
218}
219
220// A finished run joins the lifetime tally and, if it ranks, the hall of fame.
221export function record(life: Lifetime, fame: Fame): Lifetime {
222  const ranked = [...life.fame, fame].sort((a, b) => b.floor - a.floor || b.level - a.level || b.kills - a.kills).slice(0, 5)
223  return {
224    runs: life.runs + 1,
225    kills: life.kills + fame.kills,
226    chests: life.chests + fame.chests,
227    bosses: life.bosses + fame.bosses,
228    wipes: life.wipes + (fame.fate.startsWith('wiped') ? 1 : 0),
229    bestFloor: Math.max(life.bestFloor, fame.floor),
230    bestLevel: Math.max(life.bestLevel, fame.level),
231    fame: ranked,
232  }
233}
234
235// The lifetime tally: an old single-key tally (if any) plus every run stored under
236// its own key. Each session only ever adds keys, so two sessions ending together
237// cannot overwrite each other's runs. Runs are folded oldest first.
238export function tally(base: Lifetime | undefined, runs: readonly Fame[]): Lifetime {
239  return [...runs].sort((a, b) => a.at - b.at || a.fate.localeCompare(b.fate)).reduce(record, base ?? freshLifetime())
240}
241
242// --- Reading shell lines -------------------------------------------------------
243// Exit status belongs to whichever command ran last, which only a shell knows for
244// a compound line. So the dungeon judges single commands only: a line with
245// `&&`, `||`, `;` or `|` outside quotes never attacks and is never excused.
246
247type Parsed = { words: string[]; isCompound: boolean }
248
249// Splits into words, honouring quotes and comments, and notes any operator that
250// joins commands: `&&`, `||`, `;`, `|`, a background `&`, command substitution,
251// or a second line. Redirections (`2>&1`, `&>`, `>`) stay part of the command.
252export function parse(raw: string): Parsed {
253  // `cd dir && cmd` is one command run somewhere else.
254  const cd = /^\s*cd\s+("[^"]*"|'[^']*'|[^\s;&|]+)\s*&&\s*/.exec(raw)
255  const command = cd !== null && !/\$\(|`/.test(cd[1] as string) ? raw.slice(cd[0].length) : raw
256  const words: string[] = []
257  let word = ''
258  let quote: string | null = null
259  let isCompound = false
260  let hasWord = false
261  let pendingLine = false
262  const end = () => {
263    if (hasWord) words.push(word)
264    word = ''
265    hasWord = false
266  }
267  for (let i = 0; i < command.length; i += 1) {
268    const ch = command[i] as string
269    if (quote !== null) {
270      if (ch === quote) quote = null
271      else if (ch === '\\' && quote === '"' && i + 1 < command.length) word += command[(i += 1)]
272      else {
273        // Inside double quotes a substitution still runs another command.
274        if (quote === '"' && (ch === '`' || (ch === '$' && command[i + 1] === '('))) isCompound = true
275        word += ch
276      }
277      continue
278    }
279    if (pendingLine && !/\s/.test(ch)) {
280      isCompound = true
281      pendingLine = false
282    }
283    if (ch === "'" || ch === '"') {
284      quote = ch
285      hasWord = true
286    } else if (ch === '\\' && i + 1 < command.length) {
287      if (command[i + 1] !== '\n') word += command[i + 1]
288      i += 1
289      hasWord = hasWord || command[i] !== '\n'
290    } else if (ch === '#' && !hasWord) {
291      while (i + 1 < command.length && command[i + 1] !== '\n') i += 1
292    } else if (ch === '\n') {
293      end()
294      if (words.length > 0) pendingLine = true
295    } else if (/\s/.test(ch)) {
296      end()
297    } else if (ch === '&') {
298      const prev = command[i - 1]
299      const next = command[i + 1]
300      if (prev === '>' || next === '>') {
301        word += ch
302        hasWord = true
303      } else {
304        isCompound = true
305        if (next === '&') i += 1
306        end()
307      }
308    } else if (ch === ';' || ch === '|' || ch === '`' || (ch === '$' && command[i + 1] === '(')) {
309      isCompound = true
310      end()
311    } else {
312      word += ch
313      hasWord = true
314    }
315  }
316  end()
317  // Drop redirections and their targets: `> out.txt`, `2>&1`, `&>log`, `< in`.
318  const kept: string[] = []
319  for (let i = 0; i < words.length; i += 1) {
320    const w = words[i] as string
321    if (/^(\d*(>>?|<)|&>>?)$/.test(w)) i += 1
322    else if (!/^(\d*[<>]|&>)/.test(w)) kept.push(w)
323  }
324  return { words: kept, isCompound }
325}
326
327// Wrappers and the flags they take, peeled off to find the real executable.
328const WRAPPERS = new Set(['npx', 'bunx', 'pnpx', 'time', 'sudo', 'env', 'nice', 'exec', 'nohup'])
329const VALUED = new Set(['-n', '-u', '-g', '-C', '--prefix', '--filter', '-F', '--workspace', '--dir', '--cwd', '--package', '--cache', '-f', '--file', '--manifest-path', '-Z', '--directory'])
330const LAUNCHERS: Record<string, string> = { uv: 'run', poetry: 'run', pipenv: 'run', rye: 'run', hatch: 'run', pdm: 'run', bundle: 'exec' }
331
332function skipFlags(words: string[], from: number): number {
333  let i = from
334  while (i < words.length && (words[i] as string).startsWith('-')) {
335    i += VALUED.has(words[i] as string) ? 2 : 1
336  }
337  return i
338}
339
340export function invocation(words: readonly string[]): { exe: string; args: string[] } {
341  const w = [...words]
342  let i = 0
343  for (;;) {
344    while (i < w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i] as string)) i += 1
345    const head = w[i]
346    if (head === undefined) break
347    if (WRAPPERS.has(head)) {
348      i = skipFlags(w, i + 1)
349      continue
350    }
351    if ((head === 'pnpm' || head === 'yarn' || head === 'bun' || head === 'npm') && w[skipFlags(w, i + 1)] === 'exec') {
352      i = skipFlags(w, skipFlags(w, i + 1) + 1)
353      continue
354    }
355    // Launchers that run another program: `uv run pytest`, `bundle exec rspec`.
356    const launched = LAUNCHERS[head]
357    if (launched !== undefined && w[skipFlags(w, i + 1)] === launched) {
358      i = skipFlags(w, skipFlags(w, i + 1) + 1)
359      continue
360    }
361    break
362  }
363  const exe = (w[i] ?? '').split('/').pop() ?? ''
364  return { exe, args: w.slice(i + 1) }
365}
366
367// Flags that make a runner print, list or watch instead of running checks once.
368const NOT_A_RUN: Record<string, RegExp> = {
369  jest: /^(--listTests|--showConfig|--watch|--watchAll|--init)$/,
370  vitest: /^(--watch|-w|list|init|watch|dev)$/,
371  mocha: /^(--watch|-w)$/,
372  pytest: /^(--collect-only|--co|--fixtures|--markers)$/,
373  rspec: /^(--dry-run|--init)$/,
374  phpunit: /^(--list-tests|--list-suites)$/,
375  tsc: /^(-v|--showConfig|--init|--watch|-w|--listFilesOnly)$/,
376  eslint: /^(--fix|--fix-dry-run|--init|--print-config|--inspect-config)$/,
377  mypy: /^(--install-types)$/,
378  pyright: /^(--watch|-w|--createstub)$/,
379}
380const RUNNERS = new Set(Object.keys(NOT_A_RUN))
381
382// The subcommand after global flags, toolchain selectors (`+nightly`) and make variables.
383function subcommand(args: string[]): { sub: string | undefined; rest: string[] } {
384  let i = 0
385  while (i < args.length) {
386    const a = args[i] as string
387    if (a.startsWith('+') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) i += 1
388    else if (a.startsWith('-')) i += VALUED.has(a) ? 2 : 1
389    else break
390  }
391  return { sub: args[i], rest: args.slice(i + 1) }
392}
393
394// After `--`, `-w` is a watch flag unless a number follows it (Jest's `-w 2` is workers).
395// `--watch` / `--watchAll`, alone or with a value that turns it on (`--watch=false` runs once).
396const isWatchOn = (a: string) => /^(--watch|--watchAll)(=(?!(false|0|off|no)$).*)?$/i.test(a)
397// Flags forwarded after `--` that make the runner list or dry-run instead of running.
398const FORWARDED_NOT_A_RUN = /^(--listTests|--list-tests|--list|--collect-only|--co|--dry-run|--showConfig)$/
399
400function passesWatch(args: string[]): boolean {
401  const at = args.indexOf('--')
402  if (at === -1) return false
403  const extra = args.slice(at + 1)
404  return extra.some((a, i) => isWatchOn(a) || FORWARDED_NOT_A_RUN.test(a) || (a === '-w' && !/^\d+$/.test(extra[i + 1] ?? '')))
405}
406
407function isCheckInvocation(exe: string, args: string[]): boolean {
408  if (args.some(a => /^(--version|--help|-h)$/.test(a))) return false
409  // Watching never finishes a run, wherever the flag sits.
410  if (args.some(isWatchOn) || passesWatch(args)) return false
411  if (RUNNERS.has(exe)) return !args.some(a => (NOT_A_RUN[exe] as RegExp).test(a))
412  if (exe === 'ruff') return args[0] === 'check' && !args.includes('--fix')
413  if (exe === 'biome') return ['check', 'lint', 'ci'].includes(args[0] ?? '') && !args.includes('--write')
414  if (exe === 'ktlint') return !args.some(a => a === '-F' || a === '--format')
415  if (exe === 'swiftlint') return !['autocorrect', 'version', 'rules'].includes(args[0] ?? '') && !args.includes('--fix')
416  if (['npm', 'pnpm', 'yarn', 'bun'].includes(exe)) {
417    const { sub, rest } = subcommand(args)
418    const task = sub === 'run' || sub === 'run-script' ? subcommand(rest).sub : sub
419    return /^(test|tests|typecheck|type-check|tsc|lint|check)(:[\w-]+)?$/.test(task ?? '')
420  }
421  if (['go', 'cargo', 'swift', 'dotnet', 'deno'].includes(exe)) {
422    const { sub, rest } = subcommand(args)
423    // Compile-only and list-only modes print names and exit 0 without running anything.
424    const listing = rest.some(a => /^(--list|--list-tests|-list(=.*)?|--no-run)$/.test(a) || (exe === 'dotnet' && a === '-t'))
425    return sub === 'test' && !(exe === 'go' && rest.includes('-c')) && !listing
426  }
427  if (exe === 'node') return args.includes('--test') || (args[0] === '--run' && /^(test|tests|lint|typecheck)(:[\w-]+)?$/.test(args[1] ?? ''))
428  if (/^python(\d+(\.\d+)?)?$/.test(exe)) {
429    if (args[0] !== '-m') return false
430    if (args[1] === 'unittest') return true
431    return (args[1] === 'pytest' || args[1] === 'mypy') && isCheckInvocation(args[1], args.slice(2))
432  }
433  if (exe === 'xcodebuild') return args.includes('test') || args.includes('test-without-building')
434  if (exe === 'gradle' || exe === 'gradlew') return !args.some(a => a === '--dry-run' || a === '-m') && args.some(a => /^(.*:)?(test|check|lint)([A-Z]\w*)?$/.test(a) && !/Classes$/.test(a))
435  if (exe === 'make') {
436    // -n / --dry-run / --just-print / --recon print the recipe without running it.
437    // A short-option cluster (`-sn`, `-nk`) dry-runs too.
438    if (args.some(a => /^(--dry-run|--just-print|--recon|--question)$/.test(a) || /^-[a-zA-Z]*[nq][a-zA-Z]*$/.test(a))) return false
439    return /^(test|check|lint)$/.test(subcommand(args).sub ?? '')
440  }
441  return false
442}
443
444// Commands whose non-zero exit is an answer (no match, files differ, false), not an error.
445const PREDICATES = new Set(['rg', 'grep', 'egrep', 'fgrep', 'ag', 'ack', 'fd', 'diff', 'cmp', 'test', '[', '[[', 'which', 'type', 'pgrep', 'false'])
446// A predicate that printed one of these hit a real error, not a negative answer.
447// Only the tools' own diagnostic lines count; diff hunks and matches are content.
448const DIAGNOSTIC = /^(fatal:|error:|usage:|(rg|grep|egrep|fgrep|diff|cmp|fd|jq|test|\[|which|pgrep)(: | error)|regex parse error)|integer expression expected|unary operator expected|No such file or directory|Permission denied|command not found|: not found$/i
449// Diff and match lines are content. A leading space is a diff context line only
450// when the output is a diff; otherwise it is an indented diagnostic.
451const isDiff = (text: string) => /^(diff --git |@@ |--- |\+\+\+ )/m.test(text)
452// A shell's own complaint (`zsh:1: …`, `-bash: …`, `bash: line 0: …`) is always a diagnostic.
453const SHELL_DIAGNOSTIC = /^-?(ba|z|k|da|fi)?sh(:\d+)?: /
454const isContent = (line: string, diff: boolean) =>
455  (diff && /^([+\- ]|@@|diff --git|index )/.test(line)) || /^[^:\s]+:\d+:/.test(line)
456const hasDiagnostic = (text: string) => {
457  const diff = isDiff(text)
458  return text.split('\n').some(line => SHELL_DIAGNOSTIC.test(line) || (!isContent(line, diff) && DIAGNOSTIC.test(line.trim())))
459}
460
461function isPredicateInvocation(words: string[], exe: string, args: string[]): boolean {
462  if (PREDICATES.has(exe)) return true
463  const plain = words.filter(w => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w))
464  if (plain[0] === 'command' && plain[1] === '-v') return true
465  if (exe === 'jq') return args.some(a => a === '-e' || a === '--exit-status')
466  if (exe === 'git') {
467    let i = 0
468    while (i < args.length && (args[i] as string).startsWith('-')) i += ['-C', '-c', '--git-dir', '--work-tree'].includes(args[i] as string) ? 2 : 1
469    const sub = args[i]
470    return sub === 'grep' || sub === 'diff' || (sub === 'merge-base' && args.includes('--is-ancestor'))
471  }
472  return false
473}
474
475// A successful single command that ran a test, typecheck or lint.
476export function isCheckCommand(command: string): boolean {
477  const { words, isCompound } = parse(command)
478  if (isCompound) return false
479  // `MAKEFLAGS=-n make test` dry-runs: the variable is dropped before the executable is found.
480  // Its value is short-option clusters (GNU make allows the first without a dash), or long options.
481  const makeflags = words.find(w => w.startsWith('MAKEFLAGS='))
482  if (makeflags !== undefined) {
483    const flags = makeflags.slice('MAKEFLAGS='.length).split(/\s+/)
484    if (flags.some((f, i) => /^--(dry-run|just-print|recon)$/.test(f) || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(f) || (i === 0 && /^[a-zA-Z]*n[a-zA-Z]*$/.test(f)))) return false
485  }
486  const { exe, args } = invocation(words)
487  return isCheckInvocation(exe, args)
488}
489
490// A failed command whose exit code is an answer, or a compound line whose failure
491// cannot be pinned on a command: neither summons anything.
492export function isExcusedFailure(command: string, errorText: string): boolean {
493  // An interrupted run was the person stopping it, not the code failing.
494  if (/\binterrupted\b|Interrupted by user/i.test(errorText.slice(0, 400))) return true
495  const { words, isCompound } = parse(command)
496  if (isCompound) return !/\b([A-Z][A-Za-z]*(Error|Exception)|E[A-Z]{3,}[A-Z0-9]*)\b/.test(errorText)
497  const { exe, args } = invocation(words)
498  return isPredicateInvocation(words, exe, args) && !hasDiagnostic(errorText)
499}
500
types/index.d.ts 49 lines
1export type Foe = { name: string; glyph: string; hp: number; maxHp: number; isBoss?: boolean }
2
3export type Run = {
4  floor: number
5  level: number
6  xp: number
7  gil: number
8  // Context window remaining, 0-100.
9  hp: number
10  // Foes slain since the last bonfire (compaction).
11  streak: number
12  kills: number
13  chests: number
14  bosses: number
15  loot: string[]
16  foe: Foe | null
17  startedAt: number
18  // The party fell to a full context; no further wipes until context frees up.
19  isExhausted: boolean
20  // Context samples to ignore after a bonfire: they may predate the compaction.
21  restSkips: number
22}
23
24export type Fame = { floor: number; level: number; kills: number; chests: number; bosses: number; fate: string; at: number }
25
26export type Lifetime = {
27  runs: number
28  kills: number
29  chests: number
30  bosses: number
31  wipes: number
32  bestFloor: number
33  bestLevel: number
34  fame: Fame[]
35}
36
37declare module 'claude-code' {
38  interface PluginState {
39    'context-dungeon': {
40      run: Run | null
41      log: string[]
42      isPaneOpen: boolean
43      isTickerHidden: boolean
44      view: 'room' | 'fame'
45      lifetime: Lifetime | null
46    }
47  }
48}
49