Attacks aimed at a bush or the ooze: five points clear the square
A THORNBUSH or ROSEBUSH "will be destroyed" by five points of damage, and the FAQ lets only fire hurt a KILLER OOZE; the table refused every attack aimed at one. An attack tapped onto a bush or ooze now lands on it, the damage accumulating across attackers and turns, and the fifth point clears the square. A WIZARDBLADE is swung from the square beside it, as its text asks of a target that fills a square. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
23b4a7675e
commit
bc99ee3eb7
@@ -610,6 +610,9 @@ function castSightEdge(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The attacks that burn: what the FAQ lets hurt a KILLER OOZE. */
|
||||||
|
const FIRE_ATTACKS = new Set(["fireball"]);
|
||||||
|
|
||||||
function inThornbush(state: GameState, p: PlayerState): boolean {
|
function inThornbush(state: GameState, p: PlayerState): boolean {
|
||||||
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
|
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
|
||||||
}
|
}
|
||||||
@@ -786,6 +789,8 @@ export type GameEvent =
|
|||||||
| { type: "slowDeathCountered"; player: PlayerId; cardId: string; remaining: number }
|
| { type: "slowDeathCountered"; player: PlayerId; cardId: string; remaining: number }
|
||||||
| { type: "safeDamaged"; attacker: PlayerId; cell: Cell; amount: number; total: number }
|
| { type: "safeDamaged"; attacker: PlayerId; cell: Cell; amount: number; total: number }
|
||||||
| { type: "safeSmashed"; attacker: PlayerId; cell: Cell }
|
| { type: "safeSmashed"; attacker: PlayerId; cell: Cell }
|
||||||
|
| { type: "squareContentDamaged"; attacker: PlayerId; cell: Cell; kind: "thornbush" | "rosebush" | "ooze"; amount: number; total: number; needed: number }
|
||||||
|
| { type: "squareContentDestroyed"; attacker: PlayerId; cell: Cell; kind: "thornbush" | "rosebush" | "ooze" }
|
||||||
| { type: "trapSprung"; player: PlayerId; cardId?: string }
|
| { type: "trapSprung"; player: PlayerId; cardId?: string }
|
||||||
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
|
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
|
||||||
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
||||||
@@ -5450,6 +5455,53 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
}
|
}
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
// A THORNBUSH or ROSEBUSH: "Five points of damage will destroy it." A
|
||||||
|
// KILLER OOZE: "Only fire ... will hurt the ooze" (FAQ), and five points
|
||||||
|
// burn it away. Damage accumulates across attackers and turns. A
|
||||||
|
// same-square attack (WIZARDBLADE) is swung from a square beside it:
|
||||||
|
// "If target fills an entire square, you must be in an adjacent square."
|
||||||
|
if (cmd.target?.kind === "cell") {
|
||||||
|
const cell = cmd.target.cell;
|
||||||
|
const content = state.squareContents[cellKey(cell)];
|
||||||
|
if (content && (content.kind === "thornbush" || content.kind === "rosebush" || content.kind === "ooze")) {
|
||||||
|
const what = content.kind === "ooze" ? "ooze" : content.kind;
|
||||||
|
if (effect.sameSquare) {
|
||||||
|
const dx = cell.x - origin.x, dy = cell.y - origin.y;
|
||||||
|
const side: Side | null = dx === 1 && dy === 0 ? "E" : dx === -1 && dy === 0 ? "W" : dy === 1 && dx === 0 ? "S" : dy === -1 && dx === 0 ? "N" : null;
|
||||||
|
if (!side || boardView(state).edges[edgeKey(origin, side)] === "wall") {
|
||||||
|
return err(`you must stand beside the ${what} to use that`);
|
||||||
|
}
|
||||||
|
} else if (effect.requiresLos && !castSight(state, caster, cmd, cell)) {
|
||||||
|
return err(`no line of sight to the ${what}`);
|
||||||
|
}
|
||||||
|
if (content.kind === "ooze" && !FIRE_ATTACKS.has(inHand.cardId)) {
|
||||||
|
return err("only fire hurts the ooze");
|
||||||
|
}
|
||||||
|
const wandEvents: GameEvent[] = [];
|
||||||
|
{
|
||||||
|
const werr = spendWandCharge(state, caster, wandEvents);
|
||||||
|
if (werr) return err(werr);
|
||||||
|
}
|
||||||
|
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
|
||||||
|
if (dmg <= 0) return err(`that spell would not singe the ${what}`);
|
||||||
|
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||||
|
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||||
|
state.turn.attackUsed = true;
|
||||||
|
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||||
|
const events: GameEvent[] = [...wandEvents, {
|
||||||
|
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||||
|
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||||
|
from: origin, target: null, targetCell: { ...cell },
|
||||||
|
}];
|
||||||
|
content.damage += dmg;
|
||||||
|
events.push({ type: "squareContentDamaged", attacker: caster.id, cell: { ...cell }, kind: content.kind, amount: dmg, total: content.damage, needed: 5 });
|
||||||
|
if (content.damage >= 5) {
|
||||||
|
delete state.squareContents[cellKey(cell)];
|
||||||
|
events.push({ type: "squareContentDestroyed", attacker: caster.id, cell: { ...cell }, kind: content.kind });
|
||||||
|
}
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
}
|
||||||
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
|
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
|
||||||
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
|
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
|
||||||
// washes the slime away instead.
|
// washes the slime away instead.
|
||||||
|
|||||||
@@ -705,3 +705,68 @@ describe("spells cast at a slime wait in the gel", () => {
|
|||||||
expect(hit.slimeTraps[cellKey(spot.cell)]).toBeUndefined();
|
expect(hit.slimeTraps[cellKey(spot.cell)]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("attacks aimed at a bush or the ooze", () => {
|
||||||
|
function withContent(kind: "thornbush" | "rosebush" | "ooze") {
|
||||||
|
const state = toRound2(newGame().state);
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const { cell } = emptyNeighborCell(state, me.position);
|
||||||
|
state.squareContents[cellKey(cell)] = { kind, damage: 0, createdBy: "bob" };
|
||||||
|
return { state, me, cell };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a FIREBALL tears a thornbush apart at five points", () => {
|
||||||
|
const { state, me, cell } = withContent("thornbush");
|
||||||
|
const fireball = giveCard(state, me.id, "fireball");
|
||||||
|
const r = applyCommand(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDamaged", kind: "thornbush", amount: 5, total: 5 }));
|
||||||
|
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDestroyed", kind: "thornbush" }));
|
||||||
|
expect(r.state.squareContents[cellKey(cell)]).toBeUndefined();
|
||||||
|
expect(r.state.turn.attackUsed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a thrown dagger scratches a rosebush, and the scratch stays", () => {
|
||||||
|
const { state, me, cell } = withContent("rosebush");
|
||||||
|
const dagger = giveCard(state, me.id, "dagger");
|
||||||
|
const next = must(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
|
||||||
|
expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "rosebush", damage: 3 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only fire hurts the ooze", () => {
|
||||||
|
const { state, me, cell } = withContent("ooze");
|
||||||
|
const dagger = giveCard(state, me.id, "dagger");
|
||||||
|
const refused = applyCommand(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
if (!refused.ok) expect(refused.error).toMatch(/only fire/);
|
||||||
|
const fireball = giveCard(state, me.id, "fireball");
|
||||||
|
const burned = must(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
|
||||||
|
expect(burned.squareContents[cellKey(cell)]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a WIZARDBLADE is swung at a bush from the square beside it, never from afar", () => {
|
||||||
|
const { state, me, cell } = withContent("thornbush");
|
||||||
|
const blade = giveCard(state, me.id, "wizardblade");
|
||||||
|
const three = giveCard(state, me.id, "number-3", "N", 1);
|
||||||
|
const near = applyCommand(state, me.id, {
|
||||||
|
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell },
|
||||||
|
});
|
||||||
|
expect(near.ok).toBe(true);
|
||||||
|
if (near.ok) expect(near.state.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 3 }));
|
||||||
|
|
||||||
|
const far = sightedCellsFor(viewFor(state, me.id));
|
||||||
|
const farKey = [...far].find((k) => {
|
||||||
|
const [x, y] = k.split(",").map(Number);
|
||||||
|
return Math.abs(x! - me.position.x) + Math.abs(y! - me.position.y) >= 2 && !state.squareContents[k];
|
||||||
|
});
|
||||||
|
if (!farKey) return;
|
||||||
|
const [fx, fy] = farKey.split(",").map(Number);
|
||||||
|
state.squareContents[farKey] = { kind: "thornbush", damage: 0, createdBy: "bob" };
|
||||||
|
const refused = applyCommand(state, me.id, {
|
||||||
|
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell: { x: fx!, y: fy! } },
|
||||||
|
});
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
if (!refused.ok) expect(refused.error).toMatch(/beside/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -876,14 +876,22 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A SAFE takes the cast itself: a key turns its combination, and an
|
// A SAFE takes the cast itself: a key turns its combination, and an
|
||||||
// attack batters the box (fifteen points bursts it).
|
// attack batters the box (fifteen points bursts it). Bushes and the
|
||||||
if (selectedCard && view.squareContents[cellKey(cell)]?.kind === "safe" &&
|
// ooze take attacks the same way; five points clear the square.
|
||||||
|
const filled = view.squareContents[cellKey(cell)]?.kind;
|
||||||
|
if (selectedCard && filled === "safe" &&
|
||||||
(selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key" ||
|
(selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key" ||
|
||||||
cardDef(selectedCard.cardId).cardType === "attack")) {
|
cardDef(selectedCard.cardId).cardType === "attack")) {
|
||||||
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
|
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
|
||||||
clearSelection();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selectedCard && (filled === "thornbush" || filled === "rosebush" || filled === "ooze") &&
|
||||||
|
cardDef(selectedCard.cardId).cardType === "attack") {
|
||||||
|
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (selectedCard?.cardId === "relocate-sector") {
|
if (selectedCard?.cardId === "relocate-sector") {
|
||||||
// Any board click (re-)picks the sector; the landing is chosen from the
|
// Any board click (re-)picks the sector; the landing is chosen from the
|
||||||
// dashed ghost slots beyond the maze, since every on-board slot is taken.
|
// dashed ghost slots beyond the maze, since every on-board slot is taken.
|
||||||
@@ -992,7 +1000,7 @@
|
|||||||
// A card in hand is aimed, not walked with: a tap on a bare square
|
// A card in hand is aimed, not walked with: a tap on a bare square
|
||||||
// while holding one is a miss, never a stride into whatever is there.
|
// while holding one is a miss, never a stride into whatever is there.
|
||||||
if (selectedCard) {
|
if (selectedCard) {
|
||||||
net.flash(`${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`);
|
net.flash(`${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, a bush, or a slime — put the card down to walk`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A cell click is a move if the cell is one legal step away (the server
|
// A cell click is a move if the cell is one legal step away (the server
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
|
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
|
||||||
case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
|
case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
|
||||||
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`;
|
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`;
|
||||||
|
case "squareContentDamaged": return `${e.attacker} ${e.kind === "ooze" ? "burns" : "batters"} the ${e.kind} — ${e.amount} damage (${e.total}/${e.needed}).`;
|
||||||
|
case "squareContentDestroyed": return e.kind === "ooze" ? `🔥 The ooze burns away under ${e.attacker}'s fire!` : `🌿 The ${e.kind} is torn apart by ${e.attacker}'s attack!`;
|
||||||
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — a counter hovers over the wound…`;
|
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — a counter hovers over the wound…`;
|
||||||
case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot — ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
|
case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot — ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
|
||||||
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||||
|
|||||||
Reference in New Issue
Block a user