Files
wizwar6e/tools/claude-seat.mjs
T
Eric WagonerandClaude Fable 5 063cd2a78c The seat file follows the join, not the ghost
Joining a new room while an old seat file lingered kept the OLD room
id beside the NEW room's token — orphaning the fresh seat as an
unkickable lobby ghost the moment the file was cleared. A join now
records the room it actually joined; only resumes inherit the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 10:43:42 -04:00

172 lines
8.1 KiB
JavaScript

// 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 <ROOM> <NAME>
// node claude-seat.mjs view
// node claude-seat.mjs do '<command json>'
// 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") {
// A join names its room explicitly; only a resume inherits the file's.
const s = { roomId: verb === "join" ? args[0].toUpperCase() : seat.roomId, 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 <ROOM> <NAME>");
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}`));