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

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.
You need:
claude --version, and update with claude update.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.
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.
/pvp on
/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.
Claude's done · ⚔ 3 kills, 1 death.The spinner shows your live score while you play, e.g. ⚔ 2K 1D.
| | 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.
| | | | :- | :- | | /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.
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.
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.~/.claude-pvp), listening on 127.0.0.1 only, and the bots inside its own Node process.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.open and osascript on macOS, xdotool or wmctrl on Linux.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
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.~/.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.What it reads
HOME or USERPROFILE (where to keep ~/.claude-pvp), and TERM_PROGRAM, WINDOWID and __CFBundleIdentifier (which window to switch back to).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 |
/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.
hooks/register.ts 600 lines1// 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