Rules rev 4: a creature's blow can be counteracted
The WRAITH card assumes the window exists — "REFLECTIONs used on the
wraith's touch will damage the wraith" — but creature damage applied
instantly, so BLUNT had nothing to catch, as playtesting found. Under
revision 4 every creature blow against a wizard opens the same
counteraction stack a spell does: the wraith's entry touch, the
democratic monster's claw, and commanded troll/skeleton/shadow
attacks. The blow's damage rides the stack; BLUNT halves it (round
up); reflections work by name against creatures — half back for
REFLECTION, the whole blow for FULL REFLECTION — landing on the
creature, not its controller; FULL SHIELD correctly does nothing
("does not stop any physical attack"); the wraith's card theft is a
secondary effect that lands only if damage does. No aim-miss rolls
against invisible or shrunk defenders — the creature is already in
the square. The attack fanfare shows the creature's own card.
Earlier revisions keep the instant touch so every stored game — and
the live one that found this — replays unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3f3e6d9853
commit
d1eace9530
@@ -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,15 +4582,34 @@ 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) {
|
||||
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,12 +4619,28 @@ 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) {
|
||||
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."
|
||||
if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface Room {
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** 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";
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
|
||||
<div class="attack-notice">
|
||||
<div class="attack-headline">
|
||||
{#if view.stack.defenderId === view.you}
|
||||
{#if view.stack.defenderId === view.you && attackingCreature}
|
||||
<strong>{view.stack.attackerId}</strong>'s {cardDef(attackingCreature.kind).name} attacks you!
|
||||
{:else if view.stack.defenderId === view.you}
|
||||
<strong>{view.stack.attackerId}</strong> attacks you!
|
||||
{:else}
|
||||
<strong>{view.stack.counters[view.stack.counters.length - 1]?.player}</strong> counters your spell!
|
||||
{/if}
|
||||
</div>
|
||||
{#if view.stack.defenderId === view.you && view.stack.attackCard}
|
||||
{#if view.stack.defenderId === view.you && attackingCreature}
|
||||
<div class="attack-card"><Card card={{ instanceId: `atk-${attackingCreature.id}`, cardId: attackingCreature.kind }} /></div>
|
||||
{:else if view.stack.defenderId === view.you && view.stack.attackCard}
|
||||
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
|
||||
{#if view.stack.numberValue != null}
|
||||
<div class="attack-power">powered by a {view.stack.numberValue}</div>
|
||||
@@ -1131,7 +1138,7 @@
|
||||
<span class="hint-alert">
|
||||
{#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" : ""}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user