Expansion wave 4: 27 combat, curse, and utility cards
Fortune: GIFT FROM ABOVE (+3, no ceiling), GIFT FROM BELOW (a trap in the deck — 3 damage on the draw, harmless in the opening deal). Modifiers: POWER ATTACK burns life into any damage spell. Curses: WEAKNESS (drop your treasure, take double, carry nothing), STRENGTH (double physical dealt; the two cancel), WALKING DEAD (half a point per space walked, forever), DISEASE (the victim becomes a carrier who infects everyone in squares they enter), IDIOT (the victim shambles toward their nearest own treasure, able only to counteract, until they stand on it and ask "What am I doing here?"). Defense: EMPATHY (attacks bite their caster too), FORCE FIELD (spell-stopping counteraction). Mischief: MENTAL SWAP (trade whole hands), MENTAL FORCE (march someone three spaces), BUTT-HEAD (become a goat, ram for the distance charged), HEAVE-HO (throw your carried treasure as a weapon), THIEF and SWAP-MEET (item larceny), CHAOS (all hands in a pile, shuffled, redealt), ILLUSIONARY ATTACK (a fake spell that hurts if believed), WARD (a trapped treasure bites its thief), REMOVE CURSE (strip any duration spell, rolling to hit the shrunken or invisible), SWARTHMORE'S ENCHANTMENT (+1 on an enchanted object). Space-time: DIMENSIONAL WARP (paired step-through tokens), REDIRECTION (swap two outer exits' wraparounds), BIG MAN (fills the corridor: no entry, no punches, no casting past him), FEAR (nobody approaches within 3), and the out-of-turn pair — INTERRUPT and OPPORTUNITY FIRE — which open a one-action window in another player's turn. Only THUMB OF GOD remains, awaiting its digital redesign. 117 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3a35922a40
commit
06eea72ded
+686
-8
@@ -148,6 +148,8 @@ export interface CastStack {
|
|||||||
/** Power/duration multiplier from AMPLIFY (and EXTEND for durations). */
|
/** Power/duration multiplier from AMPLIFY (and EXTEND for durations). */
|
||||||
amplifyFactor: number;
|
amplifyFactor: number;
|
||||||
extendFactor: number;
|
extendFactor: number;
|
||||||
|
/** POWER ATTACK: extra damage bought with the caster's life. */
|
||||||
|
powerAttackPoints: number;
|
||||||
params: CastParams | null;
|
params: CastParams | null;
|
||||||
kind: "spell" | "physical";
|
kind: "spell" | "physical";
|
||||||
counters: { player: PlayerId; card: CardInstance; nullified: boolean }[];
|
counters: { player: PlayerId; card: CardInstance; nullified: boolean }[];
|
||||||
@@ -203,6 +205,12 @@ export interface GameState {
|
|||||||
gluedCells: Record<string, true>;
|
gluedCells: Record<string, true>;
|
||||||
/** SAFE cells unlocked until end of turn (lock cards / the creator). */
|
/** SAFE cells unlocked until end of turn (lock cards / the creator). */
|
||||||
openSafes: string[];
|
openSafes: string[];
|
||||||
|
/** SWARTHMORE'S ENCHANTMENT: enchanted object instances (+1 magical). */
|
||||||
|
enchantedObjects: Record<string, true>;
|
||||||
|
/** DIMENSIONAL WARP token pairs. */
|
||||||
|
dimWarps: { a: Cell; b: Cell }[];
|
||||||
|
/** INTERRUPT / OPPORTUNITY FIRE: one out-of-turn action window. */
|
||||||
|
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
||||||
players: PlayerState[];
|
players: PlayerState[];
|
||||||
treasures: TreasureState[];
|
treasures: TreasureState[];
|
||||||
sustained: SustainedEffect[];
|
sustained: SustainedEffect[];
|
||||||
@@ -233,6 +241,10 @@ export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
|||||||
for (const [key, content] of Object.entries(state.squareContents)) {
|
for (const [key, content] of Object.entries(state.squareContents)) {
|
||||||
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
|
||||||
}
|
}
|
||||||
|
// BIG MAN: you cannot cast spells past him.
|
||||||
|
for (const p of state.players) {
|
||||||
|
if (p.alive && sustainedOn(state, p.id, "big-man").length > 0) blockers[cellKey(p.position)] = true;
|
||||||
|
}
|
||||||
return hasLineOfSight(boardView(state), from, to, blockers);
|
return hasLineOfSight(boardView(state), from, to, blockers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +471,20 @@ export type GameEvent =
|
|||||||
| { type: "safeOpened"; player: PlayerId; at: Cell }
|
| { type: "safeOpened"; player: PlayerId; at: Cell }
|
||||||
| { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell }
|
| { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell }
|
||||||
| { type: "stoneTurnedToWater"; caster: PlayerId; at: Cell | null }
|
| { type: "stoneTurnedToWater"; caster: PlayerId; at: Cell | null }
|
||||||
|
| { type: "handsSwapped"; a: PlayerId; b: PlayerId }
|
||||||
|
| { type: "handsScrambled"; caster: PlayerId }
|
||||||
|
| { type: "rammed"; attacker: PlayerId; target: PlayerId; distance: number }
|
||||||
|
| { type: "treasureThrown"; attacker: PlayerId; at: Cell; distance: number }
|
||||||
|
| { type: "illusionBelieved"; player: PlayerId; cardId: string; believed: boolean }
|
||||||
|
| { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string }
|
||||||
|
| { type: "itemsSwapped"; a: PlayerId; b: PlayerId }
|
||||||
|
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
|
||||||
|
| { type: "curseRemoved"; caster: PlayerId; target: PlayerId; cardId: string }
|
||||||
|
| { type: "objectEnchanted"; caster: PlayerId; cardId: string }
|
||||||
|
| { type: "warpTokensPlaced"; caster: PlayerId; a: Cell; b: Cell }
|
||||||
|
| { type: "warpStepped"; player: PlayerId; from: Cell; to: Cell }
|
||||||
|
| { type: "exitsRedirected"; caster: PlayerId }
|
||||||
|
| { type: "outOfTurnWindow"; player: PlayerId; kind: "interrupt" | "opportunity-fire" }
|
||||||
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
|
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
|
||||||
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
|
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
|
||||||
| { type: "doorsRelocked"; count: number }
|
| { type: "doorsRelocked"; count: number }
|
||||||
@@ -499,6 +525,7 @@ export type Command =
|
|||||||
| { type: "move"; direction: Side }
|
| { type: "move"; direction: Side }
|
||||||
| { type: "playNumberForMovement"; instanceId: string }
|
| { type: "playNumberForMovement"; instanceId: string }
|
||||||
| { type: "punch"; targetId: PlayerId }
|
| { type: "punch"; targetId: PlayerId }
|
||||||
|
| { type: "warpStep" }
|
||||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||||
| { type: "creatureAttack"; creatureId: string; targetId: string }
|
| { type: "creatureAttack"; creatureId: string; targetId: string }
|
||||||
| {
|
| {
|
||||||
@@ -516,6 +543,9 @@ export type Command =
|
|||||||
extendInstanceId?: string;
|
extendInstanceId?: string;
|
||||||
/** AROUND THE CORNER card attached (bends this cast's line of sight). */
|
/** AROUND THE CORNER card attached (bends this cast's line of sight). */
|
||||||
aroundCornerInstanceId?: string;
|
aroundCornerInstanceId?: string;
|
||||||
|
/** POWER ATTACK card attached: burn life for extra damage. */
|
||||||
|
powerAttackInstanceId?: string;
|
||||||
|
powerAttackPoints?: number;
|
||||||
target?: CastTarget;
|
target?: CastTarget;
|
||||||
params?: CastParams;
|
params?: CastParams;
|
||||||
}
|
}
|
||||||
@@ -1646,6 +1676,392 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
return "target a stone wall or a solid stone block";
|
return "target a stone wall or a solid stone block";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// --- Expansion #1: fortune and misfortune --------------------------------
|
||||||
|
"gift-from-above": {
|
||||||
|
kind: "neutral",
|
||||||
|
// "Add three points to your total, now. You may go higher than fifteen."
|
||||||
|
resolve: (state, events, caster) => {
|
||||||
|
caster.life += 3;
|
||||||
|
events.push({ type: "lifeGained", player: caster.id, amount: 3, source: "gift from above", lifeAfter: caster.life });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"power-attack": {
|
||||||
|
kind: "neutral",
|
||||||
|
// Handled as a cast modifier (powerAttackPoints); casting it alone is a
|
||||||
|
// usage error.
|
||||||
|
resolve: () => "attach Power Attack to a damage spell (choose life points to burn)",
|
||||||
|
},
|
||||||
|
strength: {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: (state, events, caster, _cmd, magnitude) => {
|
||||||
|
attachSustained(state, events, "strength", caster.id, caster.id, magnitude.duration);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
weakness: {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
sustains: true,
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||||
|
// "Opponent drops any treasure carried."
|
||||||
|
if (ctx.defender.carriedTreasureId) {
|
||||||
|
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: homeOwnerAt(ctx.state, ctx.defender.position),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"walking-dead": {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
// "1/2 point of damage for every space moved. This spell is permanent."
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||||
|
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disease: {
|
||||||
|
kind: "attack",
|
||||||
|
baseDamage: () => 0,
|
||||||
|
sameSquare: false,
|
||||||
|
validate: (state, cmd) => {
|
||||||
|
const target = state.players.find((p) => p.id === (cmd.target as { playerId?: PlayerId })?.playerId);
|
||||||
|
const caster = activePlayer(state);
|
||||||
|
if (!target) return null;
|
||||||
|
const d = Math.abs(target.position.x - caster.position.x) + Math.abs(target.position.y - caster.position.y);
|
||||||
|
return d <= 1 ? null : "disease spreads by touch — you must be adjacent";
|
||||||
|
},
|
||||||
|
sustains: true,
|
||||||
|
},
|
||||||
|
empathy: {
|
||||||
|
kind: "neutral",
|
||||||
|
// Counteraction card used proactively: while it lasts, attacks against
|
||||||
|
// you act against the attacker too.
|
||||||
|
resolve: (state, events, caster, _cmd, magnitude) => {
|
||||||
|
attachSustained(state, events, "empathy", caster.id, caster.id, magnitude.duration);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"force-field": {
|
||||||
|
kind: "counter",
|
||||||
|
// Stops the spell attack outright (daggers and blades slip through).
|
||||||
|
apply: (p) => {
|
||||||
|
if (p.kind === "spell") { p.damage = 0; p.duration = 0; p.fullyStopped = true; }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"mental-swap": {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
||||||
|
const aHand = ctx.attacker.hand;
|
||||||
|
ctx.attacker.hand = ctx.defender.hand;
|
||||||
|
ctx.defender.hand = aHand;
|
||||||
|
const aDisp = ctx.attacker.displayed;
|
||||||
|
ctx.attacker.displayed = ctx.defender.displayed;
|
||||||
|
ctx.defender.displayed = aDisp;
|
||||||
|
ctx.events.push({ type: "handsSwapped", a: ctx.attacker.id, b: ctx.defender.id });
|
||||||
|
const check = (p: PlayerState) => {
|
||||||
|
if (p.hand.length > handLimit(p)) ctx.state.pendingDiscard = p.id;
|
||||||
|
};
|
||||||
|
check(ctx.attacker);
|
||||||
|
check(ctx.defender);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"mental-force": {
|
||||||
|
kind: "attack",
|
||||||
|
baseDamage: () => 0, // no LOS printed
|
||||||
|
validate: (state, cmd) => {
|
||||||
|
const cell = cmd.params?.cell;
|
||||||
|
if (!cell) return "say where they go (within three moved spaces)";
|
||||||
|
if (!boardView(state).cells[cellKey(cell)]) return "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!;
|
||||||
|
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
|
||||||
|
const from = ctx.defender.position;
|
||||||
|
ctx.defender.position = to;
|
||||||
|
ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"butt-head": {
|
||||||
|
kind: "attack",
|
||||||
|
physical: true,
|
||||||
|
baseDamage: () => 0, // computed at resolution: distance rammed
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
||||||
|
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
|
||||||
|
Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
|
||||||
|
if (d === 0) return;
|
||||||
|
ctx.attacker.position = { ...ctx.defender.position };
|
||||||
|
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d });
|
||||||
|
applyDamage(ctx.state, ctx.events, ctx.defender, d, "goat ram", ctx.attacker.id, "physical");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"heave-ho": {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
physical: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
validate: (state) => {
|
||||||
|
const caster = activePlayer(state);
|
||||||
|
return caster.carriedTreasureId ? null : "you have no treasure to throw";
|
||||||
|
},
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (!ctx.attacker.carriedTreasureId) return;
|
||||||
|
const t = ctx.state.treasures.find((t) => t.id === ctx.attacker.carriedTreasureId)!;
|
||||||
|
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
|
||||||
|
Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
|
||||||
|
t.carriedBy = null;
|
||||||
|
t.position = { ...ctx.defender.position };
|
||||||
|
ctx.attacker.carriedTreasureId = null;
|
||||||
|
ctx.events.push({ type: "treasureThrown", attacker: ctx.attacker.id, at: ctx.defender.position, distance: d });
|
||||||
|
if (!ctx.fullyStopped && ctx.defender.alive && d > 0) {
|
||||||
|
applyDamage(ctx.state, ctx.events, ctx.defender, d, "hurled treasure", ctx.attacker.id, "physical");
|
||||||
|
}
|
||||||
|
ctx.events.push({
|
||||||
|
type: "treasureDropped", player: ctx.attacker.id, treasureId: t.id,
|
||||||
|
at: t.position!, onHomeOf: homeOwnerAt(ctx.state, t.position!),
|
||||||
|
});
|
||||||
|
checkVictory(ctx.state, ctx.events);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thief: {
|
||||||
|
kind: "attack",
|
||||||
|
sameSquare: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
validate: (_s, cmd) => (cmd.params?.cardId ? null : "name the item to steal"),
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped) return;
|
||||||
|
const wanted = ctx.stack.params!.cardId!;
|
||||||
|
if (wanted === "treasure") return; // "The item may not be a treasure."
|
||||||
|
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted && cardDef(c.cardId).cardType === "object");
|
||||||
|
if (idx === -1) return;
|
||||||
|
const [card] = ctx.defender.hand.splice(idx, 1);
|
||||||
|
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId);
|
||||||
|
ctx.attacker.hand.push(card!);
|
||||||
|
ctx.events.push({ type: "itemStolen", from: ctx.defender.id, to: ctx.attacker.id, cardId: wanted });
|
||||||
|
if (ctx.attacker.hand.length > handLimit(ctx.attacker)) ctx.state.pendingDiscard = ctx.attacker.id;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chaos: {
|
||||||
|
kind: "attack",
|
||||||
|
baseDamage: () => 0,
|
||||||
|
// Everyone's hands into one pile, shuffled, dealt back in equal counts.
|
||||||
|
// (Simplification: the FULL SHIELD opt-out and ABSORB SPELL interactions
|
||||||
|
// are not modeled — chaos resolves for all living players at once.)
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
const players = ctx.state.players.filter((p) => p.alive);
|
||||||
|
const counts = players.map((p) => p.hand.length);
|
||||||
|
const pile = players.flatMap((p) => p.hand.splice(0));
|
||||||
|
for (const p of players) p.displayed = [];
|
||||||
|
const [shuffled, rngNext] = shuffle(ctx.state.rng, pile);
|
||||||
|
ctx.state.rng = rngNext;
|
||||||
|
let i = 0;
|
||||||
|
players.forEach((p, pi) => {
|
||||||
|
p.hand = shuffled.slice(i, i + counts[pi]!);
|
||||||
|
i += counts[pi]!;
|
||||||
|
ctx.events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
|
||||||
|
});
|
||||||
|
ctx.events.push({ type: "handsScrambled", caster: ctx.attacker.id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"illusionary-attack": {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
validate: (_s, cmd) => {
|
||||||
|
const chosen = cmd.params?.cardId;
|
||||||
|
if (!chosen) return "choose the attack spell to fake";
|
||||||
|
const fx = CARD_EFFECTS[chosen];
|
||||||
|
if (!fx || fx.kind !== "attack") return "that is not an attack spell";
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||||
|
const chosen = ctx.stack.params!.cardId!;
|
||||||
|
const fx = CARD_EFFECTS[chosen] as AttackEffect;
|
||||||
|
const [roll, rngNext] = rollDie(ctx.state.rng);
|
||||||
|
ctx.state.rng = rngNext;
|
||||||
|
const believed = roll <= 2;
|
||||||
|
ctx.events.push({ type: "illusionBelieved", player: ctx.defender.id, cardId: chosen, believed });
|
||||||
|
if (!believed) return;
|
||||||
|
const dmg = fx.baseDamage(ctx.stack.numberValue, ctx.stack.params ?? null);
|
||||||
|
if (dmg > 0) {
|
||||||
|
applyDamage(ctx.state, ctx.events, ctx.defender, dmg, `illusionary ${chosen}`, ctx.attacker.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"swap-meet": {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
validate: (_s, cmd) => (cmd.params?.cardId ? null : "name your item and theirs (yours;theirs)"),
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped) return;
|
||||||
|
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
|
||||||
|
const mine = ctx.attacker.hand.findIndex((c) => c.cardId === mineId && cardDef(c.cardId).cardType === "object");
|
||||||
|
const theirs = ctx.defender.hand.findIndex((c) => c.cardId === theirsId && cardDef(c.cardId).cardType === "object");
|
||||||
|
if (mine === -1 || theirs === -1) return;
|
||||||
|
const [a] = ctx.attacker.hand.splice(mine, 1);
|
||||||
|
const [b] = ctx.defender.hand.splice(theirs, 1);
|
||||||
|
ctx.attacker.hand.push(b!);
|
||||||
|
ctx.defender.hand.push(a!);
|
||||||
|
ctx.attacker.displayed = ctx.attacker.displayed.filter((id) => id !== a!.instanceId);
|
||||||
|
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== b!.instanceId);
|
||||||
|
ctx.events.push({ type: "itemsSwapped", a: ctx.attacker.id, b: ctx.defender.id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"remove-curse": {
|
||||||
|
kind: "neutral",
|
||||||
|
// Counteraction used out of the stack: strip one duration spell.
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
if (!cmd.target || cmd.target.kind !== "player") return "choose whose curse to remove";
|
||||||
|
const wanted = cmd.params?.cardId;
|
||||||
|
if (!wanted) return "name the spell to remove";
|
||||||
|
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||||
|
if (!target) return "no such player";
|
||||||
|
const idx = state.sustained.findIndex((fx) => fx.targetId === target.id && fx.cardId === wanted);
|
||||||
|
if (idx === -1) return "no such spell on them";
|
||||||
|
// "Has to hit to affect SHRINK and INVISIBLE."
|
||||||
|
if (wanted === "invisible" || wanted === "shrink") {
|
||||||
|
const [roll, rngNext] = rollDie(state.rng);
|
||||||
|
state.rng = rngNext;
|
||||||
|
const needed = wanted === "invisible" ? 1 : 2;
|
||||||
|
if (roll > needed) {
|
||||||
|
events.push({ type: "attackMissed", attacker: caster.id, defender: target.id, attackCardId: "remove-curse", because: wanted as "invisible" | "shrink" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [fx] = state.sustained.splice(idx, 1);
|
||||||
|
if (fx!.cardId === "glue" && fx!.edge) delete state.gluedCells[fx!.edge];
|
||||||
|
events.push({ type: "curseRemoved", caster: caster.id, target: target.id, cardId: wanted });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"swarthmores-enchantment": {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
const wanted = cmd.params?.cardId;
|
||||||
|
if (!wanted) return "name the object to enchant";
|
||||||
|
// Find the instance: your hand, a target player's hand, or the floor.
|
||||||
|
let instance: CardInstance | undefined = caster.hand.find((c) => c.cardId === wanted);
|
||||||
|
if (!instance && cmd.target?.kind === "player") {
|
||||||
|
const t = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||||
|
instance = t?.hand.find((c) => c.cardId === wanted);
|
||||||
|
}
|
||||||
|
if (!instance) {
|
||||||
|
for (const objs of Object.values(state.groundObjects)) {
|
||||||
|
instance = objs.find((c) => c.cardId === wanted);
|
||||||
|
if (instance) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!instance) return "no such object in sight";
|
||||||
|
state.enchantedObjects[instance.instanceId] = true;
|
||||||
|
events.push({ type: "objectEnchanted", caster: caster.id, cardId: wanted });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ward: {
|
||||||
|
kind: "neutral",
|
||||||
|
// WARD is never cast from the hand — it springs automatically when your
|
||||||
|
// treasure is grabbed (see doPickUpTreasure).
|
||||||
|
resolve: () => "Ward waits in your hand and springs when your treasure is taken",
|
||||||
|
},
|
||||||
|
idiot: {
|
||||||
|
kind: "attack",
|
||||||
|
requiresLos: true,
|
||||||
|
baseDamage: () => 0,
|
||||||
|
// "Opponent heads straight for the nearest of his own treasures ... This
|
||||||
|
// lasts until opponent is on his own treasure."
|
||||||
|
sustains: false,
|
||||||
|
onResolved: (ctx) => {
|
||||||
|
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||||
|
const hasTreasureOut = ctx.state.treasures.some((t) => t.owner === ctx.defender.id && t.position);
|
||||||
|
if (!hasTreasureOut) return; // "ends if both treasures are being carried"
|
||||||
|
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"big-man": {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: (state, events, caster, _cmd, magnitude) => {
|
||||||
|
attachSustained(state, events, "big-man", caster.id, caster.id, magnitude.duration);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fear: {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: (state, events, caster, _cmd, magnitude) => {
|
||||||
|
attachSustained(state, events, "fear", caster.id, caster.id, magnitude.duration);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"dimensional-warp": {
|
||||||
|
kind: "neutral",
|
||||||
|
// Two tokens anywhere (except home bases); stepping between them costs 1.
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
const a = cmd.params?.cell;
|
||||||
|
const bT = cmd.target;
|
||||||
|
if (!a || !bT || bT.kind !== "cell") return "place the two warp tokens";
|
||||||
|
const b = bT.cell;
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const c of [a, b]) {
|
||||||
|
if (!view.cells[cellKey(c)]) return "off the board";
|
||||||
|
if (view.homes.some((h) => cellKey(h) === cellKey(c))) return "not on a home base";
|
||||||
|
if (state.squareContents[cellKey(c)]?.kind === "stone") return "inside solid stone";
|
||||||
|
}
|
||||||
|
if (cellKey(a) === cellKey(b)) return "the tokens go on two different squares";
|
||||||
|
state.dimWarps.push({ a: { ...a }, b: { ...b } });
|
||||||
|
events.push({ type: "warpTokensPlaced", caster: caster.id, a, b });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
redirection: {
|
||||||
|
kind: "neutral",
|
||||||
|
// "Swap two external sector exits" — their wraparound destinations trade.
|
||||||
|
resolve: (state, events, caster, cmd) => {
|
||||||
|
const a = cmd.params?.cell;
|
||||||
|
const bT = cmd.target;
|
||||||
|
if (!a || !bT || bT.kind !== "cell") return "pick the two exits to swap";
|
||||||
|
const b = bT.cell;
|
||||||
|
const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a));
|
||||||
|
const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b));
|
||||||
|
if (!wa || !wb || wa === wb) return "pick two different outer exits";
|
||||||
|
// Swap destinations and fix the reciprocal warps to match.
|
||||||
|
const destA = { ...wa.to };
|
||||||
|
const destB = { ...wb.to };
|
||||||
|
wa.to = destB;
|
||||||
|
wb.to = destA;
|
||||||
|
for (const w of state.board.warps) {
|
||||||
|
if (cellKey(w.from.cell) === cellKey(destA.cell)) w.to = { cell: { ...wb.from.cell }, side: wb.from.side };
|
||||||
|
if (cellKey(w.from.cell) === cellKey(destB.cell)) w.to = { cell: { ...wa.from.cell }, side: wa.from.side };
|
||||||
|
}
|
||||||
|
events.push({ type: "exitsRedirected", caster: caster.id });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"opportunity-fire": {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: () => "played out of turn — wait for another player's turn, then use it",
|
||||||
|
},
|
||||||
|
interrupt: {
|
||||||
|
kind: "neutral",
|
||||||
|
resolve: () => "played out of turn — use it during another player's turn",
|
||||||
|
},
|
||||||
"reuse-spell": {
|
"reuse-spell": {
|
||||||
kind: "neutral",
|
kind: "neutral",
|
||||||
// "You may retrieve any spell you use immediately after you use it (but
|
// "You may retrieve any spell you use immediately after you use it (but
|
||||||
@@ -2253,6 +2669,29 @@ function attachSustained(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
|
||||||
|
function walkingDistance(state: GameState, from: Cell, to: Cell): number {
|
||||||
|
if (cellKey(from) === cellKey(to)) return 0;
|
||||||
|
const view = boardView(state);
|
||||||
|
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
||||||
|
const queue: Cell[] = [from];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const cur = queue.shift()!;
|
||||||
|
const d = seen.get(cellKey(cur))!;
|
||||||
|
if (d >= 6) break;
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const step = stepTarget(view, cur, side);
|
||||||
|
if (step.kind === "blocked") continue;
|
||||||
|
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
|
||||||
|
if (seen.has(cellKey(step.to))) continue;
|
||||||
|
seen.set(cellKey(step.to), d + 1);
|
||||||
|
if (cellKey(step.to) === cellKey(to)) return d + 1;
|
||||||
|
queue.push(step.to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen.get(cellKey(to)) ?? Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
/** BFS steps between cells ignoring walls (teleport distance). */
|
/** BFS steps between cells ignoring walls (teleport distance). */
|
||||||
function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
|
function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
|
||||||
if (cellKey(from) === cellKey(to)) return 0;
|
if (cellKey(from) === cellKey(to)) return 0;
|
||||||
@@ -2321,7 +2760,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
while (p.hand.length < HAND_LIMIT) {
|
while (p.hand.length < HAND_LIMIT) {
|
||||||
const card = deck.shift();
|
const card = deck.shift();
|
||||||
if (!card) throw new Error("deck exhausted during deal");
|
if (!card) throw new Error("deck exhausted during deal");
|
||||||
if (isTrap(card.cardId)) {
|
if (isTrap(card.cardId) || card.cardId === "gift-from-below") {
|
||||||
|
// "Discard without any damage taken if this is dealt on the first turn."
|
||||||
discard.push(card);
|
discard.push(card);
|
||||||
events.push({ type: "trapRedrawnDuringDeal", player: p.id });
|
events.push({ type: "trapRedrawnDuringDeal", player: p.id });
|
||||||
} else {
|
} else {
|
||||||
@@ -2367,6 +2807,9 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
boobytraps: [],
|
boobytraps: [],
|
||||||
gluedCells: {},
|
gluedCells: {},
|
||||||
openSafes: [],
|
openSafes: [],
|
||||||
|
enchantedObjects: {},
|
||||||
|
dimWarps: [],
|
||||||
|
outOfTurnWindow: null,
|
||||||
players,
|
players,
|
||||||
treasures,
|
treasures,
|
||||||
sustained: [],
|
sustained: [],
|
||||||
@@ -2423,12 +2866,82 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
|
|||||||
return err("an attack is being resolved — counteract or pass");
|
return err("an attack is being resolved — counteract or pass");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePlayer(state).id !== playerId) return err("not your turn");
|
// INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window.
|
||||||
|
if (state.outOfTurnWindow) {
|
||||||
|
if (playerId !== state.outOfTurnWindow.playerId) {
|
||||||
|
return err("an interruption is being resolved");
|
||||||
|
}
|
||||||
|
if (command.type === "pass") {
|
||||||
|
const st = clone(state);
|
||||||
|
st.outOfTurnWindow = null;
|
||||||
|
return { ok: true, state: st, events: [] };
|
||||||
|
}
|
||||||
|
if (command.type !== "cast" && command.type !== "punch") {
|
||||||
|
return err("use your interruption (cast or punch) or pass");
|
||||||
|
}
|
||||||
|
const st = clone(state);
|
||||||
|
const idx = st.players.findIndex((p) => p.id === playerId);
|
||||||
|
const saved = { ...st.turn };
|
||||||
|
st.turn = {
|
||||||
|
...st.turn,
|
||||||
|
activeIndex: idx,
|
||||||
|
attackUsed: false,
|
||||||
|
secondAttackUsed: false,
|
||||||
|
attackForbidden: false,
|
||||||
|
actionsEnded: false,
|
||||||
|
};
|
||||||
|
const kind = st.outOfTurnWindow!.kind;
|
||||||
|
// OPPORTUNITY FIRE permits an attack; INTERRUPT any one spell.
|
||||||
|
if (kind === "opportunity-fire" && command.type === "cast") {
|
||||||
|
const p = st.players[idx]!;
|
||||||
|
const card = p.hand.find((c) => c.instanceId === command.instanceId);
|
||||||
|
const fx = card ? CARD_EFFECTS[card.cardId] : undefined;
|
||||||
|
if (!fx || fx.kind !== "attack") return err("opportunity fire permits an attack");
|
||||||
|
}
|
||||||
|
if (kind === "interrupt" && command.type === "punch") {
|
||||||
|
return err("interrupt lets you cast a spell, not brawl");
|
||||||
|
}
|
||||||
|
st.outOfTurnWindow = null;
|
||||||
|
const result = command.type === "cast" ? doCast(st, command) : doPunch(st, command.targetId);
|
||||||
|
if (!result.ok) return result; // the window stays open in `state`
|
||||||
|
const out = result.state;
|
||||||
|
out.turn = {
|
||||||
|
...saved,
|
||||||
|
round: out.turn.round,
|
||||||
|
};
|
||||||
|
return { ok: true, state: out, events: result.events };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePlayer(state).id !== playerId) {
|
||||||
|
// Playing INTERRUPT or OPPORTUNITY FIRE out of turn opens a window.
|
||||||
|
if (command.type === "cast" && !state.stack) {
|
||||||
|
const p = state.players.find((q) => q.id === playerId && q.alive);
|
||||||
|
const card = p?.hand.find((c) => c.instanceId === command.instanceId);
|
||||||
|
if (p && card && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) {
|
||||||
|
if (state.turn.round === 1) return err("no combat during the first round of turns");
|
||||||
|
const castBlock = castingBlocked(state, playerId);
|
||||||
|
if (castBlock) return err(castBlock);
|
||||||
|
const st = clone(state);
|
||||||
|
const pp = st.players.find((q) => q.id === playerId)!;
|
||||||
|
const taken = takeFromHand(pp, command.instanceId)!;
|
||||||
|
st.discard.push(taken);
|
||||||
|
st.outOfTurnWindow = { playerId, kind: card.cardId as "interrupt" | "opportunity-fire" };
|
||||||
|
st.lastSpellUsed[playerId] = card.cardId;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
state: st,
|
||||||
|
events: [{ type: "outOfTurnWindow", player: playerId, kind: st.outOfTurnWindow!.kind }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err("not your turn");
|
||||||
|
}
|
||||||
|
|
||||||
switch (command.type) {
|
switch (command.type) {
|
||||||
case "move": return doMove(state, command.direction);
|
case "move": return doMove(state, command.direction);
|
||||||
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId);
|
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId);
|
||||||
case "punch": return doPunch(state, command.targetId);
|
case "punch": return doPunch(state, command.targetId);
|
||||||
|
case "warpStep": return doWarpStep(state);
|
||||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||||
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
|
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
|
||||||
case "cast": return doCast(state, command);
|
case "cast": return doCast(state, command);
|
||||||
@@ -2482,6 +2995,12 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
const p = activePlayer(state);
|
const p = activePlayer(state);
|
||||||
const events: GameEvent[] = [];
|
const events: GameEvent[] = [];
|
||||||
|
|
||||||
|
// IDIOT: every move heads for the nearest of their own treasures.
|
||||||
|
if (sustainedOn(state, p.id, "idiot").length > 0) {
|
||||||
|
const steered = idiotSteer(state, p);
|
||||||
|
if (steered) direction = steered;
|
||||||
|
}
|
||||||
|
|
||||||
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
|
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
|
||||||
// move ... bumping into a wall counts as one space of movement."
|
// move ... bumping into a wall counts as one space of movement."
|
||||||
if (isBlinded(state, p)) {
|
if (isBlinded(state, p)) {
|
||||||
@@ -2557,6 +3076,25 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
// Square contents at the destination.
|
// Square contents at the destination.
|
||||||
let content = state.squareContents[cellKey(p.position)];
|
let content = state.squareContents[cellKey(p.position)];
|
||||||
if (content?.kind === "stone") return err("that square is solid stone");
|
if (content?.kind === "stone") return err("that square is solid stone");
|
||||||
|
// BIG MAN: nobody enters his square.
|
||||||
|
for (const other of state.players) {
|
||||||
|
if (other.id !== p.id && other.alive && cellKey(other.position) === cellKey(p.position) &&
|
||||||
|
sustainedOn(state, other.id, "big-man").length > 0) {
|
||||||
|
p.position = from;
|
||||||
|
return err("a giant fills that corridor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// FEAR: no one moves within 3 spaces of the fearsome one.
|
||||||
|
for (const other of state.players) {
|
||||||
|
if (other.id === p.id || !other.alive) continue;
|
||||||
|
if (sustainedOn(state, other.id, "fear").length === 0) continue;
|
||||||
|
const d = Math.abs(other.position.x - p.position.x) + Math.abs(other.position.y - p.position.y);
|
||||||
|
const dBefore = Math.abs(other.position.x - from.x) + Math.abs(other.position.y - from.y);
|
||||||
|
if (d <= 3 && d < dBefore) {
|
||||||
|
p.position = from;
|
||||||
|
return err("an unnatural dread keeps you away");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CREATE PIT: stepping onto a pit is a jump attempt — roll D4; on a 1 you
|
// CREATE PIT: stepping onto a pit is a jump attempt — roll D4; on a 1 you
|
||||||
// fall in (2 damage, movement over); otherwise you sail across to the far
|
// fall in (2 damage, movement over); otherwise you sail across to the far
|
||||||
@@ -2595,6 +3133,24 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
state.turn.movementUsed++;
|
state.turn.movementUsed++;
|
||||||
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
||||||
|
|
||||||
|
// WALKING DEAD: 1/2 point per space moved (a full point every two steps).
|
||||||
|
for (const fx of sustainedOn(state, p.id, "walking-dead")) {
|
||||||
|
fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1;
|
||||||
|
if (fx.data.halfSteps % 2 === 0) {
|
||||||
|
applyDamage(state, events, p, 1, "walking dead", null);
|
||||||
|
checkVictory(state, events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DISEASE: the carrier infects every other player in a square they enter.
|
||||||
|
if (p.alive && sustainedOn(state, p.id, "disease").length > 0) {
|
||||||
|
for (const other of state.players) {
|
||||||
|
if (!other.alive || other.id === p.id) continue;
|
||||||
|
if (cellKey(other.position) !== cellKey(p.position)) continue;
|
||||||
|
applyDamage(state, events, other, 3, "disease", null, "physical");
|
||||||
|
}
|
||||||
|
checkVictory(state, events);
|
||||||
|
}
|
||||||
|
|
||||||
if (crossedFirewall) {
|
if (crossedFirewall) {
|
||||||
events.push({ type: "firewallBurned", player: p.id });
|
events.push({ type: "firewallBurned", player: p.id });
|
||||||
const webbed = sustainedOn(state, p.id, "sticky-web").length > 0;
|
const webbed = sustainedOn(state, p.id, "sticky-web").length > 0;
|
||||||
@@ -2653,6 +3209,20 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
events.push({ type: "stuckInSlime", player: p.id, at: p.position });
|
events.push({ type: "stuckInSlime", player: p.id, at: p.position });
|
||||||
state.turn.actionsEnded = true;
|
state.turn.actionsEnded = true;
|
||||||
}
|
}
|
||||||
|
// IDIOT lifts when the victim reaches their own treasure.
|
||||||
|
if (sustainedOn(state, p.id, "idiot").length > 0) {
|
||||||
|
const onOwn = state.treasures.some(
|
||||||
|
(t) => t.owner === p.id && t.position && cellKey(t.position) === cellKey(p.position),
|
||||||
|
);
|
||||||
|
const allCarried = !state.treasures.some((t) => t.owner === p.id && t.position);
|
||||||
|
if (onOwn || allCarried) {
|
||||||
|
for (const fx of sustainedOn(state, p.id, "idiot")) {
|
||||||
|
events.push({ type: "spellExpired", effectId: fx.id, cardId: "idiot", target: p.id });
|
||||||
|
}
|
||||||
|
state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BOOBYTRAP: the real token detonates under anyone but its caster.
|
// BOOBYTRAP: the real token detonates under anyone but its caster.
|
||||||
for (const trap of [...state.boobytraps]) {
|
for (const trap of [...state.boobytraps]) {
|
||||||
if (trap.casterId === p.id) continue;
|
if (trap.casterId === p.id) continue;
|
||||||
@@ -2667,6 +3237,24 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
|||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function doWarpStep(prev: GameState): CommandResult {
|
||||||
|
const blocked = requireActionsAvailable(prev);
|
||||||
|
if (blocked) return err(blocked);
|
||||||
|
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
|
||||||
|
const state = clone(prev);
|
||||||
|
const p = activePlayer(state);
|
||||||
|
if (isLockedInPlace(state, p.id)) return err("you are locked in place");
|
||||||
|
const here = cellKey(p.position);
|
||||||
|
const pair = state.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
|
||||||
|
if (!pair) return err("you are not standing on a warp token");
|
||||||
|
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
|
||||||
|
if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone");
|
||||||
|
const from = p.position;
|
||||||
|
p.position = { ...dest };
|
||||||
|
state.turn.movementUsed++;
|
||||||
|
return { ok: true, state, events: [{ type: "warpStepped", player: p.id, from, to: p.position }] };
|
||||||
|
}
|
||||||
|
|
||||||
function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandResult {
|
function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandResult {
|
||||||
const blocked = requireActionsAvailable(prev);
|
const blocked = requireActionsAvailable(prev);
|
||||||
if (blocked) return err(blocked);
|
if (blocked) return err(blocked);
|
||||||
@@ -2720,11 +3308,34 @@ function attackPreconditions(state: GameState): string | null {
|
|||||||
function castingBlocked(state: GameState, playerId: PlayerId): string | null {
|
function castingBlocked(state: GameState, playerId: PlayerId): string | null {
|
||||||
if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa";
|
if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa";
|
||||||
if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast";
|
if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast";
|
||||||
|
if (sustainedOn(state, playerId, "idiot").length > 0) return "What am I doing here...? (you can do nothing but head for your treasure)";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** IDIOT: the victim's moves are steered toward their nearest own treasure. */
|
||||||
|
function idiotSteer(state: GameState, p: PlayerState): Side | null {
|
||||||
|
const targets = state.treasures.filter((t) => t.owner === p.id && t.position);
|
||||||
|
if (targets.length === 0) return null;
|
||||||
|
const view = boardView(state);
|
||||||
|
let best: { side: Side; dist: number } | null = null;
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const step = stepTarget(view, p.position, side);
|
||||||
|
if (step.kind === "blocked") continue;
|
||||||
|
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
|
||||||
|
for (const t of targets) {
|
||||||
|
const d = walkingDistance(state, step.to, t.position!);
|
||||||
|
if (best === null || d < best.dist) best = { side, dist: d };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.side ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
|
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
|
||||||
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
|
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
|
||||||
|
if (sustainedOn(state, target.id, "big-man").length > 0 &&
|
||||||
|
cellKey(attacker.position) === cellKey(target.position)) {
|
||||||
|
return "he fills the corridor — there is no room to swing";
|
||||||
|
}
|
||||||
if (inThornbush(state, attacker)) return "you cannot attack from inside a thornbush";
|
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 (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, attacker.id)) return "you are mist — you may not attack";
|
||||||
@@ -2782,6 +3393,7 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
|
|||||||
numberValue: null,
|
numberValue: null,
|
||||||
amplifyFactor: 1,
|
amplifyFactor: 1,
|
||||||
extendFactor: 1,
|
extendFactor: 1,
|
||||||
|
powerAttackPoints: 0,
|
||||||
params: null,
|
params: null,
|
||||||
kind: "physical",
|
kind: "physical",
|
||||||
counters: [],
|
counters: [],
|
||||||
@@ -2800,6 +3412,8 @@ interface CastConsumables {
|
|||||||
add: CardInstance | null;
|
add: CardInstance | null;
|
||||||
extend: CardInstance | null;
|
extend: CardInstance | null;
|
||||||
aroundCorner: CardInstance | null;
|
aroundCorner: CardInstance | null;
|
||||||
|
powerAttack: CardInstance | null;
|
||||||
|
powerAttackPoints: number;
|
||||||
magnitude: Magnitude;
|
magnitude: Magnitude;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2855,6 +3469,18 @@ function gatherModifiers(
|
|||||||
aroundCorner = c;
|
aroundCorner = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let powerAttack: CardInstance | null = null;
|
||||||
|
let powerAttackPoints = 0;
|
||||||
|
if (cmd.powerAttackInstanceId) {
|
||||||
|
const c = find(cmd.powerAttackInstanceId);
|
||||||
|
if (!c || c.cardId !== "power-attack") return "POWER ATTACK card not in hand";
|
||||||
|
const pts = cmd.powerAttackPoints ?? 0;
|
||||||
|
if (!Number.isInteger(pts) || pts < 1) return "choose how many life points to burn";
|
||||||
|
if (pts >= caster.life) return "that would kill you";
|
||||||
|
powerAttack = c;
|
||||||
|
powerAttackPoints = pts;
|
||||||
|
}
|
||||||
|
|
||||||
// POWERSTONE: "Add 1 to any NUMBER card played."
|
// POWERSTONE: "Add 1 to any NUMBER card played."
|
||||||
const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0;
|
const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0;
|
||||||
const sum = numbers.length > 0
|
const sum = numbers.length > 0
|
||||||
@@ -2868,6 +3494,8 @@ function gatherModifiers(
|
|||||||
add,
|
add,
|
||||||
extend,
|
extend,
|
||||||
aroundCorner,
|
aroundCorner,
|
||||||
|
powerAttack,
|
||||||
|
powerAttackPoints,
|
||||||
magnitude: {
|
magnitude: {
|
||||||
numberValue: sum,
|
numberValue: sum,
|
||||||
power: (sum ?? 1) * amp,
|
power: (sum ?? 1) * amp,
|
||||||
@@ -2894,7 +3522,7 @@ function consumeCast(
|
|||||||
takeFromHand(caster, card.instanceId);
|
takeFromHand(caster, card.instanceId);
|
||||||
state.discard.push(card);
|
state.discard.push(card);
|
||||||
}
|
}
|
||||||
for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend, mods.aroundCorner]) {
|
for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend, mods.aroundCorner, mods.powerAttack]) {
|
||||||
if (!c) continue;
|
if (!c) continue;
|
||||||
takeFromHand(caster, c.instanceId);
|
takeFromHand(caster, c.instanceId);
|
||||||
state.discard.push(c);
|
state.discard.push(c);
|
||||||
@@ -3052,6 +3680,10 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
const werr = spendWandCharge(state, caster, wandEvents);
|
const werr = spendWandCharge(state, caster, wandEvents);
|
||||||
if (werr) return err(werr);
|
if (werr) return err(werr);
|
||||||
}
|
}
|
||||||
|
if (mods.powerAttackPoints > 0) {
|
||||||
|
caster.life -= mods.powerAttackPoints;
|
||||||
|
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
|
||||||
|
}
|
||||||
|
|
||||||
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
|
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
|
||||||
// go intended distance" — if the die disagrees with the true direction,
|
// go intended distance" — if the die disagrees with the true direction,
|
||||||
@@ -3091,6 +3723,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
numberValue: mods.magnitude.numberValue,
|
numberValue: mods.magnitude.numberValue,
|
||||||
amplifyFactor: 2 ** mods.amplifies.length,
|
amplifyFactor: 2 ** mods.amplifies.length,
|
||||||
extendFactor: mods.extend ? 2 : 1,
|
extendFactor: mods.extend ? 2 : 1,
|
||||||
|
powerAttackPoints: mods.powerAttackPoints,
|
||||||
params: cmd.params ?? null,
|
params: cmd.params ?? null,
|
||||||
kind: effect.physical ? "physical" : "spell",
|
kind: effect.physical ? "physical" : "spell",
|
||||||
counters: [],
|
counters: [],
|
||||||
@@ -3116,6 +3749,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
numberValue: mods.magnitude.numberValue,
|
numberValue: mods.magnitude.numberValue,
|
||||||
amplifyFactor: 2 ** mods.amplifies.length,
|
amplifyFactor: 2 ** mods.amplifies.length,
|
||||||
extendFactor: mods.extend ? 2 : 1,
|
extendFactor: mods.extend ? 2 : 1,
|
||||||
|
powerAttackPoints: mods.powerAttackPoints,
|
||||||
params: cmd.params ?? null,
|
params: cmd.params ?? null,
|
||||||
kind: effect.physical ? "physical" : "spell",
|
kind: effect.physical ? "physical" : "spell",
|
||||||
counters: [],
|
counters: [],
|
||||||
@@ -3334,6 +3968,22 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
base = (stack.numberValue ?? 1) * defender.hand.filter((c) => isMagicStone(c.cardId)).length;
|
base = (stack.numberValue ?? 1) * defender.hand.filter((c) => isMagicStone(c.cardId)).length;
|
||||||
}
|
}
|
||||||
base *= stack.amplifyFactor;
|
base *= stack.amplifyFactor;
|
||||||
|
base += stack.powerAttackPoints;
|
||||||
|
// SWARTHMORE'S ENCHANTMENT: an enchanted thrown object bites one deeper.
|
||||||
|
if (stack.attackCard && state.enchantedObjects[stack.attackCard.instanceId]) {
|
||||||
|
base += 1;
|
||||||
|
}
|
||||||
|
// STRENGTH: "Doubles all physical damage you do to others."
|
||||||
|
if (stack.kind === "physical" && sustainedOn(state, attacker.id, "strength").length > 0) {
|
||||||
|
base *= 2;
|
||||||
|
}
|
||||||
|
// WEAKNESS: "takes two times normal damage from any point-type spells or
|
||||||
|
// physical attacks" (Strength and Weakness cancel each other).
|
||||||
|
{
|
||||||
|
const weak = sustainedOn(state, defender.id, "weakness").length;
|
||||||
|
const strong = sustainedOn(state, defender.id, "strength").length;
|
||||||
|
if (weak > 0 && strong === 0) base *= 2;
|
||||||
|
}
|
||||||
const baseDuration = effect?.sustains
|
const baseDuration = effect?.sustains
|
||||||
? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor
|
? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor
|
||||||
: 0;
|
: 0;
|
||||||
@@ -3387,6 +4037,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
if (pipe.reflectedDamage > 0) {
|
if (pipe.reflectedDamage > 0) {
|
||||||
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
|
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
|
||||||
}
|
}
|
||||||
|
// EMPATHY: "Any attack done in any form against you acts against both
|
||||||
|
// you and the caster of the spell."
|
||||||
|
if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) {
|
||||||
|
applyDamage(state, events, attacker, damageDealt, `${attackId ?? "punch"} (empathy)`, defender.id, pipe.kind);
|
||||||
|
}
|
||||||
// SHADOWSTONE: physical damage you deal feeds your life total.
|
// SHADOWSTONE: physical damage you deal feeds your life total.
|
||||||
if (damageDealt > 0 && pipe.kind === "physical" && displays(attacker, "shadowstone") && attacker.alive) {
|
if (damageDealt > 0 && pipe.kind === "physical" && displays(attacker, "shadowstone") && attacker.alive) {
|
||||||
attacker.life += damageDealt;
|
attacker.life += damageDealt;
|
||||||
@@ -3541,6 +4196,7 @@ function doPickUpTreasure(prev: GameState): CommandResult {
|
|||||||
const state = clone(prev);
|
const state = clone(prev);
|
||||||
const p = activePlayer(state);
|
const p = activePlayer(state);
|
||||||
if (p.carriedTreasureId) return err("you can only carry one treasure at a time");
|
if (p.carriedTreasureId) return err("you can only carry one treasure at a time");
|
||||||
|
if (sustainedOn(state, p.id, "weakness").length > 0) return err("you are too weak to carry treasure");
|
||||||
const here = cellKey(p.position);
|
const here = cellKey(p.position);
|
||||||
if (state.gluedCells[here]) return err("it is glued fast to the floor");
|
if (state.gluedCells[here]) return err("it is glued fast to the floor");
|
||||||
const safe = state.squareContents[here]?.kind === "safe";
|
const safe = state.squareContents[here]?.kind === "safe";
|
||||||
@@ -3556,11 +4212,24 @@ function doPickUpTreasure(prev: GameState): CommandResult {
|
|||||||
t.position = null;
|
t.position = null;
|
||||||
p.carriedTreasureId = t.id;
|
p.carriedTreasureId = t.id;
|
||||||
state.turn.actionsEnded = true;
|
state.turn.actionsEnded = true;
|
||||||
return {
|
const events: GameEvent[] = [
|
||||||
ok: true,
|
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
|
||||||
state,
|
];
|
||||||
events: [{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }],
|
// WARD: the treasure's owner may have trapped it. (Simplification: springs
|
||||||
};
|
// automatically whenever the owner holds the card.)
|
||||||
|
const owner = state.players.find((q) => q.id === t.owner);
|
||||||
|
if (owner && owner.alive && owner.id !== p.id) {
|
||||||
|
const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward");
|
||||||
|
if (wardIdx !== -1) {
|
||||||
|
const [card] = owner.hand.splice(wardIdx, 1);
|
||||||
|
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
|
||||||
|
state.discard.push(card!);
|
||||||
|
events.push({ type: "wardSprung", owner: owner.id, victim: p.id });
|
||||||
|
applyDamage(state, events, p, 3, "warded treasure", null);
|
||||||
|
checkVictory(state, events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
|
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
|
||||||
@@ -3838,6 +4507,15 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
|||||||
events.push({ type: "trapSprung", player: p.id });
|
events.push({ type: "trapSprung", player: p.id });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (card.cardId === "gift-from-below") {
|
||||||
|
// "You lose 3 points to magical damage, now ... then discard and redraw."
|
||||||
|
state.discard.push(card);
|
||||||
|
events.push({ type: "trapSprung", player: p.id });
|
||||||
|
applyDamage(state, events, p, 3, "gift from below", null);
|
||||||
|
checkVictory(state, events);
|
||||||
|
if (!p.alive) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
drawn.push(card);
|
drawn.push(card);
|
||||||
toDraw--;
|
toDraw--;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface GameView {
|
|||||||
wandCharges: Record<string, number>;
|
wandCharges: Record<string, number>;
|
||||||
/** Boobytrap tokens: everyone sees the four; only the caster sees which is real. */
|
/** Boobytrap tokens: everyone sees the four; only the caster sees which is real. */
|
||||||
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
|
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
|
||||||
|
dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[];
|
||||||
|
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||||
@@ -111,6 +113,8 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
knownIllusionEdges,
|
knownIllusionEdges,
|
||||||
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
|
||||||
wandCharges: { ...state.wandCharges },
|
wandCharges: { ...state.wandCharges },
|
||||||
|
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
|
||||||
|
outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null,
|
||||||
boobytraps: state.boobytraps.map((t) => {
|
boobytraps: state.boobytraps.map((t) => {
|
||||||
const [rx, ry] = t.realKey.split(",").map(Number) as [number, number];
|
const [rx, ry] = t.realKey.split(",").map(Number) as [number, number];
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ describe("stack discipline", () => {
|
|||||||
it("unimplemented cards refuse to cast with a clear error", () => {
|
it("unimplemented cards refuse to cast with a clear error", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
const caster = activePlayer(state);
|
const caster = activePlayer(state);
|
||||||
const card = giveCard(state, caster.id, "chaos"); // expansion1, unimplemented
|
const card = giveCard(state, caster.id, "thumb-of-god"); // awaiting digital redesign
|
||||||
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
|
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (!result.ok) expect(result.error).toMatch(/not implemented/);
|
if (!result.ok) expect(result.error).toMatch(/not implemented/);
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyCommand,
|
||||||
|
activePlayer,
|
||||||
|
createGame,
|
||||||
|
sustainedOn,
|
||||||
|
type Command,
|
||||||
|
type GameState,
|
||||||
|
type PlayerId,
|
||||||
|
} from "../src/game";
|
||||||
|
import { cellKey } from "../src/board";
|
||||||
|
import type { CardInstance } from "../src/cards";
|
||||||
|
|
||||||
|
function newGame(seed = 42) {
|
||||||
|
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function must(state: GameState, player: PlayerId, command: Command): GameState {
|
||||||
|
const result = applyCommand(state, player, command);
|
||||||
|
if (!result.ok) throw new Error(`command failed: ${result.error}`);
|
||||||
|
return result.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
|
||||||
|
const p = state.players.find((p) => p.id === playerId)!;
|
||||||
|
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
||||||
|
p.hand[slot] = instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRound2(state: GameState): GameState {
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||||
|
defender.position = { ...attacker.position };
|
||||||
|
return { attacker: attacker.id, defender: defender.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function castAt(
|
||||||
|
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
|
||||||
|
extra: Partial<Extract<Command, { type: "cast" }>> = {},
|
||||||
|
): GameState {
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: card.instanceId,
|
||||||
|
target: { kind: "player", playerId: defender }, ...extra,
|
||||||
|
});
|
||||||
|
return must(state, defender, { type: "pass" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("expansion combat cards", () => {
|
||||||
|
it("power attack burns life for extra damage", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const fb = giveCard(state, attacker, "fireball");
|
||||||
|
giveCard(state, attacker, "power-attack", "PA", 1);
|
||||||
|
state = castAt(state, attacker, defender, fb, {
|
||||||
|
powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 3,
|
||||||
|
});
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(7); // 5+3
|
||||||
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weakness doubles damage taken and forbids carrying treasure", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const wk = giveCard(state, attacker, "weakness");
|
||||||
|
giveCard(state, attacker, "number-3", "N", 1);
|
||||||
|
state = castAt(state, attacker, defender, wk, { numberInstanceIds: ["number-3#N"] });
|
||||||
|
expect(sustainedOn(state, defender, "weakness").length).toBe(1);
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
const t = state.treasures.find((t) => t.owner === attacker && t.position)!;
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
d.position = { ...t.position! };
|
||||||
|
expect(applyCommand(state, defender, { type: "pickUpTreasure" }).ok).toBe(false);
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
||||||
|
const d2 = state.players.find((p) => p.id === defender)!;
|
||||||
|
d2.position = { ...state.players.find((p) => p.id === attacker)!.position };
|
||||||
|
state = castAt(state, attacker, defender, fb);
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5x2
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walking dead bleeds half a point per space walked", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const wd = giveCard(state, attacker, "walking-dead");
|
||||||
|
state = castAt(state, attacker, defender, wd);
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
// Defender walks: every second step costs a point.
|
||||||
|
let lifeStart = state.players.find((p) => p.id === defender)!.life;
|
||||||
|
let steps = 0;
|
||||||
|
for (const dir of ["N", "S", "E", "W", "N", "S"] as const) {
|
||||||
|
const r = applyCommand(state, defender, { type: "move", direction: dir });
|
||||||
|
if (r.ok) { state = r.state; steps++; }
|
||||||
|
if (steps === 2) break;
|
||||||
|
}
|
||||||
|
if (steps === 2) {
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mental swap trades entire hands", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const ms = giveCard(state, attacker, "mental-swap");
|
||||||
|
const aCards = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId);
|
||||||
|
const dCards = state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId);
|
||||||
|
state = castAt(state, attacker, defender, ms);
|
||||||
|
const aAfter = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId);
|
||||||
|
expect(aAfter).toEqual(dCards);
|
||||||
|
// (the swap card itself was consumed from the attacker's hand pre-swap)
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId))
|
||||||
|
.toEqual(aCards.filter((id) => id !== ms.instanceId));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("butt-head rams for the distance charged", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||||
|
// Stand them 3 apart on the same column if possible; else same square +N.
|
||||||
|
defender.position = { x: attacker.position.x, y: attacker.position.y >= 3 ? attacker.position.y - 3 : attacker.position.y + 3 };
|
||||||
|
const bh = giveCard(state, attacker.id, "butt-head");
|
||||||
|
state = castAt(state, attacker.id, defender.id, bh);
|
||||||
|
const a = state.players.find((p) => p.id === attacker.id)!;
|
||||||
|
const d = state.players.find((p) => p.id === defender.id)!;
|
||||||
|
expect(cellKey(a.position)).toBe(cellKey(d.position));
|
||||||
|
expect(d.life).toBe(12); // 3 spaces = 3 damage
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empathy turns an attack back on its caster as well", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
// Defender raises empathy on their own turn.
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
const em = giveCard(state, defender, "empathy", "E", 0);
|
||||||
|
giveCard(state, defender, "number-3", "N", 1);
|
||||||
|
state = must(state, defender, {
|
||||||
|
type: "cast", instanceId: em.instanceId, numberInstanceIds: ["number-3#N"],
|
||||||
|
});
|
||||||
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||||
|
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
||||||
|
state = castAt(state, attacker, defender, fb);
|
||||||
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
|
||||||
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ward springs when a trapped treasure is grabbed", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
||||||
|
giveCard(state, enemy.id, "ward", "W", 0);
|
||||||
|
const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!;
|
||||||
|
me.position = { ...treasure.position! };
|
||||||
|
state = must(state, me.id, { type: "pickUpTreasure" });
|
||||||
|
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
|
||||||
|
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opportunity fire opens an out-of-turn attack window", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const active = activePlayer(state).id;
|
||||||
|
const lurker = state.players.find((p) => p.id !== active)!;
|
||||||
|
lurker.position = { ...state.players.find((p) => p.id === active)!.position };
|
||||||
|
const of_ = giveCard(state, lurker.id, "opportunity-fire", "OF", 0);
|
||||||
|
const fb = giveCard(state, lurker.id, "fireball", "F", 1);
|
||||||
|
// Out of turn: play opportunity fire, then the attack.
|
||||||
|
state = must(state, lurker.id, { type: "cast", instanceId: of_.instanceId });
|
||||||
|
expect(state.outOfTurnWindow?.playerId).toBe(lurker.id);
|
||||||
|
state = must(state, lurker.id, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: active },
|
||||||
|
});
|
||||||
|
state = must(state, active, { type: "pass" });
|
||||||
|
expect(state.players.find((p) => p.id === active)!.life).toBe(10);
|
||||||
|
// Turn structure is intact: the original player is still active.
|
||||||
|
expect(activePlayer(state).id).toBe(active);
|
||||||
|
expect(state.outOfTurnWindow).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idiot marches its victim toward their own treasure and forbids casting", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const id = giveCard(state, attacker, "idiot");
|
||||||
|
state = castAt(state, attacker, defender, id);
|
||||||
|
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
||||||
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||||
|
// Casting is refused; moving is steered (any direction request works).
|
||||||
|
const fb = giveCard(state, defender, "fireball", "F", 0);
|
||||||
|
expect(applyCommand(state, defender, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
|
||||||
|
}).ok).toBe(false);
|
||||||
|
const before = state.players.find((p) => p.id === defender)!.position;
|
||||||
|
const r = applyCommand(state, defender, { type: "move", direction: "N" });
|
||||||
|
if (r.ok) {
|
||||||
|
const after = r.state.players.find((p) => p.id === defender)!.position;
|
||||||
|
expect(cellKey(after)).not.toBe(cellKey(before));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,13 +55,15 @@
|
|||||||
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
|
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
|
||||||
"killer-ooze", "rosebush", "dust-cloud", "fill-square-with-slime", "create-pit",
|
"killer-ooze", "rosebush", "dust-cloud", "fill-square-with-slime", "create-pit",
|
||||||
"handful-of-tacks", "glue", "safe", "trader", "stone-to-water", "boobytrap",
|
"handful-of-tacks", "glue", "safe", "trader", "stone-to-water", "boobytrap",
|
||||||
|
"dimensional-warp", "redirection",
|
||||||
]);
|
]);
|
||||||
|
const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]);
|
||||||
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
|
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
|
||||||
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
|
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
|
||||||
const SELF_CARDS = new Set([
|
const SELF_CARDS = new Set([
|
||||||
"invisible", "shrink", "mist-body",
|
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
|
||||||
]);
|
]);
|
||||||
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu"]);
|
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "swap-meet", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
|
||||||
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
|
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
|
||||||
const cellSelectMode = $derived(
|
const cellSelectMode = $derived(
|
||||||
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
|
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
|
||||||
@@ -142,6 +144,7 @@
|
|||||||
// duration self-spells wait so a number card can be attached.
|
// duration self-spells wait so a number card can be attached.
|
||||||
const INSTANT = new Set([
|
const INSTANT = new Set([
|
||||||
"speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash",
|
"speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash",
|
||||||
|
"gift-from-above", "chaos", "interrupt", "opportunity-fire",
|
||||||
"bloodstone", "brainstone", "powerstone", "shadowstone",
|
"bloodstone", "brainstone", "powerstone", "shadowstone",
|
||||||
"shieldstone", "soulstone", "speedstone", "visionstone",
|
"shieldstone", "soulstone", "speedstone", "visionstone",
|
||||||
]);
|
]);
|
||||||
@@ -197,7 +200,7 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard?.cardId === "trader") {
|
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
|
||||||
if (!tradeFrom) { tradeFrom = cell; return; }
|
if (!tradeFrom) { tradeFrom = cell; return; }
|
||||||
net.command({
|
net.command({
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
type: "cast", instanceId: selectedCard.instanceId,
|
||||||
@@ -485,8 +488,11 @@
|
|||||||
{#if selectedCard?.cardId === "boobytrap"}
|
{#if selectedCard?.cardId === "boobytrap"}
|
||||||
— place 4 tokens ({trapCells.length}/4; the FIRST is the real trap)
|
— place 4 tokens ({trapCells.length}/4; the FIRST is the real trap)
|
||||||
{/if}
|
{/if}
|
||||||
{#if selectedCard?.cardId === "trader"}
|
{#if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)}
|
||||||
{tradeFrom ? "— now the second item square" : "— click the first item square"}
|
{tradeFrom ? "— now the second square" : "— click the first square"}
|
||||||
|
{/if}
|
||||||
|
{#if selectedCard?.cardId === "power-attack"}
|
||||||
|
(select an attack first, then attach Power Attack via number input)
|
||||||
{/if}
|
{/if}
|
||||||
{#if selectedCard?.cardId === "relocate-sector"}
|
{#if selectedCard?.cardId === "relocate-sector"}
|
||||||
{#if pendingSectorFrom}
|
{#if pendingSectorFrom}
|
||||||
@@ -543,6 +549,17 @@
|
|||||||
{#if discardSelection.size > 0}
|
{#if discardSelection.size > 0}
|
||||||
<button onclick={doDiscard}>Discard {discardSelection.size} selected</button>
|
<button onclick={doDiscard}>Discard {discardSelection.size} selected</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if isYourTurn && view.dimWarps.some((w) => {
|
||||||
|
const me = view.players.find((p) => p.id === view.you)!;
|
||||||
|
return (w.a.x === me.position.x && w.a.y === me.position.y) ||
|
||||||
|
(w.b.x === me.position.x && w.b.y === me.position.y);
|
||||||
|
})}
|
||||||
|
<button onclick={() => net.command({ type: "warpStep" })}>Step through the warp</button>
|
||||||
|
{/if}
|
||||||
|
{#if view.outOfTurnWindow?.playerId === view.you}
|
||||||
|
<div class="banner respond">Your interruption! Cast your spell (or pass).</div>
|
||||||
|
<button onclick={pass}>Pass (waste it)</button>
|
||||||
|
{/if}
|
||||||
{#if isYourTurn}
|
{#if isYourTurn}
|
||||||
<button onclick={pickUp}>Pick up treasure</button>
|
<button onclick={pickUp}>Pick up treasure</button>
|
||||||
<button onclick={drop}>Drop treasure</button>
|
<button onclick={drop}>Drop treasure</button>
|
||||||
|
|||||||
@@ -128,6 +128,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- dimensional warp tokens -->
|
||||||
|
{#each view.dimWarps as w, wi (wi)}
|
||||||
|
{#each [w.a, w.b] as tok, i (i)}
|
||||||
|
<circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3}
|
||||||
|
class="dimwarp" />
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
|
||||||
<!-- boobytrap tokens: face-down for everyone (the caster knows the real one) -->
|
<!-- boobytrap tokens: face-down for everyone (the caster knows the real one) -->
|
||||||
{#each view.boobytraps as trap, ti (ti)}
|
{#each view.boobytraps as trap, ti (ti)}
|
||||||
{#each trap.cells as tc, i (i)}
|
{#each trap.cells as tc, i (i)}
|
||||||
@@ -274,6 +282,7 @@
|
|||||||
.safe { fill: #7d8894; stroke: #2f3844; stroke-width: 2; }
|
.safe { fill: #7d8894; stroke: #2f3844; stroke-width: 2; }
|
||||||
.trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
|
.trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
|
||||||
.trap-real { stroke: #d3352b; stroke-width: 2.5; }
|
.trap-real { stroke: #d3352b; stroke-width: 2.5; }
|
||||||
|
.dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; }
|
||||||
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
|
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
|
||||||
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
|
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
|
||||||
.creature { cursor: pointer; }
|
.creature { cursor: pointer; }
|
||||||
|
|||||||
@@ -78,6 +78,42 @@ function humanize(e: GameEvent): string | null {
|
|||||||
case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`;
|
case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`;
|
||||||
case "impScorches": return `The fire imp scorches ${e.player}!`;
|
case "impScorches": return `The fire imp scorches ${e.player}!`;
|
||||||
case "monsterBoosted": return `The monster GROWS — its ${e.boost} doubles!`;
|
case "monsterBoosted": return `The monster GROWS — its ${e.boost} doubles!`;
|
||||||
|
case "wandCharged": return `${e.player} charges a wand (${e.charges} charges).`;
|
||||||
|
case "wandUsed": return e.chargesLeft > 0 ? `The wand crackles (${e.chargesLeft} left).` : null;
|
||||||
|
case "wandExhausted": return `${e.player}'s wand crumbles to dust.`;
|
||||||
|
case "wallWarpedOpen": return `A section of wall shimmers out of existence!`;
|
||||||
|
case "wallsWarpedBack": return `The warped wall snaps back into place.`;
|
||||||
|
case "shoved": return `${e.player} is shoved bodily by ${e.by}!`;
|
||||||
|
case "webbed": return `${e.player} is tangled in sticky webs!`;
|
||||||
|
case "cardRetrieved": return `${e.player} plucks a card from the discard pile.`;
|
||||||
|
case "slippedInOoze": return `${e.player} slips flat on their face in the ooze!`;
|
||||||
|
case "struggledInOoze": return e.stood ? `${e.player} staggers upright.` : `${e.player} flounders in the ooze.`;
|
||||||
|
case "steppedOnTacks": return `${e.player} steps on tacks! OW OW OW.`;
|
||||||
|
case "jumpedPit": return `${e.player} leaps the pit!`;
|
||||||
|
case "fellInPit": return `${e.player} misjudges the jump and plummets in!`;
|
||||||
|
case "climbedFromPit": return e.success ? `${e.player} hauls themselves out of the pit.` : `${e.player} scrabbles at the pit walls in vain.`;
|
||||||
|
case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`;
|
||||||
|
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
|
||||||
|
case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`;
|
||||||
|
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
|
||||||
|
case "safeCreated": return `A massive safe slams down around the loot.`;
|
||||||
|
case "safeOpened": return null;
|
||||||
|
case "itemsTraded": return `Two items blink and trade places.`;
|
||||||
|
case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`;
|
||||||
|
case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`;
|
||||||
|
case "handsScrambled": return `CHAOS! Every hand is thrown in a pile and redealt!`;
|
||||||
|
case "rammed": return `BAAA! ${e.attacker} turns into a goat and rams ${e.target} (${e.distance} spaces)!`;
|
||||||
|
case "treasureThrown": return `${e.attacker} HURLS their treasure (${e.distance} spaces)!`;
|
||||||
|
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
|
||||||
|
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
|
||||||
|
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
|
||||||
|
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
|
||||||
|
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
|
||||||
|
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
|
||||||
|
case "warpTokensPlaced": return `Two dimensional warp tokens hum to life.`;
|
||||||
|
case "warpStepped": return `${e.player} steps through the dimensional warp!`;
|
||||||
|
case "exitsRedirected": return `The maze's outer exits twist and reconnect!`;
|
||||||
|
case "outOfTurnWindow": return `${e.player} interrupts the flow of time (${e.kind === "interrupt" ? "Interrupt" : "Opportunity Fire"})!`;
|
||||||
case "trapRedrawnDuringDeal": return null;
|
case "trapRedrawnDuringDeal": return null;
|
||||||
case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
|
case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
|
||||||
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;
|
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;
|
||||||
|
|||||||
Reference in New Issue
Block a user