The Alter Ego casts from its own square; the first-stride cue leaves the maze

Randy raised his double, selected it, and could cast nothing through
it: the card's whole point — "it may use any of the spells in your
hand, and you need not be in its L.O.S." — had never been wired. A
cast may now name the double, and the double's square becomes the
spell's origin: sight, reach, the launch point the reel films, and
the direction a knockback or rout pushes. The hand, the attack, and
every answer to it stay the caster's. Spells that move the caster's
own body — a goat's charge, a swap — do not travel through it. On the
board, the double's sight lights the eligible squares; selecting the
double and then a card aims from where it stands. A test hides the
caster and fires through the double.

The first-stride caption sat on the maze and hid the walls beside the
wizard; it now stands above the board with a color chip for "you."

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-15 23:38:58 -04:00
co-authored by Claude Fable 5.1
parent 5f58a0955a
commit 10d5c5815d
5 changed files with 158 additions and 78 deletions
+57 -29
View File
@@ -212,6 +212,9 @@ export interface CastStack {
reflectedBase?: { damage: number; duration: number }; reflectedBase?: { damage: number; duration: number };
/** The wraith's touch also steals a random card if damage lands. */ /** The wraith's touch also steals a random card if damage lands. */
creatureTouch?: "wraith" | "claw"; creatureTouch?: "wraith" | "claw";
/** Where the spell left from when not the attacker's own square: an
* ALTER EGO's. Knockbacks and routs push away from here. */
origin?: Cell;
/** A spell freed from slime: counteractions cannot touch its caster. */ /** A spell freed from slime: counteractions cannot touch its caster. */
trapped?: boolean; trapped?: boolean;
/** STRENGTH's tear: "this would be an attack" the grab rides the stack /** STRENGTH's tear: "this would be an attack" the grab rides the stack
@@ -836,6 +839,10 @@ export type Command =
powerAttackPoints?: number; powerAttackPoints?: number;
target?: CastTarget; target?: CastTarget;
params?: CastParams; params?: CastParams;
/** ALTER EGO: the double's creature id, when the spell leaves from
* its square "it may use any of the spells in your hand, and you
* need not be in its L.O.S. to do so." */
via?: string;
} }
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell } | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell }
| { type: "cancelAmbush"; ambushId: string } | { type: "cancelAmbush"; ambushId: string }
@@ -903,6 +910,8 @@ interface Magnitude {
} }
interface ResolutionContext { interface ResolutionContext {
/** The square the blow came from: the attacker's, or their double's. */
origin: Cell;
state: GameState; state: GameState;
events: GameEvent[]; events: GameEvent[];
attacker: PlayerState; attacker: PlayerState;
@@ -1069,7 +1078,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => { onResolved: (ctx) => {
const knock = ctx.stack.params?.knockback ?? 0; const knock = ctx.stack.params?.knockback ?? 0;
if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return; if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return;
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock); knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock, ctx.origin);
}, },
}, },
"sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 }, "sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 },
@@ -1114,7 +1123,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return; if (ctx.fullyStopped || !ctx.defender.alive) return;
const n = ctx.stack.numberValue ?? 1; const n = ctx.stack.numberValue ?? 1;
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n, ctx.origin);
ctx.defender.lostTurns++; ctx.defender.lostTurns++;
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 }); ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
}, },
@@ -5243,6 +5252,19 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId); const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId);
if (!inHand) return err("card not in hand"); if (!inHand) return err("card not in hand");
const def = cardDef(inHand.cardId); const def = cardDef(inHand.cardId);
// ALTER EGO: "It may use any of the spells in your hand, and you need
// not be in its L.O.S. to do so." The double's square is where the
// spell leaves from — sight, reach, and the blow's direction are its —
// while the hand, the attack, and any answer to it stay the caster's.
// Spells that move the caster's own body do not travel through it.
let origin: Cell = caster.position;
if (cmd.via) {
const ego = state.creatures.find((c) => c.id === cmd.via);
if (!ego || ego.kind !== "alter-ego" || ego.controllerId !== caster.id) return err("that is not your double");
if (!cmd.target) return err("your double casts only at a target");
if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body — not your double's to cast`);
origin = ego.position;
}
const effect = CARD_EFFECTS[inHand.cardId]; const effect = CARD_EFFECTS[inHand.cardId];
if (!effect) return err(`${def.name} is not implemented yet`); if (!effect) return err(`${def.name} is not implemented yet`);
if (effect.kind === "counter") { if (effect.kind === "counter") {
@@ -5328,7 +5350,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [{ const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null, from: origin, target: null, targetCell: null,
}]; }];
attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration); attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration);
return { ok: true, state, events }; return { ok: true, state, events };
@@ -5342,12 +5364,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "creature") { if (cmd.target?.kind === "creature") {
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId); const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return err("no such creature"); if (!creature) return err("no such creature");
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(creature.position) !== cellKey(origin)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
if (effect.requiresLos && !(mods.aroundCorner if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, creature.position) ? bentLos(state, caster, origin, creature.position)
: casterLos(state, caster, caster.position, creature.position))) { : casterLos(state, caster, origin, creature.position))) {
return err("no line of sight to the creature"); return err("no line of sight to the creature");
} }
const wandEvents: GameEvent[] = []; const wandEvents: GameEvent[] = [];
@@ -5362,7 +5384,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [...wandEvents, { const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: creature.position, from: origin, target: null, targetCell: creature.position,
}]; }];
// "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP). // "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP).
if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") { if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") {
@@ -5383,7 +5405,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "cell" && if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") { state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") {
const cell = cmd.target.cell; const cell = cmd.target.cell;
if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(cell) !== cellKey(origin)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
// castSight carries the whole sight law: straight lines, the // castSight carries the whole sight law: straight lines, the
@@ -5405,7 +5427,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [...wandEvents, { const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: { ...cell }, from: origin, target: null, targetCell: { ...cell },
}]; }];
const box = state.squareContents[cellKey(cell)]!; const box = state.squareContents[cellKey(cell)]!;
box.damage += dmg; box.damage += dmg;
@@ -5424,8 +5446,8 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") { state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
const cell = cmd.target.cell; const cell = cmd.target.cell;
if (effect.requiresLos && !(mods.aroundCorner if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, cell) ? bentLos(state, caster, origin, cell)
: casterLos(state, caster, caster.position, cell))) { : casterLos(state, caster, origin, cell))) {
return err("no line of sight to the slime"); return err("no line of sight to the slime");
} }
const wandEvents: GameEvent[] = []; const wandEvents: GameEvent[] = [];
@@ -5469,7 +5491,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (current !== "wall" && current !== "door") { if (current !== "wall" && current !== "door") {
return err("there is no wall or door on that edge to attack"); return err("there is no wall or door on that edge to attack");
} }
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) { if (effect.sameSquare && !touchesEdge(origin, cell, side)) {
return err("you must stand beside the wall"); return err("you must stand beside the wall");
} }
if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) { if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) {
@@ -5495,7 +5517,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events2: GameEvent[] = [...wandEvents, { const events2: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: cell, from: origin, target: null, targetCell: cell,
}]; }];
const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId); const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId);
if (problem) return err(problem); if (problem) return err(problem);
@@ -5524,7 +5546,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [{ const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null, from: origin, target: null, targetCell: null,
}]; }];
const queue = turnOrderFrom(state, caster.id).filter((id) => const queue = turnOrderFrom(state, caster.id).filter((id) =>
id !== caster.id && state.players.find((p) => p.id === id)!.alive); id !== caster.id && state.players.find((p) => p.id === id)!.alive);
@@ -5536,7 +5558,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself"); if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return err("no such living player"); if (!target || !target.alive) return err("no such living player");
if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(target.position) !== cellKey(origin)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
const statusBlock = attackBlockedByStatus(state, caster, target); const statusBlock = attackBlockedByStatus(state, caster, target);
@@ -5544,8 +5566,8 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const preEvents: GameEvent[] = []; const preEvents: GameEvent[] = [];
if (effect.requiresLos) { if (effect.requiresLos) {
const sighted = mods.aroundCorner const sighted = mods.aroundCorner
? bentLos(state, caster, caster.position, target.position) ? bentLos(state, caster, origin, target.position)
: casterLos(state, caster, caster.position, target.position); : casterLos(state, caster, origin, target.position);
if (!sighted) return err("no line of sight to the target"); if (!sighted) return err("no line of sight to the target");
} }
// Attacking someone breaks any BUDDY pact you swore to them. // Attacking someone breaks any BUDDY pact you swore to them.
@@ -5579,9 +5601,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
// go intended distance" — if the die disagrees with the true direction, // go intended distance" — if the die disagrees with the true direction,
// the spell hits whoever lies that way, or dissipates. // the spell hits whoever lies that way, or dissipates.
if (isBlinded(state, caster) && if (isBlinded(state, caster) &&
cellKey(target.position) !== cellKey(caster.position)) { cellKey(target.position) !== cellKey(origin)) {
const dx = target.position.x - caster.position.x; const dx = target.position.x - origin.x;
const dy = target.position.y - caster.position.y; const dy = target.position.y - origin.y;
const intended: Side = const intended: Side =
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N"; Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
const [roll, rngNext] = rollDie(state.rng); const [roll, rngNext] = rollDie(state.rng);
@@ -5590,11 +5612,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (rolled !== intended) { if (rolled !== intended) {
const along = state.players.find((p) => { const along = state.players.find((p) => {
if (!p.alive || p.id === caster.id) return false; if (!p.alive || p.id === caster.id) return false;
const px = p.position.x - caster.position.x; const px = p.position.x - origin.x;
const py = p.position.y - caster.position.y; const py = p.position.y - origin.y;
const dirOf: Side | null = const dirOf: Side | null =
Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null; Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null;
return dirOf === rolled && casterLos(state, caster, caster.position, p.position); return dirOf === rolled && casterLos(state, caster, origin, p.position);
}); });
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
state.turn.attackUsed = true; state.turn.attackUsed = true;
@@ -5616,11 +5638,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
kind: effect.physical ? "physical" : "spell", kind: effect.physical ? "physical" : "spell",
counters: [], counters: [],
waitingOn: along.id, waitingOn: along.id,
...(cmd.via ? { origin } : {}),
}; };
missEvents.push({ missEvents.push({
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: along.id, targetCell: along.position, from: origin, target: along.id, targetCell: along.position,
}); });
return { ok: true, state, events: missEvents }; return { ok: true, state, events: missEvents };
} }
@@ -5642,6 +5665,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
counters: [], counters: [],
waitingOn: target.id, waitingOn: target.id,
...(mods.aroundCorner ? { bentCorner: true as const } : {}), ...(mods.aroundCorner ? { bentCorner: true as const } : {}),
...(cmd.via ? { origin } : {}),
}; };
state.lastSpellUsed[caster.id] = inHand.cardId; state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, ...preEvents]; const events: GameEvent[] = [...wandEvents, ...preEvents];
@@ -5654,7 +5678,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
numberCards: mods.numbers, numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue, numberValue: mods.magnitude.numberValue,
amplifies: mods.amplifies.length, amplifies: mods.amplifies.length,
from: caster.position, from: origin,
target: target.id, target: target.id,
targetCell: target.position, targetCell: target.position,
}); });
@@ -5674,7 +5698,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
cardId: inHand.cardId, cardId: inHand.cardId,
numberCards: mods.numbers, numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue, numberValue: mods.magnitude.numberValue,
from: caster.position, from: origin,
target: cmd.target?.kind === "player" ? cmd.target.playerId : null, target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null, targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
}); });
@@ -6291,6 +6315,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
effect.onResolved({ effect.onResolved({
state, events, state, events,
attacker: defender, defender: attacker, attacker: defender, defender: attacker,
origin: defender.position,
damageDealt: 0, fullyStopped: false, reversed: false, duration: 0, damageDealt: 0, fullyStopped: false, reversed: false, duration: 0,
stack: { ...stack, params: { ...stack.params, cardId: chosen } }, stack: { ...stack, params: { ...stack.params, cardId: chosen } },
}); });
@@ -6434,6 +6459,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
events, events,
attacker, attacker,
defender, defender,
origin: stack.origin ?? attacker.position,
damageDealt, damageDealt,
fullyStopped: pipe.fullyStopped, fullyStopped: pipe.fullyStopped,
reversed: pipe.reversed, reversed: pipe.reversed,
@@ -6472,11 +6498,12 @@ function goAwayRout(
attacker: PlayerState, attacker: PlayerState,
defender: PlayerState, defender: PlayerState,
squares: number, squares: number,
origin: Cell = attacker.position,
): void { ): void {
if (isLockedInPlace(state, defender.id)) return; if (isLockedInPlace(state, defender.id)) return;
const from = defender.position; const from = defender.position;
const dist = (c: Cell) => const dist = (c: Cell) =>
Math.abs(attacker.position.x - c.x) + Math.abs(attacker.position.y - c.y); Math.abs(origin.x - c.x) + Math.abs(origin.y - c.y);
let heading: Side | null = null; let heading: Side | null = null;
let moved = 0; let moved = 0;
for (let i = 0; i < squares; i++) { for (let i = 0; i < squares; i++) {
@@ -6527,9 +6554,10 @@ function knockBack(
attacker: PlayerState, attacker: PlayerState,
defender: PlayerState, defender: PlayerState,
squares: number, squares: number,
origin: Cell = attacker.position,
): void { ): void {
const dx = defender.position.x - attacker.position.x; const dx = defender.position.x - origin.x;
const dy = defender.position.y - attacker.position.y; const dy = defender.position.y - origin.y;
let dir: Side | null = null; let dir: Side | null = null;
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W"; if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
else if (dy !== 0) dir = dy > 0 ? "S" : "N"; else if (dy !== 0) dir = dy > 0 ? "S" : "N";
+13 -10
View File
@@ -220,20 +220,23 @@ export function fearCells(view: GameView): Set<string> {
* reflects what this player knows (illusion walls they believe in block 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. * The basis for the client's "dim the ineligible squares" targeting aid.
*/ */
export function sightedCellsFor(view: GameView): Set<string> { export function sightedCellsFor(view: GameView, from?: Cell): Set<string> {
const out = new Set<string>(); const out = new Set<string>();
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return out; if (!me) return out;
// Sight is taken from the viewer's square unless a cast leaves from
// elsewhere — an ALTER EGO's — in which case it is the double's.
const eye = from ?? me.position;
const { board, blockers } = sightBasis(view); const { board, blockers } = sightBasis(view);
// In a dust cloud a wizard sees only their own square (rev 19). // In a dust cloud a wizard sees only their own square (rev 19).
if (dustAtEnd(view, me.position, me.position, true)) { if (dustAtEnd(view, eye, eye, true)) {
out.add(`${me.position.x},${me.position.y}`); out.add(`${eye.x},${eye.y}`);
return out; return out;
} }
for (const key of Object.keys(board.cells)) { for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number]; const [x, y] = key.split(",").map(Number) as [number, number];
if (dustAtEnd(view, me.position, { x, y })) continue; if (dustAtEnd(view, eye, { x, y })) continue;
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); if (sightBetween(board, eye, { x, y }, blockers)) out.add(key);
} }
// VISIONSTONE lets its bearer see through exactly one wall or door — // VISIONSTONE lets its bearer see through exactly one wall or door —
// any one — so a square is also sighted if removing a single edge // any one — so a square is also sighted if removing a single edge
@@ -248,7 +251,7 @@ export function sightedCellsFor(view: GameView): Set<string> {
const opened = { ...board, edges }; const opened = { ...board, edges };
for (let i = unseen.length - 1; i >= 0; i--) { for (let i = unseen.length - 1; i >= 0; i--) {
const [x, y] = unseen[i]!.split(",").map(Number) as [number, number]; const [x, y] = unseen[i]!.split(",").map(Number) as [number, number];
if (sightBetween(opened, me.position, { x, y }, blockers)) { if (sightBetween(opened, eye, { x, y }, blockers)) {
out.add(unseen[i]!); out.add(unseen[i]!);
unseen.splice(i, 1); unseen.splice(i, 1);
} }
@@ -263,8 +266,8 @@ export function sightedCellsFor(view: GameView): Set<string> {
* plus every square visible from one of those — the caster looks to a middle * plus every square visible from one of those — the caster looks to a middle
* cell and the spell turns there, mirroring the engine's bentLos. * cell and the spell turns there, mirroring the engine's bentLos.
*/ */
export function bentSightedCellsFor(view: GameView): Set<string> { export function bentSightedCellsFor(view: GameView, from?: Cell): Set<string> {
const out = sightedCellsFor(view); const out = sightedCellsFor(view, from);
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return out; if (!me) return out;
const mids = [...out].map((k) => { const mids = [...out].map((k) => {
@@ -431,12 +434,12 @@ const SUMMON_CARD_IDS = new Set([
* aid — mirroring the engine's own validation from the viewer's knowledge. * aid — mirroring the engine's own validation from the viewer's knowledge.
* Null = this card's eligibility is not modeled; light everything. * Null = this card's eligibility is not modeled; light everything.
*/ */
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set<string> | null { export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false, from?: Cell): Set<string> | null {
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return null; if (!me) return null;
const cells = Object.keys(view.board.cells); const cells = Object.keys(view.board.cells);
const key = (x: number, y: number) => `${x},${y}`; const key = (x: number, y: number) => `${x},${y}`;
const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view); const sighted = bentCorner ? bentSightedCellsFor(view, from) : sightedCellsFor(view, from);
if (CREATION_CARD_IDS.has(cardId)) { if (CREATION_CARD_IDS.has(cardId)) {
// emptySquareTarget: on the board, unoccupied by content, home, wizard, // emptySquareTarget: on the board, unoccupied by content, home, wizard,
+38 -2
View File
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame, gameLos,
} from "../src/game";
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained, faceOff } from "./helpers";
/** Summon a creature next to its creator (round 2+, consumes the attack). */ /** Summon a creature next to its creator (round 2+, consumes the attack). */
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") { function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
@@ -961,3 +962,38 @@ describe("the imp's fire is a spell (rev 8)", () => {
expect(after.alive).toBe(true); expect(after.alive).toBe(true);
}); });
}); });
describe("the alter ego casts from its own square", () => {
it("a fireball leaves from the double, by the double's sight, and answers to the caster", () => {
let { state } = newGame(42);
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
const ego = giveCard(state, attacker, "alter-ego");
state = must(state, attacker, { type: "cast", instanceId: ego.instanceId });
const double = state.creatures.find((c) => c.kind === "alter-ego" && c.controllerId === attacker)!;
expect(cellKey(double.position)).toBe(cellKey(a.position));
// The caster steps out of the defender's sight; the double keeps it.
const view = boardView(state);
const hidden = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; })
.find((c) => !gameLos(state, c, d.position) && !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c)));
expect(hidden).toBeDefined();
const me = state.players.find((p) => p.id === attacker)!;
me.position = { ...hidden! };
const fb = giveCard(state, attacker, "fireball");
const blind = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
expect(blind.ok).toBe(false);
const wrong = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: "no-such-double" });
expect(wrong.ok).toBe(false);
const viaEgo = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: double.id });
expect(viaEgo.ok).toBe(true);
if (!viaEgo.ok) return;
expect(viaEgo.state.stack?.attackerId).toBe(attacker);
expect(cellKey(viaEgo.state.stack!.origin!)).toBe(cellKey(double.position));
const cast = viaEgo.events.find((e) => e.type === "spellCast");
expect(cast && cast.type === "spellCast" ? cellKey(cast.from) : null).toBe(cellKey(double.position));
const done = must(viaEgo.state, defender, { type: "pass" });
expect(done.players.find((p) => p.id === defender)!.life).toBe(10);
});
});
+50 -14
View File
@@ -590,8 +590,15 @@
/** Attachment plumbing for a cast command: the chosen mods and the /** Attachment plumbing for a cast command: the chosen mods and the
* attached NUMBER ride along (an explicit numberInstanceIds wins). */ * attached NUMBER ride along (an explicit numberInstanceIds wins). */
/** Your ALTER EGO, when it is the selected token: casts leave from its square. */
const castingDouble = $derived(
selectedCreature && view
? view.creatures.find((c) => c.id === selectedCreature && c.kind === "alter-ego" && c.controllerId === view!.you) ?? null
: null,
);
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T { function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
applyMods(cmd); applyMods(cmd);
if (castingDouble && cmd.target) cmd.via = castingDouble.id;
if (attachedNumber && !cmd.numberInstanceIds) { if (attachedNumber && !cmd.numberInstanceIds) {
cmd.numberInstanceIds = [attachedNumber.instanceId]; cmd.numberInstanceIds = [attachedNumber.instanceId];
} }
@@ -1150,7 +1157,7 @@
function clickPlayer(playerId: string) { function clickPlayer(playerId: string) {
if (!view || !yourMoment) return; if (!view || !yourMoment) return;
if (selectedCreature) { if (selectedCreature && !(castingDouble && selectedCard)) {
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId }); dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
selectedCreature = null; selectedCreature = null;
return; return;
@@ -1540,12 +1547,9 @@
.filter((t) => t.kind !== "blocked") .filter((t) => t.kind !== "blocked")
.map((t) => cellKey(t.to)); .map((t) => cellKey(t.to));
}); });
/** The first stride of a first game gets one line on the board itself. */ /** The first stride of a first game gets one line above the board
const stepCaption = $derived( * off the maze, so the walls around the wizard stay in plain sight. */
stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0 const stepCaption = $derived(stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0);
? { cell: me.position, text: "This is you — tap a lit square to walk" }
: null,
);
const litCells = $derived.by(() => { const litCells = $derived.by(() => {
if (view && me && !selectedDef && stepLight) return new Set([cellKey(me.position), ...stepCells]); if (view && me && !selectedDef && stepLight) return new Set([cellKey(me.position), ...stepCells]);
if (!view || !selectedDef || !yourMoment) return null; if (!view || !selectedDef || !yourMoment) return null;
@@ -1553,10 +1557,10 @@
if (!me) return null; if (!me) return null;
const bent = attachedMods.some((m) => m.cardId === "around-the-corner"); const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
if (CELL_CARDS.has(selectedCard!.cardId)) { if (CELL_CARDS.has(selectedCard!.cardId)) {
return eligibleCellsFor(view, selectedCard!.cardId, bent); return eligibleCellsFor(view, selectedCard!.cardId, bent, castingDouble?.position);
} }
if (selectedDef.adjacent === true) { if (selectedDef.adjacent === true) {
const { x, y } = me.position; const { x, y } = castingDouble?.position ?? me.position;
return new Set( return new Set(
[[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]] [[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]
.map(([cx, cy]) => `${cx},${cy}`) .map(([cx, cy]) => `${cx},${cy}`)
@@ -1564,7 +1568,7 @@
); );
} }
if (selectedDef.los !== true) return null; if (selectedDef.los !== true) return null;
return bent ? bentSightedCellsFor(view) : sightedCellsFor(view); return bent ? bentSightedCellsFor(view, castingDouble?.position) : sightedCellsFor(view, castingDouble?.position);
}); });
/** Creatures awaiting your orders this turn (yours + any democratic monster). */ /** Creatures awaiting your orders this turn (yours + any democratic monster). */
@@ -2479,8 +2483,12 @@
{#if selectedCreature && !selectedDef} {#if selectedCreature && !selectedDef}
{@const sc = view.creatures.find((c) => c.id === selectedCreature)} {@const sc = view.creatures.find((c) => c.id === selectedCreature)}
{#if sc} {#if sc}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong> — {creatureStats(sc)}. {#if sc.kind === "alter-ego"}
Tap a square beside it to march, a target in its square to attack.</span> <span>Your <strong>double</strong> stands ready — select a card and it casts from the double's square, by the double's sight.</span>
{:else}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong> — {creatureStats(sc)}.
Tap a square beside it to march, a target in its square to attack.</span>
{/if}
{#if dmWarnCell} {#if dmWarnCell}
<span class="hint-alert">⚠ it will claw YOU the moment it enters your square — tap again if you mean it</span> <span class="hint-alert">⚠ it will claw YOU the moment it enters your square — tap again if you mean it</span>
{/if} {/if}
@@ -2673,6 +2681,12 @@
</button> </button>
</div> </div>
{/if} {/if}
{#if stepCaption && me}
<div class="step-caption">
<span class="step-chip" style:background={playerColor(me.id)}></span>
This is you — tap a lit square beside your wizard to walk
</div>
{/if}
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}> <div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
{#if prefs.liveFp && view.you && !net.spectating} {#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)} <LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
@@ -2712,7 +2726,6 @@
onEdgePeek={(tip) => (peekEdge = tip)} onEdgePeek={(tip) => (peekEdge = tip)}
{litCells} {litCells}
spotCells={stepCells} spotCells={stepCells}
spotCaption={stepCaption}
{sightTrace} {sightTrace}
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null} povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null} povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
@@ -3487,8 +3500,31 @@
margin: 0.2rem 0 0.1rem; margin: 0.2rem 0 0.1rem;
} }
/* The eyes toggle floats over the board's corner so it costs the maze no height. */ /* The eyes toggle floats over the board's corner so it costs the maze no height. */
.board-zone { position: relative; } .board-zone { position: relative; flex-direction: column; align-items: stretch; }
.view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; } .view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; }
.step-caption {
align-self: flex-start;
max-width: calc(100% - 11rem);
margin: 0 0 0.4rem 0.4rem;
padding: 0.3rem 0.8rem;
border-radius: 999px;
background: rgba(28, 22, 12, 0.92);
border: 1.5px solid #f2cf5b;
color: #f7ecc8;
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 0.45rem;
}
.step-chip {
display: inline-block;
width: 0.8em;
height: 0.8em;
border-radius: 2px;
border: 1.5px solid #f7ecc8;
}
.boxlid-tag { .boxlid-tag {
font-size: 0.95rem; font-size: 0.95rem;
color: #6b5a41; color: #6b5a41;
-23
View File
@@ -35,7 +35,6 @@
markedCells = null, markedCells = null,
litCells = null, litCells = null,
spotCells = [], spotCells = [],
spotCaption = null,
markedSector = null, markedSector = null,
ghostSlots = null, ghostSlots = null,
onGhostClick, onGhostClick,
@@ -70,8 +69,6 @@
litCells?: Set<string> | null; litCells?: Set<string> | null;
/** The step light's squares: a pulsing rim on each. */ /** The step light's squares: a pulsing rim on each. */
spotCells?: string[]; spotCells?: string[];
/** One line hung above a square — the first stride's cue. */
spotCaption?: { cell: { x: number; y: number }; text: string } | null;
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */ /** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
markedSector?: { x: number; y: number } | null; markedSector?: { x: number; y: number } | null;
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground /** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
@@ -697,17 +694,6 @@
{@const sy = Number(key.split(",")[1])} {@const sy = Number(key.split(",")[1])}
<rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" /> <rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" />
{/each} {/each}
{#if spotCaption}
{@const cols = Math.max(...Object.keys(view.board.cells).map((k) => Number(k.split(",")[0]))) + 1}
{@const cw = spotCaption.text.length * 6.6 + 20}
{@const cx = Math.min(Math.max(spotCaption.cell.x * CELL + CELL / 2, cw / 2 + 4), cols * CELL - cw / 2 - 4)}
{@const above = spotCaption.cell.y > 0}
{@const cy = above ? spotCaption.cell.y * CELL - 14 : (spotCaption.cell.y + 1) * CELL + 14}
<g class="spot-caption" transform={`translate(${cx}, ${cy})`}>
<rect x={-cw / 2} y={-12} width={cw} height={24} rx="12" />
<text x="0" y="4">{spotCaption.text}</text>
</g>
{/if}
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim --> <!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
{#each fearAura as key (key)} {#each fearAura as key (key)}
{@const bx = Number(key.split(",")[0])} {@const bx = Number(key.split(",")[0])}
@@ -865,15 +851,6 @@
0%, 100% { opacity: 0.35; } 0%, 100% { opacity: 0.35; }
50% { opacity: 1; } 50% { opacity: 1; }
} }
.spot-caption { pointer-events: none; }
.spot-caption rect { fill: rgba(28, 22, 12, 0.92); stroke: #f2cf5b; stroke-width: 1.5; }
.spot-caption text {
fill: #f7ecc8;
font-size: 12px;
font-family: "Oswald", "Helvetica Neue", Arial, sans-serif;
letter-spacing: 0.02em;
text-anchor: middle;
}
.marked-cell { .marked-cell {
fill: rgba(211, 133, 43, 0.18); fill: rgba(211, 133, 43, 0.18);
stroke: #d3852b; stroke: #d3852b;