Card wave 4: all eight magic stones and Slow Death
Stones are displayed permanents whose powers weave into the core systems: BLOODSTONE (-1 to every hit taken, cancels Slow Death's drip), SOULSTONE (last three points immune to spell damage — punches and daggers still finish the job), BRAINSTONE (+2 cards now, hand limit 9 — limits are now per-player and dynamic), POWERSTONE (+1 per number card played, movement included), SPEEDSTONE (+1 movement, overridden by SLOW), SHADOWSTONE (physical damage you deal feeds your life), SHIELDSTONE (number cards become counteractions that shave their value off damage AND duration), VISIONSTONE (LOS through any single wall or door, at the holder's option). Damage now carries a spell/physical kind everywhere. SLOW DEATH is a permanent curse: one magical point per card drawn, forever — Fireball still burns stones off their display. Re-displaying a stone is refused (no Brainstone double-draws). 49 basic-set cards implemented, 15 remain (the hard five: bent LOS, Blind, Illusion Wall, Ugly, sector manipulation). 72 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2d88b4ab40
commit
4ab44cc535
+151
-15
@@ -180,6 +180,26 @@ export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
||||
return hasLineOfSight(boardView(state), from, to, blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* LOS for a caster: VISIONSTONE lets its holder see through exactly one
|
||||
* wall or door (of any type), at their option.
|
||||
*/
|
||||
function casterLos(state: GameState, caster: PlayerState, from: Cell, to: Cell): boolean {
|
||||
if (gameLos(state, from, to)) return true;
|
||||
if (!displays(caster, "visionstone")) return false;
|
||||
// Try ignoring each single blocking edge in turn.
|
||||
const view = boardView(state);
|
||||
const blockers: Record<string, true> = {};
|
||||
for (const key of Object.keys(state.squareContents)) blockers[key] = true;
|
||||
for (const key of Object.keys(view.edges)) {
|
||||
if ((view.edges[key] ?? "open") === "open") continue;
|
||||
const edges = { ...view.edges };
|
||||
delete edges[key];
|
||||
if (hasLineOfSight({ ...view, edges }, from, to, blockers)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function inThornbush(state: GameState, p: PlayerState): boolean {
|
||||
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
|
||||
}
|
||||
@@ -192,6 +212,16 @@ function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
|
||||
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
||||
}
|
||||
|
||||
/** Is a stone (or other displayable) face-up in front of this player? */
|
||||
export function displays(p: PlayerState, cardId: string): boolean {
|
||||
return p.hand.some((c) => c.cardId === cardId && p.displayed.includes(c.instanceId));
|
||||
}
|
||||
|
||||
/** BRAINSTONE: "Hand limit is now nine cards (including this card)." */
|
||||
export function handLimit(p: PlayerState): number {
|
||||
return displays(p, "brainstone") ? HAND_LIMIT + 2 : HAND_LIMIT;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events
|
||||
|
||||
@@ -332,6 +362,8 @@ type NeutralEffect = {
|
||||
kind: "neutral";
|
||||
/** Card stays in hand and is displayed (MASTER KEY). */
|
||||
keepInHand?: boolean;
|
||||
/** Displaying is a one-time action (magic stones): re-casting is an error. */
|
||||
displayOnce?: boolean;
|
||||
resolve: (
|
||||
state: GameState,
|
||||
events: GameEvent[],
|
||||
@@ -542,7 +574,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
ctx.attacker.hand.push(...stolen);
|
||||
ctx.events.push({ type: "cardsStolen", from: ctx.defender.id, to: ctx.attacker.id, count: stolen.length });
|
||||
ctx.events.push({ type: "cardsStolenPrivate", visibleTo: ctx.attacker.id, cards: stolen });
|
||||
if (ctx.attacker.hand.length > HAND_LIMIT) ctx.state.pendingDiscard = ctx.attacker.id;
|
||||
if (ctx.attacker.hand.length > handLimit(ctx.attacker)) ctx.state.pendingDiscard = ctx.attacker.id;
|
||||
},
|
||||
},
|
||||
telepath: {
|
||||
@@ -640,7 +672,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
for (const c of [cell, neighbor(cell, side)]) {
|
||||
for (const p of state.players) {
|
||||
if (p.alive && cellKey(p.position) === cellKey(c)) {
|
||||
applyDamage(state, events, p, 4, "collapsing wall", caster.id);
|
||||
applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -992,6 +1024,36 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
return "drag targets an object square or a player";
|
||||
},
|
||||
},
|
||||
// --- Magic stones ---------------------------------------------------------
|
||||
bloodstone: stoneEffect("bloodstone"),
|
||||
powerstone: stoneEffect("powerstone"),
|
||||
shadowstone: stoneEffect("shadowstone"),
|
||||
soulstone: stoneEffect("soulstone"),
|
||||
speedstone: stoneEffect("speedstone"),
|
||||
shieldstone: stoneEffect("shieldstone"),
|
||||
visionstone: stoneEffect("visionstone"),
|
||||
brainstone: stoneEffect("brainstone", (state, events, caster) => {
|
||||
// "Draw two more cards, now."
|
||||
const drawn: CardInstance[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const card = drawOne(state, events);
|
||||
if (card) drawn.push(card);
|
||||
}
|
||||
caster.hand.push(...drawn);
|
||||
events.push({ type: "cardsDrawn", player: caster.id, count: drawn.length });
|
||||
events.push({ type: "cardsDrawnPrivate", visibleTo: caster.id, cards: drawn });
|
||||
applySlowDeathOnDraw(state, events, caster, drawn.length);
|
||||
}),
|
||||
"slow-death": {
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
baseDamage: () => 0,
|
||||
// "This is permanent. Once SLOW DEATH is on, it can't be turned off."
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
|
||||
},
|
||||
},
|
||||
"reuse-spell": {
|
||||
kind: "neutral",
|
||||
// "You may retrieve any spell you use immediately after you use it (but
|
||||
@@ -1005,7 +1067,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const [card] = state.discard.splice(i, 1);
|
||||
caster.hand.push(card!);
|
||||
events.push({ type: "spellReused", player: caster.id, card: card! });
|
||||
if (caster.hand.length > HAND_LIMIT) state.pendingDiscard = caster.id;
|
||||
if (caster.hand.length > handLimit(caster)) state.pendingDiscard = caster.id;
|
||||
delete state.lastSpellUsed[caster.id];
|
||||
return null;
|
||||
}
|
||||
@@ -1015,6 +1077,20 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
},
|
||||
};
|
||||
|
||||
/** A displayable stone: casting it turns it face-up; its power is passive. */
|
||||
function stoneEffect(cardId: string, onDisplay?: (state: GameState, events: GameEvent[], caster: PlayerState) => void): NeutralEffect {
|
||||
void cardId;
|
||||
return {
|
||||
kind: "neutral",
|
||||
keepInHand: true,
|
||||
displayOnce: true,
|
||||
resolve: (state, events, caster) => {
|
||||
onDisplay?.(state, events, caster);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Thrown weapons land in the target's square, whatever the counters did. */
|
||||
function landThrownObject(ctx: ResolutionContext, cardId: string): void {
|
||||
const card = ctx.stack.attackCard!;
|
||||
@@ -1062,7 +1138,7 @@ function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Si
|
||||
const blockedSpaces = 2 - moved;
|
||||
events.push({ type: "washedBack", player: p.id, from, to: p.position, blockedSpaces });
|
||||
if (blockedSpaces > 0) {
|
||||
applyDamage(state, events, p, blockedSpaces, "waterwall crush", null);
|
||||
applyDamage(state, events, p, blockedSpaces, "waterwall crush", null, "physical");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1422,7 +1498,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
// point of physical damage from thorns."
|
||||
if (content?.kind === "thornbush" && p.alive) {
|
||||
events.push({ type: "enteredThornbush", player: p.id, at: p.position });
|
||||
applyDamage(state, events, p, 1, "thorns", null);
|
||||
applyDamage(state, events, p, 1, "thorns", null, "physical");
|
||||
p.lostTurns++;
|
||||
state.turn.actionsEnded = true;
|
||||
checkVictory(state, events);
|
||||
@@ -1446,7 +1522,7 @@ function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandRe
|
||||
if (!card) return err("card not in hand");
|
||||
if (!isNumberCard(card.cardId)) return err("not a number card");
|
||||
|
||||
const value = numberValue(card.cardId);
|
||||
const value = numberValue(card.cardId) + (displays(p, "powerstone") ? 1 : 0);
|
||||
state.discard.push(card);
|
||||
state.turn.movementAllowance += value;
|
||||
state.turn.numberPlayedForMovement = true;
|
||||
@@ -1585,7 +1661,11 @@ function gatherModifiers(
|
||||
extend = c;
|
||||
}
|
||||
|
||||
const sum = numbers.length > 0 ? numbers.reduce((t, c) => t + numberValue(c.cardId), 0) : null;
|
||||
// POWERSTONE: "Add 1 to any NUMBER card played."
|
||||
const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0;
|
||||
const sum = numbers.length > 0
|
||||
? numbers.reduce((t, c) => t + numberValue(c.cardId), 0) + stoneBonus
|
||||
: null;
|
||||
const amp = 2 ** amplifies.length;
|
||||
const ext = extend ? 2 : 1;
|
||||
return {
|
||||
@@ -1636,6 +1716,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (effect.kind === "counter") {
|
||||
return err(`${def.name} is a counteraction — play it in response to an attack`);
|
||||
}
|
||||
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
|
||||
return err(`${def.name} is already displayed`);
|
||||
}
|
||||
|
||||
// Physical actions (objects like MASTER KEY) are not spells; spells are
|
||||
// blocked by NO SPELL / MEDUSA.
|
||||
@@ -1662,7 +1745,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
}
|
||||
const statusBlock = attackBlockedByStatus(state, caster, target);
|
||||
if (statusBlock) return err(statusBlock);
|
||||
if (effect.requiresLos && !gameLos(state, caster.position, target.position)) {
|
||||
if (effect.requiresLos && !casterLos(state, caster, caster.position, target.position)) {
|
||||
return err("no line of sight to the target");
|
||||
}
|
||||
// Attacking someone breaks any BUDDY pact you swore to them.
|
||||
@@ -1778,10 +1861,26 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
|
||||
{ type: "attackResolved", attacker: stack.attackerId, defender: stack.defenderId, attackCardId: attackCard.cardId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false },
|
||||
];
|
||||
state.stack = null;
|
||||
if (player.hand.length > HAND_LIMIT) state.pendingDiscard = player.id;
|
||||
if (player.hand.length > handLimit(player)) state.pendingDiscard = player.id;
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
// SHIELDSTONE: "use a NUMBER card as a counteraction against point- or
|
||||
// duration-based spells, reducing effects by [its] value."
|
||||
if (isNumberCard(card.cardId)) {
|
||||
if (!displays(player, "shieldstone")) return err("only a displayed Shieldstone lets you counter with number cards");
|
||||
if (stack.kind !== "spell") return err("shieldstone counters spells, not physical attacks");
|
||||
takeFromHand(player, instanceId);
|
||||
state.discard.push(card);
|
||||
stack.counters.push({ player: playerId, card, nullified: false });
|
||||
stack.waitingOn = stack.attackerId;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
|
||||
};
|
||||
}
|
||||
|
||||
const isCounter = def.cardType === "counteraction" || def.cardType === "neutral/counteraction";
|
||||
if (!isCounter || !(card.cardId in CARD_EFFECTS) || CARD_EFFECTS[card.cardId]!.kind !== "counter") {
|
||||
return err(`${def.name} cannot counteract (or is not implemented yet)`);
|
||||
@@ -1880,6 +1979,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
};
|
||||
for (const counter of stack.counters) {
|
||||
if (counter.nullified) continue;
|
||||
if (isNumberCard(counter.card.cardId)) {
|
||||
// SHIELDSTONE number counter: reduce point AND duration effects.
|
||||
const v = numberValue(counter.card.cardId);
|
||||
pipe.damage = Math.max(0, pipe.damage - v);
|
||||
pipe.duration = Math.max(0, pipe.duration - v);
|
||||
continue;
|
||||
}
|
||||
const ce = CARD_EFFECTS[counter.card.cardId];
|
||||
if (ce && ce.kind === "counter") ce.apply(pipe);
|
||||
}
|
||||
@@ -1898,12 +2004,17 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
events.push({ type: "lifeGained", player: defender.id, amount: pipe.damage, source: `${attackId} (reversed)`, lifeAfter: defender.life });
|
||||
damageDealt = pipe.damage; // secondary effects still take effect
|
||||
} else if (pipe.damage > 0) {
|
||||
applyDamage(state, events, defender, pipe.damage, attackId ?? `punch from ${attacker.id}`, attacker.id);
|
||||
applyDamage(state, events, defender, pipe.damage, attackId ?? `punch from ${attacker.id}`, attacker.id, pipe.kind);
|
||||
damageDealt = pipe.damage;
|
||||
}
|
||||
if (pipe.reflectedDamage > 0) {
|
||||
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
|
||||
}
|
||||
// SHADOWSTONE: physical damage you deal feeds your life total.
|
||||
if (damageDealt > 0 && pipe.kind === "physical" && displays(attacker, "shadowstone") && attacker.alive) {
|
||||
attacker.life += damageDealt;
|
||||
events.push({ type: "lifeGained", player: attacker.id, amount: damageDealt, source: "shadowstone", lifeAfter: attacker.life });
|
||||
}
|
||||
if (effect?.sustains && pipe.duration > 0 && !pipe.fullyStopped) {
|
||||
attachSustained(state, events, attackId!, attacker.id, defender.id, pipe.duration);
|
||||
if (pipe.splitDuration) {
|
||||
@@ -1982,6 +2093,7 @@ function applyDamage(
|
||||
amount: number,
|
||||
source: string,
|
||||
attackerId: PlayerId | null,
|
||||
damageKind: "spell" | "physical" = "spell",
|
||||
): void {
|
||||
// MEDUSA: "opponent is also immune to any damage."
|
||||
if (sustainedOn(state, target.id, "medusa").length > 0) {
|
||||
@@ -1989,6 +2101,16 @@ function applyDamage(
|
||||
return;
|
||||
}
|
||||
|
||||
// BLOODSTONE: "Lowers all damage done you by one point per attack."
|
||||
if (displays(target, "bloodstone")) {
|
||||
amount = Math.max(0, amount - 1);
|
||||
if (amount === 0) return;
|
||||
}
|
||||
// SOULSTONE: "Last three points ... can only be lost to physical damage."
|
||||
if (damageKind === "spell" && displays(target, "soulstone") && target.life > 3) {
|
||||
amount = Math.min(amount, target.life - 3);
|
||||
}
|
||||
|
||||
target.life -= amount;
|
||||
events.push({ type: "damaged", player: target.id, amount, source, lifeAfter: target.life });
|
||||
if (target.life > 0) return;
|
||||
@@ -2019,7 +2141,7 @@ function applyDamage(
|
||||
killer.hand.push(...taken);
|
||||
events.push({ type: "handTaken", from: target.id, to: killer.id, count: taken.length });
|
||||
events.push({ type: "handTakenPrivate", visibleTo: killer.id, cards: taken });
|
||||
if (killer.hand.length > HAND_LIMIT) state.pendingDiscard = killer.id;
|
||||
if (killer.hand.length > handLimit(killer)) state.pendingDiscard = killer.id;
|
||||
} else if (target.hand.length > 0) {
|
||||
state.discard.push(...target.hand.splice(0));
|
||||
target.displayed = [];
|
||||
@@ -2071,7 +2193,7 @@ function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
|
||||
p.hand.push(card!);
|
||||
// "YOUR TURN ENDS IF YOU PICK UP ANY OBJECT."
|
||||
state.turn.actionsEnded = true;
|
||||
if (p.hand.length > HAND_LIMIT) state.pendingDiscard = p.id;
|
||||
if (p.hand.length > handLimit(p)) state.pendingDiscard = p.id;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
@@ -2168,12 +2290,23 @@ function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]):
|
||||
cards.push(card);
|
||||
}
|
||||
state.discard.push(...cards);
|
||||
if (state.pendingDiscard === playerId && p.hand.length <= HAND_LIMIT) {
|
||||
if (state.pendingDiscard === playerId && p.hand.length <= handLimit(p)) {
|
||||
state.pendingDiscard = null;
|
||||
}
|
||||
return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] };
|
||||
}
|
||||
|
||||
/** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws." */
|
||||
function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerState, cardsDrawn: number): void {
|
||||
const stacks = sustainedOn(state, p.id, "slow-death").length;
|
||||
if (stacks === 0 || cardsDrawn === 0 || !p.alive) return;
|
||||
for (let i = 0; i < cardsDrawn * stacks; i++) {
|
||||
if (!p.alive) break;
|
||||
applyDamage(state, events, p, 1, "slow death", null);
|
||||
}
|
||||
checkVictory(state, events);
|
||||
}
|
||||
|
||||
function drawOne(state: GameState, events: GameEvent[]): CardInstance | null {
|
||||
if (state.deck.length === 0) {
|
||||
const [reshuffled, rngNext] = shuffle(state.rng, state.discard);
|
||||
@@ -2210,9 +2343,11 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
||||
}
|
||||
state.sustained = surviving;
|
||||
|
||||
// Movement allowance: SLOW forces 1, SHRINK forces 2, else base 3.
|
||||
// Movement allowance: SLOW forces 1 (and bars speed enhancements), SHRINK
|
||||
// forces 2; SPEEDSTONE adds 1 otherwise.
|
||||
let allowance = BASE_MOVEMENT;
|
||||
if (sustainedOn(state, player.id, "shrink").length > 0) allowance = Math.min(allowance, 2);
|
||||
if (displays(player, "speedstone")) allowance += 1;
|
||||
const slows = sustainedOn(state, player.id, "slow");
|
||||
if (slows.length > 0) allowance = 1;
|
||||
|
||||
@@ -2244,7 +2379,7 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
||||
const p = activePlayer(state);
|
||||
const events: GameEvent[] = [];
|
||||
|
||||
const room = HAND_LIMIT - p.hand.length;
|
||||
const room = handLimit(p) - p.hand.length;
|
||||
const count = Math.min(draw, room);
|
||||
if (count > 0) {
|
||||
const drawn: CardInstance[] = [];
|
||||
@@ -2264,6 +2399,7 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
||||
p.hand.push(...drawn);
|
||||
events.push({ type: "cardsDrawn", player: p.id, count: drawn.length });
|
||||
events.push({ type: "cardsDrawnPrivate", visibleTo: p.id, cards: drawn });
|
||||
applySlowDeathOnDraw(state, events, p, drawn.length);
|
||||
}
|
||||
|
||||
// Doors unlocked this turn relock ("the door will relock behind you").
|
||||
|
||||
Reference in New Issue
Block a user