The wizwar-duel skill: Claude takes a seat at the table
tools/claude-seat.mjs is the one-shot websocket seat (join/view/do/ chat, token persisted between invocations, live events separated from replayed history) that let Claude play room 9UA6 as a real player. The skill teaches the next session everything game one cost to learn: map reading, the command surface, the grab-ends-actions rule, warp sides, repossession, ambush transitions — and the operational lesson about narrating one's own hand to one's opponent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
dd87f1d843
commit
858fdc6a56
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: wizwar-duel
|
||||
description: Play Wiz-War live against Eric — join his room as a real seat, reason about every move yourself, and duel turn by turn over the websocket. Use when Eric wants a game, gives a room code, or says "let's play".
|
||||
---
|
||||
|
||||
# Playing Wiz-War against Eric
|
||||
|
||||
You are a PLAYER, not the automaton. Read the board, reason, and choose
|
||||
every command yourself. Eric plays in his browser; you play through the
|
||||
CLI seat at `tools/claude-seat.mjs`. He prompts "go" after his moves —
|
||||
each of his messages is your cue to look and act.
|
||||
|
||||
## The seat client
|
||||
|
||||
Run from the repo root (needs the workspace's `ws` package):
|
||||
|
||||
```bash
|
||||
node tools/claude-seat.mjs join <ROOM> Claude # take a seat (token saved)
|
||||
node tools/claude-seat.mjs view # board + hand + last events
|
||||
node tools/claude-seat.mjs do '<command json>' # one engine command
|
||||
node tools/claude-seat.mjs chat "text" # table talk
|
||||
```
|
||||
|
||||
Seat state persists in `/tmp/wizwar-claude-seat.json` (override:
|
||||
`WIZWAR_SEAT`); server defaults to production (`WIZWAR_SERVER` to point
|
||||
elsewhere). Eric creates the room, gives you the code, and starts the
|
||||
game after you join.
|
||||
|
||||
Commands are the engine's `Command` union (packages/engine/src/game.ts):
|
||||
`{"type":"move","direction":"N|S|E|W"}`, `cast` (with `target`, optional
|
||||
`numberInstanceIds`/`params`), `playNumberForMovement`, `pickUpTreasure`,
|
||||
`dropTreasure`, `warpStep`, `counteract`, `pass`, `punch`, `setAmbush`,
|
||||
`wardChoice`, `endTurn` (with `draw`). A refused command costs nothing —
|
||||
probing legality is free, so when unsure, try it and read the error.
|
||||
|
||||
## Reading the map
|
||||
|
||||
- The renderer prints y=0 at top; **y grows SOUTH**. N = y-1, S = y+1.
|
||||
- `C2` is you, `P1` etc. are opponents, `$c`/`$1` treasures, `h?` homes,
|
||||
two-letter codes are square contents (SA safe, RO rosebush, ST stone,
|
||||
DU dust). `D` on a line is a door, `—` a wall, blank is open.
|
||||
- WARPS line lists rim passages as `2,0N→2,9`: standing at (2,0) and
|
||||
moving N carries you to (2,9). **The warp rides one specific side of
|
||||
its square** — check which before you walk (a wrong guess burns moves;
|
||||
ask me how I know).
|
||||
- Dimensional-warp TOKENS (cast by players) are separate: stand on one
|
||||
and `warpStep`. Creatures use `creatureWarpStep`.
|
||||
|
||||
## Hard-won rules knowledge (each cost me something in game one)
|
||||
|
||||
- `pickUpTreasure` ENDS your turn's actions AND movement — arrive with
|
||||
the grab as your last act, never mid-plan. `dropTreasure` at home too.
|
||||
- You can pick up ANY floor treasure, including your own stolen-and-
|
||||
delivered one sitting on the enemy's home square. Repossession is real.
|
||||
- THIEF and punches need same-square; THIEF steals a *named* card and
|
||||
fizzles (card spent) on a wrong guess.
|
||||
- MENTAL FORCE moves the victim ≤3 *walked* spaces — walls and relocked
|
||||
doors shrink its reach; rev 5+ refuses impossible destinations.
|
||||
- Doors relock behind you when you pass through (rev 4+). Your own
|
||||
escape route can seal itself — and seal pursuers out.
|
||||
- LOS threads through rim warps: you can be seen (and shot) through a
|
||||
warp mouth from the far side of the board. Camping one square off the
|
||||
mouth keeps you hidden.
|
||||
- lock-in-place freezes movement AND warpStep for a full turn; REUSE
|
||||
SPELL retrieves only your LAST cast spell, enabling one re-lock.
|
||||
- An ambush (`setAmbush` interrupt/opportunity-fire + committed attack)
|
||||
triggers on LOS *entry* transitions only — someone already in sight
|
||||
never springs it. It survives forever and fires out of turn.
|
||||
- Walking-dead bleeds ½ life per space moved, permanently. Once cursed,
|
||||
every plan must be priced in steps.
|
||||
|
||||
## Playing well
|
||||
|
||||
- Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for
|
||||
my full shield, and won game one with a home-to-home dimensional-warp
|
||||
superhighway. Infrastructure beats sprinting — watch what he builds,
|
||||
and consider your own wormhole early.
|
||||
- Win = 2 enemy treasures delivered to your home, or last wizard alive.
|
||||
Track BOTH players' step-counted delivery timelines every turn; the
|
||||
race is decided in tempo, not damage.
|
||||
- Hold counters (full-shield, absorb) for permanent curses and lethal
|
||||
damage. Absorb soaks 3 points; it cannot touch durations.
|
||||
- OPSEC: your prose is visible to Eric. NEVER name the cards you hold or
|
||||
draw, and never announce plans. (Game one: I narrated my hand like a
|
||||
debug log until he asked why I was making it easy.) Trash talk freely
|
||||
— about the board, never your hand.
|
||||
- Send `chat` for table talk at dramatic moments; it's half the fun.
|
||||
|
||||
## Cadence
|
||||
|
||||
1. On "go": `view`, read HIS events since your last turn (the client
|
||||
prints only NEW events; `view` shows a short history tail).
|
||||
2. Think about the whole board — his timeline, yours, threats, LOS.
|
||||
3. Execute your commands one at a time, checking errors.
|
||||
4. `endTurn` with a draw that respects the 7-card hand limit.
|
||||
5. Tell Eric it's his turn — reasoning aloud is fine, hand contents are
|
||||
not. When a stack waits on him, say so and wait for "go".
|
||||
@@ -0,0 +1,170 @@
|
||||
// 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") {
|
||||
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 <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}`));
|
||||
Reference in New Issue
Block a user