diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index a2b817c..e0f303d 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -247,7 +247,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 26; +export const CURRENT_RULES_REV = 27; /** Every rulings revision since the baseline, newest last — the entries a * game's deckRev freezes it before or after. Shown to players as the house @@ -278,6 +278,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [ { rev: 24, note: "A REFLECTION against a permanent curse afflicts both wizards: WALKING DEAD and IDIOT say so on the card, and SLOW DEATH follows REFLECTION's own rule that a spell works for both parties. Older games left the caster untouched." }, { rev: 25, note: "An attack on a bush or the ooze is an attack on an inanimate object like any wall: POWER ATTACK's bought points count, and a thrown DAGGER or LARGE ROCK lands at the foot of the bush instead of vanishing into the discard. Older games took neither." }, { rev: 26, note: "BUTT-HEAD's ram is physical damage like any other: STRENGTH doubles it, AMPLIFY doubles it, WEAKNESS doubles it, POWER ATTACK adds to it, and ABSORB or BLUNT soak it. Older games dealt the bare distance, whatever rode the cast." }, + { rev: 27, note: "An ILLUSIONARY ATTACK is priced as the spell it fakes, so a BLUNT halves it, an ABSORB soaks it, and a shield stops it, exactly as against the real thing; the belief roll then decides whether the weighed blow lands at all. Older games dealt a believed illusion's full points past every counteraction." }, ]; export interface GameConfig { @@ -2542,6 +2543,8 @@ const CARD_EFFECTS: Record return null; }, onResolved: (ctx) => { + // Rev 27 prices the illusion into the pipeline and rolls belief there. + if ((ctx.state.config.deckRev ?? 1) >= 27) return; if (ctx.fullyStopped || !ctx.defender.alive) return; const chosen = ctx.stack.params!.cardId!; const fx = CARD_EFFECTS[chosen] as AttackEffect; @@ -6386,6 +6389,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void { // Rev 26: BUTT-HEAD's ram is priced here, as the physical blow it is, // so everything below weighs it like any other. if (attackId === "butt-head" && (state.config.deckRev ?? 1) >= 26) base = ramDistance(state, attacker, defender); + // Rev 27: an ILLUSIONARY ATTACK is priced as the spell it fakes, so the + // trail weighs it like the real thing. Whether the weighed blow lands at + // all is the belief roll's, taken below once the trail has spoken. + if (attackId === "illusionary-attack" && (state.config.deckRev ?? 1) >= 27 && stack.params?.cardId) { + const faked = CARD_EFFECTS[stack.params.cardId]; + if (faked?.kind === "attack") base = faked.baseDamage(stack.numberValue, stack.params); + } base *= stack.amplifyFactor; base += stack.powerAttackPoints; // SWARTHMORE'S ENCHANTMENT: an enchanted thrown object bites one deeper. @@ -6482,6 +6492,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } weigh(); + // Rev 27: "Your opponent has a 50% chance of believing the spell to be + // real. If so, he takes damage from the spell" — the weighed damage, the + // counteractions having met the illusion as they would the real spell. + // Disbelief leaves nothing to land, and a total stop leaves nothing to + // believe in, so no die is rolled then. + if (attackId === "illusionary-attack" && (state.config.deckRev ?? 1) >= 27 && !stack.reflectedBase && + (pipe.damage > 0 || pipe.reflectedDamage > 0 || pipe.duration > 0)) { + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + const believed = roll <= 2; + events.push({ type: "illusionBelieved", player: defender.id, cardId: stack.params!.cardId!, believed }); + if (!believed) { + pipe.damage = 0; + pipe.reflectedDamage = 0; + pipe.duration = 0; + } + } // A surviving teleport counter whisks the defender away before anything lands. const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 5c1ff4f..f38a348 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -939,3 +939,55 @@ describe("the goat's ram is a physical blow like any other (rev 26)", () => { expect(state.players.find((p) => p.id === mark)!.life).toBe(15 - distance); }); }); + +describe("an illusion is priced as the spell it fakes (rev 27)", () => { + /** A faked FIREBALL met by the given counteraction, then both wizards pass. */ + function fakedFireball(counter: string | null, deckRev?: number) { + let { state } = createGame({ playerIds: ["liar", "mark"], seed: 42, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const ill = giveCard(state, attacker, "illusionary-attack"); + if (counter) giveCard(state, defender, counter, "C", 0); + state = must(state, attacker, { + type: "cast", instanceId: ill.instanceId, target: { kind: "player", playerId: defender }, params: { cardId: "fireball" }, + }); + let done = applyCommand(state, defender, { type: "pass" }); + if (counter) { + state = must(state, defender, { type: "counteract", instanceId: `${counter}#C` }); + // The attacker's pass settles a total stop at once; otherwise the + // defender's pass closes the window. + done = applyCommand(state, attacker, { type: "pass" }); + if (done.ok && done.state.stack) done = applyCommand(done.state, defender, { type: "pass" }); + } + if (!done.ok) throw new Error(done.error); + const belief = done.events.find((e) => e.type === "illusionBelieved"); + const believed = belief?.type === "illusionBelieved" ? belief.believed : null; + return { events: done.events, believed, life: done.state.players.find((p) => p.id === defender)!.life }; + } + + it("BLUNT halves a believed fake fireball to three, and disbelief costs nothing", () => { + const { events, believed, life } = fakedFireball("blunt"); + expect(believed).not.toBeNull(); + expect(events).toContainEqual(expect.objectContaining({ type: "attackResolved", incoming: 5 })); + expect(life).toBe(believed ? 12 : 15); + }); + + it("FULL SHIELD stops the fake outright: nothing to believe in, no die rolled", () => { + const { events, believed, life } = fakedFireball("full-shield"); + expect(believed).toBeNull(); + expect(events).toContainEqual(expect.objectContaining({ type: "attackResolved", fullyStopped: true })); + expect(life).toBe(15); + }); + + it("uncountered, a believed fake fireball still does its five", () => { + const { believed, life } = fakedFireball(null); + expect(believed).not.toBeNull(); + expect(life).toBe(believed ? 10 : 15); + }); + + it("older games dealt a believed illusion's full points past a BLUNT", () => { + const { believed, life } = fakedFireball("blunt", 26); + expect(believed).not.toBeNull(); + expect(life).toBe(believed ? 10 : 15); + }); +});