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
172 lines
8.1 KiB
JavaScript
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}`));
|