SLOPSHOPPER

MC PvP Bots

Fight Minecraft PvP bots while Claude works, and get handed back the moment it's done or needs you.

newspinnerguardcommandtoaststatus
v0.1.2MITupdated 2026-10-06FatihBastan/mc-pvp-bots
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mc-pvp-bots
› 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 ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pvp ⎿ mc-pvp-bots: Off (not set up: /pvp setup). ⎿ mc-pvp-bots: 8 bots, mixed · drop in after 10s · launcher prism ⎿ mc-pvp-bots: /pvp [on | off | setup | status | play | stop | bots 4|6|8 | difficulty mixed|easy|medium|hard] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

⚔ MC PvP Bots

Fight Minecraft PvP bots while Claude works. Get handed back the moment it's done.

You send Claude a big task. Instead of watching a spinner, you're dropped into a small arena with up to 8 bots. When Claude finishes, or needs an answer from you, the fight freezes and your terminal comes back.

It all runs on your own computer. No servers to pay for, no accounts besides your own Minecraft.


1. Before you start

You need:

  • Minecraft: Java Edition. You need to own it.
  • Prism Launcher. Open it once and add your Microsoft account. You don't need to create any profile; the mod makes its own.
  • Java 21 or newer
  • Node.js 20 or newer
  • Claude Code 2.1.287 or newer. Check with claude --version, and update with claude update.
  • 4–6 GB of free memory while you play

On Windows, install the first three with:

winget install --exact --id PrismLauncher.PrismLauncher --source winget
winget install --exact --id EclipseAdoptium.Temurin.21.JDK --source winget
winget install --exact --id OpenJS.NodeJS.LTS --source winget

On macOS:

brew install --cask prismlauncher temurin@21
brew install node

Open a new terminal afterwards so java and node are found.

Don't want Prism? Set the launcher to manual in /config and join 127.0.0.1:25599 from your normal Minecraft launcher, using version 1.21.4.

2. Install

In Claude Code:

/plugin marketplace add FatihBastan/mc-pvp-bots
/plugin install mc-pvp-bots@mc-pvp-bots
/reload-plugins

Restarting Claude Code works instead of /reload-plugins.

3. First run

/pvp on
  1. Accept Minecraft's EULA when asked. Setup runs once and takes a couple of minutes; watch the line under the prompt.
  2. Wait for "Arena ready".
  3. Try it right away with /pvp play. Minecraft opens and joins the arena. The very first launch downloads Minecraft 1.21.4 into Prism, so give it a minute.

After that, just use Claude as usual. Any task that keeps Claude busy for more than 10 seconds drops you in.

How it plays

  • Short questions never interrupt you. You're only pulled in once Claude has been working for 10 seconds.
  • 3… 2… 1… FIGHT. Every time you enter, you get a countdown where nobody can hit you.
  • Only 1–2 bots chase you at once (one more if you hit it). The rest fight each other, so you can crash their fights.
  • Need to check on Claude mid-fight? Just alt-tab. After 2 seconds without moving the camera you're marked AFK: safe, and ignored by every bot. Move the camera when you're back and you get a fresh countdown.
  • Claude needs you (a permission, a question, a form)? The bots freeze, a bell rings, a red banner says what it needs, and your terminal comes back. Answer it and you're straight back in the fight after a 3-2-1.
  • Claude's done? Everything freezes, your terminal comes back, and you get your score: Claude's done · ⚔ 3 kills, 1 death.

The spinner shows your live score while you play, e.g. ⚔ 2K 1D.

The bots

| | Reacts in | Misses | Tricks | | :- | -: | -: | :- | | Rookie | 420 ms | often | mostly walks straight at you | | Brawler | 260 ms | sometimes | strafes, the odd crit | | Duelist | 160 ms | rarely | strafes, crits, combos | | Sweat | 90 ms | almost never | all of it, all the time |

The default 8-bot lobby has 2 Rookies, 3 Brawlers, 2 Duelists and 1 Sweat. Everyone gets iron armor and a diamond sword that never breaks.

Commands

| | | | :- | :- | | /pvp | Is it on, is the arena up, who's in it | | /pvp on · /pvp off | Start or stop being dropped in. Off also closes everything. | | /pvp bots 4 · 6 · 8 | How many bots | | /pvp difficulty mixed · easy · medium · hard | How good they are | | /pvp play | Jump in right now | | /pvp stop | Close the arena now |

Timings, window switching and more are in /config, under mc-pvp-bots.

FAQ

Does it slow Claude down? No. The game runs in its own processes. Minecraft is capped at 60 fps with low render distance, the server is a tiny empty world, and the bots do nothing while frozen.

Does anything get sent online? Not from the arena. The server only accepts connections from your own computer, and it reports nothing anywhere (the server's built-in usage stats are switched off). The only downloads are the one-time setup: the Paper server, the bot library, and Minecraft itself through Prism. Minecraft talks to Microsoft as usual when you log in, like it always does. The full list is under What it runs and sends.

What happens if I close something? Nothing is left running. Close Minecraft and the bots stop. Quit or crash Claude Code and the arena pauses by itself. When nobody's used it for 15 minutes, it shuts down and closes the Minecraft it opened. It never touches your other Minecraft instances.

Can I play with friends? Not yet. It's you against bots, on your own machine.

Does it work on Windows and Linux? Yes. Switching windows works best on macOS and Linux (X11). On Wayland and some Windows setups you may have to alt-tab yourself; the game tells you when.

Why Minecraft 1.21.4? It's the version the bots are most reliable on. Prism installs it for you in a separate instance, so your own Minecraft is untouched.

What it runs and sends

Everything the mod does outside Claude Code, for anyone who wants to check before installing.

Programs it starts

  • node arena/ctl.mjs from the plugin folder, with setup, ensure or stop. ensure starts the arena in the background (node arena/daemon.mjs), and stop shuts it down.
  • The arena runs Java with the Paper server (kept in ~/.claude-pvp), listening on 127.0.0.1 only, and the bots inside its own Node process.
  • Prism Launcher, to start Minecraft in its own "Claude PvP" instance.
  • Setup, once: npm ci --omit=dev --ignore-scripts in arena/runtime, which installs the bot library exactly as the lockfile pins it and runs no install scripts.
  • To switch between the game and your terminal: PowerShell on Windows, open and osascript on macOS, xdotool or wmctrl on Linux.
  • To find and close only its own processes: ps or PowerShell, and taskkill on Windows. It checks a process's command line before closing it, so it never touches your other Minecraft.

What goes over the network

  • Setup downloads, once: the Paper server from papermc.io over https (its sha256 is checked before it's used), the bot library from the npm registry (pinned by the lockfile), and Minecraft 1.21.4 through Prism.
  • After that, the mod only talks to the arena on your own computer, at http://127.0.0.1:25601 (the control port in /config). It sends: the number of bots, the difficulty, your timing settings, the Claude Code session id (so each session gets a fresh leaderboard), and which terminal window to bring back (its app id, window id, TERM_PROGRAM, and Claude Code's process id). It never sends your prompts, Claude's answers, files or tool inputs, there or anywhere else.
  • Each of those requests carries a random token that setup creates in ~/.claude-pvp/control.json (readable only by you), so other programs on your computer can't control the arena. It isn't a login for any online service and never leaves your computer.
  • No telemetry, and Paper's usage stats are switched off.

What it reads

  • Environment: HOME or USERPROFILE (where to keep ~/.claude-pvp), and TERM_PROGRAM, WINDOWID and __CFBundleIdentifier (which window to switch back to).
  • Prism's settings, to find its instances folder. It never opens Prism's accounts file; it only checks the file isn't empty, to remind you to add your account.

What each hook does

| Hook | Why | | :- | :- | | turn.start, turn.complete | Start the 10-second drop-in timer, and hand you back when Claude is done | | tool.call | Notice when Claude asks you something (AskUserQuestion, ExitPlanMode) and when a call ends, to put you back in. Every call and its result pass through unchanged. | | classic.PermissionRequest | Notice that a permission dialog is about to show, so the fight freezes and your terminal comes back. It decides nothing: the request goes on unchanged, and Claude Code, your settings and you answer it. The mod never approves, denies or changes a permission. | | classic.Notification, classic.ElicitationResult | Notice other "Claude needs you" prompts, and forms you've filled in | | ui.render (Spinner, ToolProgress) | Show your score next to the spinner, and spot when an approved command starts running | | command.run (/pvp only) | The /pvp command | | session.start, session.end | Load your settings, and freeze the arena when you quit Claude Code |

Uninstall

/pvp off
/plugin uninstall mc-pvp-bots@mc-pvp-bots

Then delete the ~/.claude-pvp folder, and the "Claude PvP" instance in Prism if you like.


Curious how it works, or want to hack on it? See HOW-IT-WORKS.md.

Made by Fatih Baştan · MIT License · Bots by mineflayer, server by Paper

NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.

Source 1 files
hooks/register.ts 600 lines
1// mc-pvp-bots: drops you into a local Minecraft free-for-all against bots while
2// Claude works, and hands you back the moment it's done or needs you.
3//
4// The game side (Paper server, bots, launching Minecraft, switching windows)
5// is a small Node daemon in ../arena; this module decides WHEN, and talks to
6// it over HTTP on 127.0.0.1.
7
8import type { EngineInterface, PluginOptions, Register } from 'claude-code'
9
10type Api = EngineInterface
11
12type Config = {
13  bots: number
14  difficulty: string
15  dropInMs: number
16  handBackMs: number
17  graceMs: number
18  afkMs: number
19  switchWindows: boolean
20  closeMinecraft: boolean
21  launcher: string
22  prismPath: string
23  idleMinutes: number
24  serverPort: number
25  controlPort: number
26}
27
28type Drop = { kills: number; deaths: number }
29type Terminal = { bundleId?: string; windowId?: string; termProgram?: string; pid?: string }
30
31// What ctl.mjs and the daemon answer
32type Reply = {
33  ok?: boolean
34  error?: string
35  phase?: string
36  port?: number
37  token?: string
38  inGame?: boolean
39  launching?: boolean
40  joinAddress?: string
41  drop?: Drop
42  wasLive?: boolean
43  wasRunning?: boolean
44  notice?: string | null
45  paused?: boolean
46  humans?: string[]
47  bots?: string[]
48  focusCap?: number
49  done?: boolean
50  step?: string
51  detail?: string
52  warnings?: string[]
53  parent?: number
54}
55
56const USAGE = '/pvp [on | off | setup | status | play | stop | bots 4|6|8 | difficulty mixed|easy|medium|hard]'
57const ASKS_USER = new Set(['AskUserQuestion', 'ExitPlanMode'])
58// Notifications that mean Claude is blocked on you. Not idle_prompt: that
59// one fires when Claude has simply been waiting a while, e.g. while you play
60// after /pvp play, and must not pull you out of a fight
61const PROMPT_NOTIFICATION = /^(permission_prompt|elicitation(_url)?_dialog|needs_input)$/
62
63// Module state. A hot reload resets it; what must survive lives in $.store.
64let config: Config
65let isOn = false
66let isSetUp = false
67let dataDir = ''
68let terminal: Terminal = {}
69// idle: not in the arena · waiting: turn running, drop-in timer armed · playing
70let phase: 'idle' | 'waiting' | 'playing' = 'idle'
71let isTurnRunning = false
72let dropTimer: { cancel(): void } | null = null
73let scoreTimer: { cancel(): void } | null = null
74let drop: Drop = { kills: 0, deaths: 0 }
75let conn: { port: number; token: string } | null = null
76let starting: Promise<boolean> | null = null
77let isSettingUp = false
78// Tool calls under way, newest last (a permission dialog belongs to one of
79// them), and the one whose dialog pulled you out, to spot when it's answered
80const running: { id: string; tool: string }[] = []
81let awaitingToolUseId: string | null = null
82// Pulled out of a fight to answer Claude: once answered you go straight back
83// in (the arena's 3-2-1 countdown follows). The short wait absorbs a turn
84// that ends right after your answer, so you aren't flicked in and out.
85const RESUME_MS = 1500
86let resumeOnAnswer = false
87
88function readConfig(options: PluginOptions, overrides: { bots?: unknown; difficulty?: unknown }): Config {
89  const num = (v: unknown, d: number, min: number, max: number) => {
90    const n = Number(v)
91    return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : d
92  }
93  const bots = Number(overrides.bots ?? options.bots ?? 8)
94  return {
95    bots: [4, 6, 8].includes(bots) ? bots : 8,
96    difficulty: String(overrides.difficulty ?? options.difficulty ?? 'mixed'),
97    dropInMs: num(options.dropInSeconds, 10, 0, 300) * 1000,
98    handBackMs: num(options.handBackSeconds, 2, 0, 10) * 1000,
99    graceMs: num(options.graceSeconds, 3, 0, 10) * 1000,
100    afkMs: num(options.afkSeconds, 2, 0, 60) * 1000,
101    switchWindows: options.switchWindows !== false,
102    closeMinecraft: options.closeMinecraft !== false,
103    launcher: options.launcher === 'manual' ? 'manual' : 'prism',
104    prismPath: typeof options.prismPath === 'string' ? options.prismPath : '',
105    idleMinutes: num(options.idleMinutes, 15, 1, 240),
106    serverPort: num(options.serverPort, 25599, 1024, 65535),
107    controlPort: num(options.controlPort, 25601, 1024, 65535),
108  }
109}
110
111function ctlPath($: Api) {
112  return `${$.plugin.root}/arena/ctl.mjs`
113}
114
115function ctlArgs() {
116  return [
117    '--data', dataDir,
118    '--port', String(config.controlPort),
119    '--server-port', String(config.serverPort),
120    '--idle-minutes', String(config.idleMinutes),
121    '--launcher', config.launcher,
122    '--prism-path', config.prismPath,
123  ]
124}
125
126function lastJson(text: string): Reply | null {
127  const lines = text.trim().split('\n').filter(Boolean)
128  for (let i = lines.length - 1; i >= 0; i--) {
129    try {
130      return JSON.parse(lines[i]!) as Reply
131    } catch {}
132  }
133  return null
134}
135
136// Starts the daemon if it isn't running. Cheap when it is.
137function ensureDaemon($: Api): Promise<boolean> {
138  if (!dataDir) return Promise.resolve(false)
139  starting ??= (async () => {
140    try {
141      const ran = await $.process.run(['node', ctlPath($), 'ensure', ...ctlArgs()], { timeoutMs: 20_000 })
142      const reply = lastJson(ran.stdout)
143      if (!reply?.ok || !reply.token || !reply.port) {
144        $.ui.log(reply?.error ?? (ran.stderr.trim() || 'the arena did not start'), { to: 'debug' })
145        conn = null
146        return false
147      }
148      conn = { port: reply.port, token: reply.token }
149      // ctl's parent is Claude Code itself: the arena walks up from it to the
150      // window to hand you back to
151      const parent = String(reply.parent ?? '')
152      if (/^[1-9]\d{0,9}$/.test(parent)) terminal = { ...terminal, pid: parent }
153      return true
154    } catch (error) {
155      $.ui.log(`could not run node (${String(error)})`, { to: 'debug' })
156      conn = null
157      return false
158    } finally {
159      starting = null
160    }
161  })()
162  return starting
163}
164
165async function api($: Api, path: string, body?: object): Promise<Reply | null> {
166  for (let attempt = 0; attempt < 2; attempt++) {
167    if (!conn && !(await ensureDaemon($))) return null
168    try {
169      const res = await $.http.fetch(`http://127.0.0.1:${conn!.port}${path}`, { method: body ? 'POST' : 'GET', headers: { 'x-arena-token': conn!.token, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })
170      return JSON.parse(res.text) as Reply
171    } catch {
172      // The daemon idled out or crashed: start it again once
173      conn = null
174    }
175  }
176  return null
177}
178
179// On the way out of the session: one direct call, no restart of the daemon
180async function sendPause($: Api, to: { port: number; token: string }) {
181  try {
182    await $.http.fetch(`http://127.0.0.1:${to.port}/pause`, { method: 'POST', headers: { 'x-arena-token': to.token, 'content-type': 'application/json' }, body: JSON.stringify({ reason: 'lost' }) })
183  } catch {
184    // The arena is already gone; it pauses by itself once check-ins stop
185  }
186}
187
188const ON_STATUS = '⚔ pvp on'
189
190// What the arena is doing, in the status line until you're in the game
191function showProgress($: Api, s: Reply | null) {
192  if (!s) return
193  if (s.humans && s.humans.length > 0) $.ui.status('⚔ in the arena')
194  else if (s.phase === 'booting') $.ui.status('⚔ arena warming up (the first boot takes a minute)…')
195  else if (s.launching) $.ui.status('⚔ starting Minecraft…')
196  else if (s.joinAddress) $.ui.status(`⚔ join ${s.joinAddress} in Minecraft 1.21.4`)
197  else if (s.inGame) $.ui.status('⚔ in the arena')
198  else if (s.phase === 'ready') $.ui.status('⚔ waiting for Minecraft to join…')
199}
200
201function playBody() {
202  return {
203    bots: config.bots,
204    difficulty: config.difficulty,
205    launcher: config.launcher,
206    graceMs: config.graceMs,
207    afkMs: config.afkMs,
208    closeGame: config.closeMinecraft,
209  }
210}
211
212function scoreText(d: Drop) {
213  return `${d.kills} ${d.kills === 1 ? 'kill' : 'kills'}, ${d.deaths} ${d.deaths === 1 ? 'death' : 'deaths'}`
214}
215
216function redraw($: Api) {
217  $.ui.invalidate('ui.render')
218}
219
220function cancelTimers() {
221  dropTimer?.cancel()
222  dropTimer = null
223  scoreTimer?.cancel()
224  scoreTimer = null
225}
226
227// The call a permission dialog is for: the newest one under way with that tool
228function callFor(tool: string): string | null {
229  for (let i = running.length - 1; i >= 0; i--) {
230    if (running[i]!.tool === tool) return running[i]!.id || null
231  }
232  return null
233}
234
235// Claude carries on after a dialog you answered (or any tool call ended)
236function resumeAfterAnswer($: Api) {
237  if (!resumeOnAnswer) return armDropIn($)
238  resumeOnAnswer = false
239  armDropIn($, RESUME_MS)
240}
241
242function armDropIn($: Api, delayMs = config.dropInMs) {
243  if (!isOn || !isSetUp || !isTurnRunning || phase !== 'idle') return
244  phase = 'waiting'
245  dropTimer = $.clock.after(delayMs, () => void dropIn($))
246}
247
248async function dropIn($: Api) {
249  if (phase !== 'waiting') return
250  dropTimer = null
251  phase = 'playing'
252  drop = { kills: 0, deaths: 0 }
253  redraw($)
254  // The session's id names the match: a new Claude Code session gets a fresh leaderboard
255  const match = await $.session.id().catch(() => '')
256  const reply = await api($, '/play', { ...playBody(), ...(match ? { match } : {}) })
257  // Claude may have finished while we waited on the daemon
258  if (phase !== 'playing') return
259  if (!reply?.ok) {
260    phase = 'idle'
261    redraw($)
262    $.ui.status(isOn ? ON_STATUS : undefined)
263    $.ui.log(`⚔ ${reply?.error ?? "couldn't reach the arena"}`)
264    return
265  }
266  showProgress($, reply)
267  // Every 4 s: the live score for the spinner, and the arena's lease. If
268  // these stop (Claude Code quit or crashed), the arena pauses by itself.
269  scoreTimer = $.clock.every(4000, async () => {
270    if (phase !== 'playing') return
271    const s = await api($, '/status')
272    // Something went wrong after /play answered (a launch that needed the
273    // arena to finish booting first): say so where it stays visible
274    if (s?.notice) $.ui.log(`⚔ ${s.notice}`)
275    if (s?.paused) {
276      // It stood down on its own: you closed Minecraft, or it never came up.
277      // Back to waiting; the next tool call drops you in again.
278      cancelTimers()
279      phase = 'idle'
280      $.ui.status(isOn ? ON_STATUS : undefined)
281      redraw($)
282      return
283    }
284    showProgress($, s)
285    if (s?.drop && (s.drop.kills !== drop.kills || s.drop.deaths !== drop.deaths)) {
286      drop = s.drop
287      redraw($)
288    }
289  })
290}
291
292// Claude is done, was interrupted, or needs the person
293type PullReason = 'done' | 'aborted' | 'permission' | 'question' | 'needs-you'
294const NEEDS_YOU = new Set<PullReason>(['permission', 'question', 'needs-you'])
295
296async function pullOut($: Api, reason: PullReason) {
297  const was = phase
298  cancelTimers()
299  phase = 'idle'
300  // Only someone taken out of a fight gets put straight back after answering;
301  // before the first drop-in the usual delay still applies
302  if (NEEDS_YOU.has(reason)) resumeOnAnswer = resumeOnAnswer || was === 'playing'
303  if (was !== 'playing') return
304  $.ui.status(isOn ? ON_STATUS : undefined)
305  redraw($)
306  const reply = await api($, '/pause', {
307    reason,
308    handBackMs: config.handBackMs,
309    terminal: config.switchWindows ? terminal : null,
310  })
311  const d = reply?.drop ?? drop
312  if (reply?.wasLive && (d.kills > 0 || d.deaths > 0)) {
313    $.ui.toast(`${NEEDS_YOU.has(reason) ? 'Claude needs you' : "Claude's done"} · ⚔ ${scoreText(d)}`)
314  }
315}
316
317// Stops the daemon, its server and bots, and (if set) the Minecraft it launched
318async function stopArena($: Api): Promise<boolean> {
319  const ran = await $.process.run(['node', ctlPath($), 'stop', ...ctlArgs(), ...(config.closeMinecraft ? [] : ['--keep-game'])]).catch(() => null)
320  conn = null
321  return lastJson(ran?.stdout ?? '')?.wasRunning === true
322}
323
324async function runSetup($: Api, acceptEula: boolean) {
325  if (isSettingUp) return
326  if (!dataDir) {
327    $.ui.log('⚔ needs HOME (or USERPROFILE) set to know where to keep its files.')
328    return
329  }
330  isSettingUp = true
331  $.ui.status('⚔ setting up the arena…')
332  let final: Reply | null = null
333  let errText = ''
334  try {
335    const stream = $.process.spawn({
336      argv: ['node', ctlPath($), 'setup', ...ctlArgs(), ...(acceptEula ? ['--accept-eula'] : [])],
337    })
338    let pending = ''
339    for await (const { stream: pipe, text } of stream) {
340      if (pipe === 'stderr') {
341        errText = (errText + text).slice(-600)
342        continue
343      }
344      const lines = (pending + text).split('\n')
345      pending = lines.pop() ?? ''
346      for (const line of lines) {
347        const msg = lastJson(line)
348        if (!msg) continue
349        if (msg.done) final = msg
350        else if (msg.detail) $.ui.status(`⚔ setup: ${msg.detail}`)
351      }
352    }
353    if (!final && pending) final = lastJson(pending)
354  } catch (error) {
355    errText = `could not run node: ${String(error)}. The arena needs Node 20+ on your PATH.`
356  } finally {
357    isSettingUp = false
358  }
359
360  if (!final?.ok) {
361    $.ui.status(undefined)
362    const why = final?.error ?? (errText.trim() || 'setup stopped')
363    $.ui.log(`setup failed: ${why}`)
364    $.ui.toast(`⚔ Setup failed: ${why}`, { timeoutMs: 10_000 })
365    return
366  }
367  isSetUp = true
368  await $.store.set('isSetUp', true)
369  for (const w of final.warnings ?? []) $.ui.log(`⚔ ${w}`)
370  $.ui.status('⚔ first boot of the arena (about a minute, once)…')
371  // First boot builds the world: do it now, not during your first fight
372  const finish = (error?: string) => {
373    $.ui.status(isOn ? '⚔ pvp on' : undefined)
374    if (error) $.ui.toast(`⚔ The arena didn't start: ${error}`, { timeoutMs: 10_000 })
375    else $.ui.toast(isOn ? '⚔ Arena ready. You drop in once Claude has worked for a bit.' : '⚔ Arena ready. /pvp on to start dropping in.', { timeoutMs: 6000 })
376  }
377  if (!(await ensureDaemon($))) return finish(`see ${dataDir}/daemon.out`)
378  let polls = 0
379  const poll = $.clock.every(1500, async () => {
380    const s = await api($, '/status')
381    if (s?.phase === 'ready' || s?.phase === 'error' || ++polls > 120) {
382      poll.cancel()
383      finish(s?.phase === 'ready' ? undefined : (s?.error ?? 'it is still booting; check /pvp status'))
384    }
385  })
386}
387
388const EULA_QUESTION = "The arena runs a Minecraft server on your machine, which needs you to accept Minecraft's EULA (aka.ms/MinecraftEULA). Do you accept it?"
389
390async function askEula($: Api): Promise<boolean> {
391  let answer = 'Cancel'
392  try {
393    answer = await $.ui.ask(EULA_QUESTION, ['I accept the EULA', 'Cancel'])
394  } catch {
395    // Dismissed, or nobody there to ask: nothing is accepted
396  }
397  return answer === 'I accept the EULA'
398}
399
400export const register: Register = (on, options) => {
401  // Overrides from /pvp bots and /pvp difficulty are read in session.start
402  config = readConfig(options, {})
403
404  on('session.start', async ($, e, next) => {
405    isOn = (await $.store.get('isOn')) === true
406    isSetUp = (await $.store.get('isSetUp')) === true
407    config = readConfig(options, { bots: await $.store.get('bots'), difficulty: await $.store.get('difficulty') })
408    // Never fall back to a relative folder: that would put a server and its
409    // world inside whatever project Claude Code was opened in
410    const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
411    dataDir = home ? `${home}/.claude-pvp` : ''
412    terminal = {
413      bundleId: await $.env.get('__CFBundleIdentifier'),
414      windowId: await $.env.get('WINDOWID'),
415      termProgram: await $.env.get('TERM_PROGRAM'),
416    }
417    await $.command.register({
418      name: 'pvp',
419      description: 'Minecraft PvP vs bots while Claude works',
420      argumentHint: '[on|off|setup|status|play|stop|bots N|difficulty D]',
421    })
422    if (isOn && e.isInteractive) $.ui.status('⚔ pvp on')
423    return next(e)
424  })
425
426  on('command.run', { command: 'pvp' }, async ($, e) => {
427    const [word = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
428    switch (word) {
429      case 'on': {
430        isOn = true
431        await $.store.set('isOn', true)
432        if (!isSetUp) {
433          const accepted = await askEula($)
434          if (!accepted) return { text: 'The EULA needs accepting to run a server. Nothing was installed.' }
435          void runSetup($, true)
436          return { text: 'On. Setting up first (Node packages, Paper server, Prism instance); progress is in the status line.' }
437        }
438        $.ui.status('⚔ pvp on')
439        $.clock.after(0, () => void ensureDaemon($))
440        return { text: `On: ${config.bots} bots, ${config.difficulty}. You drop in after ${config.dropInMs / 1000}s of Claude working.` }
441      }
442      case 'off': {
443        isOn = false
444        await $.store.set('isOn', false)
445        cancelTimers()
446        phase = 'idle'
447        $.ui.status(undefined)
448        // Off means nothing keeps running: server, bots, and our Minecraft
449        const stopped = await stopArena($)
450        return { text: `Off${stopped ? '; the arena and its Minecraft are closed' : ''}.` }
451      }
452      case 'setup': {
453        const accepted = await askEula($)
454        if (!accepted) return { text: 'Setup cancelled; nothing was installed.' }
455        void runSetup($, true)
456        return { text: 'Setting up the arena; progress is in the status line.' }
457      }
458      case 'play': {
459        if (!isSetUp) return { text: 'Run /pvp setup first.' }
460        cancelTimers()
461        phase = 'waiting'
462        void dropIn($)
463        return { text: 'Dropping you in.' }
464      }
465      case 'stop': {
466        cancelTimers()
467        phase = 'idle'
468        return { text: (await stopArena($)) ? 'Arena stopped.' : 'The arena was not running.' }
469      }
470      case 'bots': {
471        const n = Number(value)
472        if (![4, 6, 8].includes(n)) return { text: 'Bots: 4, 6 or 8.' }
473        await $.store.set('bots', n)
474        config = { ...config, bots: n }
475        if (conn) await api($, '/config', { bots: n })
476        return { text: `${n} bots from the next drop-in.` }
477      }
478      case 'difficulty': {
479        if (!['mixed', 'easy', 'medium', 'hard'].includes(value)) return { text: 'Difficulty: mixed, easy, medium or hard.' }
480        await $.store.set('difficulty', value)
481        config = { ...config, difficulty: value }
482        if (conn) await api($, '/config', { difficulty: value })
483        return { text: `Bots are ${value} from the next drop-in.` }
484      }
485      case '':
486      case 'status': {
487        const lines = [
488          `${isOn ? 'On' : 'Off'}${isSetUp ? '' : ' (not set up: /pvp setup)'}.`,
489          `${config.bots} bots, ${config.difficulty} · drop in after ${config.dropInMs / 1000}s · launcher ${config.launcher}`,
490        ]
491        if (conn || isSetUp) {
492          const s = conn ? await api($, '/status') : null
493          if (s?.ok) {
494            const state = s.error ? `: ${s.error}` : s.phase !== 'ready' ? '' : s.paused ? ', paused (fights start when Claude works, or /pvp play)' : ', live'
495            lines.push(`Arena ${s.phase}${state} · bots online ${s.bots?.length ?? 0} · max ${s.focusCap} on you at once · in game: ${s.humans?.join(', ') || 'nobody'}`)
496          } else {
497            lines.push('Arena not running (it starts on the next long turn).')
498          }
499        }
500        lines.push(USAGE)
501        return { text: lines.join('\n') }
502      }
503      default:
504        return { text: USAGE }
505    }
506  })
507
508  on('turn.start', async ($, e, next) => {
509    isTurnRunning = true
510    resumeOnAnswer = false
511    if (isOn && isSetUp) {
512      // Warm the arena now so the drop-in is instant; off the turn's path
513      if (!conn) $.clock.after(0, () => void ensureDaemon($))
514      armDropIn($)
515    }
516    return next(e)
517  })
518
519  on('turn.complete', async ($, e, next) => {
520    if (e.agentId) return next(e)
521    isTurnRunning = false
522    resumeOnAnswer = false
523    await pullOut($, e.isAborted ? 'aborted' : 'done')
524    return next(e)
525  })
526
527  // A permission dialog is about to show, so Claude is waiting on you: the
528  // fight freezes (in the background, so the dialog isn't held up). Decides
529  // nothing: the request goes on unchanged, and Claude Code, your settings
530  // and you answer it. If a settings hook of yours answers instead, the call
531  // simply carries on and you're back in a moment later.
532  on('classic.PermissionRequest', async ($, e, next) => {
533    awaitingToolUseId = callFor(e.tool_name)
534    pullOut($, 'permission').catch(() => undefined)
535    return next(e)
536  })
537
538  // You approved and the command is still running: Claude Code shows its
539  // "ctrl+b to run in background" hint under long-running calls. That's the
540  // first sign the dialog is answered, so go back in right away, not only
541  // when the command finishes.
542  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
543    if (e.props.tool_use_id === awaitingToolUseId) {
544      awaitingToolUseId = null
545      resumeAfterAnswer($)
546    }
547    return next(e)
548  })
549
550  // Only watches: every call goes on to Claude Code unchanged and its result
551  // comes back unchanged. A question to you freezes the fight first, and a
552  // call that ends (answered, or just finished) puts you back in.
553  on('tool.call', async ($, e, next) => {
554    // Our own EULA question is not Claude needing you
555    if (next.origin.plugin === 'mc-pvp-bots') return next(e)
556    if (ASKS_USER.has(e.tool)) await pullOut($, 'question')
557    const call = { id: e.tool_use_id ?? '', tool: e.tool }
558    running.push(call)
559    try {
560      return await next(e)
561    } finally {
562      running.splice(running.indexOf(call), 1)
563      if (call.id && call.id === awaitingToolUseId) awaitingToolUseId = null
564      resumeAfterAnswer($)
565    }
566  })
567
568  // An MCP form you filled in: Claude carries on
569  on('classic.ElicitationResult', async ($, e, next) => {
570    const result = await next(e)
571    resumeAfterAnswer($)
572    return result
573  })
574
575  on('classic.Notification', async ($, e, next) => {
576    if (PROMPT_NOTIFICATION.test(e.notification_type)) {
577      await pullOut($, e.notification_type === 'permission_prompt' ? 'permission' : 'question')
578    }
579    return next(e)
580  })
581
582  // Leaving Claude Code mid-fight (/exit, Ctrl+C, closing the terminal):
583  // freeze the arena now. If this never runs (a crash, kill -9), the arena
584  // still pauses once the 4-second check-ins stop.
585  on('session.end', async ($, e, next) => {
586    if (phase === 'playing' && conn) {
587      cancelTimers()
588      phase = 'idle'
589      await sendPause($, conn)
590    }
591    return next(e)
592  })
593
594  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
595    if (phase !== 'playing') return next(e)
596    const score = drop.kills || drop.deaths ? ` · ⚔ ${drop.kills}K ${drop.deaths}D` : ' · ⚔ in the arena'
597    return next({ ...e, props: { ...e.props, suffix: score } })
598  })
599}
600