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; }