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). */
|
||||
amplifyFactor: number;
|
||||
extendFactor: number;
|
||||
/** POWER ATTACK: extra damage bought with the caster's life. */
|
||||
powerAttackPoints: number;
|
||||
params: CastParams | null;
|
||||
kind: "spell" | "physical";
|
||||
counters: { player: PlayerId; card: CardInstance; nullified: boolean }[];
|
||||
@@ -203,6 +205,12 @@ export interface GameState {
|
||||
gluedCells: Record<string, true>;
|
||||
/** SAFE cells unlocked until end of turn (lock cards / the creator). */
|
||||
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[];
|
||||
treasures: TreasureState[];
|
||||
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)) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -459,6 +471,20 @@ export type GameEvent =
|
||||
| { type: "safeOpened"; player: PlayerId; at: Cell }
|
||||
| { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell }
|
||||
| { 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: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
|
||||
| { type: "doorsRelocked"; count: number }
|
||||
@@ -499,6 +525,7 @@ export type Command =
|
||||
| { type: "move"; direction: Side }
|
||||
| { type: "playNumberForMovement"; instanceId: string }
|
||||
| { type: "punch"; targetId: PlayerId }
|
||||
| { type: "warpStep" }
|
||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||
| { type: "creatureAttack"; creatureId: string; targetId: string }
|
||||
| {
|
||||
@@ -516,6 +543,9 @@ export type Command =
|
||||
extendInstanceId?: string;
|
||||
/** AROUND THE CORNER card attached (bends this cast's line of sight). */
|
||||
aroundCornerInstanceId?: string;
|
||||
/** POWER ATTACK card attached: burn life for extra damage. */
|
||||
powerAttackInstanceId?: string;
|
||||
powerAttackPoints?: number;
|
||||
target?: CastTarget;
|
||||
params?: CastParams;
|
||||
}
|
||||
@@ -1646,6 +1676,392 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
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": {
|
||||
kind: "neutral",
|
||||
// "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). */
|
||||
function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
|
||||
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) {
|
||||
const card = deck.shift();
|
||||
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);
|
||||
events.push({ type: "trapRedrawnDuringDeal", player: p.id });
|
||||
} else {
|
||||
@@ -2367,6 +2807,9 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
||||
boobytraps: [],
|
||||
gluedCells: {},
|
||||
openSafes: [],
|
||||
enchantedObjects: {},
|
||||
dimWarps: [],
|
||||
outOfTurnWindow: null,
|
||||
players,
|
||||
treasures,
|
||||
sustained: [],
|
||||
@@ -2423,12 +2866,82 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
|
||||
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) {
|
||||
case "move": return doMove(state, command.direction);
|
||||
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId);
|
||||
case "punch": return doPunch(state, command.targetId);
|
||||
case "warpStep": return doWarpStep(state);
|
||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
|
||||
case "cast": return doCast(state, command);
|
||||
@@ -2482,6 +2995,12 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
const p = activePlayer(state);
|
||||
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
|
||||
// move ... bumping into a wall counts as one space of movement."
|
||||
if (isBlinded(state, p)) {
|
||||
@@ -2557,6 +3076,25 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
// Square contents at the destination.
|
||||
let content = state.squareContents[cellKey(p.position)];
|
||||
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
|
||||
// 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++;
|
||||
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) {
|
||||
events.push({ type: "firewallBurned", player: p.id });
|
||||
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 });
|
||||
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.
|
||||
for (const trap of [...state.boobytraps]) {
|
||||
if (trap.casterId === p.id) continue;
|
||||
@@ -2667,6 +3237,24 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
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 {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
if (blocked) return err(blocked);
|
||||
@@ -2720,11 +3308,34 @@ function attackPreconditions(state: GameState): 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, "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;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
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, target)) return "you cannot attack someone in a thornbush";
|
||||
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,
|
||||
amplifyFactor: 1,
|
||||
extendFactor: 1,
|
||||
powerAttackPoints: 0,
|
||||
params: null,
|
||||
kind: "physical",
|
||||
counters: [],
|
||||
@@ -2800,6 +3412,8 @@ interface CastConsumables {
|
||||
add: CardInstance | null;
|
||||
extend: CardInstance | null;
|
||||
aroundCorner: CardInstance | null;
|
||||
powerAttack: CardInstance | null;
|
||||
powerAttackPoints: number;
|
||||
magnitude: Magnitude;
|
||||
}
|
||||
|
||||
@@ -2855,6 +3469,18 @@ function gatherModifiers(
|
||||
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."
|
||||
const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0;
|
||||
const sum = numbers.length > 0
|
||||
@@ -2868,6 +3494,8 @@ function gatherModifiers(
|
||||
add,
|
||||
extend,
|
||||
aroundCorner,
|
||||
powerAttack,
|
||||
powerAttackPoints,
|
||||
magnitude: {
|
||||
numberValue: sum,
|
||||
power: (sum ?? 1) * amp,
|
||||
@@ -2894,7 +3522,7 @@ function consumeCast(
|
||||
takeFromHand(caster, card.instanceId);
|
||||
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;
|
||||
takeFromHand(caster, c.instanceId);
|
||||
state.discard.push(c);
|
||||
@@ -3052,6 +3680,10 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const werr = spendWandCharge(state, caster, wandEvents);
|
||||
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
|
||||
// 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,
|
||||
amplifyFactor: 2 ** mods.amplifies.length,
|
||||
extendFactor: mods.extend ? 2 : 1,
|
||||
powerAttackPoints: mods.powerAttackPoints,
|
||||
params: cmd.params ?? null,
|
||||
kind: effect.physical ? "physical" : "spell",
|
||||
counters: [],
|
||||
@@ -3116,6 +3749,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
numberValue: mods.magnitude.numberValue,
|
||||
amplifyFactor: 2 ** mods.amplifies.length,
|
||||
extendFactor: mods.extend ? 2 : 1,
|
||||
powerAttackPoints: mods.powerAttackPoints,
|
||||
params: cmd.params ?? null,
|
||||
kind: effect.physical ? "physical" : "spell",
|
||||
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.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
|
||||
? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor
|
||||
: 0;
|
||||
@@ -3387,6 +4037,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
if (pipe.reflectedDamage > 0) {
|
||||
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.
|
||||
if (damageDealt > 0 && pipe.kind === "physical" && displays(attacker, "shadowstone") && attacker.alive) {
|
||||
attacker.life += damageDealt;
|
||||
@@ -3541,6 +4196,7 @@ function doPickUpTreasure(prev: GameState): CommandResult {
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
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);
|
||||
if (state.gluedCells[here]) return err("it is glued fast to the floor");
|
||||
const safe = state.squareContents[here]?.kind === "safe";
|
||||
@@ -3556,11 +4212,24 @@ function doPickUpTreasure(prev: GameState): CommandResult {
|
||||
t.position = null;
|
||||
p.carriedTreasureId = t.id;
|
||||
state.turn.actionsEnded = true;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }],
|
||||
};
|
||||
const events: GameEvent[] = [
|
||||
{ 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 {
|
||||
@@ -3838,6 +4507,15 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
||||
events.push({ type: "trapSprung", player: p.id });
|
||||
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);
|
||||
toDraw--;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user