"It is possible, though time-consuming, to punch a wall down. A wall takes 20 points of damage to destroy; a door takes 15. Any attack against an inanimate object counts as your one attack for the turn." Damage accumulates per edge in wallDamage (remapped through sector rotations, public in the view), fed two ways: a punchWall command for the bare-fisted (1 point, from a square touching the edge) and attack spells cast at an edge target — LOS to the wall for L.O.S. cards, touching it for same-square cards, amplify and power-attack honored, wand charges spent, no counteractions since stonework plays none. Thrown daggers and rocks clatter to the floor at the foot of the wall. At the threshold the edge opens through the same override path destroy-wall uses. On the table: damaged walls wear spreading cracks, an attack card's hint offers "or a wall line to batter it" with the edge layer live, and a "Punch a wall…" stamp arms a click-the-wall mode. The chronicle counts the blows: "alice batters the wall with bare fists — 3/20." Not deployed — a live game is in progress. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
174 lines
6.5 KiB
TypeScript
174 lines
6.5 KiB
TypeScript
// 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 { sightBetween, type AssembledBoard } from "./board";
|
|
import { type CardInstance } from "./cards";
|
|
import {
|
|
boardView,
|
|
LOS_BLOCKING_CONTENT,
|
|
type AmbushState,
|
|
type CastStack,
|
|
type CreatureState,
|
|
type GameState,
|
|
type PlayerId,
|
|
type SquareContent,
|
|
type SustainedEffect,
|
|
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;
|
|
displayed: CardInstance[];
|
|
/** Which of the six physical wizard colors this player plays. */
|
|
colorIndex: number;
|
|
}
|
|
|
|
export interface GameView {
|
|
you: PlayerId;
|
|
phase: GameState["phase"];
|
|
winner: PlayerId | null;
|
|
winReason: "treasures" | "lastStanding" | 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;
|
|
/** Duration spells in play (public knowledge). */
|
|
sustained: SustainedEffect[];
|
|
squareContents: Record<string, SquareContent>;
|
|
groundObjects: Record<string, CardInstance[]>;
|
|
doorStates: Record<string, "jammed" | "removed">;
|
|
/** Accumulated attack damage per edge (public — cracks show). */
|
|
wallDamage: Record<string, number>;
|
|
openDoorEdges: string[];
|
|
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
|
knownIllusionEdges: string[];
|
|
creatures: CreatureState[];
|
|
/** Charges left on displayed wands (public), by card instance id. */
|
|
wandCharges: Record<string, number>;
|
|
/** Boobytrap tokens: everyone sees the four; only the caster sees which is real. */
|
|
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
|
|
dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[];
|
|
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
|
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
|
yourAmbushes: AmbushState[];
|
|
/** Once the game is finished, every hand goes face-up on the table. */
|
|
revealedHands: Record<PlayerId, CardInstance[]> | null;
|
|
}
|
|
|
|
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|
const you = state.players.find((p) => p.id === playerId);
|
|
// Illusion walls render as real walls unless this viewer knows better.
|
|
const base = boardView(state);
|
|
const knownIllusionEdges: string[] = [];
|
|
let edges = base.edges;
|
|
for (const [key, wall] of Object.entries(state.illusionWalls)) {
|
|
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
|
|
if (knows) {
|
|
knownIllusionEdges.push(key);
|
|
} else {
|
|
if (edges === base.edges) edges = { ...base.edges };
|
|
edges[key] = "wall";
|
|
}
|
|
}
|
|
const board = edges === base.edges ? base : { ...base, edges };
|
|
return {
|
|
you: playerId,
|
|
phase: state.phase,
|
|
winner: state.winner,
|
|
winReason: state.winReason,
|
|
turn: state.turn,
|
|
activePlayerId: state.players[state.turn.activeIndex]!.id,
|
|
board,
|
|
players: state.players.map((p, i) => ({
|
|
id: p.id,
|
|
colorIndex: state.config.colors?.[i] ?? i,
|
|
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,
|
|
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
|
|
})),
|
|
yourHand: you ? [...you.hand] : [],
|
|
revealedHands: state.phase === "finished"
|
|
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
|
|
: null,
|
|
treasures: state.treasures.map((t) => ({ ...t })),
|
|
deckCount: state.deck.length,
|
|
discardCount: state.discard.length,
|
|
stack: state.stack,
|
|
pendingDiscard: state.pendingDiscard,
|
|
sustained: state.sustained.map((s) => ({ ...s })),
|
|
squareContents: { ...state.squareContents },
|
|
groundObjects: Object.fromEntries(
|
|
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
|
),
|
|
doorStates: { ...state.doorStates },
|
|
wallDamage: { ...state.wallDamage },
|
|
openDoorEdges: [...state.openDoorEdges],
|
|
knownIllusionEdges,
|
|
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
|
wandCharges: { ...state.wandCharges },
|
|
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
|
|
outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null,
|
|
yourAmbushes: state.ambushes
|
|
.filter((a) => a.ownerId === playerId)
|
|
.map((a) => ({ ...a, numbers: [...a.numbers] })),
|
|
boobytraps: state.boobytraps.map((t) => {
|
|
const [rx, ry] = t.realKey.split(",").map(Number) as [number, number];
|
|
return {
|
|
casterId: t.casterId,
|
|
cells: t.cells.map((c) => ({ ...c })),
|
|
realCell: t.casterId === playerId ? { x: rx, y: ry } : null,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Every cell the viewing player can see from where they stand, by the same
|
|
* line-of-sight rules the engine enforces — computed from the VIEW, so it
|
|
* reflects what this player knows (illusion walls they believe in block it).
|
|
* The basis for the client's "dim the ineligible squares" targeting aid.
|
|
*/
|
|
export function sightedCellsFor(view: GameView): Set<string> {
|
|
const out = new Set<string>();
|
|
const me = view.players.find((p) => p.id === view.you);
|
|
if (!me) return out;
|
|
const blockers: Record<string, true> = {};
|
|
for (const [key, content] of Object.entries(view.squareContents)) {
|
|
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
|
}
|
|
for (const p of view.players) {
|
|
if (p.alive && view.sustained.some((s) => s.cardId === "big-man" && s.targetId === p.id)) {
|
|
blockers[`${p.position.x},${p.position.y}`] = true;
|
|
}
|
|
}
|
|
for (const key of Object.keys(view.board.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
if (sightBetween(view.board, me.position, { x, y }, blockers)) out.add(key);
|
|
}
|
|
return out;
|
|
}
|