Credibility pass: the illusion tests roll both ways, and the seams from the last batch are sanded

Scope: 66b5c06..HEAD.

- The rev-27 illusion tests search seeds for a believed and a doubted
  roll and assert fixed life totals; seed 42 alone never believed, so
  the BLUNT pricing went unchecked.
- Three docblocks stranded above newly inserted functions are back on
  their own (openCreatureStack, resolvedBlow, castTrace).
- POWER ATTACK's life trade is one helper instead of three copies; the
  goat's charge distance is one helper for its validator, its ram and
  its pricing.
- The family list loads once into a shared store; the lobby and gallery
  share one talk filter; Board uses the engine's neighbor().
- A creature's blow shows the card that summoned it by the creature's
  kind, not by matching the blow's display name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-26 00:22:20 -04:00
co-authored by Claude Opus 5.5
parent e97deb8d6f
commit 3a6b0ff43e
8 changed files with 104 additions and 104 deletions
+26 -20
View File
@@ -2433,17 +2433,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
// Earlier revisions rammed at any range; their ledgers keep it.
validate: (state, _cmd, _caster, target) => {
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const caster = activePlayer(state);
const legs = state.turn.movementAllowance - state.turn.movementUsed;
const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
const { d, legs } = charge(state, activePlayer(state), target);
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
return null;
},
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
if ((ctx.state.config.deckRev ?? 1) >= 10) {
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs));
const { d, legs } = charge(ctx.state, ctx.attacker, ctx.defender);
if (d === 0) return;
if (d > legs) {
// The target slipped beyond the charge (a counter-teleport, say):
@@ -2454,9 +2451,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
ctx.state.turn.movementUsed += d;
ctx.attacker.position = { ...ctx.defender.position };
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d });
// Rev 26: the ram was priced into the blow itself (ramDistance),
// so STRENGTH, AMPLIFY, and the counters have already weighed it.
// Older games dealt the bare distance here, past all of them.
// From rev 26 the ram is priced into the blow itself (ramDistance),
// so STRENGTH, AMPLIFY, and the counters have already weighed it;
// earlier revs deal the bare distance here, past all of them.
if ((ctx.state.config.deckRev ?? 1) < 26) resolvedBlow(ctx, d, "goat ram", "physical");
return;
}
@@ -3357,7 +3354,6 @@ function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerSt
};
}
/** A creature's blow opens a counteraction window like any attack. */
/** What a creature's blow is called on the stack and in the chronicle:
* the counteraction is played against this, and the damage is dealt as it. */
function creatureBlowName(kind: CreatureState["kind"]): string {
@@ -3369,6 +3365,7 @@ function creatureBlowName(kind: CreatureState["kind"]): string {
}
}
/** A creature's blow opens a counteraction window like any attack. */
function openCreatureStack(
state: GameState,
creature: CreatureState,
@@ -5584,8 +5581,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (werr) return err(werr);
}
if (likeAWall && mods.powerAttackPoints > 0) {
caster.life -= mods.powerAttackPoints;
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents);
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
@@ -5681,8 +5677,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (werr) return err(werr);
}
if (mods.powerAttackPoints > 0) {
caster.life -= mods.powerAttackPoints;
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents);
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
@@ -5773,8 +5768,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (werr) return err(werr);
}
if (mods.powerAttackPoints > 0) {
caster.life -= mods.powerAttackPoints;
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents);
}
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
@@ -6051,17 +6045,23 @@ function checkAmbushes(
}
}
/** Damage dealt inside an attack's own resolution hook still honors
* EMPATHY — "Any attack done in ANY form against you acts against both you
* and the caster". */
/** The goat's charge: the corridor distance to its mark, and the legs
* this turn has left to cover it. */
function charge(state: GameState, attacker: PlayerState, defender: PlayerState): { d: number; legs: number } {
const legs = state.turn.movementAllowance - state.turn.movementUsed;
return { d: walkingDistance(state, attacker.position, defender.position, chargeReach(state, legs)), legs };
}
/** How far the goat charges: the corridor distance to its mark within
* this turn's legs, or zero when the mark is underfoot or out of reach. */
function ramDistance(state: GameState, attacker: PlayerState, defender: PlayerState): number {
const legs = state.turn.movementAllowance - state.turn.movementUsed;
const d = walkingDistance(state, attacker.position, defender.position, chargeReach(state, legs));
const { d, legs } = charge(state, attacker, defender);
return d > legs ? 0 : d;
}
/** Damage dealt inside an attack's own resolution hook still honors
* EMPATHY — "Any attack done in ANY form against you acts against both you
* and the caster". */
function resolvedBlow(
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
): void {
@@ -6072,6 +6072,12 @@ function resolvedBlow(
}
}
/** POWER ATTACK: the caster pays in life for the extra points. */
function payPowerAttack(state: GameState, caster: PlayerState, points: number, events: GameEvent[]): void {
caster.life -= points;
events.push({ type: "lifeTraded", player: caster.id, points, newAllowance: state.turn.movementAllowance });
}
/** A NUMBER card riding a counteraction sets the raised spell's duration. */
function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number {
const num = (numberInstanceIds ?? [])
+2 -2
View File
@@ -931,8 +931,8 @@ describe("the imp's fire is a spell (rev 8)", () => {
if (!r.ok) throw new Error(r.error);
state = r.state;
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
// The scorch rides the stack now: a counteraction window, spell-kind,
// named for the imp so the counter is played against the scorch, never a punch.
// The scorch rides the stack as a spell-kind counteraction window,
// named for the imp: the counter is played against the scorch.
expect(state.stack?.creatureId).toBeTruthy();
expect(state.stack?.kind).toBe("spell");
expect(state.stack?.sourceName).toBe("fire imp's scorch");
+19 -12
View File
@@ -942,8 +942,8 @@ describe("the goat's ram is a physical blow like any other (rev 26)", () => {
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 } : {}) });
function fakedFireball(counter: string | null, deckRev?: number, seed = 42) {
let { state } = createGame({ playerIds: ["liar", "mark"], seed, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const ill = giveCard(state, attacker, "illusionary-attack");
@@ -965,11 +965,22 @@ describe("an illusion is priced as the spell it fakes (rev 27)", () => {
return { events: done.events, believed, life: done.state.players.find((p) => p.id === defender)!.life };
}
/** The first seed whose die comes up each way: one believed, one disbelieved. */
function bothWays(counter: string | null, deckRev?: number) {
const found: Partial<Record<"believed" | "doubted", ReturnType<typeof fakedFireball>>> = {};
for (let seed = 1; seed <= 40 && !(found.believed && found.doubted); seed++) {
const r = fakedFireball(counter, deckRev, seed);
expect(r.believed).not.toBeNull();
found[r.believed ? "believed" : "doubted"] ??= r;
}
return { believed: found.believed!, doubted: found.doubted! };
}
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);
const { believed, doubted } = bothWays("blunt");
expect(believed.events).toContainEqual(expect.objectContaining({ type: "attackResolved", incoming: 5 }));
expect(believed.life).toBe(12);
expect(doubted.life).toBe(15);
});
it("FULL SHIELD stops the fake outright: nothing to believe in, no die rolled", () => {
@@ -980,14 +991,10 @@ describe("an illusion is priced as the spell it fakes (rev 27)", () => {
});
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);
expect(bothWays(null).believed.life).toBe(10);
});
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);
expect(bothWays("blunt", 26).believed.life).toBe(10);
});
});