// Claude's seat at the table: a one-shot websocket client. Each run // connects, resumes the seat by token, performs one verb, prints, exits. // node claude-seat.mjs join // node claude-seat.mjs view // node claude-seat.mjs do '' // node claude-seat.mjs chat "text" import WebSocket from "ws"; import { readFileSync, writeFileSync, existsSync } from "node:fs"; // Seat state (room, name, token) persists between one-shot invocations. const SEAT_FILE = process.env.WIZWAR_SEAT ?? "/tmp/wizwar-claude-seat.json"; const URL_WS = process.env.WIZWAR_SERVER ?? "wss://wizwar.kestrelsnest.social/ws"; const [verb, ...args] = process.argv.slice(2); const seat = existsSync(SEAT_FILE) ? JSON.parse(readFileSync(SEAT_FILE, "utf8")) : null; const ws = new WebSocket(URL_WS); const send = (m) => ws.send(JSON.stringify(m)); const die = (msg, code = 1) => { console.log(msg); process.exit(code); }; setTimeout(() => die("timeout: no answer from the maze"), 20000); let view = null; let pendingEvents = []; let historyEvents = []; function cellK(c) { return `${c.x},${c.y}`; } function renderView(v) { const B = v.board; const lines = []; const label = new Map(); // cellKey -> 2-char code const codes = {}; v.players.forEach((p, i) => { codes[p.id] = String(i + 1); label.set(cellK(p.position), (p.id === v.you ? "C" : "P") + (i + 1)); }); const treas = new Map(); for (const t of v.treasures) if (t.position && !t.carriedBy) treas.set(cellK(t.position), t); // top border row by row for (let y = 0; y < B.height; y++) { let top = ""; let mid = ""; for (let x = 0; x < B.width; x++) { const k = `${x},${y}`; if (!B.cells[k]) { top += " "; mid += " "; continue; } const nEdge = y === 0 ? (B.edges[`H:${x},${y - 1}`] ?? "open") : (B.edges[`H:${x},${y - 1}`] ?? "open"); const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " "); const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open"); const wc = wEdge === "wall" ? "|" : wEdge === "door" ? "D" : wEdge === "firewall" ? "F" : " "; let body = label.get(k) ?? ""; if (!body) { const sq = v.squareContents[k]; if (sq) body = sq.kind.slice(0, 2).toUpperCase(); else if (treas.has(k)) body = "$" + (treas.get(k).owner === v.you ? "c" : codes[treas.get(k).owner] ?? "?"); else if (B.homes.some((h) => cellK(h) === k)) { const who = v.players.find((p) => cellK(p.home) === k); body = "h" + (who ? (who.id === v.you ? "C" : codes[who.id]) : "?"); } else if ((v.groundObjects[k] ?? []).length) body = "ob"; else if (v.creatures.some((c) => cellK(c.position) === k)) { body = v.creatures.find((c) => cellK(c.position) === k).kind.slice(0, 2); } else body = " "; } mid += wc + " " + body.padEnd(2, " ") + " "; } lines.push(top + "+"); lines.push(mid + "|"); } let bottom = ""; for (let x = 0; x < B.width; x++) bottom += "+————"; lines.push(bottom + "+"); const out = []; out.push(`ROOM ${seat?.roomId} — you are ${v.you} (round ${v.turn.round}, ${v.activePlayerId}'s turn)`); out.push(lines.join("\n")); out.push("WARPS (rim passages): " + B.warps.map((w) => `${cellK(w.from.cell)}${w.from.side}→${cellK(w.to.cell)}`).join(" ")); for (const p of v.players) { const t = p.carriedTreasureId ? " CARRYING TREASURE" : ""; out.push(` ${p.id === v.you ? "ME " : " "}${p.id}: ${p.life} life @${cellK(p.position)} home@${cellK(p.home)} hand:${p.handCount}${t}${p.alive ? "" : " DEAD"} displayed:[${p.displayed.map((c) => c.cardId).join(",")}]`); } out.push("TREASURES: " + v.treasures.map((t) => `${t.id}(${t.owner})${t.carriedBy ? `held by ${t.carriedBy}` : t.position ? `@${cellK(t.position)}` : "?"}`).join(" ")); if (v.sustained.length) out.push("SUSTAINED: " + v.sustained.map((s) => `${s.cardId} on ${s.targetId} (${s.remainingTurns})`).join(" ")); const sq = Object.entries(v.squareContents); if (sq.length) out.push("CONTENTS: " + sq.map(([k, c]) => `${c.kind}@${k}`).join(" ")); if (v.creatures.length) out.push("CREATURES: " + v.creatures.map((c) => `${c.kind}@${cellK(c.position)} (${c.controllerId}, ${c.life} life)`).join(" ")); out.push(`TURN: moves ${v.turn.movementUsed}/${v.turn.movementAllowance} attackUsed:${v.turn.attackUsed} actionsEnded:${v.turn.actionsEnded} numberPlayed:${v.turn.numberPlayedForMovement}`); out.push("MY HAND: " + v.yourHand.map((c) => `${c.instanceId}`).join(" ")); if (v.stack) out.push("STACK! " + JSON.stringify(v.stack)); if (v.wardPending) out.push("WARD PENDING: " + JSON.stringify(v.wardPending)); if (v.chaosPending) out.push("CHAOS PENDING: " + JSON.stringify(v.chaosPending)); if (v.outOfTurnWindow) out.push("OUT-OF-TURN WINDOW: " + JSON.stringify(v.outOfTurnWindow)); if (v.phase === "finished") out.push(`GAME OVER — winner: ${v.winner} (${v.winReason})`); return out.join("\n"); } function eventLine(e) { const skip = new Set(["cardsDealtPrivate"]); if (skip.has(e.type)) return null; return JSON.stringify(e); } ws.on("message", (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type === "error") die(`SERVER: ${msg.message}`); if (msg.type === "seat") { const s = { roomId: seat?.roomId ?? args[0]?.toUpperCase(), name: msg.playerId, token: msg.token }; writeFileSync(SEAT_FILE, JSON.stringify(s)); if (verb === "join") { console.log(`seated as ${msg.playerId} in ${s.roomId}`); afterJoin(); } } if (msg.type === "events") { // A join replays the whole chronicle (replayed: true); only live // events are news. `view` prints the tail of history instead. if (!msg.replayed) { for (const e of msg.events) { const l = eventLine(e); if (l) pendingEvents.push(l); } } else if (verb === "view") { for (const e of msg.events) { const l = eventLine(e); if (l) historyEvents.push(l); } } } if (msg.type === "state") { view = msg.view; finishAfterState(); } if (msg.type === "catchUp") { const steps = msg.steps; if (steps.length) view = steps[steps.length - 1].view; finish(); } if (msg.type === "chat") pendingEvents.push(`CHAT ${msg.player}: ${msg.text}`); if (msg.type === "room") { /* roster updates, ignore */ } }); let done = false; function finish() { if (done) return; done = true; if (verb === "view" && historyEvents.length) { console.log("RECENT EVENTS (history tail):\n" + historyEvents.slice(-12).join("\n") + "\n"); } if (pendingEvents.length) console.log("NEW EVENTS:\n" + pendingEvents.join("\n") + "\n"); if (view) console.log(renderView(view)); else console.log("(no game running yet — waiting for the start)"); process.exit(0); } let stateTimer = null; function finishAfterState() { // commands produce one state per player broadcast; give trailing events a beat if (stateTimer) clearTimeout(stateTimer); stateTimer = setTimeout(finish, 700); } function afterJoin() { send({ type: "catchUp", sinceSeq: 0 }); setTimeout(() => { if (!done) finish(); }, 6000); } ws.on("open", () => { if (verb === "join") { if (!args[0] || !args[1]) die("usage: join "); send({ type: "join", roomId: args[0].toUpperCase(), name: args[1], token: null }); } else if (!seat) { die("no seat yet — join first"); } else { send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token }); setTimeout(() => { if (verb === "view") { send({ type: "catchUp", sinceSeq: 0 }); } else if (verb === "do") { send({ type: "command", command: JSON.parse(args[0]) }); setTimeout(() => { if (!done) finish(); }, 8000); } else if (verb === "chat") { send({ type: "chat", text: args[0] }); setTimeout(() => { console.log("said."); process.exit(0); }, 800); } else die(`unknown verb: ${verb}`); }, 600); } }); ws.on("error", (e) => die(`socket error: ${e.message}`));