SLOPSHOPPER

tokeneater

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

newpanespinnercommandtoastprompt
v0.5.0MITupdated 2026-10-08jeffbruchado/tokeneater-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tokeneater
│ ┃ tokeneater ✕ › fix the failing auth test and add an audit log call │ ┃ tokeneater needs Node.js 22 or newer to play │ ┃ (found dev). Press r to try again. ● tokeneater: tokeneater.surface terminal dock │ ┃ e: split q: eject r: play again w: up a: ● tokeneater: tokeneater: tokeneater needs Node.js 22 or newer to pla │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tokeneater │ ⎿ tokeneater: tokeneater is open: steer with the pointer or w a s │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tokeneater
tokeneater needs Node.js 22 or newer to play (found dev). Press r to try again. e: split q: eject r: play again w: up a: left s: down
README

tokeneater for Claude Code

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.

Install

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).

Requirements

  • Claude Code 2.1.288 or newer, with plugin hooks modules available.
  • Node.js 22 or newer on your PATH: the game runs in a small Node process beside the pane.
  • In a terminal: the fullscreen layout (/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.
  • In the Claude desktop app: the Code tab, where the pane docks beside the conversation and opens by itself when you send a prompt.

Play

InputDoes
Mouse over the panesteers 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
qeject mass
r (Enter after a click)play again
Escgives 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).

Options

/plugin configure tokeneater@tokeneater (or claude plugin configure tokeneater@tokeneater):

  • Nickname: the name other players see (up to 16 characters); empty lets the server pick one.
  • Open on prompt: on by default, a prompt you send opens the game's pane by itself (in the desktop app, or a fullscreen terminal at least 144 columns wide). Off: only /tokeneater opens it.

Updates

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.

License

MIT. Play in the browser at playtokeneater.com.

Source 4 files
bundle/register.js 1256 lines
1// 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 lines
1// ../../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};
342
bundle/input-surface.js 251 lines
1// ../../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};
251
types/index.d.ts 15 lines
1// 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