From 858fdc6a56392efb925aec7c9ef90668a218488e Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 26 Aug 2026 00:00:57 -0400 Subject: [PATCH] The wizwar-duel skill: Claude takes a seat at the table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- .claude/skills/wizwar-duel/SKILL.md | 97 ++++++++++++++++ tools/claude-seat.mjs | 170 ++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 .claude/skills/wizwar-duel/SKILL.md create mode 100644 tools/claude-seat.mjs diff --git a/.claude/skills/wizwar-duel/SKILL.md b/.claude/skills/wizwar-duel/SKILL.md new file mode 100644 index 0000000..147b002 --- /dev/null +++ b/.claude/skills/wizwar-duel/SKILL.md @@ -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 Claude # take a seat (token saved) +node tools/claude-seat.mjs view # board + hand + last events +node tools/claude-seat.mjs do '' # 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". diff --git a/tools/claude-seat.mjs b/tools/claude-seat.mjs new file mode 100644 index 0000000..e5a74e2 --- /dev/null +++ b/tools/claude-seat.mjs @@ -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 +// 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}`));