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:
co-authored by
Claude Fable 5.1
parent
5f58a0955a
commit
10d5c5815d
+57
-29
@@ -212,6 +212,9 @@ export interface CastStack {
|
||||
reflectedBase?: { damage: number; duration: number };
|
||||
/** The wraith's touch also steals a random card if damage lands. */
|
||||
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. */
|
||||
trapped?: boolean;
|
||||
/** STRENGTH's tear: "this would be an attack" — the grab rides the stack
|
||||
@@ -836,6 +839,10 @@ export type Command =
|
||||
powerAttackPoints?: number;
|
||||
target?: CastTarget;
|
||||
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: "cancelAmbush"; ambushId: string }
|
||||
@@ -903,6 +910,8 @@ interface Magnitude {
|
||||
}
|
||||
|
||||
interface ResolutionContext {
|
||||
/** The square the blow came from: the attacker's, or their double's. */
|
||||
origin: Cell;
|
||||
state: GameState;
|
||||
events: GameEvent[];
|
||||
attacker: PlayerState;
|
||||
@@ -1069,7 +1078,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
onResolved: (ctx) => {
|
||||
const knock = ctx.stack.params?.knockback ?? 0;
|
||||
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 },
|
||||
@@ -1114,7 +1123,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
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.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);
|
||||
if (!inHand) return err("card not in hand");
|
||||
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];
|
||||
if (!effect) return err(`${def.name} is not implemented yet`);
|
||||
if (effect.kind === "counter") {
|
||||
@@ -5328,7 +5350,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [{
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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);
|
||||
return { ok: true, state, events };
|
||||
@@ -5342,12 +5364,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (cmd.target?.kind === "creature") {
|
||||
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
||||
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");
|
||||
}
|
||||
if (effect.requiresLos && !(mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, creature.position)
|
||||
: casterLos(state, caster, caster.position, creature.position))) {
|
||||
? bentLos(state, caster, origin, creature.position)
|
||||
: casterLos(state, caster, origin, creature.position))) {
|
||||
return err("no line of sight to the creature");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
@@ -5362,7 +5384,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [...wandEvents, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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).
|
||||
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" &&
|
||||
state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") {
|
||||
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");
|
||||
}
|
||||
// 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, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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)]!;
|
||||
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") {
|
||||
const cell = cmd.target.cell;
|
||||
if (effect.requiresLos && !(mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, cell)
|
||||
: casterLos(state, caster, caster.position, cell))) {
|
||||
? bentLos(state, caster, origin, cell)
|
||||
: casterLos(state, caster, origin, cell))) {
|
||||
return err("no line of sight to the slime");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
@@ -5469,7 +5491,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (current !== "wall" && current !== "door") {
|
||||
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");
|
||||
}
|
||||
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, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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);
|
||||
if (problem) return err(problem);
|
||||
@@ -5524,7 +5546,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [{
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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) =>
|
||||
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");
|
||||
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 (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");
|
||||
}
|
||||
const statusBlock = attackBlockedByStatus(state, caster, target);
|
||||
@@ -5544,8 +5566,8 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const preEvents: GameEvent[] = [];
|
||||
if (effect.requiresLos) {
|
||||
const sighted = mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, target.position)
|
||||
: casterLos(state, caster, caster.position, target.position);
|
||||
? bentLos(state, caster, origin, target.position)
|
||||
: casterLos(state, caster, origin, target.position);
|
||||
if (!sighted) return err("no line of sight to the target");
|
||||
}
|
||||
// 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,
|
||||
// the spell hits whoever lies that way, or dissipates.
|
||||
if (isBlinded(state, caster) &&
|
||||
cellKey(target.position) !== cellKey(caster.position)) {
|
||||
const dx = target.position.x - caster.position.x;
|
||||
const dy = target.position.y - caster.position.y;
|
||||
cellKey(target.position) !== cellKey(origin)) {
|
||||
const dx = target.position.x - origin.x;
|
||||
const dy = target.position.y - origin.y;
|
||||
const intended: Side =
|
||||
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
|
||||
const [roll, rngNext] = rollDie(state.rng);
|
||||
@@ -5590,11 +5612,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (rolled !== intended) {
|
||||
const along = state.players.find((p) => {
|
||||
if (!p.alive || p.id === caster.id) return false;
|
||||
const px = p.position.x - caster.position.x;
|
||||
const py = p.position.y - caster.position.y;
|
||||
const px = p.position.x - origin.x;
|
||||
const py = p.position.y - origin.y;
|
||||
const dirOf: Side | 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);
|
||||
state.turn.attackUsed = true;
|
||||
@@ -5616,11 +5638,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
kind: effect.physical ? "physical" : "spell",
|
||||
counters: [],
|
||||
waitingOn: along.id,
|
||||
...(cmd.via ? { origin } : {}),
|
||||
};
|
||||
missEvents.push({
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
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 };
|
||||
}
|
||||
@@ -5642,6 +5665,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
counters: [],
|
||||
waitingOn: target.id,
|
||||
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
|
||||
...(cmd.via ? { origin } : {}),
|
||||
};
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [...wandEvents, ...preEvents];
|
||||
@@ -5654,7 +5678,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
numberCards: mods.numbers,
|
||||
numberValue: mods.magnitude.numberValue,
|
||||
amplifies: mods.amplifies.length,
|
||||
from: caster.position,
|
||||
from: origin,
|
||||
target: target.id,
|
||||
targetCell: target.position,
|
||||
});
|
||||
@@ -5674,7 +5698,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
cardId: inHand.cardId,
|
||||
numberCards: mods.numbers,
|
||||
numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position,
|
||||
from: origin,
|
||||
target: cmd.target?.kind === "player" ? cmd.target.playerId : 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({
|
||||
state, events,
|
||||
attacker: defender, defender: attacker,
|
||||
origin: defender.position,
|
||||
damageDealt: 0, fullyStopped: false, reversed: false, duration: 0,
|
||||
stack: { ...stack, params: { ...stack.params, cardId: chosen } },
|
||||
});
|
||||
@@ -6434,6 +6459,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
events,
|
||||
attacker,
|
||||
defender,
|
||||
origin: stack.origin ?? attacker.position,
|
||||
damageDealt,
|
||||
fullyStopped: pipe.fullyStopped,
|
||||
reversed: pipe.reversed,
|
||||
@@ -6472,11 +6498,12 @@ function goAwayRout(
|
||||
attacker: PlayerState,
|
||||
defender: PlayerState,
|
||||
squares: number,
|
||||
origin: Cell = attacker.position,
|
||||
): void {
|
||||
if (isLockedInPlace(state, defender.id)) return;
|
||||
const from = defender.position;
|
||||
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 moved = 0;
|
||||
for (let i = 0; i < squares; i++) {
|
||||
@@ -6527,9 +6554,10 @@ function knockBack(
|
||||
attacker: PlayerState,
|
||||
defender: PlayerState,
|
||||
squares: number,
|
||||
origin: Cell = attacker.position,
|
||||
): void {
|
||||
const dx = defender.position.x - attacker.position.x;
|
||||
const dy = defender.position.y - attacker.position.y;
|
||||
const dx = defender.position.x - origin.x;
|
||||
const dy = defender.position.y - origin.y;
|
||||
let dir: Side | null = null;
|
||||
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
|
||||
else if (dy !== 0) dir = dy > 0 ? "S" : "N";
|
||||
|
||||
+13
-10
@@ -220,20 +220,23 @@ export function fearCells(view: GameView): Set<string> {
|
||||
* 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> {
|
||||
export function sightedCellsFor(view: GameView, from?: Cell): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
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);
|
||||
// In a dust cloud a wizard sees only their own square (rev 19).
|
||||
if (dustAtEnd(view, me.position, me.position, true)) {
|
||||
out.add(`${me.position.x},${me.position.y}`);
|
||||
if (dustAtEnd(view, eye, eye, true)) {
|
||||
out.add(`${eye.x},${eye.y}`);
|
||||
return out;
|
||||
}
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (dustAtEnd(view, me.position, { x, y })) continue;
|
||||
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
||||
if (dustAtEnd(view, eye, { x, y })) continue;
|
||||
if (sightBetween(board, eye, { x, y }, blockers)) out.add(key);
|
||||
}
|
||||
// VISIONSTONE lets its bearer see through exactly one wall or door —
|
||||
// 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 };
|
||||
for (let i = unseen.length - 1; i >= 0; i--) {
|
||||
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]!);
|
||||
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
|
||||
* cell and the spell turns there, mirroring the engine's bentLos.
|
||||
*/
|
||||
export function bentSightedCellsFor(view: GameView): Set<string> {
|
||||
const out = sightedCellsFor(view);
|
||||
export function bentSightedCellsFor(view: GameView, from?: Cell): Set<string> {
|
||||
const out = sightedCellsFor(view, from);
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return out;
|
||||
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.
|
||||
* 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);
|
||||
if (!me) return null;
|
||||
const cells = Object.keys(view.board.cells);
|
||||
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)) {
|
||||
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 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). */
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user