diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index c65a178..b6b772b 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -182,6 +182,10 @@ export interface CastStack { waitingOn: PlayerId; /** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */ defenderShielded?: boolean; + /** Set when a creature, not a wizard, delivers the attack. */ + creatureId?: string; + /** The wraith's touch also steals a random card if damage lands. */ + creatureTouch?: "wraith" | "claw"; } export interface CastParams { @@ -207,7 +211,8 @@ export interface GameConfig { * Rules revision, frozen per game so stored games replay unchanged. * Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not * applicable in a 2-player game."). Rev 3: WARD springs only when armed, - * and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. + * and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4: + * creature blows open a counteraction window like any attack. */ deckRev?: number; } @@ -2527,12 +2532,19 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id }); // Touch effects on entering a player's square. + const rev4 = (state.config.deckRev ?? 1) >= 4; for (const p of state.players) { if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue; if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator if (creature.kind === "wraith" && !creature.attackUsed) { creature.attackUsed = true; events.push({ type: "creatureTouched", creatureId: creature.id, player: p.id }); + if (rev4) { + // The victim may counteract ("REFLECTIONs used on the wraith's touch + // will damage the wraith" — the card assumes exactly this window). + openCreatureStack(state, creature, p, 2, "wraith"); + return { ok: true, state, events }; + } applyDamage(state, events, p, 2, "wraith's touch", null); if (p.alive && p.hand.length > 0) { const [idx, rngNext] = nextInt(state.rng, p.hand.length); @@ -2546,6 +2558,10 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C if (creature.kind === "democratic-monster" && !creature.attackUsed) { creature.attackUsed = true; // "may attack only one player per round of turns" events.push({ type: "creatureTouched", creatureId: creature.id, player: p.id }); + if (rev4) { + openCreatureStack(state, creature, p, 2, "claw"); + return { ok: true, state, events }; + } applyDamage(state, events, p, 2, "clawing monster", null); } } @@ -2553,6 +2569,31 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C return { ok: true, state, events }; } +/** Rules rev 4: a creature's blow opens a counteraction window like any attack. */ +function openCreatureStack( + state: GameState, + creature: CreatureState, + victim: PlayerState, + damage: number, + touch?: "wraith" | "claw", +): void { + state.stack = { + attackerId: creature.controllerId, + defenderId: victim.id, + attackCard: null, + numberValue: null, + amplifyFactor: 1, + extendFactor: 1, + powerAttackPoints: 0, + params: { damage }, + kind: "physical", + counters: [], + waitingOn: victim.id, + creatureId: creature.id, + ...(touch ? { creatureTouch: touch } : {}), + }; +} + function doCreatureAttack(prev: GameState, creatureId: string, targetId: string): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); @@ -2593,6 +2634,10 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string) } events.push({ type: "creatureAttacked", creatureId: creature.id, target: targetId, dieRoll: roll }); if (targetPlayer) { + if ((state.config.deckRev ?? 1) >= 4) { + openCreatureStack(state, creature, targetPlayer, amount); + return { ok: true, state, events }; + } applyDamage(state, events, targetPlayer, amount, `${creature.kind}'s blow`, null, "physical"); } else { damageCreature(state, events, targetCreature!, amount, `${creature.kind}'s blow`); @@ -4447,8 +4492,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void { const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null; // Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and - // SHRINK (50% miss). "If a spell misses, it dissipates harmlessly." - if (sustainedOn(state, defender.id, "invisible").length > 0) { + // SHRINK (50% miss). "If a spell misses, it dissipates harmlessly." A + // creature sharing the square has nothing to aim: no miss rolls. + if (!stack.creatureId && sustainedOn(state, defender.id, "invisible").length > 0) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; if (roll !== 1) { @@ -4457,7 +4503,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { return; } } - if (sustainedOn(state, defender.id, "shrink").length > 0) { + if (!stack.creatureId && sustainedOn(state, defender.id, "shrink").length > 0) { const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; if (roll > 2) { @@ -4467,7 +4513,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } } - let base = effect ? effect.baseDamage(stack.numberValue, stack.params) : 1; // punch = 1 + let base = effect + ? effect.baseDamage(stack.numberValue, stack.params) + : stack.creatureId + ? (stack.params?.damage ?? 1) // a creature's blow + : 1; // a punch // Webs burn: "Any fire damage done to player in webs causes two extra points." if (attackId === "fireball" && sustainedOn(state, defender.id, "sticky-web").length > 0) { base += 2; @@ -4532,14 +4582,33 @@ function resolveStack(state: GameState, events: GameEvent[]): void { pipe.fullyStopped = true; continue; } + if (stack.creatureId && + (counter.card.cardId === "reflection" || counter.card.cardId === "full-reflection")) { + // "REFLECTIONs used on the wraith's touch will damage the wraith." + if (counter.card.cardId === "reflection") { + const half = Math.ceil(pipe.damage / 2); + pipe.reflectedDamage += half; + pipe.damage = half; + } else { + pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage) + } + continue; + } const ce = CARD_EFFECTS[counter.card.cardId]; if (ce && ce.kind === "counter") ce.apply(pipe); } let damageDealt = 0; + const attackingCreature = stack.creatureId + ? state.creatures.find((c) => c.id === stack.creatureId) + : undefined; if (pipe.redirected) { if (pipe.damage > 0) { - applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id); + if (attackingCreature) { + damageCreature(state, events, attackingCreature, pipe.damage, "reflected touch"); + } else { + applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id); + } } if (effect?.sustains && pipe.duration > 0) { attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration); @@ -4550,11 +4619,27 @@ 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, pipe.kind); + const source = attackId ?? + (attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`); + applyDamage(state, events, defender, pipe.damage, source, attacker.id, pipe.kind); damageDealt = pipe.damage; } + // "he takes 2 points of damage and loses a random card" — the theft is a + // secondary effect, stopped only when all the damage is. + if (stack.creatureTouch === "wraith" && damageDealt > 0 && defender.alive && defender.hand.length > 0) { + const [idx, rngNext] = nextInt(state.rng, defender.hand.length); + state.rng = rngNext; + const [card] = defender.hand.splice(idx, 1); + defender.displayed = defender.displayed.filter((id) => id !== card!.instanceId); + state.discard.push(card!); + events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] }); + } if (pipe.reflectedDamage > 0) { - applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id); + if (attackingCreature) { + damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch"); + } else { + 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." diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 40287c8..194df61 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId } from "../src/game"; +import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; import { cellKey, SIDES, stepTarget, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; @@ -282,3 +282,62 @@ describe("expansion support cards", () => { expect(state.creatures.length).toBe(0); }); }); + +describe("counteracting a creature's blow (rules rev 4)", () => { + function rev4() { + return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 }); + } + function wraithOnVictim(state: GameState) { + state = toRound2(state); + const controller = activePlayer(state); + const victim = state.players.find((p) => p.id !== controller.id)!; + const spot = emptyNeighborCell(state, controller.position); + state.creatures.push({ + id: "w1", kind: "wraith", controllerId: controller.id, position: { ...controller.position }, + damage: 0, maxDamage: 4, movesPerTurn: 3, movementUsed: 0, + wallPassesPerTurn: 1, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [], + }); + victim.position = { ...spot.cell }; + return { state, controller: controller.id, victim: victim.id, side: spot.side }; + } + + it("BLUNT halves the wraith's touch; the card theft still lands", () => { + let { state } = rev4(); + const rig = wraithOnVictim(state); + state = rig.state; + giveCard(state, rig.victim, "blunt", "B", 0); + const handBefore = state.players.find((p) => p.id === rig.victim)!.hand.filter(Boolean).length; + state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side }); + expect(state.stack?.creatureId).toBe("w1"); + state = must(state, rig.victim, { type: "counteract", instanceId: "blunt#B" }); + state = must(state, rig.controller, { type: "pass" }); + state = must(state, rig.victim, { type: "pass" }); + const v = state.players.find((p) => p.id === rig.victim)!; + expect(v.life).toBe(14); // 2 halved up to 1 + expect(v.hand.length).toBe(handBefore - 2); // blunt spent + a card stolen + }); + + it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => { + let { state } = rev4(); + const rig = wraithOnVictim(state); + state = rig.state; + giveCard(state, rig.victim, "full-reflection", "FR", 0); + state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side }); + state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" }); + state = must(state, rig.controller, { type: "pass" }); + state = must(state, rig.victim, { type: "pass" }); + const v = state.players.find((p) => p.id === rig.victim)!; + expect(v.life).toBe(15); + const wraith = state.creatures.find((c) => c.id === "w1")!; + expect(wraith.damage).toBe(2); + }); + + it("legacy games (rev < 4) keep the instant touch", () => { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); + const rig = wraithOnVictim(state); + state = rig.state; + state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side }); + expect(state.stack).toBeNull(); + expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 7e84e66..76661ba 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -44,7 +44,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 3; +const RULES_REV = 4; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 3dbb377..af658a3 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -83,9 +83,12 @@ const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you); const attackNoticeKey = $derived( youMustRespond && view?.stack - ? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? "punch"}:${view.stack.counters.length}` + ? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? view.stack.creatureId ?? "punch"}:${view.stack.counters.length}` : null, ); + const attackingCreature = $derived( + view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null, + ); const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you); const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null); const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false); @@ -746,13 +749,17 @@
- {#if view.stack.defenderId === view.you} + {#if view.stack.defenderId === view.you && attackingCreature} + {view.stack.attackerId}'s {cardDef(attackingCreature.kind).name} attacks you! + {:else if view.stack.defenderId === view.you} {view.stack.attackerId} attacks you! {:else} {view.stack.counters[view.stack.counters.length - 1]?.player} counters your spell! {/if}
- {#if view.stack.defenderId === view.you && view.stack.attackCard} + {#if view.stack.defenderId === view.you && attackingCreature} +
+ {:else if view.stack.defenderId === view.you && view.stack.attackCard}
{#if view.stack.numberValue != null}
powered by a {view.stack.numberValue}
@@ -1131,7 +1138,7 @@ {#if view.stack.defenderId === view.you} {view.stack.attackerId} attacks with - {view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} — + {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} — tap a counteraction card, or {:else} {view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""} diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 9aac436..554c720 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -101,7 +101,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 3, + deckRev: 4, }; const { state, events } = createGame(config); this.config = config;