diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index ff7a43e..182157d 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -8,3 +8,4 @@ export * from "./board"; export * from "./cards"; export * from "./setups"; export * from "./game"; +export * from "./view"; diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts new file mode 100644 index 0000000..d5e08de --- /dev/null +++ b/packages/engine/src/view.ts @@ -0,0 +1,73 @@ +// Per-player projection of GameState: everything public, plus YOUR hand. +// The server sends this after every state change; clients never see the +// deck order or other players' hands. + +import { type AssembledBoard } from "./board"; +import { type CardInstance } from "./cards"; +import { + boardView, + type CastStack, + type GameState, + type PlayerId, + type TreasureState, + type TurnState, +} from "./game"; + +export interface PlayerPublicView { + id: PlayerId; + position: { x: number; y: number }; + home: { x: number; y: number }; + life: number; + alive: boolean; + handCount: number; + carriedTreasureId: string | null; + lostTurns: number; + extraTurns: number; +} + +export interface GameView { + you: PlayerId; + phase: GameState["phase"]; + winner: PlayerId | null; + turn: TurnState; + activePlayerId: PlayerId; + /** Board with dynamic wall changes already merged in. */ + board: AssembledBoard; + players: PlayerPublicView[]; + yourHand: CardInstance[]; + treasures: TreasureState[]; + deckCount: number; + discardCount: number; + /** Cards on the stack are face-up: the whole exchange is public. */ + stack: CastStack | null; + pendingDiscard: PlayerId | null; +} + +export function viewFor(state: GameState, playerId: PlayerId): GameView { + const you = state.players.find((p) => p.id === playerId); + return { + you: playerId, + phase: state.phase, + winner: state.winner, + turn: state.turn, + activePlayerId: state.players[state.turn.activeIndex]!.id, + board: boardView(state), + players: state.players.map((p) => ({ + id: p.id, + position: p.position, + home: p.home, + life: p.life, + alive: p.alive, + handCount: p.hand.length, + carriedTreasureId: p.carriedTreasureId, + lostTurns: p.lostTurns, + extraTurns: p.extraTurns, + })), + yourHand: you ? [...you.hand] : [], + treasures: state.treasures.map((t) => ({ ...t })), + deckCount: state.deck.length, + discardCount: state.discard.length, + stack: state.stack, + pendingDiscard: state.pendingDiscard, + }; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e6c7d47..9db61dd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,13 +1,133 @@ -import { WebSocketServer } from "ws"; +// Websocket front door. Protocol (JSON messages): +// client -> server: +// {type:"create", name} create a room, become host +// {type:"join", roomId, name} join (or rejoin) a room +// {type:"start"} host starts the game +// {type:"command", command} a game Command for the engine +// server -> client: +// {type:"welcome"} on connect +// {type:"room", roomId, players, hostId, started} +// {type:"events", events} redacted for this recipient +// {type:"state", view} redacted full view (after every change) +// {type:"error", message} + +import { WebSocketServer, WebSocket } from "ws"; +import type { Command, PlayerId } from "@wizwar/engine"; +import { + createRoom, + getRoom, + joinRoom, + redactFor, + runCommand, + startGame, + viewForPlayer, + type Room, +} from "./rooms"; const port = Number(process.env.PORT ?? 8787); const wss = new WebSocketServer({ port }); +interface Session { + socket: WebSocket; + playerId: PlayerId | null; + roomId: string | null; +} + +const sessions = new Set(); + +function send(socket: WebSocket, message: unknown): void { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); +} + +function roomInfo(room: Room) { + return { + type: "room", + roomId: room.id, + players: room.players, + hostId: room.hostId, + started: room.state !== null, + }; +} + +function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void { + for (const s of sessions) { + if (s.roomId === room.id && s.playerId) { + send(s.socket, makeMessage(s.playerId)); + } + } +} + +function broadcastRoomState(room: Room): void { + broadcast(room, (playerId) => roomInfo(room)); + if (room.state) { + broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) })); + } +} + wss.on("connection", (socket) => { - socket.send(JSON.stringify({ type: "hello", game: "wizwar" })); + const session: Session = { socket, playerId: null, roomId: null }; + sessions.add(session); + send(socket, { type: "welcome", game: "wizwar" }); + + socket.on("close", () => sessions.delete(session)); + socket.on("message", (data) => { - // TODO: route client commands into game rooms once the engine exists. - console.log("received:", data.toString()); + let msg: Record; + try { + msg = JSON.parse(data.toString()); + } catch { + return send(socket, { type: "error", message: "invalid JSON" }); + } + + try { + switch (msg.type) { + case "create": { + const name = String(msg.name ?? "").trim(); + if (!name) return send(socket, { type: "error", message: "name required" }); + const room = createRoom(name); + session.playerId = name; + session.roomId = room.id; + broadcastRoomState(room); + break; + } + case "join": { + const name = String(msg.name ?? "").trim(); + const roomId = String(msg.roomId ?? "").trim(); + if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" }); + const room = getRoom(roomId); + if (!room) return send(socket, { type: "error", message: "no such room" }); + const problem = joinRoom(room, name); + if (problem) return send(socket, { type: "error", message: problem }); + session.playerId = name; + session.roomId = room.id; + broadcastRoomState(room); + break; + } + case "start": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host can start" }); + const result = startGame(room); + if ("error" in result) return send(socket, { type: "error", message: result.error }); + broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); + broadcastRoomState(room); + break; + } + case "command": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const result = runCommand(room, session.playerId, msg.command as Command); + if ("error" in result) return send(socket, { type: "error", message: result.error }); + broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); + broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) })); + break; + } + default: + send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` }); + } + } catch (e) { + send(socket, { type: "error", message: e instanceof Error ? e.message : "internal error" }); + } }); }); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts new file mode 100644 index 0000000..a3e5813 --- /dev/null +++ b/packages/server/src/rooms.ts @@ -0,0 +1,111 @@ +// Game rooms: the server is the authority. Each room holds one GameState and +// the append-only command log (the seed + log IS the game β€” the basis for +// replays and async play). Clients get per-player redacted views and events. + +import { + applyCommand, + createGame, + redactEvent, + viewFor, + type Command, + type GameEvent, + type GameState, + type GameView, + type PlayerId, +} from "@wizwar/engine"; + +export interface LoggedCommand { + seq: number; + playerId: PlayerId; + command: Command; + at: string; // ISO timestamp (server-side wall clock; not used by the engine) +} + +export interface Room { + id: string; + hostId: PlayerId; + players: PlayerId[]; // join order + seed: number; + state: GameState | null; // null until started + log: LoggedCommand[]; + events: GameEvent[]; // full history (unredacted β€” redact per recipient) +} + +const rooms = new Map(); + +const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +function makeRoomCode(): string { + let code = ""; + for (let i = 0; i < 4; i++) { + code += ROOM_CODE_ALPHABET[Math.floor(Math.random() * ROOM_CODE_ALPHABET.length)]; + } + return rooms.has(code) ? makeRoomCode() : code; +} + +export function createRoom(hostId: PlayerId): Room { + const room: Room = { + id: makeRoomCode(), + hostId, + players: [hostId], + seed: Math.floor(Math.random() * 0xffffffff), + state: null, + log: [], + events: [], + }; + rooms.set(room.id, room); + return room; +} + +export function getRoom(id: string): Room | undefined { + return rooms.get(id.toUpperCase()); +} + +export function joinRoom(room: Room, playerId: PlayerId): string | null { + if (room.state) return "game already started"; + if (room.players.includes(playerId)) return null; // rejoin is fine + if (room.players.length >= 4) return "room is full"; + room.players.push(playerId); + return null; +} + +export function startGame(room: Room): { events: GameEvent[] } | { error: string } { + if (room.state) return { error: "already started" }; + const n = room.players.length; + if (n !== 2 && n !== 4) return { error: "supported player counts: 2 or 4" }; + const { state, events } = createGame({ + playerIds: room.players, + seed: room.seed, + sets: ["basic"], + }); + room.state = state; + room.events.push(...events); + return { events }; +} + +export function runCommand( + room: Room, + playerId: PlayerId, + command: Command, +): { events: GameEvent[] } | { error: string } { + if (!room.state) return { error: "game not started" }; + const result = applyCommand(room.state, playerId, command); + if (!result.ok) return { error: result.error }; + room.state = result.state; + room.log.push({ + seq: room.log.length, + playerId, + command, + at: new Date().toISOString(), + }); + room.events.push(...result.events); + return { events: result.events }; +} + +export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null { + return room.state ? viewFor(room.state, playerId) : null; +} + +export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] { + return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null); +} diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 145f222..60242ca 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1,19 +1,364 @@
-

Wiz-War

-

server: {status}

+

Wiz-War 6th edition

+ + {#if net.error} +
{net.error}
+ {/if} + + {#if !net.roomId} +
+

server: {net.status}

+ +
+ + or + + +
+
+ {:else if !net.started} +
+

Room {net.roomId}

+

Share the code with your opponents.

+
    + {#each net.players as p (p)} +
  • {p}{p === net.hostId ? " (host)" : ""}
  • + {/each} +
+ {#if net.you === net.hostId} + + {:else} +

Waiting for {net.hostId} to start…

+ {/if} +
+ {:else if view} +
+
+ +
+ +
+ {#if view.phase === "finished"} + + {:else if youMustRespond} + + {:else if view.stack} + + {:else if isYourTurn} + + {:else} + + {/if} + +
+ {#each view.players as p (p.id)} +
+ + {p.id}{p.id === view.you ? " (you)" : ""} + β™₯ {p.life} + πŸ‚  {p.handCount} + {#if p.lostTurns > 0}πŸ’«{p.lostTurns}{/if} + {#if p.carriedTreasureId}πŸ’°{/if} +
+ {/each} +
deck {view.deckCount} Β· discard {view.discardCount}
+
+ + {#if selectedDef} +
+ {selectedDef.name} + {#if edgeSelectMode} + β€” click a wall line on the board + {:else if selectedDef.cardType === "attack"} + β€” click a target wizard{attachedNumber ? ` (powered by a ${numberTotal})` : " (click a number card to power it)"} + {/if} + {#if selectedCard?.cardId === "waterbolt"} + + (knockback {numberTotal - wbDamage}) + {/if} + +
+ {/if} + +
+ {#each view.yourHand as card (card.instanceId)} + {@const def = cardDef(card.cardId)} + + {/each} +
+ +
+ {#if youMustDiscard} + + {/if} + {#if discardSelection.size > 0} + + {/if} + {#if isYourTurn} + + + + + {/if} +
+ +
+ {#each net.log.slice(-40) as line, i (i)} +
{line}
+ {/each} +
+
+
+ {/if}
diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte new file mode 100644 index 0000000..3a724ad --- /dev/null +++ b/packages/web/src/Board.svelte @@ -0,0 +1,192 @@ + + + + + {#each cells as c (`${c.x},${c.y}`)} + onCellClick?.(c)} + onkeydown={() => {}} + /> + {/each} + + + {#each view.players as p (p.id)} + ✦ + {/each} + {#each view.treasures as t (t.id)} + {#if t.position} + + {/if} + {/each} + + + {#each edges as e (`${e.kind}:${e.x},${e.y}`)} + {#if e.kind === "V"} + + {:else} + + {/if} + {/each} + + + {#each view.board.warps as w, i (i)} + {w.from.side === "N" ? "↑" : w.from.side === "S" ? "↓" : w.from.side === "E" ? "β†’" : "←"} + {/each} + + + {#each [...wizardsByCell.entries()] as [key, group] (key)} + {#each group as p, i (p.id)} + {@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0)} + {@const cy = p.position.y * CELL + CELL * 0.36} + { ev.stopPropagation(); onPlayerClick?.(p.id); }} + onkeydown={() => {}} + class="wizard" + > + + {p.id[0]?.toUpperCase()} + {#if p.carriedTreasureId} + + {/if} + + {/each} + {/each} + + + {#each edgeHitboxes as h (`${h.cell.x},${h.cell.y},${h.side}`)} + { ev.stopPropagation(); onEdgeClick?.(h.cell, h.side); }} + onkeydown={() => {}} + /> + {/each} + + + diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts new file mode 100644 index 0000000..db95b0d --- /dev/null +++ b/packages/web/src/net.svelte.ts @@ -0,0 +1,124 @@ +// Websocket client + reactive session state (Svelte 5 runes). + +import type { Command, GameEvent, GameView } from "@wizwar/engine"; +import { cardDef } from "@wizwar/engine"; + +const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? "ws://localhost:8787"; + +function humanize(e: GameEvent): string | null { + switch (e.type) { + case "gameStarted": return `Game started β€” ${e.players.join(", ")}. ${e.firstPlayer} goes first.`; + case "turnStarted": return `β€” ${e.player}'s turn (round ${e.round}) β€”`; + case "turnSkipped": return `${e.player} loses a turn.`; + case "extraTurnStarted": return `${e.player} takes an extra turn!`; + case "moved": return null; // too chatty for the log + case "numberPlayedForMovement": return `${e.player} plays a ${e.value} for movement (${e.newAllowance} total).`; + case "punched": return `${e.attacker} punches ${e.target}!`; + case "spellCast": { + const num = e.numberValue ? ` with a ${e.numberValue}` : ""; + const at = e.target ? ` at ${e.target}` : ""; + return `${e.caster} casts ${cardDef(e.cardId).name}${num}${at}.`; + } + case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`; + case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`; + case "attackAbsorbedIntoHand": return `${e.player} absorbs the spell into their hand!`; + case "attackResolved": + if (e.redirected) return `The spell is reflected back at ${e.attacker}!`; + if (e.fullyStopped) return `The attack is completely stopped.`; + return null; // the damaged event tells the story + case "damaged": return `${e.player} takes ${e.amount} damage (${e.source}) β€” ${e.lifeAfter} life left.`; + case "stunned": return `${e.player} is stunned and loses a turn!`; + case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`; + case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`; + case "wallCreated": return `A wall appears!`; + case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`; + case "extraTurnGranted": return `${e.player} speeds up β€” extra turn banked.`; + case "trapSprung": return `${e.player} walked into an old TRAP! Lose a turn.`; + case "trapRedrawnDuringDeal": return null; + case "died": return `☠ ${e.player} is dead${e.killedBy ? ` β€” killed by ${e.killedBy}` : ""}.`; + case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`; + case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`; + case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`; + case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated β€” both treasures lost!` : null; + case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`; + case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null; + case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`; + case "turnEnded": return null; + case "gameWon": return `πŸ† ${e.player} WINS ${e.reason === "treasures" ? "by treasure!" : "β€” last wizard standing!"}`; + default: return null; + } +} + +class Net { + status = $state<"disconnected" | "connected">("disconnected"); + roomId = $state(null); + players = $state([]); + hostId = $state(null); + started = $state(false); + you = $state(null); + view = $state(null); + log = $state([]); + error = $state(null); + + private ws: WebSocket | null = null; + + connect(): void { + if (this.ws) return; + const ws = new WebSocket(SERVER_URL); + this.ws = ws; + ws.onopen = () => (this.status = "connected"); + ws.onclose = () => { + this.status = "disconnected"; + this.ws = null; + setTimeout(() => this.connect(), 1500); + }; + ws.onmessage = (raw) => { + const msg = JSON.parse(raw.data as string); + switch (msg.type) { + case "room": + this.roomId = msg.roomId; + this.players = msg.players; + this.hostId = msg.hostId; + this.started = msg.started; + break; + case "state": + this.view = msg.view; + break; + case "events": + for (const e of msg.events as GameEvent[]) { + const line = humanize(e); + if (line) this.log = [...this.log, line]; + } + break; + case "error": + this.error = msg.message; + setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000); + break; + } + }; + } + + private send(message: unknown): void { + if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message)); + } + + create(name: string): void { + this.you = name; + this.send({ type: "create", name }); + } + + join(roomId: string, name: string): void { + this.you = name; + this.send({ type: "join", roomId, name }); + } + + start(): void { + this.send({ type: "start" }); + } + + command(command: Command): void { + this.send({ type: "command", command }); + } +} + +export const net = new Net();