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:
co-authored by
Claude Fable 5
parent
67743dd17e
commit
2d88b4ab40
+506
-11
@@ -76,6 +76,16 @@ export interface SustainedEffect {
|
||||
remainingTurns: number;
|
||||
/** Per-card scratch (e.g. SLOW's turn parity counter). */
|
||||
data: Record<string, number>;
|
||||
/** For edge-bound spells (WALL OF FIRE): the edge to clean up on expiry. */
|
||||
edge?: string;
|
||||
}
|
||||
|
||||
/** Something occupying a whole square (FILL SQUARE WITH STONE, THORNBUSH). */
|
||||
export interface SquareContent {
|
||||
kind: "stone" | "thornbush";
|
||||
/** Damage taken so far; thornbushes die at 5. Stone is indestructible. */
|
||||
damage: number;
|
||||
createdBy: PlayerId;
|
||||
}
|
||||
|
||||
export interface TurnState {
|
||||
@@ -131,6 +141,14 @@ export interface GameState {
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
/** Door edges unlocked until the end of the current turn. */
|
||||
openDoorEdges: string[];
|
||||
/** Walls/firewalls conjured during play (dispellable), by edge key. */
|
||||
createdEdges: Record<string, true>;
|
||||
/** Square-filling creations, by cell key. */
|
||||
squareContents: Record<string, SquareContent>;
|
||||
/** Object cards lying on the floor, by cell key. */
|
||||
groundObjects: Record<string, CardInstance[]>;
|
||||
/** The last spell card each player used (for REUSE SPELL). */
|
||||
lastSpellUsed: Record<PlayerId, string>;
|
||||
players: PlayerState[];
|
||||
treasures: TreasureState[];
|
||||
sustained: SustainedEffect[];
|
||||
@@ -155,6 +173,25 @@ export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: strin
|
||||
return state.sustained.filter((s) => s.targetId === playerId && (!cardId || s.cardId === cardId));
|
||||
}
|
||||
|
||||
/** LOS including square-filling blockers (stone, thornbushes). */
|
||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
||||
const blockers: Record<string, true> = {};
|
||||
for (const key of Object.keys(state.squareContents)) blockers[key] = true;
|
||||
return hasLineOfSight(boardView(state), from, to, blockers);
|
||||
}
|
||||
|
||||
function inThornbush(state: GameState, p: PlayerState): boolean {
|
||||
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
|
||||
}
|
||||
|
||||
function isMisted(state: GameState, playerId: PlayerId): boolean {
|
||||
return sustainedOn(state, playerId, "mist-body").length > 0;
|
||||
}
|
||||
|
||||
function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
|
||||
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events
|
||||
|
||||
@@ -191,6 +228,19 @@ export type GameEvent =
|
||||
| { type: "handRevealed"; player: PlayerId; to: PlayerId }
|
||||
| { type: "handRevealedPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] }
|
||||
| { type: "wallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||
| { type: "firewallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side }; turns: number }
|
||||
| { type: "firewallExpired"; edge: string }
|
||||
| { type: "firewallBurned"; player: PlayerId }
|
||||
| { type: "waterwallCrashes"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||
| { type: "washedBack"; player: PlayerId; from: Cell; to: Cell; blockedSpaces: number }
|
||||
| { type: "squareFilled"; caster: PlayerId; cell: Cell; kind: "stone" | "thornbush" }
|
||||
| { type: "creationDispelled"; caster: PlayerId; what: string }
|
||||
| { type: "enteredThornbush"; player: PlayerId; at: Cell }
|
||||
| { type: "objectThrown"; attacker: PlayerId; cardId: string; landedAt: Cell }
|
||||
| { type: "objectDropped"; player: PlayerId; card: CardInstance; at: Cell; forced: boolean }
|
||||
| { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell }
|
||||
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
|
||||
| { type: "spellReused"; player: PlayerId; card: CardInstance }
|
||||
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
|
||||
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
|
||||
| { type: "doorsRelocked"; count: number }
|
||||
@@ -249,6 +299,8 @@ export type Command =
|
||||
| { type: "counteract"; instanceId: string }
|
||||
| { type: "pass" }
|
||||
| { type: "pickUpTreasure" }
|
||||
| { type: "pickUpObject"; instanceId: string }
|
||||
| { type: "dropObject"; instanceId: string }
|
||||
| { type: "dropTreasure" }
|
||||
| { type: "discard"; instanceIds: string[] }
|
||||
| { type: "endTurn"; draw: number };
|
||||
@@ -263,6 +315,8 @@ export type CommandResult =
|
||||
type AttackEffect = {
|
||||
kind: "attack";
|
||||
requiresLos?: boolean;
|
||||
/** Physical attacks (thrown DAGGER/ROCK): FULL SHIELD does not stop them. */
|
||||
physical?: boolean;
|
||||
/** Attacker must share the target's square (WIZARDBLADE). */
|
||||
sameSquare?: boolean;
|
||||
baseDamage: (numberValue: number | null, params: CastParams | null) => number;
|
||||
@@ -416,10 +470,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const cell = cmd.params?.cell;
|
||||
if (!cell) return "teleport opponent needs a destination cell";
|
||||
if (!boardView(state).cells[cellKey(cell)]) return "destination is off the board";
|
||||
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
|
||||
return null;
|
||||
},
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
||||
const to = ctx.stack.params!.cell!;
|
||||
const from = ctx.defender.position;
|
||||
ctx.defender.position = to;
|
||||
@@ -435,6 +491,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
baseDamage: () => 0,
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
if (isLockedInPlace(ctx.state, ctx.defender.id) || isLockedInPlace(ctx.state, ctx.attacker.id)) return;
|
||||
const a = ctx.attacker.position;
|
||||
ctx.attacker.position = ctx.defender.position;
|
||||
ctx.defender.position = a;
|
||||
@@ -561,6 +618,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall line";
|
||||
state.edgeOverrides[key] = "wall";
|
||||
state.createdEdges[key] = true;
|
||||
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
||||
return null;
|
||||
},
|
||||
@@ -577,6 +635,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall";
|
||||
state.edgeOverrides[key] = "open";
|
||||
delete state.doorStates[key];
|
||||
delete state.createdEdges[key];
|
||||
events.push({ type: "wallDestroyed", caster: caster.id, edge: { cell, side }, wasDoor: current === "door" });
|
||||
for (const c of [cell, neighbor(cell, side)]) {
|
||||
for (const p of state.players) {
|
||||
@@ -643,9 +702,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
// ... your movement ends after you play it."
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (!cmd.target || cmd.target.kind !== "cell") return "teleport needs a destination cell";
|
||||
if (isLockedInPlace(state, caster.id)) return "you are locked in place";
|
||||
const to = cmd.target.cell;
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(to)]) return "destination is off the board";
|
||||
if (state.squareContents[cellKey(to)]?.kind === "stone") return "that square is solid stone";
|
||||
if (wallIgnoringDistance(view, caster.position, to) > 4) {
|
||||
return "teleport reaches at most four spaces";
|
||||
}
|
||||
@@ -700,8 +761,328 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"mist-body": {
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster, _cmd, magnitude) => {
|
||||
attachSustained(state, events, "mist-body", caster.id, caster.id, magnitude.duration);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// --- More attacks ---------------------------------------------------------
|
||||
"lock-in-place": { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
|
||||
buddy: {
|
||||
kind: "neutral",
|
||||
// "Opponent will not attack you unless you attack first. This is
|
||||
// permanent until you attack." Neutral, LOS per card.
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (!cmd.target || cmd.target.kind !== "player") return "buddy targets a player";
|
||||
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!target || !target.alive) return "no such living player";
|
||||
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
|
||||
// Effectively permanent: broken by the caster attacking the target.
|
||||
attachSustained(state, events, "buddy", caster.id, target.id, 1_000_000_000);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
dagger: {
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
physical: true,
|
||||
keepInHand: false,
|
||||
// "You may throw it. Does three points physical damage. ... Retrievable
|
||||
// by anyone after it is thrown."
|
||||
baseDamage: () => 3,
|
||||
onResolved: (ctx) => { landThrownObject(ctx, "dagger"); },
|
||||
},
|
||||
"large-rock": {
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
physical: true,
|
||||
baseDamage: () => 2,
|
||||
onResolved: (ctx) => { landThrownObject(ctx, "large-rock"); },
|
||||
},
|
||||
"drop-object": {
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
baseDamage: () => 0,
|
||||
validate: (_state, cmd) => (cmd.params?.cardId ? null : "name the object to drop"),
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped) return;
|
||||
const wanted = ctx.stack.params!.cardId!;
|
||||
if (wanted === "treasure") {
|
||||
if (!ctx.defender.carriedTreasureId) return;
|
||||
const t = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId)!;
|
||||
t.carriedBy = null;
|
||||
t.position = ctx.defender.position;
|
||||
ctx.defender.carriedTreasureId = null;
|
||||
ctx.events.push({
|
||||
type: "treasureDropped", player: ctx.defender.id, treasureId: t.id,
|
||||
at: ctx.defender.position, onHomeOf: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
|
||||
if (idx === -1) return;
|
||||
const [card] = ctx.defender.hand.splice(idx, 1);
|
||||
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId);
|
||||
const key = cellKey(ctx.defender.position);
|
||||
ctx.state.groundObjects[key] = [...(ctx.state.groundObjects[key] ?? []), card!];
|
||||
ctx.events.push({
|
||||
type: "objectDropped", player: ctx.defender.id, card: card!,
|
||||
at: ctx.defender.position, forced: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// --- Terrain --------------------------------------------------------------
|
||||
"fill-square-with-stone": {
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
const problem = emptySquareTarget(state, cmd, caster);
|
||||
if (typeof problem === "string") return problem;
|
||||
state.squareContents[cellKey(problem)] = { kind: "stone", damage: 0, createdBy: caster.id };
|
||||
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind: "stone" });
|
||||
return null;
|
||||
},
|
||||
},
|
||||
thornbush: {
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
const problem = emptySquareTarget(state, cmd, caster);
|
||||
if (typeof problem === "string") return problem;
|
||||
state.squareContents[cellKey(problem)] = { kind: "thornbush", damage: 0, createdBy: caster.id };
|
||||
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind: "thornbush" });
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"wall-of-fire": {
|
||||
kind: "neutral",
|
||||
// Neutral use: a burning barrier for [duration] turns. (Counteraction
|
||||
// use vs WATERBOLT: TODO.)
|
||||
resolve: (state, events, caster, cmd, magnitude) => {
|
||||
if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge";
|
||||
const { cell, side } = cmd.target;
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
|
||||
return "the fire must span a corridor between two spaces";
|
||||
}
|
||||
const key = edgeKey(cell, side);
|
||||
if ((view.edges[key] ?? "open") !== "open") return "that corridor is not open";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
state.edgeOverrides[key] = "firewall";
|
||||
state.createdEdges[key] = true;
|
||||
const fx: SustainedEffect = {
|
||||
id: `fx-${state.nextEffectId++}`,
|
||||
cardId: "wall-of-fire",
|
||||
casterId: caster.id,
|
||||
targetId: caster.id,
|
||||
remainingTurns: Math.max(1, magnitude.duration),
|
||||
data: {},
|
||||
edge: key,
|
||||
};
|
||||
state.sustained.push(fx);
|
||||
events.push({ type: "firewallCreated", caster: caster.id, edge: { cell, side }, turns: fx.remainingTurns });
|
||||
return null;
|
||||
},
|
||||
},
|
||||
waterwall: {
|
||||
kind: "neutral",
|
||||
// "The moment you create it, it collapses, washing away any player within
|
||||
// two spaces back two spaces (including the caster)."
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge";
|
||||
const { cell, side } = cmd.target;
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
|
||||
return "the wave must span a corridor between two spaces";
|
||||
}
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
|
||||
// The two sides of the edge, and the push directions away from it.
|
||||
const a = cell;
|
||||
const b = neighbor(cell, side);
|
||||
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
|
||||
const pushes: { start: Cell; dir: Side }[] = [
|
||||
{ start: a, dir: away(side) },
|
||||
{ start: b, dir: side },
|
||||
];
|
||||
for (const { start, dir } of pushes) {
|
||||
// Players on the two cells extending away from the edge on this side.
|
||||
let probe = start;
|
||||
for (let dist = 0; dist < 2; dist++) {
|
||||
for (const p of state.players) {
|
||||
if (!p.alive || cellKey(p.position) !== cellKey(probe)) continue;
|
||||
washBack(state, events, p, dir);
|
||||
}
|
||||
probe = neighbor(probe, dir);
|
||||
}
|
||||
}
|
||||
checkVictory(state, events);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"dispel-creation": {
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
const view = boardView(state);
|
||||
if (cmd.target?.kind === "edge") {
|
||||
const key = edgeKey(cmd.target.cell, cmd.target.side);
|
||||
if (!state.createdEdges[key]) return "that is not a created thing";
|
||||
if (!losToEdge(view, caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
|
||||
const was = view.edges[key];
|
||||
delete state.edgeOverrides[key];
|
||||
delete state.createdEdges[key];
|
||||
state.sustained = state.sustained.filter((s) => s.edge !== key);
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: was === "firewall" ? "wall of fire" : "created wall" });
|
||||
return null;
|
||||
}
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const key = cellKey(cmd.target.cell);
|
||||
const content = state.squareContents[key];
|
||||
if (!content) return "nothing created there";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
delete state.squareContents[key];
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
||||
return null;
|
||||
}
|
||||
return "dispel targets a created wall, fire, stone, or bush";
|
||||
},
|
||||
},
|
||||
drag: {
|
||||
kind: "neutral",
|
||||
// "Drags any moveable object within L.O.S. towards you ..." (and, per the
|
||||
// rulebook's Objects section, players can be DRAGged too).
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (cmd.target?.kind === "player") {
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!target || !target.alive) return "no such living player";
|
||||
if (target.id === caster.id) return "you cannot drag yourself";
|
||||
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
|
||||
if (isLockedInPlace(state, target.id)) return "they are locked in place";
|
||||
const from = target.position;
|
||||
dragToward(state, target, caster.position);
|
||||
events.push({ type: "objectDragged", caster: caster.id, what: target.id, from, to: target.position });
|
||||
return null;
|
||||
}
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const key = cellKey(cmd.target.cell);
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
const objects = state.groundObjects[key];
|
||||
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
||||
if (objects && objects.length > 0) {
|
||||
const card = objects[objects.length - 1]!;
|
||||
objects.pop();
|
||||
if (objects.length === 0) delete state.groundObjects[key];
|
||||
const destKey = cellKey(caster.position);
|
||||
state.groundObjects[destKey] = [...(state.groundObjects[destKey] ?? []), card];
|
||||
events.push({ type: "objectDragged", caster: caster.id, what: card.cardId, from: cmd.target.cell, to: caster.position });
|
||||
return null;
|
||||
}
|
||||
if (treasure) {
|
||||
const from = treasure.position!;
|
||||
treasure.position = { ...caster.position };
|
||||
events.push({ type: "objectDragged", caster: caster.id, what: treasure.id, from, to: caster.position });
|
||||
checkVictory(state, events);
|
||||
return null;
|
||||
}
|
||||
return "nothing to drag there";
|
||||
}
|
||||
return "drag targets an object square or a player";
|
||||
},
|
||||
},
|
||||
"reuse-spell": {
|
||||
kind: "neutral",
|
||||
// "You may retrieve any spell you use immediately after you use it (but
|
||||
// not the NUMBER card)."
|
||||
resolve: (state, events, caster) => {
|
||||
const lastId = state.lastSpellUsed[caster.id];
|
||||
if (!lastId || lastId === "reuse-spell") return "no spell to retrieve";
|
||||
// The most recent copy of that card in the discard pile is yours.
|
||||
for (let i = state.discard.length - 1; i >= 0; i--) {
|
||||
if (state.discard[i]!.cardId === lastId) {
|
||||
const [card] = state.discard.splice(i, 1);
|
||||
caster.hand.push(card!);
|
||||
events.push({ type: "spellReused", player: caster.id, card: card! });
|
||||
if (caster.hand.length > HAND_LIMIT) state.pendingDiscard = caster.id;
|
||||
delete state.lastSpellUsed[caster.id];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return "that spell is no longer in the discard pile";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Thrown weapons land in the target's square, whatever the counters did. */
|
||||
function landThrownObject(ctx: ResolutionContext, cardId: string): void {
|
||||
const card = ctx.stack.attackCard!;
|
||||
const key = cellKey(ctx.defender.position);
|
||||
ctx.state.groundObjects[key] = [...(ctx.state.groundObjects[key] ?? []), card];
|
||||
// The card was discarded on cast; move it from the discard to the floor.
|
||||
const di = ctx.state.discard.findIndex((c) => c.instanceId === card.instanceId);
|
||||
if (di !== -1) ctx.state.discard.splice(di, 1);
|
||||
ctx.events.push({ type: "objectThrown", attacker: ctx.attacker.id, cardId, landedAt: ctx.defender.position });
|
||||
}
|
||||
|
||||
/** Validate a cell target for square-filling creations. */
|
||||
function emptySquareTarget(
|
||||
state: GameState,
|
||||
cmd: Extract<Command, { type: "cast" }>,
|
||||
caster: PlayerState,
|
||||
): Cell | string {
|
||||
if (!cmd.target || cmd.target.kind !== "cell") return "target a square";
|
||||
const cell = cmd.target.cell;
|
||||
const key = cellKey(cell);
|
||||
const view = boardView(state);
|
||||
if (!view.cells[key]) return "off the board";
|
||||
if (state.squareContents[key]) return "that square is occupied";
|
||||
if (view.homes.some((h) => cellKey(h) === key)) return "you cannot create on a home base";
|
||||
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there";
|
||||
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there";
|
||||
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
|
||||
if (!gameLos(state, caster.position, cell)) return "no line of sight";
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** WATERWALL: push a player 2 spaces along dir; 1 damage per blocked space. */
|
||||
function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Side): void {
|
||||
if (isLockedInPlace(state, p.id)) return;
|
||||
const view = boardView(state);
|
||||
const from = p.position;
|
||||
let moved = 0;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const step = stepTarget(view, p.position, dir);
|
||||
if (step.kind === "blocked") break;
|
||||
if (state.squareContents[cellKey(step.to)]?.kind === "stone") break;
|
||||
p.position = step.to;
|
||||
moved++;
|
||||
}
|
||||
const blockedSpaces = 2 - moved;
|
||||
events.push({ type: "washedBack", player: p.id, from, to: p.position, blockedSpaces });
|
||||
if (blockedSpaces > 0) {
|
||||
applyDamage(state, events, p, blockedSpaces, "waterwall crush", null);
|
||||
}
|
||||
}
|
||||
|
||||
/** DRAG a player straight toward the caster, stopping at walls. */
|
||||
function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
|
||||
const view = boardView(state);
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
if (cellKey(target.position) === cellKey(dest)) return;
|
||||
const dx = dest.x - target.position.x;
|
||||
const dy = dest.y - target.position.y;
|
||||
let dir: Side;
|
||||
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
|
||||
else dir = dy > 0 ? "S" : "N";
|
||||
const step = stepTarget(view, target.position, dir);
|
||||
if (step.kind === "blocked") return;
|
||||
if (state.squareContents[cellKey(step.to)]?.kind === "stone") return;
|
||||
target.position = step.to;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect helpers
|
||||
|
||||
@@ -873,6 +1254,10 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
||||
edgeOverrides: {},
|
||||
doorStates: {},
|
||||
openDoorEdges: [],
|
||||
createdEdges: {},
|
||||
squareContents: {},
|
||||
groundObjects: {},
|
||||
lastSpellUsed: {},
|
||||
players,
|
||||
treasures,
|
||||
sustained: [],
|
||||
@@ -937,6 +1322,8 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
|
||||
case "counteract": return err("nothing to counteract");
|
||||
case "pass": return err("nothing to pass on");
|
||||
case "pickUpTreasure": return doPickUpTreasure(state);
|
||||
case "pickUpObject": return doPickUpObject(state, command.instanceId);
|
||||
case "dropObject": return doDropObject(state, command.instanceId);
|
||||
case "dropTreasure": return doDropTreasure(state);
|
||||
case "discard": return doDiscard(state, playerId, command.instanceIds);
|
||||
case "endTurn": return doEndTurn(state, command.draw);
|
||||
@@ -976,25 +1363,37 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
|
||||
const mover = activePlayer(prev);
|
||||
if (sustainedOn(prev, mover.id, "medusa").length > 0) return err("you are paralyzed by Medusa");
|
||||
if (isLockedInPlace(prev, mover.id)) return err("you are locked in place");
|
||||
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
const target = stepTarget(view, p.position, direction);
|
||||
const events: GameEvent[] = [];
|
||||
const misted = isMisted(state, p.id);
|
||||
|
||||
const from = p.position;
|
||||
let via: "step" | "warp" | "passWall";
|
||||
let crossedFirewall = false;
|
||||
if (target.kind === "blocked") {
|
||||
const key = edgeKey(p.position, direction);
|
||||
const edge = view.edges[key] ?? "open";
|
||||
const dest = neighbor(p.position, direction);
|
||||
// A locked door that has been unlocked or de-locked is passable.
|
||||
if (!view.cells[cellKey(dest)]) return err("blocked");
|
||||
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
|
||||
if (!view.cells[cellKey(dest)]) return err("blocked");
|
||||
p.position = dest;
|
||||
via = "step";
|
||||
} else if (edge === "wall" && p.passWallCharges > 0 && view.cells[cellKey(dest)]) {
|
||||
// PASS THROUGH WALL: one charge, one wall.
|
||||
} else if (edge === "firewall") {
|
||||
// "Passing through it does four points of magical damage." Firewalls
|
||||
// burn even a MIST-BODY.
|
||||
p.position = dest;
|
||||
via = "step";
|
||||
crossedFirewall = true;
|
||||
} else if (misted && (edge === "wall" || edge === "door")) {
|
||||
// MIST-BODY passes through anything but solid stone.
|
||||
p.position = dest;
|
||||
via = "passWall";
|
||||
} else if (edge === "wall" && p.passWallCharges > 0) {
|
||||
p.passWallCharges--;
|
||||
p.position = dest;
|
||||
via = "passWall";
|
||||
@@ -1006,12 +1405,30 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
via = target.kind;
|
||||
}
|
||||
|
||||
// Square contents at the destination.
|
||||
const content = state.squareContents[cellKey(p.position)];
|
||||
if (content?.kind === "stone") return err("that square is solid stone");
|
||||
|
||||
state.turn.movementUsed++;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "moved", player: p.id, from, to: p.position, direction, via }],
|
||||
};
|
||||
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
||||
|
||||
if (crossedFirewall) {
|
||||
events.push({ type: "firewallBurned", player: p.id });
|
||||
applyDamage(state, events, p, 4, "wall of fire", null);
|
||||
checkVictory(state, events);
|
||||
}
|
||||
|
||||
// THORNBUSH: "his turn ends, he loses his following turn, and he takes one
|
||||
// point of physical damage from thorns."
|
||||
if (content?.kind === "thornbush" && p.alive) {
|
||||
events.push({ type: "enteredThornbush", player: p.id, at: p.position });
|
||||
applyDamage(state, events, p, 1, "thorns", null);
|
||||
p.lostTurns++;
|
||||
state.turn.actionsEnded = true;
|
||||
checkVictory(state, events);
|
||||
}
|
||||
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandResult {
|
||||
@@ -1063,6 +1480,20 @@ function castingBlocked(state: GameState, playerId: PlayerId): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
|
||||
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
|
||||
if (inThornbush(state, attacker)) return "you cannot attack from inside a thornbush";
|
||||
if (inThornbush(state, target)) return "you cannot attack someone in a thornbush";
|
||||
if (isMisted(state, attacker.id)) return "you are mist — you may not attack";
|
||||
if (isMisted(state, target.id)) return "your target is mist and cannot be attacked";
|
||||
// BUDDY: "Opponent will not attack you unless you attack first."
|
||||
const buddy = state.sustained.find(
|
||||
(s) => s.cardId === "buddy" && s.casterId === target.id && s.targetId === attacker.id,
|
||||
);
|
||||
if (buddy) return "the Buddy pact holds — you cannot bring yourself to attack them";
|
||||
return null;
|
||||
}
|
||||
|
||||
function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
|
||||
const pre = attackPreconditions(prev);
|
||||
if (pre) return err(pre);
|
||||
@@ -1075,6 +1506,11 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
|
||||
if (cellKey(target.position) !== cellKey(attacker.position)) {
|
||||
return err("you must be in the same square to punch");
|
||||
}
|
||||
const bushOrMist = attackBlockedByStatus(state, attacker, target);
|
||||
if (bushOrMist) return err(bushOrMist);
|
||||
state.sustained = state.sustained.filter(
|
||||
(s) => !(s.cardId === "buddy" && s.casterId === attacker.id && s.targetId === target.id),
|
||||
);
|
||||
|
||||
state.turn.attackUsed = true;
|
||||
state.stack = {
|
||||
@@ -1224,9 +1660,15 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) {
|
||||
return err("you must be in the same square");
|
||||
}
|
||||
if (effect.requiresLos && !hasLineOfSight(boardView(state), caster.position, target.position)) {
|
||||
const statusBlock = attackBlockedByStatus(state, caster, target);
|
||||
if (statusBlock) return err(statusBlock);
|
||||
if (effect.requiresLos && !gameLos(state, caster.position, target.position)) {
|
||||
return err("no line of sight to the target");
|
||||
}
|
||||
// Attacking someone breaks any BUDDY pact you swore to them.
|
||||
state.sustained = state.sustained.filter(
|
||||
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
|
||||
);
|
||||
if (effect.validate) {
|
||||
const problem = effect.validate(state, cmd);
|
||||
if (problem) return err(problem);
|
||||
@@ -1250,10 +1692,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
amplifyFactor: 2 ** mods.amplifies.length,
|
||||
extendFactor: mods.extend ? 2 : 1,
|
||||
params: cmd.params ?? null,
|
||||
kind: "spell",
|
||||
kind: effect.physical ? "physical" : "spell",
|
||||
counters: [],
|
||||
waitingOn: target.id,
|
||||
};
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [{
|
||||
type: "spellCast",
|
||||
caster: caster.id,
|
||||
@@ -1293,6 +1736,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (effect.keepInHand) {
|
||||
events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
|
||||
}
|
||||
if (cardDef(inHand.cardId).cardType !== "object" && inHand.cardId !== "reuse-spell") {
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
}
|
||||
const result = (effect as NeutralEffect).resolve(state, events, caster, cmd, mods.magnitude);
|
||||
if (result) return err(result); // unreachable after preview
|
||||
return { ok: true, state, events };
|
||||
@@ -1512,13 +1958,17 @@ function knockBack(
|
||||
}
|
||||
|
||||
const from = defender.position;
|
||||
if (isLockedInPlace(state, defender.id)) return;
|
||||
let moved = 0;
|
||||
const view = boardView(state);
|
||||
for (let i = 0; i < squares; i++) {
|
||||
const step = stepTarget(view, defender.position, dir);
|
||||
if (step.kind === "blocked") break;
|
||||
const content = state.squareContents[cellKey(step.to)];
|
||||
if (content?.kind === "stone") break;
|
||||
defender.position = step.to;
|
||||
moved++;
|
||||
if (content?.kind === "thornbush") break; // tangled in the thorns
|
||||
}
|
||||
if (moved > 0) {
|
||||
events.push({ type: "knockedBack", player: defender.id, from, to: defender.position, squares: moved });
|
||||
@@ -1606,6 +2056,45 @@ function doPickUpTreasure(prev: GameState): CommandResult {
|
||||
};
|
||||
}
|
||||
|
||||
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
if (blocked) return err(blocked);
|
||||
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
const key = cellKey(p.position);
|
||||
const here = state.groundObjects[key] ?? [];
|
||||
const idx = here.findIndex((c) => c.instanceId === instanceId);
|
||||
if (idx === -1) return err("that object is not here");
|
||||
const [card] = here.splice(idx, 1);
|
||||
if (here.length === 0) delete state.groundObjects[key];
|
||||
p.hand.push(card!);
|
||||
// "YOUR TURN ENDS IF YOU PICK UP ANY OBJECT."
|
||||
state.turn.actionsEnded = true;
|
||||
if (p.hand.length > HAND_LIMIT) state.pendingDiscard = p.id;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "objectPickedUp", player: p.id, card: card!, at: p.position }],
|
||||
};
|
||||
}
|
||||
|
||||
function doDropObject(prev: GameState, instanceId: string): CommandResult {
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
const card = p.hand.find((c) => c.instanceId === instanceId);
|
||||
if (!card) return err("card not in hand");
|
||||
if (cardDef(card.cardId).cardType !== "object") return err("only objects can be dropped");
|
||||
takeFromHand(p, instanceId);
|
||||
const key = cellKey(p.position);
|
||||
state.groundObjects[key] = [...(state.groundObjects[key] ?? []), card];
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "objectDropped", player: p.id, card, at: p.position, forced: false }],
|
||||
};
|
||||
}
|
||||
|
||||
function doDropTreasure(prev: GameState): CommandResult {
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
@@ -1708,6 +2197,12 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
||||
s.remainingTurns--;
|
||||
if (s.remainingTurns <= 0) {
|
||||
events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId });
|
||||
// Edge-bound spells clean up their edge (WALL OF FIRE burns out).
|
||||
if (s.edge && state.edgeOverrides[s.edge] === "firewall") {
|
||||
delete state.edgeOverrides[s.edge];
|
||||
delete state.createdEdges[s.edge];
|
||||
events.push({ type: "firewallExpired", edge: s.edge });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user