From 4ab44cc535af8f7ad59dbc917ead259d6ce5a264 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 15 Aug 2026 20:17:29 -0400 Subject: [PATCH] Card wave 4: all eight magic stones and Slow Death MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/engine/src/game.ts | 166 ++++++++++++++++++++-- packages/engine/test/wave4.test.ts | 215 +++++++++++++++++++++++++++++ packages/web/src/App.svelte | 18 ++- 3 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 packages/engine/test/wave4.test.ts diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 1b70f35..a4d20d9 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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 = {}; + 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 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 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 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 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 }, }; +/** 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): 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): 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"). diff --git a/packages/engine/test/wave4.test.ts b/packages/engine/test/wave4.test.ts new file mode 100644 index 0000000..ffe394a --- /dev/null +++ b/packages/engine/test/wave4.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { + applyCommand, + activePlayer, + createGame, + displays, + handLimit, + sustainedOn, + type Command, + type GameState, + type PlayerId, +} from "../src/game"; +import type { CardInstance } from "../src/cards"; + +function newGame(seed = 42) { + return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] }); +} + +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> = {}, +): GameState { + state = must(state, attacker, { + type: "cast", instanceId: card.instanceId, + target: { kind: "player", playerId: defender }, ...extra, + }); + return must(state, defender, { type: "pass" }); +} + +/** Display a stone for a player during their turn. */ +function displayStone(state: GameState, playerId: PlayerId, stoneId: string, slot = 0): GameState { + const stone = giveCard(state, playerId, stoneId, "S" + stoneId, slot); + return must(state, playerId, { type: "cast", instanceId: stone.instanceId }); +} + +describe("magic stones", () => { + it("bloodstone shaves one point off every hit", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + // Defender displays bloodstone on their own turn first. + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = displayStone(state, defender, "bloodstone"); + expect(displays(state.players.find((p) => p.id === defender)!, "bloodstone")).toBe(true); + state = must(state, defender, { type: "endTurn", draw: 0 }); + + const fb = giveCard(state, attacker, "fireball", "F", 1); + state = castAt(state, attacker, defender, fb); + expect(state.players.find((p) => p.id === defender)!.life).toBe(11); // 5-1 + }); + + it("soulstone keeps the last three points safe from spells", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = displayStone(state, defender, "soulstone"); + const d = state.players.find((p) => p.id === defender)!; + d.life = 5; + state = must(state, defender, { type: "endTurn", draw: 0 }); + + const fb = giveCard(state, attacker, "fireball", "F", 1); + state = castAt(state, attacker, defender, fb); + expect(state.players.find((p) => p.id === defender)!.life).toBe(3); // floored, not 0 + + // But a punch (physical) can finish the job past the floor. + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = must(state, defender, { type: "endTurn", draw: 0 }); + state = must(state, attacker, { type: "punch", targetId: defender }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(2); + }); + + it("brainstone draws two now and raises the hand limit to nine", () => { + let { state } = newGame(); + const me = activePlayer(state).id; + state = displayStone(state, me, "brainstone"); + const p = state.players.find((pl) => pl.id === me)!; + expect(p.hand.length).toBe(9); // 7 - 1 slot replaced + kept + 2 drawn + expect(handLimit(p)).toBe(9); + // Re-displaying is refused (no double draw). + const again = applyCommand(state, me, { type: "cast", instanceId: "brainstone#Sbrainstone" }); + expect(again.ok).toBe(false); + }); + + it("powerstone adds one to number cards; speedstone adds movement", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + state = displayStone(state, attacker, "powerstone"); + const lb = giveCard(state, attacker, "lightning-blast", "L", 1); + giveCard(state, attacker, "number-3", "N", 2); + state = castAt(state, attacker, defender, lb, { numberInstanceIds: ["number-3#N"] }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(11); // 3+1 + + // Speedstone: allowance 4 on the next turn. (The stunned defender's turn + // is skipped, so play comes straight back to the attacker.) + state = displayStone(state, attacker, "speedstone", 1); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + expect(activePlayer(state).id).toBe(attacker); + expect(state.turn.movementAllowance).toBe(4); + }); + + it("shieldstone lets number cards counteract spells", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = displayStone(state, defender, "shieldstone"); + state = must(state, defender, { type: "endTurn", draw: 0 }); + + const fb = giveCard(state, attacker, "fireball", "F", 1); + giveCard(state, defender, "number-4", "N4", 1); + state = must(state, attacker, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, + }); + state = must(state, defender, { type: "counteract", instanceId: "number-4#N4" }); + state = must(state, attacker, { type: "pass" }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(14); // 5-4 + }); + + it("shadowstone feeds physical damage back as life", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + state = displayStone(state, attacker, "shadowstone"); + const dagger = giveCard(state, attacker, "dagger", "D", 1); + state = castAt(state, attacker, defender, dagger); + expect(state.players.find((p) => p.id === defender)!.life).toBe(12); + expect(state.players.find((p) => p.id === attacker)!.life).toBe(18); // +3 + }); + + it("fireball burns displayed stones off when damage lands", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = displayStone(state, defender, "speedstone"); + state = must(state, defender, { type: "endTurn", draw: 0 }); + + const fb = giveCard(state, attacker, "fireball", "F", 1); + state = castAt(state, attacker, defender, fb); + const d = state.players.find((p) => p.id === defender)!; + expect(d.hand.some((c) => c.cardId === "speedstone")).toBe(false); + expect(d.displayed.length).toBe(0); + }); +}); + +describe("slow death", () => { + it("bleeds one life per card drawn, forever", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const sd = giveCard(state, attacker, "slow-death"); + state = castAt(state, attacker, defender, sd); + expect(sustainedOn(state, defender, "slow-death").length).toBe(1); + + state = must(state, attacker, { type: "endTurn", draw: 0 }); + // Defender discards two to make room, then draws two: 2 damage. + const d = state.players.find((p) => p.id === defender)!; + const toDiscard = d.hand.slice(0, 2).map((c) => c.instanceId); + state = must(state, defender, { type: "discard", instanceIds: toDiscard }); + state = must(state, defender, { type: "endTurn", draw: 2 }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(13); + + // It never expires — many turns later it still bleeds. + for (let i = 0; i < 4; i++) { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + } + expect(sustainedOn(state, defender, "slow-death").length).toBe(1); + }); + + it("bloodstone cancels slow death's per-draw point", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const sd = giveCard(state, attacker, "slow-death"); + state = castAt(state, attacker, defender, sd); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + + state = displayStone(state, defender, "bloodstone"); + const d = state.players.find((p) => p.id === defender)!; + const toDiscard = d.hand.filter((c) => c.cardId !== "bloodstone").slice(0, 2).map((c) => c.instanceId); + state = must(state, defender, { type: "discard", instanceIds: toDiscard }); + state = must(state, defender, { type: "endTurn", draw: 2 }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(15); // 1-1=0 per draw + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 0edd02e..65535d6 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -97,9 +97,14 @@ clearSelection(); return; } - // Instant untargeted spells cast immediately; duration self-spells wait - // so a number card can be attached (cast via the button in the hint bar). - if (card.cardId === "speed" || card.cardId === "pass-through-wall" || card.cardId === "reuse-spell") { + // Instant untargeted spells (and stone displays) cast immediately; + // duration self-spells wait so a number card can be attached. + const INSTANT = new Set([ + "speed", "pass-through-wall", "reuse-spell", + "bloodstone", "brainstone", "powerstone", "shadowstone", + "shieldstone", "soulstone", "speedstone", "visionstone", + ]); + if (INSTANT.has(card.cardId)) { net.command({ type: "cast", instanceId: card.instanceId }); clearSelection(); } @@ -365,6 +370,9 @@ > {def.cardType} {def.name} + {#if view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId)} + displayed + {/if} {/each} @@ -453,6 +461,10 @@ .card.marked { border-color: #c33; background: #fce6e6; } .card-type { font-size: 0.65em; text-transform: uppercase; color: #776; } .card-name { font-weight: 600; font-size: 0.9em; } + .displayed-badge { + font-size: 0.6em; text-transform: uppercase; color: #fff; + background: #7a5f2a; border-radius: 3px; padding: 0 4px; margin-top: 2px; + } .actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } button { padding: 0.4rem 0.7rem; border-radius: 6px; border: 1px solid #998; background: #fff; cursor: pointer; }