SLOPSHOPPER

samurai-dojo

A Claude samurai cuts down a Codex, Gemini, or ChatGPT foe for every tool call, and meditates, trains, and duels between them through four seasons.

newpaneguardcommandtoasttimer
★ 1v0.4.0MITupdated 2026-10-04theonly1me/claude-code-mods/plugins/samurai-dojo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · samurai-dojo
│ ┃ Samurai Dojo ✕ › fix the failing auth test and add an audit log call │ ┃ ▄ ▄ ▀▄▄ ▄▀▀▀▀▀▀▀▀ │ ┃ ▀▄▄▀ ▄▀▀▀▀▀ ▄▄▀▀▀▀▀▀▀▀▀▀▄ ⏺ Read(src/auth.ts) │ ┃ ▄▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ Ronin 5 slain 5 lifetime Summer, Autumn … ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Turn complete: the samurai salutes ✻ Worked for 42s · done 4:20 PM │ ┃ Bash: cut down a ChatGPT orb │ ┃ Bash: cut down a Gemini star › /dojo │ ┃ Flurry: three cuts in one breath ⎿ samurai-dojo: Samurai Dojo is on. It comes back each session unt │ ┃ Bash: cut down a Codex pod ⎿ samurai-dojo: Ronin 5 slain 5 lifetime Summer, Autumn in 2m │ ┃ Bash: cut down a ChatGPT orb ⎿ samurai-dojo: Rank: Ronin. 95 more to Hatamoto. Flurries this se │ ┃ Write: cut down a Gemini star ⎿ samurai-dojo: Slain this session: 5. Lifetime: 5. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Samurai Dojo
▄ ▄ ▀▄▄ ▄▀▀▀▀▀▀▀▀ ▄▄▄ ▀▄▄▀ ▄▀▀▀▀▀ ▄▄▀▀▀▀▀▀▀▀▀▀▄ ▄▀ ▀▄▄ ▄▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▄▄▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Ronin 5 slain 5 lifetime Summer, Autumn in 2m Turn complete: the samurai salutes Bash: cut down a ChatGPT orb Bash: cut down a Gemini star Flurry: three cuts in one breath Bash: cut down a Codex pod Bash: cut down a ChatGPT orb Write: cut down a Gemini star
README

Samurai Dojo

A Claude samurai guards your session. Every tool call Claude makes sends a Codex, Gemini, or ChatGPT foe into the dojo, and the samurai cuts it down when the call finishes. Between calls the samurai never stands still: it meditates, practices kata, duels wandering ronin, and fights off ambient waves, through winter, cherry blossom, summer, and autumn.

Samurai Dojo

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install samurai-dojo@claude-code-mods

Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.

How to use

  1. Type /dojo. The dojo opens in a pane.
  2. Keep working. Each tool call Claude makes becomes a foe, and each finished call is a cut.
  3. Type /dojo again to close it.
  • The dojo is hidden until you open it. Once open, it opens again in each new session until you close it.
  • Only one game shows at a time. Opening another game (for example /farm) closes the dojo.
  • In the fullscreen layout (CLAUDE_CODE_NO_FLICKER=1), the pane docks beside the transcript and shows a log of what the samurai did. Otherwise it sits above the prompt.
  • A pane that opens by itself at session start needs a terminal 144 columns wide (110 once you have opened it before). If yours is narrower, a toast tells you, and /dojo shows it at any width.
  • Close the pane with its close mark or ctrl+x x. That also turns it off for the next session.
CommandWhat it does
/dojoOpen or close the dojo
/dojo on, /dojo offOpen or close it explicitly
/dojo statsPrint your rank, kills, and flurries without opening the pane

The game plays itself. It has no keys to press.

How it plays

Real work:

  • Each tool call spawns a foe. Codex pods, Gemini stars, and ChatGPT orbs take turns.
  • When the call finishes, the samurai dashes in and cuts it down.
  • A subagent call spawns a crowned elite that takes two strikes.
  • A failed or denied call costs a parried strike first: a clang, a flinch, then the kill.
  • Three kills in quick succession draw a gold flurry crescent.
  • When Claude finishes a turn, the samurai salutes.

Between calls, the samurai picks the next activity:

  • Meditation: it sits with the sword laid down, and ki rises around it.
  • Kata: rising and falling cuts in place.
  • Duels: a grey ronin walks in. They trade cuts until one clean cut ends it, or both bow.
  • Waves: a few ChatGPT, Gemini, and Codex foes attack, sometimes led by a crowned captain.

This ambient action is decoration. Only real work counts toward kills and ranks. When a tool call starts, the samurai drops what it is doing, and a dueling ronin runs off.

Seasons

The season changes every 5 minutes: winter (snow on the floor and a bare tree), cherry blossom (pink petals), summer (fireflies), and autumn (falling red leaves). The farm in Little Harvest uses the same clock, so both show the same season.

Ranks

Lifetime kills earn ranks, shown by the color of the samurai's headband and sash: Ronin (red), Hatamoto at 100 (blue), Daimyo at 500 (violet), and Shogun at 2,000 (gold).

See the marketplace README for configuration, privacy, updates, and removal.

Source 38 files
hooks/register.tsx 93 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { configureStage, installStage } from './shared/pixel/stage'
5import { FRAME_MS, MAX_COLUMNS, MIN_COLUMNS, STAGE_ROWS } from './sim/constants'
6import { createDojo } from './sim/dojo'
7import { nextRankOf } from './sim/rank'
8
9const LIFETIME_KEY = 'lifetimeKills'
10
11const tallyAtom = atom({ plugin: 'samurai-dojo', key: 'tally' } as const, {
12  codex: 0,
13  gemini: 0,
14  chatgpt: 0,
15})
16
17export const register: Register = on => {
18  const dojo = createDojo()
19
20  function totalSlain(): number {
21    const { codex, gemini, chatgpt } = dojo.tally()
22    return codex + gemini + chatgpt
23  }
24
25  function report(): string[] {
26    const nextRank = nextRankOf(dojo.lifetimeKills())
27    const progress = nextRank
28      ? `${nextRank.minimumKills - dojo.lifetimeKills()} more to ${nextRank.title}.`
29      : 'The highest rank.'
30    return [
31      dojo.summary(),
32      `Rank: ${dojo.rank().title}. ${progress} Flurries this session: ${dojo.flurries()}.`,
33      `Slain this session: ${totalSlain()}. Lifetime: ${dojo.lifetimeKills()}.`,
34    ]
35  }
36
37  configureStage({
38    game: 'samurai-dojo',
39    title: 'Samurai Dojo',
40    command: {
41      name: 'dojo',
42      description: 'Show or hide the samurai dojo (on, off, stats)',
43      report,
44    },
45    rows: STAGE_ROWS,
46    minColumns: MIN_COLUMNS,
47    maxColumns: MAX_COLUMNS,
48    frameMs: FRAME_MS,
49    scene: {
50      begin: epochMs => dojo.begin(epochMs),
51      resize: columns => dojo.resize(columns),
52      tick: dtMs => dojo.tick({ dtMs }),
53      frame: () => dojo.frame(),
54      summary: () => dojo.summary(),
55      log: () => dojo.log(),
56    },
57  })
58  installStage(on)
59
60  on('session.start', async ($, e, next) => {
61    const saved = await read($, tallyAtom)
62    dojo.restore({
63      tally: { codex: saved.codex, gemini: saved.gemini, chatgpt: saved.chatgpt ?? 0 },
64      lifetimeKills: Number((await $.store.get(LIFETIME_KEY)) ?? 0),
65    })
66    return next(e)
67  })
68
69  on('tool.call', async ($, e, next) => {
70    const id = dojo.spawn({ isElite: e.tool === 'Agent', label: e.tool })
71    try {
72      const ran = await next(e)
73      dojo.defeat({ id, isFailure: ran.deny !== undefined || ran.isError === true })
74      return ran
75    } catch (error) {
76      dojo.defeat({ id, isFailure: true })
77      throw error
78    }
79  })
80
81  on('turn.complete', async ($, e, next) => {
82    dojo.celebrate()
83    await update($, tallyAtom, () => dojo.tally())
84    await $.store.set(LIFETIME_KEY, dojo.lifetimeKills())
85    return next(e)
86  })
87
88  on('session.end', async ($, e, next) => {
89    await $.store.set(LIFETIME_KEY, dojo.lifetimeKills())
90    return next(e)
91  })
92}
93
hooks/shared/pixel/stage.tsx 199 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { noop } from '../noop'
4import type { Bitmap } from './bitmap'
5import { encodeCells } from './cells'
6
7export type StageScene = {
8  begin: (epochMs: number) => void
9  resize: (columns: number) => void
10  tick: (dtMs: number) => void
11  frame: () => Bitmap
12  summary: () => string
13  log: () => readonly string[]
14}
15
16export type StageOptions = {
17  game: string
18  title: string
19  command: { name: string; description: string; report: () => readonly string[] }
20  rows: number
21  minColumns: number
22  maxColumns: number
23  frameMs: number
24  scene: StageScene
25}
26
27const POLL_MS = 1000
28const PANE_COLUMNS = 62
29const ACTIVE_FILE = '.claude/mods/active-game'
30
31let stage: StageOptions | undefined
32let activePath = ''
33let isClockRunning = false
34let isPaneOpen = false
35let isRasterMounted = false
36let lastCells = ''
37let lastSummary = ''
38let sincePollMs = 0
39
40export function configureStage(options: StageOptions): void {
41  stage = options
42}
43
44export function isStageShown(): boolean {
45  return isPaneOpen
46}
47
48function wantsShow(options: { args: string; isActive: boolean }): boolean {
49  if (options.args === 'on') {
50    return true
51  }
52  return options.args === 'off' ? false : !options.isActive
53}
54
55async function readActive($: EngineInterface): Promise<string> {
56  if (activePath === '') {
57    return ''
58  }
59  return (await $.fs.read(activePath).catch(() => '')).trim()
60}
61
62export function installStage(on: On): void {
63  on('session.start', { isInteractive: true }, async ($, e, next) => {
64    const options = stage
65    if (!options) {
66      return next(e)
67    }
68    await $.command.register({ name: options.command.name, description: options.command.description, immediate: true })
69    activePath = `${(await $.env.get('HOME')) ?? '.'}/${ACTIVE_FILE}`
70    options.scene.begin(await $.clock.now())
71    isPaneOpen = (await $.ui.panes()).some(pane => pane.id === options.game)
72    const active = await readActive($)
73    if (active === options.game && !isPaneOpen) {
74      const opened = await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
75      isPaneOpen = true
76      if (!opened.isPlaced) {
77        $.ui.toast(`${options.title} waits for a wider terminal. Run /${options.command.name} to show it now.`)
78      }
79    }
80    if (isClockRunning) {
81      return next(e)
82    }
83    isClockRunning = true
84    $.clock.every(options.frameMs, () => {
85      options.scene.tick(options.frameMs)
86      sincePollMs += options.frameMs
87      if (sincePollMs >= POLL_MS) {
88        sincePollMs = 0
89        if (isPaneOpen && options.scene.summary() !== lastSummary) {
90          $.ui.invalidate('ui.render')
91        }
92        readActive($)
93          .then(current => (isPaneOpen && current !== options.game ? $.ui.close({ id: options.game }) : undefined))
94          .catch(noop)
95      }
96      if (!isPaneOpen || !isRasterMounted) {
97        return
98      }
99      const cells = encodeCells(options.scene.frame())
100      if (cells === lastCells) {
101        return
102      }
103      lastCells = cells
104      $.ui
105        .blit({ requestId: options.game, key: `${options.game}:stage`, cells })
106        .then(result => {
107          if (result.deny !== undefined) {
108            isRasterMounted = false
109          }
110        })
111        .catch(noop)
112    })
113    return next(e)
114  })
115
116  on('command.run', { command: stage?.command.name ?? '' }, async ($, e, next) => {
117    const options = stage
118    if (!options || e.command !== options.command.name) {
119      return next(e)
120    }
121    const args = e.args.trim().toLowerCase()
122    if (args === 'stats') {
123      return { text: options.command.report().join('\n') }
124    }
125    if (args !== '' && args !== 'on' && args !== 'off') {
126      return { text: [`Usage: /${options.command.name} [on|off|stats]`, ...options.command.report()].join('\n') }
127    }
128    const pane = (await $.ui.panes()).find(candidate => candidate.id === options.game)
129    const isActive = (await readActive($)) === options.game && pane?.isPlaced === true
130    if (wantsShow({ args, isActive })) {
131      await $.fs.write(activePath, options.game)
132      await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
133      isPaneOpen = true
134      return {
135        text: [`${options.title} is on. It comes back each session until you run /${options.command.name} again.`, ...options.command.report()].join('\n'),
136      }
137    }
138    await $.fs.write(activePath, '')
139    await $.ui.close({ id: options.game })
140    isPaneOpen = false
141    return { text: `${options.title} is off. Run /${options.command.name} to bring it back.` }
142  })
143
144  on('ui.close', async ($, e, next) => {
145    const options = stage
146    if (!options || e.id !== options.game) {
147      return next(e)
148    }
149    const closed = await next(e)
150    isPaneOpen = false
151    isRasterMounted = false
152    const active = await readActive($)
153    if (e.origin.kind === 'person' && active === options.game) {
154      await $.fs.write(activePath, '')
155    }
156    return closed
157  })
158
159  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
160    const options = stage
161    if (!options || e.requestId !== options.game) {
162      return next(e)
163    }
164    lastSummary = options.scene.summary()
165    const logRows = e.props.placement === 'dock' ? Math.max(0, e.props.scroll.bodyRows - options.rows - 3) : 0
166    const lines = options.scene.log().slice(0, logRows)
167    if (e.surface !== 'terminal' || e.props.bodyColumns < options.minColumns) {
168      const { Box, Text } = $.ui.resolve(e)
169      isRasterMounted = false
170      return (
171        <Box flexDirection="column">
172          <Text>{lastSummary}</Text>
173          {e.surface === 'terminal' ? <Text dimColor>Widen this pane to see the scene.</Text> : null}
174        </Box>
175      )
176    }
177    const { Box, Raster, Text } = $.ui.resolve(e)
178    const columns = Math.min(e.props.bodyColumns, options.maxColumns)
179    options.scene.resize(columns)
180    isRasterMounted = true
181    lastCells = encodeCells(options.scene.frame())
182    return (
183      <Box flexDirection="column">
184        <Raster key={`${options.game}:stage`} columns={columns} rows={options.rows} cells={lastCells} />
185        <Text dimColor wrap="truncate">{lastSummary}</Text>
186        {lines.length > 0 ? (
187          <Box flexDirection="column" marginTop={1}>
188            {lines.map((line, index) => (
189              <Text key={`log-${index}`} dimColor={index > 0} wrap="truncate">
190                {line}
191              </Text>
192            ))}
193          </Box>
194        ) : null}
195      </Box>
196    )
197  })
198}
199
hooks/sim/constants.ts 45 lines
1export const FRAME_MS = 40
2export const STAGE_ROWS = 8
3export const STAGE_HEIGHT = STAGE_ROWS * 2
4export const FLOOR_TOP = 14
5export const MIN_COLUMNS = 44
6export const MAX_COLUMNS = 72
7export const MIN_BAND_ROWS = 10
8export const STAGE_KEY = 'stage'
9
10export const HOME_X = 2
11export const SAMURAI_TOP = 1
12export const STRIKE_REACH = 20
13export const DASH_SPEED_PIXELS_PER_SECOND = 320
14export const RETURN_SPEED_PIXELS_PER_SECOND = 130
15
16export const SLASH_MS = 420
17export const IMPACT_PROGRESS = 0.46
18export const STRIKE_START = 0.3
19export const STRIKE_END = 0.62
20export const ARC_TRAIL_PROGRESS = 0.3
21export const CHEER_MS = 1500
22export const HURT_MS = 360
23
24export const MONSTER_WIDTH = 12
25export const MONSTER_GAP = 3
26export const MONSTER_FRONT_X = 34
27export const MONSTER_SPEED_PIXELS_PER_SECOND = 15
28export const KNOCKBACK_PIXELS = 4
29export const MAX_ALIVE_MONSTERS = 4
30export const DEATH_MS = 760
31export const FLASH_MS = 120
32export const WALK_FRAME_MS = 280
33export const FLURRY_WINDOW_MS = 1500
34export const FLURRY_KILLS = 3
35
36export const RIVAL_STAND_OFFSET = 25
37export const RIVAL_WALK_PIXELS_PER_SECOND = 36
38export const RIVAL_RETREAT_PIXELS_PER_SECOND = 110
39export const EXCHANGE_MS = 560
40export const DUEL_FINISH_MS = 1400
41export const KI_INTERVAL_MS = 380
42export const WAVE_DOOM_DELAY_MS = 1700
43export const WAVE_STAGGER_MS = 420
44export const LOG_LIMIT = 8
45
hooks/sim/dojo.ts 196 lines
1import { clearBitmap, createBitmap } from '../shared/pixel/bitmap'
2import type { Bitmap } from '../shared/pixel/bitmap'
3import { nextSeasonIn, seasonAt } from '../shared/pixel/seasons'
4import type { Season } from '../shared/pixel/seasons'
5import { createWeather } from '../shared/pixel/weather'
6import { drawScene, treeCenterOf } from '../draw/scene'
7import { createAmbient } from './ambient'
8import type { AmbientWorld } from './ambient'
9import { FLOOR_TOP, HURT_MS, MAX_ALIVE_MONSTERS, MAX_COLUMNS, STAGE_HEIGHT } from './constants'
10import { advanceEffects } from './effects'
11import { createLedger } from './ledger'
12import { advanceMonsters, applyStrike, createMonster, nearestDoomed } from './monsters'
13import { rankOf } from './rank'
14import type { Rank } from './rank'
15import { advanceSamurai, createSamurai } from './samurai'
16import type { Effect, KillEvent, KillTally, Monster, MonsterKind, MonsterPlan } from './types'
17import { singularOf } from './waves'
18
19const KIND_ORDER: readonly [MonsterKind, ...MonsterKind[]] = ['codex', 'gemini', 'chatgpt']
20
21export function createDojo(options: { seed?: number } = {}) {
22  const seed = options.seed ?? 11
23  const samurai = createSamurai()
24  const ambient = createAmbient({ seed })
25  const weather = createWeather({ seed: seed + 5 })
26  const ledger = createLedger()
27  const overflowPlans = new Map<number, MonsterPlan>()
28  let monsters: Monster[] = []
29  let effects: Effect[] = []
30  let nextId = 1
31  let columns = MAX_COLUMNS
32  let bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
33  let epochMs = 0
34  let season: Season = seasonAt(0)
35
36  function recordKill(plan: MonsterPlan): void {
37    if (ledger.recordKill({ plan, clockMs: samurai.clockMs }).isFlurry) {
38      effects.push({ kind: 'flurry', x: samurai.x + 14, y: FLOOR_TOP - 6, ageMs: 0 })
39    }
40  }
41
42  function resolveImpact(target: Monster): void {
43    const outcome = applyStrike(target)
44    const impact = { x: target.x + 1, y: FLOOR_TOP - 6, ageMs: 0 }
45    if (outcome === 'parried') {
46      effects.push({ ...impact, kind: 'clang' })
47      samurai.hurtMs = HURT_MS
48      return
49    }
50    effects.push({ ...impact, kind: 'hit' })
51    if (!target.isAmbient) {
52      recordKill(target)
53    }
54  }
55
56  function takeId(): number {
57    const id = nextId
58    nextId += 1
59    return id
60  }
61
62  const world: AmbientWorld = {
63    samurai,
64    monsters,
65    effects,
66    columns,
67    season,
68    spawn: plan => {
69      const id = takeId()
70      monsters.push(createMonster({ ...plan, id, isAmbient: true, label: 'Ambient' }))
71      return id
72    },
73    doom: id => {
74      const monster = monsters.find(candidate => candidate.id === id)
75      if (monster && !monster.isDoomed) {
76        monster.isDoomed = true
77        monster.hitsRemaining = monster.isElite ? 2 : 1
78      }
79    },
80    note: ledger.note,
81  }
82
83  return {
84    begin(startMs: number): void {
85      epochMs = startMs
86      season = seasonAt(epochMs)
87      ambient.reseed(seed + Math.floor(startMs / 1000))
88    },
89
90    resize(nextColumns: number): void {
91      if (nextColumns !== columns) {
92        columns = nextColumns
93        bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
94      }
95    },
96
97    restore: ledger.restore,
98    tally: (): KillTally => ledger.tally(),
99    lifetimeKills: (): number => ledger.lifetimeKills(),
100    flurries: (): number => ledger.flurries(),
101    rank: (): Rank => rankOf(ledger.lifetimeKills()),
102    season: (): Season => season,
103    log: (): readonly string[] => ledger.lines(),
104
105    summary(): string {
106      const upcoming = nextSeasonIn(epochMs + samurai.clockMs)
107      const minutes = Math.max(1, Math.ceil(upcoming.remainingMs / 60000))
108      const lifetime = ledger.lifetimeKills()
109      return `${rankOf(lifetime).title}  ${ledger.slain()} slain  ${lifetime} lifetime  ${season.label}, ${upcoming.label} in ${minutes}m`
110    },
111
112    spawn(plan: { isElite: boolean; label: string }): number {
113      const id = takeId()
114      const kind = KIND_ORDER[(id - 1) % KIND_ORDER.length] ?? 'codex'
115      const aliveCount = monsters.filter(monster => monster.phase === 'alive').length
116      if (aliveCount >= MAX_ALIVE_MONSTERS) {
117        overflowPlans.set(id, { kind, isElite: plan.isElite, label: plan.label })
118        return id
119      }
120      monsters.push(createMonster({ id, kind, isElite: plan.isElite, isAmbient: false, label: plan.label, x: columns }))
121      return id
122    },
123
124    defeat(outcome: { id: number; isFailure: boolean }): void {
125      const monster = monsters.find(candidate => candidate.id === outcome.id)
126      if (monster) {
127        monster.isDoomed = true
128        monster.hitsRemaining = (monster.isElite ? 2 : 1) + (outcome.isFailure ? 1 : 0)
129        if (outcome.isFailure) {
130          ledger.note(`${monster.label} failed: the ${singularOf(monster.kind)} parries once`)
131        }
132        return
133      }
134      const plan = overflowPlans.get(outcome.id)
135      if (plan) {
136        overflowPlans.delete(outcome.id)
137        recordKill(plan)
138      }
139    },
140
141    celebrate(): void {
142      samurai.isCheerRequested = true
143      ledger.note('Turn complete: the samurai salutes')
144    },
145
146    tick(step: { dtMs: number }): KillEvent[] {
147      const { dtMs } = step
148      const nextSeason = seasonAt(epochMs + samurai.clockMs + dtMs)
149      if (nextSeason.name !== season.name) {
150        ledger.note(`${nextSeason.label} begins`)
151      }
152      season = nextSeason
153      const hasWork =
154        monsters.some(monster => !monster.isAmbient && monster.phase === 'alive') ||
155        overflowPlans.size > 0 ||
156        samurai.isCheerRequested
157      Object.assign(world, { monsters, effects, columns, season })
158      if (hasWork) {
159        ambient.interrupt(world)
160      }
161      ambient.advance({ world, dtMs, hasWork })
162      const target = nearestDoomed(monsters)
163      const samuraiStep = advanceSamurai({ samurai, dtMs, target })
164      if (samuraiStep.didStartDash) {
165        effects.push({ kind: 'dust', x: samurai.x + 2, y: FLOOR_TOP - 1, ageMs: 0 })
166      }
167      if (samuraiStep.didImpact && target) {
168        resolveImpact(target)
169      }
170      monsters = advanceMonsters({ monsters, dtMs })
171      effects = advanceEffects({ effects, dtMs })
172      const crown = { x: treeCenterOf(columns) - 7, y: 1, width: 15, height: 8 }
173      weather.advance({ dtMs, season, width: columns, groundY: FLOOR_TOP, sources: [crown] })
174      return ledger.takeKills()
175    },
176
177    frame(): Bitmap {
178      clearBitmap(bitmap)
179      drawScene({
180        bitmap,
181        monsters,
182        effects,
183        samurai,
184        duel: ambient.duel(),
185        totalKills: ledger.slain(),
186        rank: rankOf(ledger.lifetimeKills()),
187        season,
188        weather,
189      })
190      return bitmap
191    },
192  }
193}
194
195export type Dojo = ReturnType<typeof createDojo>
196
hooks/sim/rank.ts 29 lines
1import type { Color } from '../shared/pixel/bitmap'
2
3export type Rank = {
4  title: string
5  minimumKills: number
6  bright: Color
7  dark: Color
8}
9
10const RANKS: readonly [Rank, ...Rank[]] = [
11  { title: 'Ronin', minimumKills: 0, bright: 0xd8372e, dark: 0x8a1c1c },
12  { title: 'Hatamoto', minimumKills: 100, bright: 0x3b7dd8, dark: 0x1f4a8a },
13  { title: 'Daimyo', minimumKills: 500, bright: 0x9b5de5, dark: 0x5a2d91 },
14  { title: 'Shogun', minimumKills: 2000, bright: 0xf2c14e, dark: 0xa8801f },
15]
16
17export const BASE_SASH: Color = RANKS[0].bright
18
19export function rankOf(lifetimeKills: number): Rank {
20  return RANKS.reduce<Rank>(
21    (current, rank) => (lifetimeKills >= rank.minimumKills ? rank : current),
22    RANKS[0],
23  )
24}
25
26export function nextRankOf(lifetimeKills: number): Rank | undefined {
27  return RANKS.find(rank => rank.minimumKills > lifetimeKills)
28}
29
hooks/shared/noop.ts 4 lines
1export function noop(): undefined {
2  return undefined
3}
4
hooks/shared/pixel/bitmap.ts 83 lines
1export type Color = number
2
3export type Bitmap = {
4  width: number
5  height: number
6  pixels: (Color | null)[]
7}
8
9export type Tint = (color: Color) => Color
10
11export function createBitmap(options: { width: number; height: number }): Bitmap {
12  const { width, height } = options
13  return { width, height, pixels: Array.from({ length: width * height }, () => null) }
14}
15
16export function setPixel(options: {
17  bitmap: Bitmap
18  x: number
19  y: number
20  color: Color
21}): void {
22  const { bitmap, color } = options
23  const x = Math.round(options.x)
24  const y = Math.round(options.y)
25  if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
26    return
27  }
28  bitmap.pixels[y * bitmap.width + x] = color
29}
30
31export function getPixel(options: { bitmap: Bitmap; x: number; y: number }): Color | null {
32  const { bitmap, x, y } = options
33  if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
34    return null
35  }
36  return bitmap.pixels[y * bitmap.width + x] ?? null
37}
38
39export function fillRect(options: {
40  bitmap: Bitmap
41  x: number
42  y: number
43  width: number
44  height: number
45  color: Color
46}): void {
47  const { bitmap, color } = options
48  for (let row = 0; row < options.height; row += 1) {
49    for (let column = 0; column < options.width; column += 1) {
50      setPixel({ bitmap, x: options.x + column, y: options.y + row, color })
51    }
52  }
53}
54
55export function stamp(options: {
56  target: Bitmap
57  source: Bitmap
58  x: number
59  y: number
60  isFlipped?: boolean
61  tint?: Tint
62}): void {
63  const { target, source, tint } = options
64  source.pixels.forEach((color, index) => {
65    if (color === null) {
66      return
67    }
68    const column = index % source.width
69    const row = Math.floor(index / source.width)
70    const offset = options.isFlipped ? source.width - 1 - column : column
71    setPixel({
72      bitmap: target,
73      x: options.x + offset,
74      y: options.y + row,
75      color: tint ? tint(color) : color,
76    })
77  })
78}
79
80export function clearBitmap(bitmap: Bitmap): void {
81  bitmap.pixels.fill(null)
82}
83
hooks/shared/pixel/cells.ts 44 lines
1import { getPixel } from './bitmap'
2import type { Bitmap } from './bitmap'
3
4const TERMINAL_DEFAULT = 0x01000000
5const SPACE = 0x20
6const UPPER_HALF_BLOCK = 0x2580
7const LOWER_HALF_BLOCK = 0x2584
8
9export function cellRowsOf(bitmap: Bitmap): number {
10  return Math.floor(bitmap.height / 2)
11}
12
13export function toCellWords(bitmap: Bitmap): Uint32Array {
14  const rows = cellRowsOf(bitmap)
15  const words = new Uint32Array(bitmap.width * rows * 3)
16  for (let row = 0; row < rows; row += 1) {
17    for (let column = 0; column < bitmap.width; column += 1) {
18      const upper = getPixel({ bitmap, x: column, y: row * 2 })
19      const lower = getPixel({ bitmap, x: column, y: row * 2 + 1 })
20      const offset = (row * bitmap.width + column) * 3
21      if (upper === null && lower === null) {
22        words.set([SPACE, TERMINAL_DEFAULT, TERMINAL_DEFAULT], offset)
23      } else if (upper === null && lower !== null) {
24        words.set([LOWER_HALF_BLOCK, lower, TERMINAL_DEFAULT], offset)
25      } else if (upper !== null) {
26        words.set([UPPER_HALF_BLOCK, upper, lower ?? TERMINAL_DEFAULT], offset)
27      }
28    }
29  }
30  return words
31}
32
33function toBinaryString(bytes: Uint8Array): string {
34  let binary = ''
35  bytes.forEach(byte => {
36    binary += String.fromCharCode(byte)
37  })
38  return binary
39}
40
41export function encodeCells(bitmap: Bitmap): string {
42  return btoa(toBinaryString(new Uint8Array(toCellWords(bitmap).buffer)))
43}
44
hooks/shared/pixel/seasons.ts 154 lines
1import type { Color } from './bitmap'
2import { mixColors } from './colors'
3
4export type SeasonName = 'winter' | 'blossom' | 'summer' | 'autumn'
5
6export type ParticleKind = 'snow' | 'petal' | 'firefly' | 'leaf'
7
8export type SeasonPalette = {
9  ground: Color
10  groundShade: Color
11  foliageLight: Color
12  foliage: Color
13  foliageDark: Color
14  accent: Color
15  trunk: Color
16  skyTop: Color
17  skyLow: Color
18  moon: Color
19  particles: readonly [Color, ...Color[]]
20}
21
22export type Season = {
23  name: SeasonName
24  label: string
25  progress: number
26  particle: ParticleKind
27  palette: SeasonPalette
28}
29
30export const SEASON_MS = 5 * 60 * 1000
31const BLEND_MS = 20 * 1000
32
33const ORDER: readonly [SeasonName, ...SeasonName[]] = ['winter', 'blossom', 'summer', 'autumn']
34
35const LABELS: Record<SeasonName, string> = {
36  winter: 'Winter',
37  blossom: 'Cherry blossom',
38  summer: 'Summer',
39  autumn: 'Autumn',
40}
41
42const PARTICLES: Record<SeasonName, ParticleKind> = {
43  winter: 'snow',
44  blossom: 'petal',
45  summer: 'firefly',
46  autumn: 'leaf',
47}
48
49export const SEASON_PALETTES: Record<SeasonName, SeasonPalette> = {
50  winter: {
51    ground: 0xeef4fb,
52    groundShade: 0xb9c8da,
53    foliageLight: 0xf4f8ff,
54    foliage: 0xd5e2f0,
55    foliageDark: 0x9fb3c8,
56    accent: 0xffffff,
57    trunk: 0x4a3a32,
58    skyTop: 0x1b2440,
59    skyLow: 0x5d7398,
60    moon: 0xdfe9f5,
61    particles: [0xffffff, 0xe3eefa, 0xc6d8ec],
62  },
63  blossom: {
64    ground: 0x7fbf6a,
65    groundShade: 0x4f8f45,
66    foliageLight: 0xffd6e5,
67    foliage: 0xf5a3c0,
68    foliageDark: 0xd8739a,
69    accent: 0xffffff,
70    trunk: 0x5a3a2c,
71    skyTop: 0x3a3f7a,
72    skyLow: 0xe8a7c2,
73    moon: 0xffe6ef,
74    particles: [0xffc2d8, 0xf59bbd, 0xffe4ee],
75  },
76  summer: {
77    ground: 0x5fae4a,
78    groundShade: 0x3c7f32,
79    foliageLight: 0x8fd16a,
80    foliage: 0x4fa83d,
81    foliageDark: 0x2f7a2b,
82    accent: 0xf2e86b,
83    trunk: 0x5b3b25,
84    skyTop: 0x2d64b3,
85    skyLow: 0x9fd4f2,
86    moon: 0xffe9a3,
87    particles: [0xf8f27a, 0xd9f56b, 0xfff6b8],
88  },
89  autumn: {
90    ground: 0xb9984f,
91    groundShade: 0x7d6233,
92    foliageLight: 0xf7b955,
93    foliage: 0xe0752e,
94    foliageDark: 0xa8401f,
95    accent: 0xf2d04e,
96    trunk: 0x4f3322,
97    skyTop: 0x3b2a4f,
98    skyLow: 0xe59a5a,
99    moon: 0xffb866,
100    particles: [0xe8742c, 0xd14b23, 0xf2b441, 0x9b5a2a],
101  },
102}
103
104function blendPalettes(options: { from: SeasonPalette; to: SeasonPalette; amount: number }): SeasonPalette {
105  const { from, to, amount } = options
106  const mix = (start: Color, end: Color): Color => mixColors({ from: start, to: end, amount })
107  return {
108    ground: mix(from.ground, to.ground),
109    groundShade: mix(from.groundShade, to.groundShade),
110    foliageLight: mix(from.foliageLight, to.foliageLight),
111    foliage: mix(from.foliage, to.foliage),
112    foliageDark: mix(from.foliageDark, to.foliageDark),
113    accent: mix(from.accent, to.accent),
114    trunk: mix(from.trunk, to.trunk),
115    skyTop: mix(from.skyTop, to.skyTop),
116    skyLow: mix(from.skyLow, to.skyLow),
117    moon: mix(from.moon, to.moon),
118    particles: amount < 0.5 ? from.particles : to.particles,
119  }
120}
121
122function seasonName(index: number): SeasonName {
123  return ORDER[((index % ORDER.length) + ORDER.length) % ORDER.length] ?? ORDER[0]
124}
125
126export function seasonAt(epochMs: number): Season {
127  const index = Math.floor(epochMs / SEASON_MS)
128  const intoSeasonMs = epochMs - index * SEASON_MS
129  const name = seasonName(index)
130  const previous = seasonName(index - 1)
131  const target = SEASON_PALETTES[name]
132  const palette =
133    intoSeasonMs < BLEND_MS
134      ? blendPalettes({ from: SEASON_PALETTES[previous], to: target, amount: intoSeasonMs / BLEND_MS })
135      : target
136  return {
137    name,
138    label: LABELS[name],
139    progress: intoSeasonMs / SEASON_MS,
140    particle: PARTICLES[name],
141    palette,
142  }
143}
144
145export function nextSeasonIn(epochMs: number): { name: SeasonName; label: string; remainingMs: number } {
146  const index = Math.floor(epochMs / SEASON_MS)
147  const name = seasonName(index + 1)
148  return { name, label: LABELS[name], remainingMs: (index + 1) * SEASON_MS - epochMs }
149}
150
151export function startOfSeason(options: { name: SeasonName; cycle?: number }): number {
152  return (ORDER.indexOf(options.name) + (options.cycle ?? 0) * ORDER.length) * SEASON_MS
153}
154
hooks/shared/pixel/weather.ts 149 lines
1import { setPixel } from './bitmap'
2import type { Bitmap, Color } from './bitmap'
3import type { ParticleKind, Season } from './seasons'
4
5export type Area = { x: number; y: number; width: number; height: number }
6
7type Particle = {
8  kind: ParticleKind
9  x: number
10  y: number
11  speedX: number
12  speedY: number
13  phase: number
14  color: Color
15  ageMs: number
16  lifeMs: number
17  restMs: number
18}
19
20type KindStyle = { perSecond: number; fallMin: number; fallMax: number; restMs: number; fromSources: number }
21
22const REFERENCE_WIDTH = 72
23
24const STYLES: Record<ParticleKind, KindStyle> = {
25  snow: { perSecond: 2.6, fallMin: 3, fallMax: 6, restMs: 4500, fromSources: 0 },
26  petal: { perSecond: 1.8, fallMin: 4, fallMax: 7, restMs: 5000, fromSources: 0.6 },
27  firefly: { perSecond: 0.9, fallMin: 0, fallMax: 0, restMs: 0, fromSources: 0 },
28  leaf: { perSecond: 1.4, fallMin: 6, fallMax: 10, restMs: 9000, fromSources: 0.75 },
29}
30
31function seeded(seed: number): () => number {
32  let state = seed >>> 0 || 1
33  return () => {
34    state = (Math.imul(state, 1664525) + 1013904223) >>> 0
35    return state / 4294967296
36  }
37}
38
39export function createWeather(options: { seed: number; density?: number }) {
40  const random = seeded(options.seed)
41  const density = options.density ?? 1
42  let particles: Particle[] = []
43  let spawnDebt = 0
44
45  function pick<T>(list: readonly [T, ...T[]]): T {
46    return list[Math.floor(random() * list.length)] ?? list[0]
47  }
48
49  function spawn(spawnOptions: { season: Season; width: number; groundY: number; sources: readonly Area[] }): void {
50    const { season, width, groundY, sources } = spawnOptions
51    const kind = season.particle
52    const style = STYLES[kind]
53    const source = random() < style.fromSources ? sources[Math.floor(random() * sources.length)] : undefined
54    const base = {
55      kind,
56      phase: random() * Math.PI * 2,
57      color: pick(season.palette.particles),
58      ageMs: 0,
59      restMs: 0,
60    }
61    if (kind === 'firefly') {
62      particles.push({
63        ...base,
64        x: random() * width,
65        y: 2 + random() * Math.max(1, groundY - 4),
66        speedX: (random() - 0.5) * 4,
67        speedY: (random() - 0.5) * 2,
68        lifeMs: 6000 + random() * 5000,
69      })
70      return
71    }
72    particles.push({
73      ...base,
74      x: source ? source.x + random() * source.width : random() * (width + 12),
75      y: source ? source.y + random() * source.height : -1,
76      speedX: kind === 'snow' ? 0 : -1.5 - random() * 2.5,
77      speedY: style.fallMin + random() * (style.fallMax - style.fallMin),
78      lifeMs: Number.POSITIVE_INFINITY,
79    })
80  }
81
82  function move(moveOptions: { particle: Particle; dtMs: number; groundY: number }): void {
83    const { particle, dtMs, groundY } = moveOptions
84    const seconds = dtMs / 1000
85    particle.ageMs += dtMs
86    if (particle.restMs > 0) {
87      particle.restMs -= dtMs
88      return
89    }
90    const sway = Math.sin(particle.ageMs / 420 + particle.phase)
91    if (particle.kind === 'firefly') {
92      particle.speedX += (Math.sin(particle.ageMs / 900 + particle.phase) - particle.speedX * 0.3) * seconds
93      particle.speedY += (Math.cos(particle.ageMs / 700 + particle.phase) - particle.speedY * 0.4) * seconds
94      particle.x += particle.speedX * seconds * 3
95      particle.y = Math.min(groundY - 2, Math.max(1, particle.y + particle.speedY * seconds * 2))
96      return
97    }
98    const flutter = particle.kind === 'snow' ? 1.6 : particle.kind === 'petal' ? 3.5 : 5
99    particle.x += (particle.speedX + sway * flutter) * seconds
100    particle.y += particle.speedY * seconds
101    if (particle.y >= groundY - 1) {
102      particle.y = groundY - 1
103      particle.restMs = STYLES[particle.kind].restMs
104      particle.lifeMs = particle.ageMs + particle.restMs
105    }
106  }
107
108  return {
109    advance(advanceOptions: {
110      dtMs: number
111      season: Season
112      width: number
113      groundY: number
114      sources?: readonly Area[]
115    }): void {
116      const { dtMs, season, width, groundY } = advanceOptions
117      const sources = advanceOptions.sources ?? []
118      spawnDebt += (dtMs / 1000) * STYLES[season.particle].perSecond * density * (width / REFERENCE_WIDTH)
119      while (spawnDebt >= 1) {
120        spawnDebt -= 1
121        spawn({ season, width, groundY, sources })
122      }
123      particles.forEach(particle => move({ particle, dtMs, groundY }))
124      particles = particles.filter(
125        particle => particle.ageMs < particle.lifeMs && particle.x > -4 && particle.x < width + 16,
126      )
127    },
128
129    draw(bitmap: Bitmap): void {
130      particles.forEach(particle => {
131        if (particle.kind === 'firefly' && Math.sin(particle.ageMs / 260 + particle.phase) < 0.1) {
132          return
133        }
134        setPixel({ bitmap, x: particle.x, y: particle.y, color: particle.color })
135        const isWide = particle.kind === 'leaf' || (particle.kind === 'petal' && particle.restMs > 0)
136        if (isWide && Math.sin(particle.ageMs / 300 + particle.phase) > 0) {
137          setPixel({ bitmap, x: particle.x + 1, y: particle.y, color: particle.color })
138        }
139      })
140    },
141
142    count(): number {
143      return particles.length
144    },
145  }
146}
147
148export type Weather = ReturnType<typeof createWeather>
149
hooks/draw/scene.ts 48 lines
1import type { Bitmap } from '../shared/pixel/bitmap'
2import type { Season } from '../shared/pixel/seasons'
3import { drawSeasonTree } from '../shared/pixel/tree'
4import type { Weather } from '../shared/pixel/weather'
5import { FLOOR_TOP } from '../sim/constants'
6import type { Rank } from '../sim/rank'
7import type { Duel, Effect, Monster, Samurai } from '../sim/types'
8import { drawBackdrop } from './backdrop'
9import { drawEffect } from './effects'
10import { drawKillCount } from './hud'
11import { drawMonster } from './monsters'
12import { drawRival } from './rival'
13import { drawSamurai } from './samurai'
14
15export function treeCenterOf(width: number): number {
16  return Math.max(24, width - 22)
17}
18
19export function drawScene(options: {
20  bitmap: Bitmap
21  monsters: readonly Monster[]
22  effects: readonly Effect[]
23  samurai: Samurai
24  duel: Duel | undefined
25  totalKills: number
26  rank: Rank
27  season: Season
28  weather: Weather
29}): void {
30  const { bitmap, rank, season, samurai, duel } = options
31  drawBackdrop({ bitmap, season })
32  drawSeasonTree({
33    bitmap,
34    centerX: treeCenterOf(bitmap.width),
35    groundY: FLOOR_TOP,
36    season,
37    clockMs: samurai.clockMs,
38  })
39  drawKillCount({ bitmap, total: options.totalKills, iconColor: rank.bright })
40  options.monsters.forEach(monster => drawMonster({ bitmap, monster }))
41  if (duel) {
42    drawRival({ bitmap, duel, clockMs: samurai.clockMs })
43  }
44  drawSamurai({ bitmap, samurai, rank, duel })
45  options.weather.draw(bitmap)
46  options.effects.forEach(effect => drawEffect({ bitmap, effect }))
47}
48
hooks/sim/ambient.ts 179 lines
1import type { Season } from '../shared/pixel/seasons'
2import { FLOOR_TOP, HOME_X, KI_INTERVAL_MS, SAMURAI_TOP, WAVE_DOOM_DELAY_MS, WAVE_STAGGER_MS } from './constants'
3import { advanceDuel, attackerOf, createDuel, retreat } from './duel'
4import { describeWave, MEDITATION_LINES, pickWave } from './waves'
5import type { Activity, Duel, Effect, Monster, MonsterKind, Samurai } from './types'
6
7type AmbientKind = 'kata' | 'meditate' | 'duel' | 'wave' | 'rest'
8
9export type AmbientWorld = {
10  samurai: Samurai
11  monsters: readonly Monster[]
12  effects: Effect[]
13  columns: number
14  season: Season
15  spawn: (plan: { kind: MonsterKind; isElite: boolean; x: number }) => number
16  doom: (id: number) => void
17  note: (line: string) => void
18}
19
20const WEIGHTS: readonly { kind: AmbientKind; weight: number }[] = [
21  { kind: 'duel', weight: 3 },
22  { kind: 'wave', weight: 3 },
23  { kind: 'meditate', weight: 2 },
24  { kind: 'kata', weight: 2 },
25]
26
27const ACTIVITY: Record<AmbientKind, Activity> = {
28  kata: 'kata',
29  meditate: 'meditate',
30  duel: 'duel',
31  wave: 'guard',
32  rest: 'guard',
33}
34
35const KATA_LINES = ['Practices kata', 'Drills the rising cut', 'Drills the falling cut']
36
37function seeded(seed: number): () => number {
38  let state = Math.imul(seed ^ 0x9e3779b9, 2654435761) >>> 0 || 7
39  return () => {
40    state = (Math.imul(state, 1664525) + 1013904223) >>> 0
41    return state / 4294967296
42  }
43}
44
45export function createAmbient(options: { seed: number }) {
46  let random = seeded(options.seed)
47  let current: { kind: AmbientKind; ms: number; durationMs: number } = { kind: 'rest', ms: 0, durationMs: 600 }
48  let previous: AmbientKind = 'rest'
49  let duel: Duel | undefined
50  let dooms: { id: number; atMs: number }[] = []
51  let sinceKiMs = 0
52
53  function choose(): AmbientKind {
54    const choices = WEIGHTS.filter(entry => entry.kind !== previous)
55    const total = choices.reduce((sum, entry) => sum + entry.weight, 0)
56    let roll = random() * total
57    for (const entry of choices) {
58      roll -= entry.weight
59      if (roll < 0) {
60        return entry.kind
61      }
62    }
63    return 'kata'
64  }
65
66  function begin(world: AmbientWorld): void {
67    const nextKind = current.kind === 'rest' ? choose() : 'rest'
68    if (current.kind !== 'rest') {
69      previous = current.kind
70    }
71    current = { kind: nextKind, ms: 0, durationMs: 0 }
72    world.samurai.activity = ACTIVITY[nextKind]
73    world.samurai.trainingMs = 0
74    if (nextKind === 'rest') {
75      current.durationMs = 500 + random() * 600
76    } else if (nextKind === 'kata') {
77      current.durationMs = 5000 + random() * 2500
78      world.note(KATA_LINES[Math.floor(random() * KATA_LINES.length)] ?? 'Practices kata')
79    } else if (nextKind === 'meditate') {
80      current.durationMs = 7000 + random() * 3000
81      world.note(MEDITATION_LINES[world.season.name])
82    } else if (nextKind === 'duel') {
83      duel = createDuel({ columns: world.columns, isWin: random() < 0.7, exchanges: 3 + Math.floor(random() * 4) })
84      world.note('A wandering ronin challenges the samurai')
85    } else {
86      const wave = pickWave(random)
87      dooms = wave.map((plan, index) => ({
88        id: world.spawn({ ...plan, x: world.columns + 2 + index * 15 }),
89        atMs: WAVE_DOOM_DELAY_MS + index * WAVE_STAGGER_MS,
90      }))
91      world.note(describeWave(wave))
92    }
93  }
94
95  function advanceDuelIn(step: { world: AmbientWorld; dtMs: number }): boolean {
96    const { world, dtMs } = step
97    if (!duel) {
98      return true
99    }
100    const outcome = advanceDuel({ duel, dtMs, columns: world.columns })
101    if (outcome.didClash) {
102      const lean = attackerOf(duel) === 'samurai' ? 1 : -1
103      world.effects.push({ kind: 'clang', x: HOME_X + 20 + lean, y: FLOOR_TOP - 8, ageMs: 0 })
104    }
105    if (outcome.didFinishStrike) {
106      world.effects.push({ kind: 'hit', x: duel.rivalX + 4, y: FLOOR_TOP - 7, ageMs: 0 })
107      world.note('One clean cut ends the duel')
108    }
109    if (outcome.didBow) {
110      world.note('The duel ends in a bow')
111    }
112    if (outcome.isOver) {
113      duel = undefined
114      return true
115    }
116    return false
117  }
118
119  return {
120    reseed(seed: number): void {
121      random = seeded(seed)
122    },
123
124    duel(): Duel | undefined {
125      return duel
126    },
127
128    interrupt(world: AmbientWorld): void {
129      if (duel && !duel.isRetreat && duel.phase !== 'leave' && !duel.hasFinishStruck) {
130        retreat(duel)
131        world.note('The ronin flees: real work arrives')
132      }
133      dooms.forEach(entry => world.doom(entry.id))
134      dooms = []
135      if (current.kind !== 'rest') {
136        previous = current.kind
137        current = { kind: 'rest', ms: 0, durationMs: 400 }
138        world.samurai.activity = 'guard'
139      }
140    },
141
142    advance(step: { world: AmbientWorld; dtMs: number; hasWork: boolean }): void {
143      const { world, dtMs, hasWork } = step
144      const isDuelDone = advanceDuelIn({ world, dtMs })
145      if (hasWork || world.samurai.mode !== 'train') {
146        return
147      }
148      current.ms += dtMs
149      dooms = dooms.filter(entry => {
150        if (current.ms < entry.atMs) {
151          return true
152        }
153        world.doom(entry.id)
154        return false
155      })
156      if (current.kind === 'meditate') {
157        sinceKiMs += dtMs
158        if (sinceKiMs >= KI_INTERVAL_MS) {
159          sinceKiMs = 0
160          world.effects.push({ kind: 'ki', x: world.samurai.x + 3 + random() * 9, y: SAMURAI_TOP + 6, ageMs: 0 })
161        }
162      }
163      const isWaveDone =
164        current.kind === 'wave' && dooms.length === 0 && !world.monsters.some(monster => monster.isAmbient && monster.phase === 'alive')
165      const isDone =
166        current.kind === 'duel'
167          ? isDuelDone
168          : current.kind === 'wave'
169            ? isWaveDone && current.ms > 1000
170            : current.ms >= current.durationMs
171      if (isDone) {
172        begin(world)
173      }
174    },
175  }
176}
177
178export type Ambient = ReturnType<typeof createAmbient>
179