Play tokeneater, the multiplayer cell-eating game, in a Claude Code pane (terminal or desktop app) while a prompt runs.

Play tokeneater, the multiplayer cell-eating game, in a pane beside your Claude Code conversation while a prompt runs, in the terminal or in the Code tab of the Claude desktop app. The pane joins the same rooms as the browser game; a toast says when the agent is done, and the pane stays until you close it. On the Claude mobile app and in VS Code, where a pane cannot play, it links to a room to play in the browser.
This repository is the plugin's distribution: it is published automatically from the game's sources, so changes made here are overwritten by the next release.
In Claude Code:
/plugin marketplace add jeffbruchado/tokeneater-mod
/plugin install tokeneater@tokeneater
From a shell, the same with claude plugin marketplace add jeffbruchado/tokeneater-mod and claude plugin install tokeneater@tokeneater.
Then type /tokeneater (in a terminal, switch to the fullscreen layout first: /tui fullscreen).
PATH: the game runs in a small Node process beside the pane./tui fullscreen) and at least 110 columns. The pane opens by itself when you send a prompt in a terminal at least 144 columns wide; /tokeneater opens and closes it at any width.| Input | Does |
|---|---|
| Mouse over the pane | steers your cell |
w a s d (arrows too after a click) | steer up, left, down, right; two together (or one after the other) go between them, and a key held keeps turning its way. The cell keeps going until you steer again |
e (Space too after a click) | split |
q | eject mass |
r (Enter after a click) | play again |
Esc | gives the keyboard back to the prompt |
In a terminal, the pane shows the map with the ranking (the top 5 and your own row) in its top-right corner, a minimap in its bottom-right corner, and your mass, room and rank on its last row. In the desktop app, the ranking and the minimap sit under the map, and the keys play once you click the map (the buttons under it play too).
/plugin configure tokeneater@tokeneater (or claude plugin configure tokeneater@tokeneater):
/tokeneater opens it.Third-party marketplaces do not update by themselves unless you turn auto-update on for them in /plugin → Marketplaces. To update by hand: /plugin marketplace update tokeneater, then /plugin → Installed → tokeneater → Update now.
MIT. Play in the browser at playtokeneater.com.
bundle/register.js 1256 lines1// src/mod-options.ts
2function readOptions(options) {
3 const nickname = options["nickname"];
4 return {
5 nickname: typeof nickname === "string" ? nickname.trim() : "",
6 isAutoOpenOn: options["autoOpen"] !== false
7 };
8}
9function bridgeEnv(options, target) {
10 const env = { TE_NICKNAME: options.nickname };
11 const { size, surface } = target;
12 if (size !== null) {
13 env["TE_COLUMNS"] = String(size.columns);
14 env["TE_ROWS"] = String(size.rows);
15 }
16 if (surface !== "terminal") env["TE_SURFACE"] = surface;
17 return env;
18}
19
20// ../../packages/shared/src/legal.ts
21var SPONSOR_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1e3;
22var ABANDONED_DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
23var EXPIRED_BAN_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
24var AUDIT_LOG_RETENTION_MS = 365 * 24 * 60 * 60 * 1e3;
25var LAPSED_HOLD_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
26var UNUSED_LOGO_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
27
28// ../../packages/shared/src/engine/constants.ts
29var TICK_RATE_HZ = 20;
30var TICK_MS = 1e3 / TICK_RATE_HZ;
31var START_MASS = 10;
32var RADIUS_PER_SQRT_MASS = 10;
33var SLIDE_STOP_SPEED = 5;
34var EJECT_BLOB_MASS = 12;
35var DECAY_RATE_PER_S = 2e-3;
36
37// ../../packages/shared/src/engine/geometry.ts
38function massToRadius(mass) {
39 return mass > 0 ? RADIUS_PER_SQRT_MASS * Math.sqrt(mass) : 0;
40}
41function toUnitsPerTick(unitsPerSecond) {
42 return unitsPerSecond / TICK_RATE_HZ;
43}
44
45// ../../packages/shared/src/engine/movement.ts
46var STOP_SPEED = toUnitsPerTick(SLIDE_STOP_SPEED);
47var STOP_SPEED_SQUARED = STOP_SPEED * STOP_SPEED;
48
49// ../../packages/shared/src/engine/decay.ts
50var DECAY_FACTOR_PER_TICK = 1 - DECAY_RATE_PER_S / TICK_RATE_HZ;
51
52// ../../packages/shared/src/engine/virus.ts
53var FULL_TURN = 2 * Math.PI;
54
55// ../../packages/shared/src/mod/install.ts
56var MOD_INSTALL = {
57 /** The public repository the mod release publishes to: the marketplace players add. */
58 marketplaceRepo: "jeffbruchado/tokeneater-mod",
59 /**
60 * The plugin's install id, `<plugin>@<marketplace>`: the `name` of
61 * `apps/mod/plugin/.claude-plugin/plugin.json` and of `apps/mod/marketplace/marketplace.json`.
62 */
63 pluginId: "tokeneater@tokeneater",
64 /** The plugin's command, which opens and closes the pane. */
65 paneCommand: "/tokeneater",
66 /** The Claude Code command that switches to the fullscreen layout, where the pane docks. */
67 fullscreenCommand: "/tui fullscreen",
68 /** Oldest Claude Code with the function hooks the plugin is built on. */
69 minClaudeCodeVersion: "2.1.288",
70 /** Oldest Node.js major the bridge runs on: the first with a global `WebSocket` by default. */
71 minNodeMajor: 22,
72 /** Narrowest terminal (columns) whose fullscreen layout docks the pane. */
73 dockMinColumns: 110,
74 /** Narrowest terminal (columns) the pane opens in by itself when a prompt is sent. */
75 autoOpenMinColumns: 144
76};
77var MOD_HOTKEYS = {
78 split: "e",
79 eject: "q",
80 respawn: "r",
81 up: "w",
82 left: "a",
83 down: "s",
84 right: "d"
85};
86
87// ../../packages/shared/src/protocol/messages.ts
88var MAX_PLAYERS_PER_ROOM = 50;
89var ROOM_NAME_MAX_BYTES = 32;
90var ROOM_NAME_PATTERN = new RegExp(`^[a-z0-9-]{1,${String(ROOM_NAME_MAX_BYTES)}}$`);
91var MAX_JOIN_EXCLUDE = 5;
92var RECONNECT_GRACE_SECONDS = 5;
93var JOIN_REJECTIONS = ["nickname", "banned"];
94var JOIN_UNAVAILABILITIES = ["capacity", "rate_limited", "unavailable"];
95
96// ../../packages/shared/src/protocol/utf8.ts
97var { TextEncoder, TextDecoder } = globalThis;
98var encoder = new TextEncoder();
99var decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
100
101// ../../packages/shared/src/protocol/client-codec.ts
102var SPLIT_FLAG = 1;
103var EJECT_FLAG = 2;
104var KNOWN_FLAGS = SPLIT_FLAG | EJECT_FLAG;
105
106// ../../packages/shared/src/protocol/slots.ts
107var SPONSOR_LOGO_MAX_BYTES = 512 * 1024;
108
109// ../../packages/shared/src/protocol/view.ts
110var BASE_VIEW_HALF_WIDTH = 800;
111var VIEW_GROWTH = 0.1;
112var MAX_VIEW_HALF_WIDTH = 2400;
113var VIEW_ASPECT_RATIO = 16 / 9;
114function viewForMass(totalMass) {
115 const growth = totalMass > 0 ? VIEW_GROWTH * Math.sqrt(totalMass / START_MASS) : 0;
116 const halfWidth = Math.min(BASE_VIEW_HALF_WIDTH * (1 + growth), MAX_VIEW_HALF_WIDTH);
117 return { halfWidth, halfHeight: halfWidth / VIEW_ASPECT_RATIO };
118}
119
120// ../../packages/shared/src/client/input/intent-sender.ts
121var PRESS_SPACING_MS = TICK_MS + 10;
122var MAX_HEADING_ERROR_RAD = Math.PI / 90;
123
124// ../../packages/shared/src/client/net/connection-phase.ts
125var RESUME_WINDOW_MS = RECONNECT_GRACE_SECONDS * 1e3;
126
127// ../../packages/shared/src/client/net/lobby-client.ts
128var ROOMS_PATH = "/api/rooms";
129var JOIN_PATH = "/api/join";
130var DEFAULT_RETRY_AFTER_MS = 5e3;
131var MIN_RETRY_AFTER_MS = 1e3;
132var MAX_RETRY_AFTER_MS = 3e4;
133var MS_PER_SECOND = 1e3;
134var HTTP_FORBIDDEN = 403;
135var HTTP_SERVICE_UNAVAILABLE = 503;
136var HTTP_CLIENT_ERRORS_FROM = 400;
137var HTTP_SERVER_ERRORS_FROM = 500;
138function createLobbyClient(fetchFn, client = "web") {
139 return {
140 async rooms() {
141 try {
142 const response = await fetchFn(ROOMS_PATH, { headers: { accept: "application/json" } });
143 if (!response.ok) return { ok: false };
144 const body = await response.json();
145 return isRoomsStatus(body) ? { ok: true, value: body } : { ok: false };
146 } catch {
147 return { ok: false };
148 }
149 },
150 async join(request) {
151 try {
152 const response = await fetchFn(JOIN_PATH, {
153 method: "POST",
154 headers: { accept: "application/json", "content-type": "application/json" },
155 body: JSON.stringify(joinBody(request, client)),
156 cache: "no-store"
157 });
158 return await joinResult(response);
159 } catch {
160 return { ok: false, reason: "unreachable" };
161 }
162 }
163 };
164}
165function joinBody(request, client) {
166 const exclude = request.exclude?.slice(-MAX_JOIN_EXCLUDE) ?? [];
167 const nickname = request.nickname ?? "";
168 return {
169 ...request.room === void 0 ? {} : { room: request.room },
170 ...exclude.length === 0 ? {} : { exclude },
171 ...nickname === "" ? {} : { nickname },
172 ...client === "web" ? {} : { client }
173 };
174}
175async function joinResult(response) {
176 if (response.status === HTTP_SERVICE_UNAVAILABLE) {
177 const reason = refusalReason(await bodyOf(response), isJoinUnavailability) ?? "unavailable";
178 return { ok: false, reason, retryAfterMs: retryAfterMs(response.headers) };
179 }
180 if (response.status === HTTP_FORBIDDEN) {
181 const reason = refusalReason(await bodyOf(response), isJoinRejection);
182 return reason === null ? { ok: false, reason: "refused" } : { ok: false, reason };
183 }
184 if (response.status >= HTTP_CLIENT_ERRORS_FROM && response.status < HTTP_SERVER_ERRORS_FROM) {
185 return { ok: false, reason: "refused" };
186 }
187 if (!response.ok) return { ok: false, reason: "unreachable" };
188 const body = await response.json();
189 if (!isPlacement(body)) return { ok: false, reason: "unreachable" };
190 const { room, socketPath, requested } = body;
191 return { ok: true, room, socketPath, requested: isRequestedRoom(requested) ? requested : null };
192}
193var REJECTIONS = new Set(JOIN_REJECTIONS);
194var UNAVAILABILITIES = new Set(JOIN_UNAVAILABILITIES);
195function isJoinRejection(value) {
196 return REJECTIONS.has(value);
197}
198function isJoinUnavailability(value) {
199 return UNAVAILABILITIES.has(value);
200}
201async function bodyOf(response) {
202 try {
203 return await response.json();
204 } catch {
205 return void 0;
206 }
207}
208function refusalReason(body, isKnown) {
209 if (!isRecord(body) || body["ok"] !== false) return null;
210 const reason = body["reason"];
211 return isKnown(reason) ? reason : null;
212}
213function retryAfterMs(headers) {
214 const value = headers.get("retry-after")?.trim() ?? "";
215 if (!/^\d+$/.test(value)) return DEFAULT_RETRY_AFTER_MS;
216 const waitMs = Number(value) * MS_PER_SECOND;
217 return Math.min(MAX_RETRY_AFTER_MS, Math.max(MIN_RETRY_AFTER_MS, waitMs));
218}
219function isPlacement(value) {
220 return isRecord(value) && value["ok"] === true && isRoomName(value["room"]) && isSameOriginPath(value["socketPath"]);
221}
222function isRequestedRoom(value) {
223 return isRecord(value) && isRoomName(value["room"]) && (value["outcome"] === "full" || value["outcome"] === "gone");
224}
225function isRoomName(value) {
226 return typeof value === "string" && ROOM_NAME_PATTERN.test(value);
227}
228function isSameOriginPath(value) {
229 return typeof value === "string" && value.startsWith("/") && !value.startsWith("//");
230}
231function isRoomsStatus(value) {
232 if (!isRecord(value)) return false;
233 const rooms = value["rooms"];
234 const totals = value["totals"];
235 return Array.isArray(rooms) && rooms.every(isRoomSummary) && isRecord(totals) && isCount(totals["rooms"]) && isCount(totals["players"]);
236}
237function isRoomSummary(value) {
238 return isRecord(value) && typeof value["name"] === "string" && isCount(value["players"]);
239}
240function isRecord(value) {
241 return typeof value === "object" && value !== null;
242}
243function isCount(value) {
244 return typeof value === "number" && Number.isInteger(value) && value >= 0;
245}
246
247// ../../packages/shared/src/client/net/room-link.ts
248var ROOM_LINK_PREFIX = "/r/";
249function roomPath(room) {
250 return `${ROOM_LINK_PREFIX}${encodeURIComponent(room)}`;
251}
252function roomLink(origin, room) {
253 return `${origin}${roomPath(room)}`;
254}
255
256// ../../packages/shared/src/client/net/site-links.ts
257var SPONSOR_PAGE_PATH = "/sponsor";
258var SPONSOR_LOGIN_PATH = `${SPONSOR_PAGE_PATH}/login`;
259var SPONSOR_DASHBOARD_PATH = `${SPONSOR_PAGE_PATH}/dashboard`;
260var SIGN_IN_ERROR_PARAM = "error";
261var SIGN_IN_ERROR_EXPIRED = "expired";
262var EXPIRED_SIGN_IN_PATH = `${SPONSOR_LOGIN_PATH}?${SIGN_IN_ERROR_PARAM}=${SIGN_IN_ERROR_EXPIRED}`;
263var MAGIC_LINK_LIFETIME_MS = 15 * 60 * 1e3;
264
265// ../../packages/shared/src/client/render/camera.ts
266var BASE_VIEW = viewForMass(0);
267
268// ../../packages/shared/src/client/render/own-cells.ts
269var CORRECTION_MS = 100;
270var CORRECTION_TIME_CONSTANT_MS = CORRECTION_MS / 3;
271
272// ../../packages/shared/src/client/render/palette.ts
273var BLOB_RADIUS = massToRadius(EJECT_BLOB_MASS);
274
275// src/pane-layout.ts
276var PANE_ID = "tokeneater";
277var PANE_TITLE = "tokeneater";
278var PANE_COLUMNS = 120;
279var DOCK_MIN_COLUMNS = MOD_INSTALL.dockMinColumns;
280var AUTO_OPEN_MIN_COLUMNS = MOD_INSTALL.autoOpenMinColumns;
281var CONTROL_ROWS = 1;
282var STATUS_ROWS = 1;
283var MAX_RASTER_COLUMNS = 512;
284var MAX_RASTER_ROWS = 256;
285var PLAY_SURFACES = ["terminal", "desktop"];
286var HUD_HEIGHT_PX = 244;
287var DESKTOP_ROW_PX = 18.9;
288var DESKTOP_HUD_ROWS = Math.ceil(HUD_HEIGHT_PX / DESKTOP_ROW_PX);
289var HUD_REDRAW_INTERVAL_MS = 100;
290function rasterSize(bodyColumns, bodyRows) {
291 return gameSize(bodyColumns, Math.floor(bodyRows) - CONTROL_ROWS);
292}
293function desktopMapSize(bodyColumns, bodyRows, controlRows) {
294 return gameSize(bodyColumns, Math.floor(bodyRows) - DESKTOP_HUD_ROWS - controlRows);
295}
296function gameSize(columns, rows) {
297 const size = {
298 columns: Math.min(Math.floor(columns), MAX_RASTER_COLUMNS),
299 rows: Math.min(rows, MAX_RASTER_ROWS)
300 };
301 return size.columns < 1 || size.rows <= STATUS_ROWS ? null : size;
302}
303function isSameSize(a, b) {
304 return a?.columns === b?.columns && a?.rows === b?.rows;
305}
306function isDockable(screen) {
307 return screen !== null && screen.isFullscreen && screen.columns >= DOCK_MIN_COLUMNS;
308}
309function playSurfaceOf(surface) {
310 return PLAY_SURFACES.find((play) => play === surface) ?? null;
311}
312function playSurface(surfaces, desktopDocks) {
313 if (surfaces.includes("terminal")) return "terminal";
314 return surfaces.includes("desktop") || desktopDocks !== null ? "desktop" : null;
315}
316function shouldAutoOpen(facts) {
317 if (!facts.isAutoOpenOn) return false;
318 if (facts.originKind !== "composer" || facts.isOpen) return false;
319 if (facts.isMidTurn && facts.wasClosedThisTurn) return false;
320 if (facts.surface === "desktop") return facts.desktopDocks === true;
321 const { screen } = facts;
322 return facts.surface === "terminal" && screen !== null && screen.isFullscreen && screen.columns >= AUTO_OPEN_MIN_COLUMNS;
323}
324var BLANK_CELL_BASE64 = "IAAAAAAAAAEAAAAB";
325function blankCells(size) {
326 return BLANK_CELL_BASE64.repeat(size.columns * size.rows);
327}
328
329// src/pane-text.ts
330function withThousands(value) {
331 return String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
332}
333var DOCK_NOTICE = `tokeneater needs the fullscreen layout (${MOD_INSTALL.fullscreenCommand}) and a terminal at least ${String(DOCK_MIN_COLUMNS)} columns wide. Then run ${MOD_INSTALL.paneCommand} again.`;
334var FALLBACK_NOTICE = "tokeneater cannot be played in this pane.";
335var FALLBACK_READING = "Finding a room to play\u2026";
336var FALLBACK_HOME_LABEL = "Play tokeneater in the browser";
337function fallbackRoomLabel(room, seats) {
338 return `Play room ${room} (${String(seats.players)}/${String(seats.max)}) in the browser`;
339}
340var FALLBACK_OPENED_TEXT = "tokeneater is open: it cannot be played here, so it links to a room to play in the browser.";
341var IDLE_NOTICE = "Press r to play.";
342var RESTARTING_NOTICE = "The game stopped: starting it again\u2026";
343var TOO_SMALL_NOTICE = "The pane is too small to play in: make it taller.";
344var RETRY_HINT = "Press r to try again.";
345var OPENED_TEXT = `tokeneater is open: steer with the pointer or ${MOD_HOTKEYS.up} ${MOD_HOTKEYS.left} ${MOD_HOTKEYS.down} ${MOD_HOTKEYS.right}, ${MOD_HOTKEYS.split} splits, ${MOD_HOTKEYS.eject} ejects (after a click on the map, the arrows and Space too). Esc returns to the prompt.`;
346var DESKTOP_KEY_HINT = `Click the map, then ${[MOD_HOTKEYS.up, MOD_HOTKEYS.left, MOD_HOTKEYS.down, MOD_HOTKEYS.right].join(" ").toUpperCase()} or the arrows steer.`;
347function playingElsewhereNotice(surface) {
348 return `tokeneater is playing in ${surface === "desktop" ? "the desktop app" : "the terminal"} now.`;
349}
350var HIDDEN_GAME_NOTICE = "tokeneater is still playing, out of view here: dock the pane again to see it, or close it to leave the game.";
351var CLOSED_TEXT = "tokeneater closed.";
352var COMMAND_DESCRIPTION = "Play tokeneater in a side pane (run again to close it)";
353function turnToast(status) {
354 const done = "Claude is done: back to your prompt whenever you like.";
355 if (status === null || status.phase !== "playing" || status.isDead || status.room === null) {
356 return done;
357 }
358 return `${done} You are at mass ${withThousands(status.mass)} in room ${status.room}.`;
359}
360
361// src/pane-input.ts
362var MAX_AIM_OFFSET = 4;
363var PANE_KEYS = [
364 {
365 id: "split",
366 hotkey: MOD_HOTKEYS.split,
367 label: "split",
368 input: { kind: "press", action: "split" }
369 },
370 {
371 id: "eject",
372 hotkey: MOD_HOTKEYS.eject,
373 label: "eject",
374 input: { kind: "press", action: "eject" }
375 },
376 {
377 id: "respawn",
378 hotkey: MOD_HOTKEYS.respawn,
379 label: "play again",
380 input: { kind: "press", action: "respawn" }
381 },
382 { id: "up", hotkey: MOD_HOTKEYS.up, label: "up", input: { kind: "keys", dx: 0, dy: -1 } },
383 { id: "left", hotkey: MOD_HOTKEYS.left, label: "left", input: { kind: "keys", dx: -1, dy: 0 } },
384 { id: "down", hotkey: MOD_HOTKEYS.down, label: "down", input: { kind: "keys", dx: 0, dy: 1 } },
385 { id: "right", hotkey: MOD_HOTKEYS.right, label: "right", input: { kind: "keys", dx: 1, dy: 0 } }
386];
387
388// src/surface-message.ts
389var MAX_PRESSES_PER_MESSAGE = 8;
390var MAX_STEERS_PER_MESSAGE = 8;
391var PANE_ACTIONS = ["split", "eject", "respawn"];
392var KEY_DIRECTIONS = [-1, 0, 1];
393function messageInputs(message) {
394 const presses = message.presses.map((action) => ({ kind: "press", action }));
395 return [...message.steers ?? [], ...presses];
396}
397function parseSurfaceMessage(data) {
398 if (!isRecord2(data) || !hasOnly(data, ["steers", "presses"])) return null;
399 const { steers, presses } = data;
400 if (!Array.isArray(presses) || presses.length > MAX_PRESSES_PER_MESSAGE) return null;
401 if (!presses.every(isPaneAction)) return null;
402 if (steers === void 0) return { presses };
403 if (!Array.isArray(steers) || steers.length > MAX_STEERS_PER_MESSAGE) return null;
404 const parsed = steers.map(parseSteer);
405 return parsed.every(isSteer) ? { steers: parsed, presses } : null;
406}
407function isSteer(value) {
408 return value !== null;
409}
410function parseSteer(value) {
411 if (!isRecord2(value)) return null;
412 if (value["kind"] === "aim" && hasOnly(value, ["kind", "x", "y"])) {
413 const { x, y } = value;
414 return isAimOffset(x) && isAimOffset(y) ? { kind: "aim", x, y } : null;
415 }
416 if (value["kind"] === "keys" && hasOnly(value, ["kind", "dx", "dy"])) {
417 const { dx, dy } = value;
418 return isKeyDirection(dx) && isKeyDirection(dy) ? { kind: "keys", dx, dy } : null;
419 }
420 return null;
421}
422function isRecord2(value) {
423 return typeof value === "object" && value !== null && !Array.isArray(value);
424}
425function hasOnly(value, keys) {
426 return Object.keys(value).every((key) => keys.includes(key));
427}
428function isAimOffset(value) {
429 return typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_AIM_OFFSET;
430}
431function isKeyDirection(value) {
432 return KEY_DIRECTIONS.some((direction) => direction === value);
433}
434function isPaneAction(value) {
435 return PANE_ACTIONS.some((action) => action === value);
436}
437
438// src/map-rows.ts
439var HEX_COLOUR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
440function readMapRows(value) {
441 if (!isRecord3(value)) return null;
442 const { columns, rows } = value;
443 const palette = listOf(value["palette"]);
444 const lines = listOf(value["lines"]);
445 if (!isCount2(columns) || !isCount2(rows) || lines === null) return null;
446 if (palette === null || palette.length === 0 || !palette.every(isHexColour)) return null;
447 const read = [];
448 let rowCount = 0;
449 for (const line of lines) {
450 const mapLine = readLine(line, { columns, colours: palette.length });
451 if (mapLine === null) return null;
452 rowCount += typeof mapLine === "number" ? mapLine : 1;
453 read.push(mapLine);
454 }
455 if (rowCount !== rows) return null;
456 return { columns, rows, palette, lines: read };
457}
458function readLine(value, bounds) {
459 if (isCount2(value)) return value;
460 const items = listOf(value);
461 if (items === null) return null;
462 const segments = [];
463 let end = 0;
464 for (const item of items) {
465 const segment = readSegment(item, bounds.colours);
466 if (segment === null) return null;
467 end += segment[0] + segment[1];
468 segments.push(segment);
469 }
470 return end <= bounds.columns ? segments : null;
471}
472function readSegment(value, colours) {
473 const fields = listOf(value);
474 if (fields === null) return null;
475 const [margin, width, ...rest] = fields;
476 if (!isWhole(margin) || !isCount2(width)) return null;
477 const isColour = (colour) => isWhole(colour) && colour < colours;
478 if (rest.length === 1) {
479 const [colour] = rest;
480 return isColour(colour) && colour > 0 ? [margin, width, colour] : null;
481 }
482 if (rest.length === 2) {
483 const [top, bottom] = rest;
484 return isColour(top) && isColour(bottom) ? [margin, width, top, bottom] : null;
485 }
486 const [background, ink, text] = rest;
487 const isText = rest.length === 3 && typeof text === "string";
488 return isText && isColour(background) && isColour(ink) ? [margin, width, background, ink, text] : null;
489}
490function isHexColour(value) {
491 return typeof value === "string" && HEX_COLOUR.test(value);
492}
493function listOf(value) {
494 return Array.isArray(value) ? value : null;
495}
496function isRecord3(value) {
497 return typeof value === "object" && value !== null && !Array.isArray(value);
498}
499function isWhole(value) {
500 return typeof value === "number" && Number.isInteger(value) && value >= 0;
501}
502function isCount2(value) {
503 return isWhole(value) && value > 0;
504}
505
506// src/bridge-lines.ts
507var SVG_SOURCE_MAX_CHARS = 131072;
508var SVG_ALT_MAX_CHARS = 200;
509var LineReader = class {
510 pending = "";
511 /** Takes the next piece of stdout; what the lines it completed said. */
512 push(text) {
513 const read = this.pending + text;
514 const end = read.lastIndexOf("\n");
515 this.pending = read.slice(end + 1);
516 let batch = { events: [], frame: null, map: null, hud: null };
517 if (end < 0) return batch;
518 for (const line of read.slice(0, end).split("\n")) batch = takeLine(batch, readLine2(line));
519 return batch;
520 }
521};
522function takeLine(batch, line) {
523 if (line === null) return batch;
524 if ("cells" in line) return { ...batch, frame: line };
525 switch (line.kind) {
526 case "map":
527 return { ...batch, map: line.rows };
528 case "hud":
529 return { ...batch, hud: line.hud };
530 default:
531 batch.events.push(line);
532 return batch;
533 }
534}
535function readLine2(line) {
536 const space = line.indexOf(" ");
537 if (space < 0) return null;
538 const rest = line.slice(space + 1);
539 switch (line.slice(0, space)) {
540 case "READY":
541 return readReady(rest);
542 case "F":
543 return readFrame(rest);
544 case "C":
545 return readMap(rest);
546 case "V":
547 return readHud(rest);
548 case "S":
549 return readStatus(rest);
550 case "E":
551 return { kind: "problem", message: rest };
552 default:
553 return null;
554 }
555}
556function readReady(text) {
557 const [port, token, ...extra] = text.split(" ");
558 if (extra.length > 0 || token === void 0 || token === "") return null;
559 const number = wholeNumber(port);
560 return number === null ? null : { kind: "ready", port: number, token };
561}
562function readFrame(text) {
563 const [columnsText, rowsText, cells, ...extra] = text.split(" ");
564 const columns = wholeNumber(columnsText);
565 const rows = wholeNumber(rowsText);
566 if (extra.length > 0 || columns === null || rows === null || cells === void 0) return null;
567 return { size: { columns, rows }, cells };
568}
569function readMap(text) {
570 const rows = readMapRows(parseJson(text));
571 return rows === null ? null : { kind: "map", rows };
572}
573function readHud(text) {
574 const value = parseJson(text);
575 if (!isRecord4(value)) return null;
576 const { svg, alt, w, h: h2 } = value;
577 const isHud = typeof svg === "string" && svg.length <= SVG_SOURCE_MAX_CHARS && typeof alt === "string" && alt.length <= SVG_ALT_MAX_CHARS && isLength(w) && isLength(h2);
578 return isHud ? { kind: "hud", hud: { svg, alt, w, h: h2 } } : null;
579}
580function readStatus(text) {
581 const value = parseJson(text);
582 if (!isRecord4(value)) return null;
583 const { phase, room, mass, isDead } = value;
584 const isStatus = typeof phase === "string" && (typeof room === "string" || room === null) && typeof mass === "number" && typeof isDead === "boolean";
585 return isStatus ? { kind: "status", status: { phase, room, mass, isDead } } : null;
586}
587function parseJson(text) {
588 try {
589 return JSON.parse(text);
590 } catch {
591 return void 0;
592 }
593}
594function isLength(value) {
595 return typeof value === "number" && Number.isFinite(value) && value > 0;
596}
597function isRecord4(value) {
598 return typeof value === "object" && value !== null && !Array.isArray(value);
599}
600function wholeNumber(text) {
601 return text !== void 0 && /^\d+$/.test(text) ? Number(text) : null;
602}
603
604// src/bridge-run.ts
605var MIN_NODE_MAJOR = MOD_INSTALL.minNodeMajor;
606var NODE_VERSION_ARGV = ["node", "--version"];
607var NODE_CHECK_TIMEOUT_MS = 1e4;
608function nodeProblem(exitCode, stdout) {
609 const needs = `tokeneater needs Node.js ${String(MIN_NODE_MAJOR)} or newer to play`;
610 if (stdout === null || exitCode !== 0) return `${needs} (Node.js was not found).`;
611 const version = stdout.trim();
612 const major = Number(/^v(\d+)\./.exec(version)?.[1] ?? 0);
613 return major >= MIN_NODE_MAJOR ? null : `${needs} (found ${version}).`;
614}
615var FATAL_EXIT_CODES = [2, 3];
616var MAX_RESTARTS = 2;
617var RESTART_DELAY_MS = 1e3;
618function shouldRestart(end, restartsSoFar) {
619 if (end.code !== null && FATAL_EXIT_CODES.includes(end.code)) return false;
620 return restartsSoFar < MAX_RESTARTS;
621}
622function endProblem(end, lastProblem) {
623 if (lastProblem !== null) return lastProblem;
624 if (end.signal !== null) return `The game stopped (${end.signal}).`;
625 return `The game stopped (exit code ${String(end.code)}).`;
626}
627
628// src/control-requests.ts
629var CONTROL_PATHS = {
630 input: "/input",
631 press: "/press",
632 resize: "/resize",
633 leave: "/leave"
634};
635var TOKEN_HEADER = "x-te-token";
636function inputRequest(input) {
637 switch (input.kind) {
638 case "aim":
639 return { path: CONTROL_PATHS.input, body: { x: input.x, y: input.y } };
640 case "keys":
641 return { path: CONTROL_PATHS.input, body: { keys: { dx: input.dx, dy: input.dy } } };
642 case "press":
643 return { path: CONTROL_PATHS.press, body: { action: input.action } };
644 }
645}
646function resizeRequest(size) {
647 return { path: CONTROL_PATHS.resize, body: { columns: size.columns, rows: size.rows } };
648}
649var LEAVE_REQUEST = { path: CONTROL_PATHS.leave, body: {} };
650function controlUrl(port, path) {
651 return `http://127.0.0.1:${String(port)}${path}`;
652}
653
654// plugin/hooks/bridge-control.ts
655async function requestControl($, run, request) {
656 if (run.control === null) return false;
657 const { port, token } = run.control;
658 try {
659 const response = await $.http.fetch(controlUrl(port, request.path), {
660 method: "POST",
661 headers: { "content-type": "application/json", [TOKEN_HEADER]: token },
662 body: JSON.stringify(request.body)
663 });
664 if (!response.ok) {
665 $.ui.log(`tokeneater bridge: ${request.path} answered ${String(response.status)}`, {
666 to: "debug"
667 });
668 }
669 return response.ok;
670 } catch (error) {
671 $.ui.log(`tokeneater bridge: ${request.path} failed: ${String(error)}`, { to: "debug" });
672 return false;
673 }
674}
675async function checkNode($) {
676 try {
677 const { exitCode, stdout } = await $.process.run(NODE_VERSION_ARGV, {
678 timeoutMs: NODE_CHECK_TIMEOUT_MS
679 });
680 return nodeProblem(exitCode, stdout);
681 } catch {
682 return nodeProblem(1, null);
683 }
684}
685
686// plugin/hooks/desktop-frames.ts
687function keepMap(host, rows) {
688 host.lastMap = rows;
689}
690function handOutMap(host) {
691 const rows = host.lastMap;
692 if (rows === null || rows === host.answeredMap) return void 0;
693 host.answeredMap = rows;
694 return rows;
695}
696async function keepHud($, host, hud) {
697 host.lastHud = hud;
698 host.hud.isOwed = true;
699 if (host.hud.isPacing) return;
700 host.hud.isPacing = true;
701 while (host.hud.isOwed) {
702 host.hud.isOwed = false;
703 $.ui.invalidate("ui.render");
704 await $.clock.sleep(HUD_REDRAW_INTERVAL_MS);
705 }
706 host.hud.isPacing = false;
707}
708function forgetDesktopFrames(host) {
709 host.lastMap = null;
710 host.answeredMap = null;
711 host.lastHud = null;
712}
713
714// plugin/hooks/host-state.ts
715var RASTER_KEY = "game";
716function createBridgeHost(options) {
717 return {
718 options,
719 run: null,
720 starting: null,
721 isWanted: false,
722 isRestarting: false,
723 restarts: 0,
724 hasNode: false,
725 problem: null,
726 lastFrame: null,
727 mountedSize: null,
728 lastStatus: null,
729 surface: "terminal",
730 lastMap: null,
731 answeredMap: null,
732 lastHud: null,
733 hud: { isPacing: false, isOwed: false },
734 fallback: null
735 };
736}
737function hostPhase(host) {
738 if (host.isRestarting) return "restarting";
739 if (host.run !== null || host.starting !== null) return "running";
740 return host.problem === null ? "idle" : "failed";
741}
742function hostStatus(host) {
743 return host.run === null ? null : host.lastStatus;
744}
745function cellsFor(host, size) {
746 const frame = host.lastFrame;
747 return frame !== null && isSameSize(frame.size, size) ? frame.cells : blankCells(size);
748}
749
750// plugin/hooks/bridge-host.ts
751var BRIDGE_ENTRY = "bundle/bridge.mjs";
752async function startBridge($, host, surface) {
753 host.isWanted = true;
754 host.surface = surface;
755 if (host.run !== null) return;
756 host.starting ??= spawnBridge($, host).finally(() => {
757 host.starting = null;
758 $.ui.invalidate("ui.render");
759 });
760 await host.starting;
761}
762async function stopBridge($, host) {
763 host.isWanted = false;
764 const { run } = host;
765 if (run === null) return;
766 run.isEnding = true;
767 await leaveBridge($, run);
768}
769async function sendInput($, host, { input, surface }) {
770 const isPlayAgain = input.kind === "press" && input.action === "respawn";
771 if (isPlayAgain && hostPhase(host) !== "running") {
772 host.restarts = 0;
773 await startBridge($, host, surface);
774 return;
775 }
776 if (host.run !== null) await requestControl($, host.run, inputRequest(input));
777}
778async function forwardMessage($, host, { input: message, surface }) {
779 for (const input of messageInputs(message)) await sendInput($, host, { input, surface });
780}
781function mountPane($, host, size) {
782 host.mountedSize = size;
783 const { run } = host;
784 if (run === null || run.control === null || isSameSize(run.size, size)) return;
785 run.size = size;
786 void requestControl($, run, resizeRequest(size));
787}
788async function spawnBridge($, host) {
789 host.problem = null;
790 host.lastStatus = null;
791 $.ui.invalidate("ui.render");
792 const problem = host.hasNode ? null : await checkNode($);
793 if (problem !== null) {
794 failBridge($, host, problem);
795 return;
796 }
797 host.hasNode = true;
798 if (!host.isWanted) return;
799 const { mountedSize: size, surface } = host;
800 const run = {
801 stream: $.process.spawn({
802 argv: ["node", `${$.plugin.root}/${BRIDGE_ENTRY}`],
803 env: bridgeEnv(host.options, { size, surface })
804 }),
805 control: null,
806 size,
807 surface,
808 lastProblem: null,
809 isEnding: false
810 };
811 host.run = run;
812 void readBridge($, { host, run });
813}
814async function readBridge($, running) {
815 const { stream } = running.run;
816 const reader = new LineReader();
817 let end = { code: null, signal: null };
818 try {
819 for await (const piece of stream) {
820 if (piece.stream === "stderr") {
821 $.ui.log(piece.text.trimEnd(), { to: "debug" });
822 continue;
823 }
824 const batch = reader.push(piece.text);
825 for (const event of batch.events) takeEvent($, running, event);
826 if (batch.frame !== null) await paintFrame($, running.host, batch.frame);
827 if (batch.map !== null) keepMap(running.host, batch.map);
828 if (batch.hud !== null) void keepHud($, running.host, batch.hud);
829 }
830 end = await stream.result;
831 } catch (error) {
832 $.ui.log(`tokeneater bridge: ${String(error)}`, { to: "debug" });
833 }
834 await bridgeEnded($, running, end);
835}
836function takeEvent($, { host, run }, event) {
837 switch (event.kind) {
838 case "ready":
839 run.control = { port: event.port, token: event.token };
840 if (run.isEnding) void leaveBridge($, run);
841 else if (host.mountedSize !== null) mountPane($, host, host.mountedSize);
842 break;
843 case "status":
844 host.lastStatus = event.status;
845 if (event.status.phase === "playing") host.restarts = 0;
846 break;
847 case "problem":
848 run.lastProblem = event.message;
849 $.ui.log(`tokeneater bridge: ${event.message}`, { to: "debug" });
850 }
851}
852async function paintFrame($, host, frame) {
853 host.lastFrame = frame;
854 if (!isSameSize(frame.size, host.mountedSize)) return;
855 await $.ui.blit({
856 requestId: PANE_ID,
857 key: RASTER_KEY,
858 cells: frame.cells,
859 columns: frame.size.columns,
860 rows: frame.size.rows
861 });
862}
863async function bridgeEnded($, running, end) {
864 const { host, run } = running;
865 host.run = null;
866 host.lastFrame = null;
867 forgetDesktopFrames(host);
868 if (!host.isWanted) {
869 $.ui.invalidate("ui.render");
870 return;
871 }
872 if (run.isEnding) {
873 await startBridge($, host, host.surface);
874 return;
875 }
876 if (!shouldRestart(end, host.restarts)) {
877 failBridge($, host, endProblem(end, run.lastProblem));
878 return;
879 }
880 host.restarts += 1;
881 host.isRestarting = true;
882 $.ui.invalidate("ui.render");
883 await $.clock.sleep(RESTART_DELAY_MS);
884 host.isRestarting = false;
885 if (host.isWanted) await startBridge($, host, run.surface);
886 else $.ui.invalidate("ui.render");
887}
888function failBridge($, host, problem) {
889 host.problem = problem;
890 $.ui.log(`tokeneater: ${problem}`, { to: "debug" });
891 $.ui.invalidate("ui.render");
892}
893async function leaveBridge($, run) {
894 if (run.control === null) return;
895 if (!await requestControl($, run, LEAVE_REQUEST)) killBridge($, run);
896}
897function killBridge($, run) {
898 $.ui.log("tokeneater bridge: /leave was not taken, ending the bridge", { to: "debug" });
899 void run.stream.return({ code: null, signal: null }).catch((error) => {
900 $.ui.log(`tokeneater bridge: could not end it: ${String(error)}`, { to: "debug" });
901 });
902}
903
904// src/desktop-controls.ts
905var KEY_CHROME_COLUMNS = 5;
906var KEY_GAP_COLUMNS = 1;
907function desktopControlRows(columns) {
908 const width = Math.max(1, Math.floor(columns));
909 const buttons = PANE_KEYS.map((key) => key.label.length + KEY_CHROME_COLUMNS);
910 const words = DESKTOP_KEY_HINT.split(" ").map((word) => word.length);
911 return wrappedRows(buttons, KEY_GAP_COLUMNS, width) + wrappedRows(words, 1, width);
912}
913function wrappedRows(widths, gap, width) {
914 let rows = 1;
915 let used = 0;
916 for (const piece of widths) {
917 const next = used === 0 ? piece : used + gap + piece;
918 if (next > width && used > 0) {
919 rows += 1;
920 used = piece;
921 } else {
922 used = next;
923 }
924 }
925 return rows;
926}
927
928// src/fallback.ts
929var GAME_SITE_ORIGIN = "https://playtokeneater.com";
930var ROOMS_URL = `${GAME_SITE_ORIGIN}${ROOMS_PATH}`;
931var ROOMS_READ_TIMEOUT_MS = 3e3;
932async function roomsOf(answer) {
933 const response = lobbyResponseOf(answer);
934 const lobby = createLobbyClient(() => Promise.resolve(response));
935 const read = await lobby.rooms();
936 return read.ok ? read.value : null;
937}
938function fallbackTarget(rooms) {
939 const open = (rooms?.rooms ?? []).filter(
940 (room) => room.players < MAX_PLAYERS_PER_ROOM && ROOM_NAME_PATTERN.test(room.name)
941 );
942 const fullest = open.reduce(
943 (best, room) => best === null || room.players > best.players ? room : best,
944 null
945 );
946 if (fullest === null) return { href: `${GAME_SITE_ORIGIN}/`, label: FALLBACK_HOME_LABEL };
947 return {
948 href: roomLink(GAME_SITE_ORIGIN, fullest.name),
949 label: fallbackRoomLabel(fullest.name, { players: fullest.players, max: MAX_PLAYERS_PER_ROOM })
950 };
951}
952function lobbyResponseOf(answer) {
953 return {
954 ok: answer.ok,
955 status: answer.status,
956 headers: { get: (name) => answer.headers[name.toLowerCase()] ?? null },
957 // Parsed inside the promise: a body that is not JSON rejects, as a `fetch` response's does.
958 json: () => Promise.resolve(answer.text).then((text) => JSON.parse(text))
959 };
960}
961
962// plugin/hooks/fallback-pane.tsx
963var LINK_KEY = "play-in-browser";
964function drawFallback($, e, host) {
965 const { Box, Text, Link } = $.ui.resolve(e);
966 if (host.run !== null) {
967 const isHere = host.run.surface === e.surface;
968 return /* @__PURE__ */ h(Text, null, isHere ? HIDDEN_GAME_NOTICE : playingElsewhereNotice(host.run.surface));
969 }
970 if (host.fallback === null) void readFallback($, host);
971 const target = host.fallback?.target ?? null;
972 return /* @__PURE__ */ h(Box, { key: "pane", flexDirection: "column" }, /* @__PURE__ */ h(Text, null, FALLBACK_NOTICE), target === null ? /* @__PURE__ */ h(Text, { dimColor: true }, FALLBACK_READING) : /* @__PURE__ */ h(Link, { key: LINK_KEY, href: target.href, label: target.label }));
973}
974async function readFallback($, host) {
975 const read = { target: null };
976 host.fallback = read;
977 read.target = fallbackTarget(await readRooms($));
978 if (host.fallback === read) $.ui.invalidate("ui.render");
979}
980function forgetFallback(host) {
981 host.fallback = null;
982}
983async function readRooms($) {
984 let endWait = () => void 0;
985 const waited = new Promise((resolve) => {
986 endWait = resolve;
987 });
988 const timer = $.clock.after(ROOMS_READ_TIMEOUT_MS, () => {
989 endWait(null);
990 });
991 try {
992 return await Promise.race([fetchRooms($), waited]);
993 } finally {
994 timer.cancel();
995 }
996}
997async function fetchRooms($) {
998 try {
999 const answer = await $.http.fetch(ROOMS_URL, { headers: { accept: "application/json" } });
1000 return await roomsOf(answer);
1001 } catch (error) {
1002 $.ui.log(`tokeneater fallback: the rooms read failed: ${String(error)}`, { to: "debug" });
1003 return null;
1004 }
1005}
1006
1007// plugin/hooks/pane-parts.tsx
1008var BUTTON_GAP = 2;
1009function drawKeys($, e, host) {
1010 const { Box, Button } = $.ui.resolve(e);
1011 return /* @__PURE__ */ h(Box, { key: "keys", flexDirection: "row", flexWrap: "wrap", columnGap: BUTTON_GAP }, PANE_KEYS.map((key) => /* @__PURE__ */ h(
1012 Button,
1013 {
1014 key: key.id,
1015 label: key.label,
1016 hotkey: key.hotkey,
1017 plain: true,
1018 onPress: () => {
1019 void sendInput($, host, { input: key.input, surface: e.surface });
1020 }
1021 }
1022 )));
1023}
1024function noticeOf(host, isTooSmall) {
1025 switch (hostPhase(host)) {
1026 case "idle":
1027 return IDLE_NOTICE;
1028 case "failed":
1029 return `${host.problem ?? ""} ${RETRY_HINT}`;
1030 case "restarting":
1031 return RESTARTING_NOTICE;
1032 case "running":
1033 return isTooSmall ? TOO_SMALL_NOTICE : null;
1034 }
1035}
1036
1037// plugin/hooks/desktop-pane.tsx
1038var MAP_KEY = "map";
1039function drawDesktopPane($, e, host) {
1040 const isDocked = e.props.placement === "dock" && e.viewport?.isFullscreen === true;
1041 if (!isDocked) return drawFallback($, e, host);
1042 const { Box, Text } = $.ui.resolve(e);
1043 if (host.run !== null && host.run.surface !== "desktop") {
1044 return /* @__PURE__ */ h(Text, null, playingElsewhereNotice(host.run.surface));
1045 }
1046 const { bodyColumns } = e.props;
1047 const size = desktopMapSize(
1048 bodyColumns,
1049 e.props.scroll.bodyRows,
1050 desktopControlRows(bodyColumns)
1051 );
1052 const notice = noticeOf(host, size === null);
1053 if (size === null || notice !== null) {
1054 return /* @__PURE__ */ h(Box, { key: "pane", flexDirection: "column" }, /* @__PURE__ */ h(Text, null, notice ?? TOO_SMALL_NOTICE), drawKeys($, e, host));
1055 }
1056 mountPane($, host, size);
1057 return drawDesktopGame($, e, { host, size });
1058}
1059function drawDesktopGame($, e, { host, size }) {
1060 const { Box, Text, Svg, Client } = $.ui.resolve(e);
1061 const hud = host.lastHud;
1062 return /* @__PURE__ */ h(Box, { key: "pane", flexDirection: "column" }, /* @__PURE__ */ h(
1063 Client,
1064 {
1065 key: MAP_KEY,
1066 module: "./map-surface.js",
1067 width: size.columns,
1068 height: size.rows,
1069 props: host.lastMap
1070 }
1071 ), /* @__PURE__ */ h(Box, { key: "hud", height: DESKTOP_HUD_ROWS, flexShrink: 0 }, hud !== null && /* @__PURE__ */ h(Svg, { source: hud.svg, alt: hud.alt, width: hud.w, height: hud.h })), drawKeys($, e, host), /* @__PURE__ */ h(Text, { dimColor: true }, DESKTOP_KEY_HINT));
1072}
1073
1074// plugin/hooks/pane.tsx
1075var INPUT_KEY = "input";
1076function drawPane($, e, host) {
1077 if (e.surface === "desktop") return drawDesktopPane($, e, host);
1078 if (e.surface !== "terminal") return drawFallback($, e, host);
1079 const { Box, Text } = $.ui.resolve(e);
1080 if (e.props.placement === "inline") return /* @__PURE__ */ h(Text, null, DOCK_NOTICE);
1081 if (host.run !== null && host.run.surface !== "terminal") {
1082 return /* @__PURE__ */ h(Text, null, playingElsewhereNotice(host.run.surface));
1083 }
1084 const size = rasterSize(e.props.bodyColumns, e.props.scroll.bodyRows);
1085 const notice = noticeOf(host, size === null);
1086 if (size === null || notice !== null) {
1087 return /* @__PURE__ */ h(Box, { key: "pane", flexDirection: "column" }, /* @__PURE__ */ h(Text, null, notice ?? TOO_SMALL_NOTICE), drawKeys($, e, host));
1088 }
1089 mountPane($, host, size);
1090 return drawGame($, e, { host, size });
1091}
1092function drawGame($, e, { host, size }) {
1093 const { Box, Raster, Client } = $.ui.resolve(e);
1094 return /* @__PURE__ */ h(Box, { key: "pane", flexDirection: "column" }, /* @__PURE__ */ h(Box, { key: "game", width: size.columns, height: size.rows }, /* @__PURE__ */ h(
1095 Raster,
1096 {
1097 key: RASTER_KEY,
1098 columns: size.columns,
1099 rows: size.rows,
1100 cells: cellsFor(host, size)
1101 }
1102 ), /* @__PURE__ */ h(Box, { key: "overlay", position: "absolute", top: 0, left: 0 }, /* @__PURE__ */ h(
1103 Client,
1104 {
1105 key: INPUT_KEY,
1106 module: "./input-surface.js",
1107 width: size.columns,
1108 height: size.rows
1109 }
1110 ))), drawKeys($, e, host));
1111}
1112
1113// src/mod-log.ts
1114function surfaceLog(surface, isDocked) {
1115 return `tokeneater.surface ${surface ?? "none"} ${isDocked ? "dock" : "inline"}`;
1116}
1117
1118// plugin/hooks/pane-hooks.ts
1119var CLOSED_THIS_TURN = { plugin: "tokeneater", key: "closedThisTurn" };
1120function measureScreen(mod, viewport) {
1121 if (viewport?.isFullscreen === void 0) return;
1122 mod.screen = { columns: viewport.columns, isFullscreen: viewport.isFullscreen };
1123}
1124function measureDesktop(mod, viewport) {
1125 if (viewport?.isFullscreen === void 0) return;
1126 mod.desktopDocks = viewport.isFullscreen;
1127}
1128function forgetDesktop(mod) {
1129 mod.desktopDocks = null;
1130}
1131async function openOnSubmit($, mod, e) {
1132 const isMidTurn = e.turnId !== void 0;
1133 if (!isMidTurn) await $.state.set(CLOSED_THIS_TURN, false);
1134 if (!mod.host.options.isAutoOpenOn) return;
1135 const { value: wasClosedThisTurn = false } = await $.state.get(CLOSED_THIS_TURN);
1136 const surface = await surfaceOf($, mod);
1137 const facts = {
1138 isAutoOpenOn: mod.host.options.isAutoOpenOn,
1139 originKind: e.origin.kind,
1140 isMidTurn,
1141 wasClosedThisTurn,
1142 surface,
1143 screen: mod.screen,
1144 desktopDocks: mod.desktopDocks
1145 };
1146 if (surface === null || !shouldAutoOpen({ ...facts, isOpen: await isPaneOpen($) })) return;
1147 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS });
1148 $.ui.log(surfaceLog(surface, opened.isPlaced), { to: "debug" });
1149 if (opened.isPlaced) void startBridge($, mod.host, surface);
1150}
1151async function togglePane($, mod, e) {
1152 const { host } = mod;
1153 if (await isPaneOpen($)) {
1154 await $.state.set(CLOSED_THIS_TURN, true);
1155 await $.ui.close({ id: PANE_ID });
1156 return { text: CLOSED_TEXT };
1157 }
1158 const surface = await surfaceOf($, mod);
1159 const isDocked = isDockedOn(mod, surface, e);
1160 await $.ui.open({
1161 id: PANE_ID,
1162 title: PANE_TITLE,
1163 columns: PANE_COLUMNS,
1164 ...isDocked ? { focus: true } : {}
1165 });
1166 $.ui.log(surfaceLog(surface, isDocked), { to: "debug" });
1167 if (surface === null || !isDocked) {
1168 return { text: surface === "terminal" ? DOCK_NOTICE : FALLBACK_OPENED_TEXT };
1169 }
1170 await startBridge($, host, surface);
1171 return { text: OPENED_TEXT };
1172}
1173function isDockedOn(mod, surface, e) {
1174 if (surface === "desktop") return mod.desktopDocks === true;
1175 return surface === "terminal" && isDockable(e.presentation);
1176}
1177async function paneClosed($, host, e) {
1178 forgetFallback(host);
1179 if (e.origin.kind === "person") await $.state.set(CLOSED_THIS_TURN, true);
1180 await stopBridge($, host);
1181}
1182async function turnEnded($, host, e) {
1183 await $.state.set(CLOSED_THIS_TURN, false);
1184 if (!e.isAborted && await isPaneOpen($)) $.ui.toast(turnToast(hostStatus(host)));
1185}
1186async function surfaceOf($, mod) {
1187 return playSurface(await $.session.surfaces(), mod.desktopDocks);
1188}
1189async function isPaneOpen($) {
1190 return (await $.ui.panes()).some((pane) => pane.id === PANE_ID);
1191}
1192
1193// plugin/hooks/register.tsx
1194function register(on, options) {
1195 const mod = {
1196 host: createBridgeHost(readOptions(options)),
1197 screen: null,
1198 desktopDocks: null
1199 };
1200 const { host } = mod;bundle/map-surface.js 342 lines1// ../../packages/shared/src/legal.ts
2var SPONSOR_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1e3;
3var ABANDONED_DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
4var EXPIRED_BAN_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
5var AUDIT_LOG_RETENTION_MS = 365 * 24 * 60 * 60 * 1e3;
6var LAPSED_HOLD_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
7var UNUSED_LOGO_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
8
9// ../../packages/shared/src/engine/constants.ts
10var TICK_RATE_HZ = 20;
11var TICK_MS = 1e3 / TICK_RATE_HZ;
12var START_MASS = 10;
13var RADIUS_PER_SQRT_MASS = 10;
14var SLIDE_STOP_SPEED = 5;
15var EJECT_BLOB_MASS = 12;
16var DECAY_RATE_PER_S = 2e-3;
17
18// ../../packages/shared/src/engine/geometry.ts
19function massToRadius(mass) {
20 return mass > 0 ? RADIUS_PER_SQRT_MASS * Math.sqrt(mass) : 0;
21}
22function toUnitsPerTick(unitsPerSecond) {
23 return unitsPerSecond / TICK_RATE_HZ;
24}
25
26// ../../packages/shared/src/engine/movement.ts
27var STOP_SPEED = toUnitsPerTick(SLIDE_STOP_SPEED);
28var STOP_SPEED_SQUARED = STOP_SPEED * STOP_SPEED;
29
30// ../../packages/shared/src/engine/decay.ts
31var DECAY_FACTOR_PER_TICK = 1 - DECAY_RATE_PER_S / TICK_RATE_HZ;
32
33// ../../packages/shared/src/engine/virus.ts
34var FULL_TURN = 2 * Math.PI;
35
36// ../../packages/shared/src/mod/install.ts
37var MOD_INSTALL = {
38 /** The public repository the mod release publishes to: the marketplace players add. */
39 marketplaceRepo: "jeffbruchado/tokeneater-mod",
40 /**
41 * The plugin's install id, `<plugin>@<marketplace>`: the `name` of
42 * `apps/mod/plugin/.claude-plugin/plugin.json` and of `apps/mod/marketplace/marketplace.json`.
43 */
44 pluginId: "tokeneater@tokeneater",
45 /** The plugin's command, which opens and closes the pane. */
46 paneCommand: "/tokeneater",
47 /** The Claude Code command that switches to the fullscreen layout, where the pane docks. */
48 fullscreenCommand: "/tui fullscreen",
49 /** Oldest Claude Code with the function hooks the plugin is built on. */
50 minClaudeCodeVersion: "2.1.288",
51 /** Oldest Node.js major the bridge runs on: the first with a global `WebSocket` by default. */
52 minNodeMajor: 22,
53 /** Narrowest terminal (columns) whose fullscreen layout docks the pane. */
54 dockMinColumns: 110,
55 /** Narrowest terminal (columns) the pane opens in by itself when a prompt is sent. */
56 autoOpenMinColumns: 144
57};
58var MOD_HOTKEYS = {
59 split: "e",
60 eject: "q",
61 respawn: "r",
62 up: "w",
63 left: "a",
64 down: "s",
65 right: "d"
66};
67
68// ../../packages/shared/src/protocol/messages.ts
69var ROOM_NAME_MAX_BYTES = 32;
70var ROOM_NAME_PATTERN = new RegExp(`^[a-z0-9-]{1,${String(ROOM_NAME_MAX_BYTES)}}$`);
71var RECONNECT_GRACE_SECONDS = 5;
72var JOIN_REJECTIONS = ["nickname", "banned"];
73var JOIN_UNAVAILABILITIES = ["capacity", "rate_limited", "unavailable"];
74
75// ../../packages/shared/src/protocol/utf8.ts
76var { TextEncoder, TextDecoder } = globalThis;
77var encoder = new TextEncoder();
78var decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
79
80// ../../packages/shared/src/protocol/client-codec.ts
81var SPLIT_FLAG = 1;
82var EJECT_FLAG = 2;
83var KNOWN_FLAGS = SPLIT_FLAG | EJECT_FLAG;
84
85// ../../packages/shared/src/protocol/slots.ts
86var SPONSOR_LOGO_MAX_BYTES = 512 * 1024;
87
88// ../../packages/shared/src/protocol/view.ts
89var BASE_VIEW_HALF_WIDTH = 800;
90var VIEW_GROWTH = 0.1;
91var MAX_VIEW_HALF_WIDTH = 2400;
92var VIEW_ASPECT_RATIO = 16 / 9;
93function viewForMass(totalMass) {
94 const growth = totalMass > 0 ? VIEW_GROWTH * Math.sqrt(totalMass / START_MASS) : 0;
95 const halfWidth = Math.min(BASE_VIEW_HALF_WIDTH * (1 + growth), MAX_VIEW_HALF_WIDTH);
96 return { halfWidth, halfHeight: halfWidth / VIEW_ASPECT_RATIO };
97}
98
99// ../../packages/shared/src/client/input/intent-sender.ts
100var PRESS_SPACING_MS = TICK_MS + 10;
101var MAX_HEADING_ERROR_RAD = Math.PI / 90;
102
103// ../../packages/shared/src/client/net/connection-phase.ts
104var RESUME_WINDOW_MS = RECONNECT_GRACE_SECONDS * 1e3;
105
106// ../../packages/shared/src/client/net/lobby-client.ts
107var REJECTIONS = new Set(JOIN_REJECTIONS);
108var UNAVAILABILITIES = new Set(JOIN_UNAVAILABILITIES);
109
110// ../../packages/shared/src/client/net/site-links.ts
111var SPONSOR_PAGE_PATH = "/sponsor";
112var SPONSOR_LOGIN_PATH = `${SPONSOR_PAGE_PATH}/login`;
113var SPONSOR_DASHBOARD_PATH = `${SPONSOR_PAGE_PATH}/dashboard`;
114var SIGN_IN_ERROR_PARAM = "error";
115var SIGN_IN_ERROR_EXPIRED = "expired";
116var EXPIRED_SIGN_IN_PATH = `${SPONSOR_LOGIN_PATH}?${SIGN_IN_ERROR_PARAM}=${SIGN_IN_ERROR_EXPIRED}`;
117var MAGIC_LINK_LIFETIME_MS = 15 * 60 * 1e3;
118
119// ../../packages/shared/src/client/render/camera.ts
120var BASE_VIEW = viewForMass(0);
121
122// ../../packages/shared/src/client/render/own-cells.ts
123var CORRECTION_MS = 100;
124var CORRECTION_TIME_CONSTANT_MS = CORRECTION_MS / 3;
125
126// ../../packages/shared/src/client/render/palette.ts
127var BLOB_RADIUS = massToRadius(EJECT_BLOB_MASS);
128
129// src/pane-layout.ts
130var DOCK_MIN_COLUMNS = MOD_INSTALL.dockMinColumns;
131var AUTO_OPEN_MIN_COLUMNS = MOD_INSTALL.autoOpenMinColumns;
132var STATUS_ROWS = 1;
133var HUD_HEIGHT_PX = 244;
134var DESKTOP_ROW_PX = 18.9;
135var DESKTOP_HUD_ROWS = Math.ceil(HUD_HEIGHT_PX / DESKTOP_ROW_PX);
136
137// src/pane-input.ts
138var MAX_AIM_OFFSET = 4;
139var PANE_KEYS = [
140 {
141 id: "split",
142 hotkey: MOD_HOTKEYS.split,
143 label: "split",
144 input: { kind: "press", action: "split" }
145 },
146 {
147 id: "eject",
148 hotkey: MOD_HOTKEYS.eject,
149 label: "eject",
150 input: { kind: "press", action: "eject" }
151 },
152 {
153 id: "respawn",
154 hotkey: MOD_HOTKEYS.respawn,
155 label: "play again",
156 input: { kind: "press", action: "respawn" }
157 },
158 { id: "up", hotkey: MOD_HOTKEYS.up, label: "up", input: { kind: "keys", dx: 0, dy: -1 } },
159 { id: "left", hotkey: MOD_HOTKEYS.left, label: "left", input: { kind: "keys", dx: -1, dy: 0 } },
160 { id: "down", hotkey: MOD_HOTKEYS.down, label: "down", input: { kind: "keys", dx: 0, dy: 1 } },
161 { id: "right", hotkey: MOD_HOTKEYS.right, label: "right", input: { kind: "keys", dx: 1, dy: 0 } }
162];
163var SURFACE_KEYS = /* @__PURE__ */ new Map([
164 [" ", { kind: "press", action: "split" }],
165 ["space", { kind: "press", action: "split" }],
166 ["return", { kind: "press", action: "respawn" }],
167 ["up", { kind: "keys", dx: 0, dy: -1 }],
168 ["left", { kind: "keys", dx: -1, dy: 0 }],
169 ["down", { kind: "keys", dx: 0, dy: 1 }],
170 ["right", { kind: "keys", dx: 1, dy: 0 }]
171]);
172function inputForKey(key) {
173 const letter = key.toLowerCase();
174 const paneKey = PANE_KEYS.find((entry) => entry.hotkey === letter);
175 if (paneKey !== void 0) return paneKey.input;
176 return SURFACE_KEYS.get(key) ?? null;
177}
178function aimAtPointer(point, region) {
179 const mapRows = region.rows - STATUS_ROWS;
180 if (region.columns < 1 || mapRows < 1) return null;
181 const column = point.fine?.x ?? point.x + 0.5;
182 const row = point.fine?.y ?? point.y + 0.5;
183 return {
184 kind: "aim",
185 x: clampOffset(column / region.columns * 2 - 1),
186 y: clampOffset(row / mapRows * 2 - 1)
187 };
188}
189function clampOffset(offset) {
190 return Math.min(Math.max(offset, -MAX_AIM_OFFSET), MAX_AIM_OFFSET);
191}
192
193// src/surface-message.ts
194var MAX_PRESSES_PER_MESSAGE = 8;
195var MAX_STEERS_PER_MESSAGE = 8;
196
197// src/input-queue.ts
198var POST_INTERVAL_MS = 33;
199var InputQueue = class {
200 steers = [];
201 presses = [];
202 /** Queues `input`. */
203 add(input) {
204 if (input.kind === "aim") this.steers = [input];
205 else if (input.kind === "keys") this.addKey(input);
206 else if (this.presses.length < MAX_PRESSES_PER_MESSAGE) this.presses.push(input.action);
207 }
208 /** The inputs queued since the last `take`, as one message; `null` when there were none. */
209 take() {
210 const { steers, presses } = this;
211 if (steers.length === 0 && presses.length === 0) return null;
212 this.steers = [];
213 this.presses = [];
214 return steers.length === 0 ? { presses } : { steers, presses };
215 }
216 addKey(key) {
217 if (this.steers.length < MAX_STEERS_PER_MESSAGE) this.steers.push(key);
218 }
219};
220var MAP_REQUEST = { presses: [] };
221var MapPosts = class {
222 constructor(queue) {
223 this.queue = queue;
224 }
225 queue;
226 wasInput = false;
227 /** The next post, or `null` for a tick that posts nothing. */
228 next() {
229 const message = this.queue.take();
230 if (message !== null) {
231 this.wasInput = true;
232 return message;
233 }
234 if (this.wasInput) {
235 this.wasInput = false;
236 return null;
237 }
238 return MAP_REQUEST;
239 }
240};
241
242// src/map-tree.ts
243var HALF = "50%";
244function mapNodes(rows) {
245 return {
246 type: "Box",
247 props: {
248 flexDirection: "column",
249 width: rows.columns,
250 height: rows.rows,
251 backgroundColor: colourOf(rows.palette, 0),
252 overflow: "hidden"
253 },
254 children: rows.lines.map((line) => lineNode(line, rows.palette))
255 };
256}
257function lineNode(line, palette) {
258 if (typeof line === "number") return { type: "Box", props: { height: line } };
259 return {
260 type: "Box",
261 props: { flexDirection: "row", height: 1 },
262 children: line.map((segment) => segmentNode(segment, palette))
263 };
264}
265function segmentNode(segment, palette) {
266 const place = placeOf(segment);
267 if (segment.length === 3) {
268 return { type: "Box", props: { ...place, backgroundColor: colourOf(palette, segment[2]) } };
269 }
270 if (segment.length === 4) {
271 return {
272 type: "Box",
273 props: { ...place, height: 1, flexDirection: "column" },
274 children: [halfNode(segment[2], palette), halfNode(segment[3], palette)]
275 };
276 }
277 const [, , background, ink, text] = segment;
278 return {
279 type: "Box",
280 props: { ...place, ...backgroundOf(background, palette), overflow: "hidden" },
281 children: [{ type: "Text", props: { color: colourOf(palette, ink) }, children: [text] }]
282 };
283}
284function placeOf(segment) {
285 const [margin, width] = segment;
286 return margin > 0 ? { marginLeft: margin, width } : { width };
287}
288function halfNode(colour, palette) {
289 return { type: "Box", props: { height: HALF, ...backgroundOf(colour, palette) } };
290}
291function backgroundOf(colour, palette) {
292 return colour === 0 ? {} : { backgroundColor: colourOf(palette, colour) };
293}
294function colourOf(palette, colour) {
295 const css = palette[colour];
296 if (css === void 0)
297 throw new Error(`tokeneater: map colour ${String(colour)} is not in the palette`);
298 return css;
299}
300
301// plugin/hooks/surface-input.ts
302function listenForInput(surface, queue) {
303 surface.onPointer((event) => {
304 if (event.type !== "down" && event.type !== "move") return;
305 const aim = aimAtPointer(event, { columns: surface.columns, rows: surface.rows });
306 if (aim !== null) queue.add(aim);
307 });
308 surface.onKey((event) => {
309 const input = inputForKey(event.key);
310 if (input !== null) queue.add(input);
311 });
312}
313
314// plugin/hooks/map-surface.tsx
315var MapSurface = (rows, surface) => {
316 if (surface.state === void 0) {
317 const queue = new InputQueue();
318 listenForInput(surface, queue);
319 const posts = new MapPosts(queue);
320 surface.every(POST_INTERVAL_MS, () => {
321 const message = posts.next();
322 if (message !== null) surface.post({ ...message });
323 });
324 surface.setState({ queue });
325 }
326 const { elements } = surface;
327 if (rows === null) return elements.Box({ width: surface.columns, height: surface.rows });
328 return drawNode(mapNodes(rows), elements);
329};
330function drawNode(node, elements) {
331 if (node.type === "Text") return elements.Text({ ...node.props, children: node.children });
332 const { children } = node;
333 if (children === void 0) return elements.Box(node.props);
334 return elements.Box({
335 ...node.props,
336 children: children.map((child) => drawNode(child, elements))
337 });
338}
339export {
340 MapSurface
341};
342bundle/input-surface.js 251 lines1// ../../packages/shared/src/legal.ts
2var SPONSOR_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1e3;
3var ABANDONED_DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
4var EXPIRED_BAN_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
5var AUDIT_LOG_RETENTION_MS = 365 * 24 * 60 * 60 * 1e3;
6var LAPSED_HOLD_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
7var UNUSED_LOGO_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
8
9// ../../packages/shared/src/engine/constants.ts
10var TICK_RATE_HZ = 20;
11var TICK_MS = 1e3 / TICK_RATE_HZ;
12var START_MASS = 10;
13var RADIUS_PER_SQRT_MASS = 10;
14var SLIDE_STOP_SPEED = 5;
15var EJECT_BLOB_MASS = 12;
16var DECAY_RATE_PER_S = 2e-3;
17
18// ../../packages/shared/src/engine/geometry.ts
19function massToRadius(mass) {
20 return mass > 0 ? RADIUS_PER_SQRT_MASS * Math.sqrt(mass) : 0;
21}
22function toUnitsPerTick(unitsPerSecond) {
23 return unitsPerSecond / TICK_RATE_HZ;
24}
25
26// ../../packages/shared/src/engine/movement.ts
27var STOP_SPEED = toUnitsPerTick(SLIDE_STOP_SPEED);
28var STOP_SPEED_SQUARED = STOP_SPEED * STOP_SPEED;
29
30// ../../packages/shared/src/engine/decay.ts
31var DECAY_FACTOR_PER_TICK = 1 - DECAY_RATE_PER_S / TICK_RATE_HZ;
32
33// ../../packages/shared/src/engine/virus.ts
34var FULL_TURN = 2 * Math.PI;
35
36// ../../packages/shared/src/mod/install.ts
37var MOD_INSTALL = {
38 /** The public repository the mod release publishes to: the marketplace players add. */
39 marketplaceRepo: "jeffbruchado/tokeneater-mod",
40 /**
41 * The plugin's install id, `<plugin>@<marketplace>`: the `name` of
42 * `apps/mod/plugin/.claude-plugin/plugin.json` and of `apps/mod/marketplace/marketplace.json`.
43 */
44 pluginId: "tokeneater@tokeneater",
45 /** The plugin's command, which opens and closes the pane. */
46 paneCommand: "/tokeneater",
47 /** The Claude Code command that switches to the fullscreen layout, where the pane docks. */
48 fullscreenCommand: "/tui fullscreen",
49 /** Oldest Claude Code with the function hooks the plugin is built on. */
50 minClaudeCodeVersion: "2.1.288",
51 /** Oldest Node.js major the bridge runs on: the first with a global `WebSocket` by default. */
52 minNodeMajor: 22,
53 /** Narrowest terminal (columns) whose fullscreen layout docks the pane. */
54 dockMinColumns: 110,
55 /** Narrowest terminal (columns) the pane opens in by itself when a prompt is sent. */
56 autoOpenMinColumns: 144
57};
58var MOD_HOTKEYS = {
59 split: "e",
60 eject: "q",
61 respawn: "r",
62 up: "w",
63 left: "a",
64 down: "s",
65 right: "d"
66};
67
68// ../../packages/shared/src/protocol/messages.ts
69var ROOM_NAME_MAX_BYTES = 32;
70var ROOM_NAME_PATTERN = new RegExp(`^[a-z0-9-]{1,${String(ROOM_NAME_MAX_BYTES)}}$`);
71var RECONNECT_GRACE_SECONDS = 5;
72var JOIN_REJECTIONS = ["nickname", "banned"];
73var JOIN_UNAVAILABILITIES = ["capacity", "rate_limited", "unavailable"];
74
75// ../../packages/shared/src/protocol/utf8.ts
76var { TextEncoder, TextDecoder } = globalThis;
77var encoder = new TextEncoder();
78var decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
79
80// ../../packages/shared/src/protocol/client-codec.ts
81var SPLIT_FLAG = 1;
82var EJECT_FLAG = 2;
83var KNOWN_FLAGS = SPLIT_FLAG | EJECT_FLAG;
84
85// ../../packages/shared/src/protocol/slots.ts
86var SPONSOR_LOGO_MAX_BYTES = 512 * 1024;
87
88// ../../packages/shared/src/protocol/view.ts
89var BASE_VIEW_HALF_WIDTH = 800;
90var VIEW_GROWTH = 0.1;
91var MAX_VIEW_HALF_WIDTH = 2400;
92var VIEW_ASPECT_RATIO = 16 / 9;
93function viewForMass(totalMass) {
94 const growth = totalMass > 0 ? VIEW_GROWTH * Math.sqrt(totalMass / START_MASS) : 0;
95 const halfWidth = Math.min(BASE_VIEW_HALF_WIDTH * (1 + growth), MAX_VIEW_HALF_WIDTH);
96 return { halfWidth, halfHeight: halfWidth / VIEW_ASPECT_RATIO };
97}
98
99// ../../packages/shared/src/client/input/intent-sender.ts
100var PRESS_SPACING_MS = TICK_MS + 10;
101var MAX_HEADING_ERROR_RAD = Math.PI / 90;
102
103// ../../packages/shared/src/client/net/connection-phase.ts
104var RESUME_WINDOW_MS = RECONNECT_GRACE_SECONDS * 1e3;
105
106// ../../packages/shared/src/client/net/lobby-client.ts
107var REJECTIONS = new Set(JOIN_REJECTIONS);
108var UNAVAILABILITIES = new Set(JOIN_UNAVAILABILITIES);
109
110// ../../packages/shared/src/client/net/site-links.ts
111var SPONSOR_PAGE_PATH = "/sponsor";
112var SPONSOR_LOGIN_PATH = `${SPONSOR_PAGE_PATH}/login`;
113var SPONSOR_DASHBOARD_PATH = `${SPONSOR_PAGE_PATH}/dashboard`;
114var SIGN_IN_ERROR_PARAM = "error";
115var SIGN_IN_ERROR_EXPIRED = "expired";
116var EXPIRED_SIGN_IN_PATH = `${SPONSOR_LOGIN_PATH}?${SIGN_IN_ERROR_PARAM}=${SIGN_IN_ERROR_EXPIRED}`;
117var MAGIC_LINK_LIFETIME_MS = 15 * 60 * 1e3;
118
119// ../../packages/shared/src/client/render/camera.ts
120var BASE_VIEW = viewForMass(0);
121
122// ../../packages/shared/src/client/render/own-cells.ts
123var CORRECTION_MS = 100;
124var CORRECTION_TIME_CONSTANT_MS = CORRECTION_MS / 3;
125
126// ../../packages/shared/src/client/render/palette.ts
127var BLOB_RADIUS = massToRadius(EJECT_BLOB_MASS);
128
129// src/pane-layout.ts
130var DOCK_MIN_COLUMNS = MOD_INSTALL.dockMinColumns;
131var AUTO_OPEN_MIN_COLUMNS = MOD_INSTALL.autoOpenMinColumns;
132var STATUS_ROWS = 1;
133var HUD_HEIGHT_PX = 244;
134var DESKTOP_ROW_PX = 18.9;
135var DESKTOP_HUD_ROWS = Math.ceil(HUD_HEIGHT_PX / DESKTOP_ROW_PX);
136
137// src/pane-input.ts
138var MAX_AIM_OFFSET = 4;
139var PANE_KEYS = [
140 {
141 id: "split",
142 hotkey: MOD_HOTKEYS.split,
143 label: "split",
144 input: { kind: "press", action: "split" }
145 },
146 {
147 id: "eject",
148 hotkey: MOD_HOTKEYS.eject,
149 label: "eject",
150 input: { kind: "press", action: "eject" }
151 },
152 {
153 id: "respawn",
154 hotkey: MOD_HOTKEYS.respawn,
155 label: "play again",
156 input: { kind: "press", action: "respawn" }
157 },
158 { id: "up", hotkey: MOD_HOTKEYS.up, label: "up", input: { kind: "keys", dx: 0, dy: -1 } },
159 { id: "left", hotkey: MOD_HOTKEYS.left, label: "left", input: { kind: "keys", dx: -1, dy: 0 } },
160 { id: "down", hotkey: MOD_HOTKEYS.down, label: "down", input: { kind: "keys", dx: 0, dy: 1 } },
161 { id: "right", hotkey: MOD_HOTKEYS.right, label: "right", input: { kind: "keys", dx: 1, dy: 0 } }
162];
163var SURFACE_KEYS = /* @__PURE__ */ new Map([
164 [" ", { kind: "press", action: "split" }],
165 ["space", { kind: "press", action: "split" }],
166 ["return", { kind: "press", action: "respawn" }],
167 ["up", { kind: "keys", dx: 0, dy: -1 }],
168 ["left", { kind: "keys", dx: -1, dy: 0 }],
169 ["down", { kind: "keys", dx: 0, dy: 1 }],
170 ["right", { kind: "keys", dx: 1, dy: 0 }]
171]);
172function inputForKey(key) {
173 const letter = key.toLowerCase();
174 const paneKey = PANE_KEYS.find((entry) => entry.hotkey === letter);
175 if (paneKey !== void 0) return paneKey.input;
176 return SURFACE_KEYS.get(key) ?? null;
177}
178function aimAtPointer(point, region) {
179 const mapRows = region.rows - STATUS_ROWS;
180 if (region.columns < 1 || mapRows < 1) return null;
181 const column = point.fine?.x ?? point.x + 0.5;
182 const row = point.fine?.y ?? point.y + 0.5;
183 return {
184 kind: "aim",
185 x: clampOffset(column / region.columns * 2 - 1),
186 y: clampOffset(row / mapRows * 2 - 1)
187 };
188}
189function clampOffset(offset) {
190 return Math.min(Math.max(offset, -MAX_AIM_OFFSET), MAX_AIM_OFFSET);
191}
192
193// src/surface-message.ts
194var MAX_PRESSES_PER_MESSAGE = 8;
195var MAX_STEERS_PER_MESSAGE = 8;
196
197// src/input-queue.ts
198var POST_INTERVAL_MS = 33;
199var InputQueue = class {
200 steers = [];
201 presses = [];
202 /** Queues `input`. */
203 add(input) {
204 if (input.kind === "aim") this.steers = [input];
205 else if (input.kind === "keys") this.addKey(input);
206 else if (this.presses.length < MAX_PRESSES_PER_MESSAGE) this.presses.push(input.action);
207 }
208 /** The inputs queued since the last `take`, as one message; `null` when there were none. */
209 take() {
210 const { steers, presses } = this;
211 if (steers.length === 0 && presses.length === 0) return null;
212 this.steers = [];
213 this.presses = [];
214 return steers.length === 0 ? { presses } : { steers, presses };
215 }
216 addKey(key) {
217 if (this.steers.length < MAX_STEERS_PER_MESSAGE) this.steers.push(key);
218 }
219};
220
221// plugin/hooks/surface-input.ts
222function listenForInput(surface, queue) {
223 surface.onPointer((event) => {
224 if (event.type !== "down" && event.type !== "move") return;
225 const aim = aimAtPointer(event, { columns: surface.columns, rows: surface.rows });
226 if (aim !== null) queue.add(aim);
227 });
228 surface.onKey((event) => {
229 const input = inputForKey(event.key);
230 if (input !== null) queue.add(input);
231 });
232}
233
234// plugin/hooks/input-surface.tsx
235var InputSurface = (_props, surface) => {
236 if (surface.state === void 0) {
237 const queue = new InputQueue();
238 listenForInput(surface, queue);
239 surface.every(POST_INTERVAL_MS, () => {
240 const message = queue.take();
241 if (message !== null) surface.post({ ...message });
242 });
243 surface.setState({ queue });
244 }
245 const { Box } = surface.elements;
246 return /* @__PURE__ */ h(Box, { width: surface.columns, height: surface.rows });
247};
248export {
249 InputSurface
250};
251types/index.d.ts 15 lines1// The plugin's state contract: the `$.state` values it owns. They survive a reload of the hooks
2// module; the bridge, its frames and its problem do not (a reload ends the child with its loop),
3// so they live in the bridge host instead.
4declare module 'claude-code' {
5 interface PluginState {
6 tokeneater: {
7 /**
8 * Whether the person closed the pane during the running turn: a prompt typed into that turn
9 * does not open it again (FR-4.1). Cleared when the turn ends or a new one starts.
10 */
11 closedThisTurn: boolean;
12 };
13 }
14}
15