Card wave 3: terrain, thrown objects, drag, and control spells

Terrain layer: FILL SQUARE WITH STONE (impassable, blocks LOS via new
cell-blocking sight checks), THORNBUSH (enter = 1 damage + turn ends +
next turn lost; no attacking in or into a bush), WALL OF FIRE (new
firewall edge state — passable for 4 magical damage, blocks LOS,
expires with its duration), WATERWALL (instant wave: players within
two spaces washed back two, 1 damage per blocked space), and DISPEL
CREATION with provenance tracking (only conjured walls/fire/stone/
bushes dispel — printed maze is safe). Objects: DAGGER (3) and LARGE
ROCK (2) are physical throws Full Shield cannot stop; they land on the
floor and anyone may pick them up (ending their turn's actions, hand
limit enforced); DROP OBJECT forces a named object or carried treasure
to the ground; DRAG pulls floor objects, treasures, or players
straight toward the caster. Control: LOCK IN PLACE (no moving or
being moved — teleports, swaps, knockbacks and drags all respect it),
BUDDY (a pact the caster breaks by attacking), MIST-BODY (through
walls and doors, cannot attack or be attacked, still burns in
firewalls), REUSE SPELL (retrieve your last spell). Client renders
terrain, firewalls, and ground objects, with cell/edge/two-stage
targeting and card-name inputs. 40 cards implemented; 63 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 20:13:14 -04:00
co-authored by Claude Fable 5
parent 67743dd17e
commit 2d88b4ab40
6 changed files with 1029 additions and 27 deletions
+17
View File
@@ -9,6 +9,8 @@ import {
type CastStack,
type GameState,
type PlayerId,
type SquareContent,
type SustainedEffect,
type TreasureState,
type TurnState,
} from "./game";
@@ -23,6 +25,7 @@ export interface PlayerPublicView {
carriedTreasureId: string | null;
lostTurns: number;
extraTurns: number;
displayed: CardInstance[];
}
export interface GameView {
@@ -41,6 +44,12 @@ export interface GameView {
/** 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">;
openDoorEdges: string[];
}
export function viewFor(state: GameState, playerId: PlayerId): GameView {
@@ -62,6 +71,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
carriedTreasureId: p.carriedTreasureId,
lostTurns: p.lostTurns,
extraTurns: p.extraTurns,
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
})),
yourHand: you ? [...you.hand] : [],
treasures: state.treasures.map((t) => ({ ...t })),
@@ -69,5 +79,12 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
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 },
openDoorEdges: [...state.openDoorEdges],
};
}