Rev 12: vanishing and water answer the call to arms
INVISIBLE's corner reads NEUTRAL/COUNTERACTION, and ANTI-ANTI's own face names it an escape — yet the engine refused it at the door. Now it counters: the vanishing goes up mid-stack (duration from a NUMBER card riding the counteract command), the attacker's 1-in-4 hit roll happens at resolution, the spell lingers after the exchange, and ANTI-ANTI cannot pin it. WATERWALL — "Acts as counteraction to FIREBALL" — now mirrors WALL OF FIRE's waterbolt-stopping role and counts as a total stop. WALL OF FIRE itself joins the total-stop set at rev 12 only: stored rev-10/11 games bounced after it and replay so. While responding, tapping a number card holds it to ride the next counter (unless a displayed Shieldstone makes the number itself the counteraction, as before). The automatons learn the trick too: vanish when the blow is heavy and the shields are spent, and never waste ANTI-ANTI against an escape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fb2266b401
commit
4d8584ae44
@@ -258,7 +258,7 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
if (stack.attackerId === you) {
|
||||
const last = [...stack.counters].reverse().find((c) => !c.nullified);
|
||||
const anti = find("anti-anti");
|
||||
if (anti && last && last.card.cardId !== "teleport") {
|
||||
if (anti && last && last.card.cardId !== "teleport" && last.card.cardId !== "invisible") {
|
||||
return { type: "counteract", instanceId: anti.instanceId };
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,15 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
const out = escapeCell(view, me(view).position);
|
||||
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
|
||||
}
|
||||
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
||||
const vanish = find("invisible");
|
||||
if (vanish && !stack.creatureId && incoming >= 3 - flinch) {
|
||||
const num = numbersInHand(view)[0];
|
||||
return {
|
||||
type: "counteract", instanceId: vanish.instanceId,
|
||||
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
|
||||
};
|
||||
}
|
||||
// A displayed shieldstone lets numbers soak the small ones.
|
||||
const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone");
|
||||
if (stoneShown && incoming >= 2) {
|
||||
|
||||
+53
-12
@@ -635,7 +635,7 @@ export type Command =
|
||||
}
|
||||
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] }
|
||||
| { type: "cancelAmbush"; ambushId: string }
|
||||
| { type: "counteract"; instanceId: string; params?: { cell?: Cell } }
|
||||
| { type: "counteract"; instanceId: string; params?: { cell?: Cell }; numberInstanceIds?: string[] }
|
||||
| { type: "pass" }
|
||||
| { type: "pickUpTreasure" }
|
||||
| { type: "pickUpObject"; instanceId: string }
|
||||
@@ -3197,7 +3197,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
|
||||
|
||||
if (state.stack) {
|
||||
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
|
||||
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params);
|
||||
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params, command.numberInstanceIds);
|
||||
if (command.type === "pass") return doPass(state, playerId);
|
||||
return err("an attack is being resolved — counteract or pass");
|
||||
}
|
||||
@@ -4565,7 +4565,10 @@ function checkAmbushes(
|
||||
}
|
||||
}
|
||||
|
||||
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, params?: { cell?: Cell }): CommandResult {
|
||||
function doCounteract(
|
||||
prev: GameState, playerId: PlayerId, instanceId: string,
|
||||
params?: { cell?: Cell }, numberInstanceIds?: string[],
|
||||
): CommandResult {
|
||||
const state = clone(prev);
|
||||
const stack = state.stack!;
|
||||
const player = state.players.find((p) => p.id === playerId)!;
|
||||
@@ -4637,10 +4640,39 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p
|
||||
};
|
||||
}
|
||||
|
||||
// INVISIBLE (corner: NEUTRAL/COUNTERACTION): vanish as the bolt flies.
|
||||
// The spell truly goes up — duration from an attached NUMBER card — and
|
||||
// the attacker's 1-in-4 hit roll happens at resolution.
|
||||
if (card.cardId === "invisible") {
|
||||
if (stack.creatureId) return err("the creature shares your square — there is nowhere to hide");
|
||||
let duration = 1;
|
||||
const attached = (numberInstanceIds ?? [])
|
||||
.map((id) => player.hand.find((c) => c.instanceId === id))
|
||||
.filter((c): c is CardInstance => c != null && isNumberCard(c.cardId));
|
||||
const num = attached[0];
|
||||
if (num) {
|
||||
duration = numberValue(num.cardId);
|
||||
takeFromHand(player, num.instanceId);
|
||||
state.discard.push(num);
|
||||
}
|
||||
takeFromHand(player, instanceId);
|
||||
state.discard.push(card);
|
||||
const events: GameEvent[] = [
|
||||
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
|
||||
];
|
||||
attachSustained(state, events, "invisible", playerId, playerId, duration);
|
||||
stack.counters.push({ player: playerId, card, nullified: false });
|
||||
stack.waitingOn = stack.attackerId;
|
||||
state.lastSpellUsed[playerId] = card.cardId;
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
// WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT."
|
||||
if (card.cardId === "wall-of-fire") {
|
||||
if (stack.attackCard?.cardId !== "waterbolt") {
|
||||
return err("as a counteraction, Wall of Fire only stops a Waterbolt");
|
||||
// WATERWALL: "Acts as counteraction to FIREBALL" — the mirror image.
|
||||
if (card.cardId === "wall-of-fire" || card.cardId === "waterwall") {
|
||||
const stops = card.cardId === "wall-of-fire" ? "waterbolt" : "fireball";
|
||||
if (stack.attackCard?.cardId !== stops) {
|
||||
return err(`as a counteraction, ${cardDef(card.cardId).name} only stops a ${cardDef(stops).name}`);
|
||||
}
|
||||
takeFromHand(player, instanceId);
|
||||
state.discard.push(card);
|
||||
@@ -4649,7 +4681,7 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: "waterbolt" }],
|
||||
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stops }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4719,6 +4751,9 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p
|
||||
if ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport") {
|
||||
return err("ANTI-ANTI does not work against escape");
|
||||
}
|
||||
if (targetCounter.card.cardId === "invisible") {
|
||||
return err("ANTI-ANTI does not work against escape");
|
||||
}
|
||||
takeFromHand(player, instanceId);
|
||||
state.discard.push(card);
|
||||
targetCounter.nullified = true;
|
||||
@@ -4736,7 +4771,7 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p
|
||||
/** Counters that leave the defender nothing worth adding: the spell is dead,
|
||||
* escaped, thrown back whole, or drunk as a tonic. */
|
||||
const TOTAL_STOP_COUNTERS = new Set([
|
||||
"full-shield", "force-field", "teleport", "full-reflection", "reverse",
|
||||
"full-shield", "force-field", "teleport", "full-reflection", "reverse", "waterwall",
|
||||
]);
|
||||
|
||||
function doPass(prev: GameState, playerId: PlayerId): CommandResult {
|
||||
@@ -4748,9 +4783,12 @@ function doPass(prev: GameState, playerId: PlayerId): CommandResult {
|
||||
// defender may stack further counters — but not past a total stop:
|
||||
// re-prompting then reads as "your shield failed" and goads the table
|
||||
// into burning cards against a spell that is already dead (rules rev 10).
|
||||
const totalStop = (state.config.deckRev ?? 1) >= 10 &&
|
||||
const rev = state.config.deckRev ?? 1;
|
||||
const totalStop = rev >= 10 &&
|
||||
stack.counters.some((c) =>
|
||||
!c.nullified && TOTAL_STOP_COUNTERS.has(c.card.cardId) &&
|
||||
!c.nullified &&
|
||||
(TOTAL_STOP_COUNTERS.has(c.card.cardId) ||
|
||||
(rev >= 12 && c.card.cardId === "wall-of-fire")) &&
|
||||
(stack.kind === "spell" || c.card.cardId === "teleport"));
|
||||
if (!totalStop) {
|
||||
stack.waitingOn = stack.defenderId;
|
||||
@@ -4854,12 +4892,15 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
pipe.duration = Math.max(0, pipe.duration - v);
|
||||
continue;
|
||||
}
|
||||
if (counter.card.cardId === "wall-of-fire") {
|
||||
// The wave meets the fire: the waterbolt is entirely stopped.
|
||||
if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") {
|
||||
// Fire meets water, whichever was thrown first: entirely stopped.
|
||||
pipe.damage = 0;
|
||||
pipe.fullyStopped = true;
|
||||
continue;
|
||||
}
|
||||
if (counter.card.cardId === "invisible") {
|
||||
continue; // its work is the sustained vanishing and the hit roll above
|
||||
}
|
||||
if (counter.card.cardId === "teleport") {
|
||||
pipe.damage = 0;
|
||||
pipe.duration = 0;
|
||||
|
||||
@@ -844,3 +844,65 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBeLessThan(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapes and elemental walls as counteractions", () => {
|
||||
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const atk = giveCard(state, attacker, attackId);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
defenderCards.forEach((c, i) => { d.hand[i] = c; });
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: atk.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
return { state, attacker, defender, atk };
|
||||
}
|
||||
|
||||
it("invisible vanishes mid-stack: the sustained goes up, the hit needs a 1", () => {
|
||||
let { state, defender } = rigged("fireball", [
|
||||
{ instanceId: "invisible#T", cardId: "invisible" },
|
||||
{ instanceId: "number-3#T", cardId: "number-3" },
|
||||
]);
|
||||
state = must(state, defender, {
|
||||
type: "counteract", instanceId: "invisible#T", numberInstanceIds: ["number-3#T"],
|
||||
});
|
||||
const fx = state.sustained.find((s) => s.cardId === "invisible" && s.targetId === defender);
|
||||
expect(fx).toBeDefined();
|
||||
expect(fx!.remainingTurns).toBe(3);
|
||||
// The attached number left the hand with the spell.
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
expect(d.hand.some((c) => c.instanceId === "number-3#T")).toBe(false);
|
||||
});
|
||||
|
||||
it("anti-anti cannot pin the vanishing — escape is escape", () => {
|
||||
let { state, attacker, defender } = rigged("fireball", [
|
||||
{ instanceId: "invisible#T", cardId: "invisible" },
|
||||
]);
|
||||
state = must(state, defender, { type: "counteract", instanceId: "invisible#T" });
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
|
||||
const refused = applyCommand(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("waterwall meets the fireball and stops it whole", () => {
|
||||
let { state, attacker, defender } = rigged("fireball", [
|
||||
{ instanceId: "waterwall#T", cardId: "waterwall" },
|
||||
]);
|
||||
const lifeBefore = state.players.find((p) => p.id === defender)!.life;
|
||||
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
// A total stop: the attacker's declined answer resolves at once (rev 10+).
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
|
||||
});
|
||||
|
||||
it("waterwall refuses everything that is not a fireball", () => {
|
||||
let { state, defender } = rigged("dagger", [
|
||||
{ instanceId: "waterwall#T", cardId: "waterwall" },
|
||||
]);
|
||||
const refused = applyCommand(state, defender, { type: "counteract", instanceId: "waterwall#T" });
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,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 = 11;
|
||||
const RULES_REV = 12;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
const attackingCreature = $derived(
|
||||
view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null,
|
||||
);
|
||||
const myShieldstoneDisplayed = $derived(
|
||||
view?.players.find((p) => p.id === view.you)?.displayed.some((c) => c.cardId === "shieldstone") ?? false,
|
||||
);
|
||||
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
||||
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
|
||||
/** Picking up an object ends the turn's actions: the hand goes quiet until
|
||||
@@ -283,7 +286,17 @@
|
||||
selectedCard = card; // then click the escape square
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "counteract", instanceId: card.instanceId });
|
||||
// A number can ride a counter (INVISIBLE's duration) — unless a
|
||||
// displayed Shieldstone makes the number itself the counteraction.
|
||||
if (youMustRespond && isNumberCard(card.cardId) && !myShieldstoneDisplayed) {
|
||||
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: "counteract", instanceId: card.instanceId,
|
||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||||
});
|
||||
attachedNumber = null;
|
||||
return;
|
||||
}
|
||||
if (youMustDiscard || discardMode || discardSelection.size > 0) {
|
||||
|
||||
@@ -132,7 +132,7 @@ class LocalGame {
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
...(colors ? { colors } : {}),
|
||||
deckRev: 11,
|
||||
deckRev: 12,
|
||||
};
|
||||
const { state, events } = createGame(config);
|
||||
this.config = config;
|
||||
|
||||
Reference in New Issue
Block a user