Rev 13: monsters roll to hit the hidden — the invented exception dies
"The creature shares your square — there is nowhere to hide" appeared on no card and in no rulebook. Neither did the convention beneath it: the engine skipped INVISIBLE and SHRINK miss rolls for creature blows on the theory that a monster in your square has nothing to aim. The cards say otherwise — SHRINK reduces the chance to hit "in any attack", and INVISIBLE's D4 carves out no exception for monsters. From rev 13, creature blows roll the same dice as every other attack (1 finds the unseen; 1-2 find the tiny), INVISIBLE may be counter- cast against a creature's blow, and the automatons will do so. The rolls consume RNG, so earlier games keep their certainty and replay without them — the refusal survives only there, reworded to blame the revision instead of inventing a rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4d8584ae44
commit
e0da97627b
@@ -310,7 +310,7 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
}
|
}
|
||||||
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
||||||
const vanish = find("invisible");
|
const vanish = find("invisible");
|
||||||
if (vanish && !stack.creatureId && incoming >= 3 - flinch) {
|
if (vanish && (!stack.creatureId || view.deckRev >= 13) && incoming >= 3 - flinch) {
|
||||||
const num = numbersInHand(view)[0];
|
const num = numbersInHand(view)[0];
|
||||||
return {
|
return {
|
||||||
type: "counteract", instanceId: vanish.instanceId,
|
type: "counteract", instanceId: vanish.instanceId,
|
||||||
|
|||||||
@@ -4644,7 +4644,9 @@ function doCounteract(
|
|||||||
// The spell truly goes up — duration from an attached NUMBER card — and
|
// The spell truly goes up — duration from an attached NUMBER card — and
|
||||||
// the attacker's 1-in-4 hit roll happens at resolution.
|
// the attacker's 1-in-4 hit roll happens at resolution.
|
||||||
if (card.cardId === "invisible") {
|
if (card.cardId === "invisible") {
|
||||||
if (stack.creatureId) return err("the creature shares your square — there is nowhere to hide");
|
if (stack.creatureId && (state.config.deckRev ?? 1) < 13) {
|
||||||
|
return err("in this game's revision, creatures are not fooled by the vanishing");
|
||||||
|
}
|
||||||
let duration = 1;
|
let duration = 1;
|
||||||
const attached = (numberInstanceIds ?? [])
|
const attached = (numberInstanceIds ?? [])
|
||||||
.map((id) => player.hand.find((c) => c.instanceId === id))
|
.map((id) => player.hand.find((c) => c.instanceId === id))
|
||||||
@@ -4810,9 +4812,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
|
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
|
||||||
|
|
||||||
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
|
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
|
||||||
// SHRINK (50% miss). "If a spell misses, it dissipates harmlessly." A
|
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
|
||||||
// creature sharing the square has nothing to aim: no miss rolls.
|
// Creature blows roll the same dice from rev 13 — the cards carve out no
|
||||||
if (!stack.creatureId && sustainedOn(state, defender.id, "invisible").length > 0) {
|
// exception for monsters; earlier games skipped the rolls and replay so.
|
||||||
|
const missRolls = !stack.creatureId || (state.config.deckRev ?? 1) >= 13;
|
||||||
|
if (missRolls && sustainedOn(state, defender.id, "invisible").length > 0) {
|
||||||
const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them");
|
const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them");
|
||||||
if (roll !== 1) {
|
if (roll !== 1) {
|
||||||
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" });
|
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" });
|
||||||
@@ -4820,7 +4824,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!stack.creatureId && sustainedOn(state, defender.id, "shrink").length > 0) {
|
if (missRolls && sustainedOn(state, defender.id, "shrink").length > 0) {
|
||||||
const roll = rollD4(state, events, attacker.id, "aiming at the tiny — 1-2 hits");
|
const roll = rollD4(state, events, attacker.id, "aiming at the tiny — 1-2 hits");
|
||||||
if (roll > 2) {
|
if (roll > 2) {
|
||||||
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" });
|
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" });
|
||||||
|
|||||||
@@ -449,3 +449,48 @@ describe("big man (rules rev 5)", () => {
|
|||||||
expect(g.inPit).toBe(false);
|
expect(g.inPit).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
||||||
|
function creatureVsInvisible(deckRev: number) {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
state = toRound2(state);
|
||||||
|
const me = activePlayer(state).id;
|
||||||
|
const victim = state.players.find((p) => p.id !== me)!;
|
||||||
|
const r = summon(state, me, "skeleton");
|
||||||
|
state = r.state;
|
||||||
|
const bones = state.creatures[0]!;
|
||||||
|
bones.justCreated = false;
|
||||||
|
bones.attackUsed = false;
|
||||||
|
bones.position = { ...victim.position };
|
||||||
|
state.sustained.push({
|
||||||
|
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
|
||||||
|
remainingTurns: 3, data: {},
|
||||||
|
});
|
||||||
|
return { state, me, victim, trollId: bones.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||||
|
// Deterministic seed: this particular swing misses and the blow dissipates.
|
||||||
|
let { state, me, victim, trollId } = creatureVsInvisible(13);
|
||||||
|
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||||
|
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
||||||
|
state = must(state, victim.id, { type: "pass" });
|
||||||
|
const rolled = state.rng;
|
||||||
|
expect(rolled).toBeDefined();
|
||||||
|
const after = state.players.find((p) => p.id === victim.id)!;
|
||||||
|
const missed = after.life === lifeBefore;
|
||||||
|
// Whichever way seed 42's die lands, the roll HAPPENED: a die event is in the chronicle.
|
||||||
|
expect(missed || after.life < lifeBefore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
|
||||||
|
let { state, me, victim, trollId } = creatureVsInvisible(12);
|
||||||
|
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||||
|
const rngBefore = JSON.stringify(state.rng);
|
||||||
|
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
||||||
|
state = must(state, victim.id, { type: "pass" });
|
||||||
|
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
|
||||||
|
// No die was consumed on the way.
|
||||||
|
expect(JSON.stringify(state.rng)).toBe(rngBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export interface Room {
|
|||||||
const rooms = new Map<string, Room>();
|
const rooms = new Map<string, Room>();
|
||||||
|
|
||||||
/** Rules revision new games are dealt under (stored games keep their own). */
|
/** Rules revision new games are dealt under (stored games keep their own). */
|
||||||
const RULES_REV = 12;
|
const RULES_REV = 13;
|
||||||
|
|
||||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class LocalGame {
|
|||||||
seed,
|
seed,
|
||||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||||
...(colors ? { colors } : {}),
|
...(colors ? { colors } : {}),
|
||||||
deckRev: 12,
|
deckRev: 13,
|
||||||
};
|
};
|
||||||
const { state, events } = createGame(config);
|
const { state, events } = createGame(config);
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|||||||
Reference in New Issue
Block a user