Mist shields its wizard from creatures; a glued square turns with its sector; Waterbolt splits the power it is cast with
Three of Ramses's reports from KFJL: - Rev 28: MIST-BODY's "may not attack or be attacked" holds against creatures. A wraith's or democratic monster's touch, a fire imp's scorch, and an ordered troll/skeleton/shadow blow all pass the mist by; older games keep the strike their ledgers recorded. The automaton no longer orders its creatures at a misted wizard. - ROTATE and RELOCATE SECTOR threw while GLUE was down: GLUE keeps its cell's key in the sustained effect's edge slot, and the remap read it as an edge. It maps as a cell now (the throw never reached a ledger, so this ships ungated). - WATERBOLT's damage/knockback split was sized by the NUMBER's face; the engine weighs POWERSTONE's +1 and AMPLIFY's doubling, so a 5 behind a powerstone was refused for not totalling 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3368a92cf5
commit
8dcdfd2415
@@ -1472,7 +1472,9 @@ export function automatonCommand(
|
|||||||
// Command the menagerie: creatures march and maul before the wizard moves.
|
// Command the menagerie: creatures march and maul before the wizard moves.
|
||||||
for (const c of view.creatures) {
|
for (const c of view.creatures) {
|
||||||
if (c.controllerId !== you || c.justCreated) continue;
|
if (c.controllerId !== you || c.justCreated) continue;
|
||||||
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position));
|
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position) &&
|
||||||
|
// Rev 28: creatures cannot strike the mist either.
|
||||||
|
!(view.deckRev >= 28 && view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === p.id)));
|
||||||
if (enemyHere && !c.attackUsed &&
|
if (enemyHere && !c.attackUsed &&
|
||||||
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
|
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
|
||||||
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
|
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export interface CastParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||||
export const CURRENT_RULES_REV = 27;
|
export const CURRENT_RULES_REV = 28;
|
||||||
|
|
||||||
/** Every rulings revision since the baseline, newest last — the entries a
|
/** 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
|
* game's deckRev freezes it before or after. Shown to players as the house
|
||||||
@@ -279,6 +279,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
|
|||||||
{ 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: 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: 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." },
|
{ 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." },
|
||||||
|
{ rev: 28, note: "MIST-BODY's \"may not be attacked\" holds against creatures too: a wraith's chill or a democratic monster's claw passes through a misted wizard, a fire imp's scorch finds nothing to burn, and no troll, skeleton or shadow may be ordered to strike one. Older games let creatures attack the mist." },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface GameConfig {
|
export interface GameConfig {
|
||||||
@@ -624,6 +625,12 @@ function isMisted(state: GameState, playerId: PlayerId): boolean {
|
|||||||
return sustainedOn(state, playerId, "mist-body").length > 0;
|
return sustainedOn(state, playerId, "mist-body").length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rev 28: MIST-BODY — "You may not attack or be attacked" — shields its
|
||||||
|
* wizard from creatures as from wizards. Older games let them strike. */
|
||||||
|
function mistShieldsFromCreatures(state: GameState, p: PlayerState): boolean {
|
||||||
|
return (state.config.deckRev ?? 1) >= 28 && isMisted(state, p.id);
|
||||||
|
}
|
||||||
|
|
||||||
function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
|
function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
|
||||||
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
||||||
}
|
}
|
||||||
@@ -3188,6 +3195,7 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
|
|||||||
if (onlyPlayer && p.id !== onlyPlayer) continue;
|
if (onlyPlayer && p.id !== onlyPlayer) continue;
|
||||||
if (imp.justCreated && p.id === imp.controllerId) continue; // not its creator on creation turn
|
if (imp.justCreated && p.id === imp.controllerId) continue; // not its creator on creation turn
|
||||||
if (imp.scorchedThisTurn.includes(p.id)) continue;
|
if (imp.scorchedThisTurn.includes(p.id)) continue;
|
||||||
|
if (mistShieldsFromCreatures(state, p)) continue;
|
||||||
// "Imp cannot see through a FIREWALL!" — gameLos already blocks on
|
// "Imp cannot see through a FIREWALL!" — gameLos already blocks on
|
||||||
// firewall edges and terrain.
|
// firewall edges and terrain.
|
||||||
if (!gameLos(state, imp.position, p.position)) continue;
|
if (!gameLos(state, imp.position, p.position)) continue;
|
||||||
@@ -3221,6 +3229,7 @@ function creatureTouchOnArrival(state: GameState, events: GameEvent[], creature:
|
|||||||
for (const p of state.players) {
|
for (const p of state.players) {
|
||||||
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
||||||
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
||||||
|
if (mistShieldsFromCreatures(state, p)) continue;
|
||||||
if (creature.kind === "wraith" && !creature.attackUsed) {
|
if (creature.kind === "wraith" && !creature.attackUsed) {
|
||||||
creature.attackUsed = true;
|
creature.attackUsed = true;
|
||||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||||
@@ -3432,6 +3441,7 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
|
|||||||
return err("the creature must share its target's square");
|
return err("the creature must share its target's square");
|
||||||
}
|
}
|
||||||
if (targetPlayer && targetPlayer.id === creature.controllerId) return err("it will not hurt its creator");
|
if (targetPlayer && targetPlayer.id === creature.controllerId) return err("it will not hurt its creator");
|
||||||
|
if (targetPlayer && mistShieldsFromCreatures(state, targetPlayer)) return err("your target is mist and cannot be attacked");
|
||||||
|
|
||||||
creature.attackUsed = true;
|
creature.attackUsed = true;
|
||||||
let amount: number;
|
let amount: number;
|
||||||
@@ -3571,7 +3581,8 @@ function remapState(
|
|||||||
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
|
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
|
||||||
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
|
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
|
||||||
for (const fx of state.sustained) {
|
for (const fx of state.sustained) {
|
||||||
if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
|
// GLUE keeps its glued cell's key where edge spells keep an edge's.
|
||||||
|
if (fx.edge) fx.edge = fx.cardId === "glue" ? mapCellKey(fx.edge) : mapEdgeKey(fx.edge);
|
||||||
}
|
}
|
||||||
for (const p of state.players) {
|
for (const p of state.players) {
|
||||||
if (inSector(p.position)) p.position = mapCell(p.position);
|
if (inSector(p.position)) p.position = mapCell(p.position);
|
||||||
|
|||||||
@@ -1044,3 +1044,64 @@ describe("the troll's fist on a wall", () => {
|
|||||||
expect(again.ok).toBe(false);
|
expect(again.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("creatures cannot strike the mist (rev 28)", () => {
|
||||||
|
// MIST-BODY: "You may not attack or be attacked." Rev 28 holds the
|
||||||
|
// card against creatures as well as wizards; older games let them strike.
|
||||||
|
function mistRig(deckRev?: number) {
|
||||||
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
state = toRound2(state);
|
||||||
|
const creator = activePlayer(state);
|
||||||
|
state = summon(state, creator.id, "democratic-monster").state;
|
||||||
|
state = must(state, creator.id, { type: "endTurn", draw: 0 });
|
||||||
|
const mover = activePlayer(state);
|
||||||
|
const victim = state.players.find((p) => p.id !== mover.id)!;
|
||||||
|
pushSustained(state, { id: "fx-mist", cardId: "mist-body", casterId: victim.id, targetId: victim.id, remainingTurns: 2 });
|
||||||
|
const dm = state.creatures.find((c) => c.kind === "democratic-monster")!;
|
||||||
|
const spot = emptyNeighborCell(state, victim.position);
|
||||||
|
dm.position = { ...spot.cell };
|
||||||
|
return { state, mover: mover.id, victim: victim.id, dm: dm.id, into: opposite(spot.side) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a democratic monster passes through a misted wizard without clawing", () => {
|
||||||
|
const { state, mover, dm, into } = mistRig();
|
||||||
|
const r = applyCommand(state, mover, { type: "moveCreature", creatureId: dm, direction: into });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
expect(r.state.stack).toBeNull();
|
||||||
|
expect(r.events.some((e) => e.type === "creatureTouched")).toBe(false);
|
||||||
|
expect(r.state.creatures.find((c) => c.id === dm)!.attackUsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("older games let the monster claw the mist", () => {
|
||||||
|
const { state, mover, dm, into } = mistRig(27);
|
||||||
|
const r = applyCommand(state, mover, { type: "moveCreature", creatureId: dm, direction: into });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
expect(r.state.stack?.creatureId ?? null).toBe(dm);
|
||||||
|
});
|
||||||
|
|
||||||
|
function skeletonRig(deckRev?: number) {
|
||||||
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
state = toRound2(state);
|
||||||
|
const me = activePlayer(state).id;
|
||||||
|
state = summon(state, me, "skeleton").state;
|
||||||
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
||||||
|
const other = activePlayer(state).id;
|
||||||
|
state = must(state, other, { type: "endTurn", draw: 0 });
|
||||||
|
const victim = state.players.find((p) => p.id === other)!;
|
||||||
|
victim.position = { ...state.creatures[0]!.position };
|
||||||
|
pushSustained(state, { id: "fx-mist", cardId: "mist-body", casterId: other, targetId: other, remainingTurns: 2 });
|
||||||
|
return { state, me, victim: other, skeleton: state.creatures[0]!.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("no skeleton may be ordered to strike a misted wizard", () => {
|
||||||
|
const { state, me, victim, skeleton } = skeletonRig();
|
||||||
|
const r = applyCommand(state, me, { type: "creatureAttack", creatureId: skeleton, targetId: victim });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("older games let the order stand", () => {
|
||||||
|
const { state, me, victim, skeleton } = skeletonRig(27);
|
||||||
|
const r = applyCommand(state, me, { type: "creatureAttack", creatureId: skeleton, targetId: victim });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -331,6 +331,27 @@ describe("sector manipulation", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a glued treasure turns with its sector, and the glue turns with it (KFJL)", () => {
|
||||||
|
// GLUE keeps its cell's key in the sustained effect's edge slot; the
|
||||||
|
// rotation once read it as an edge key and threw.
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const chest = state.treasures.find((t) => t.owner === me.id)!;
|
||||||
|
const key = cellKey(chest.position!);
|
||||||
|
state.gluedCells[key] = true;
|
||||||
|
state.sustained.push({
|
||||||
|
id: "fx-glue", cardId: "glue", casterId: me.id, targetId: me.id, remainingTurns: 4, data: {}, edge: key,
|
||||||
|
});
|
||||||
|
const rs = giveCard(state, me.id, "rotate-sector");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: rs.instanceId,
|
||||||
|
target: { kind: "cell", cell: me.position }, params: { clockwise: true },
|
||||||
|
});
|
||||||
|
const moved = cellKey(state.treasures.find((t) => t.id === chest.id)!.position!);
|
||||||
|
expect(state.gluedCells).toEqual({ [moved]: true });
|
||||||
|
expect(state.sustained.find((s) => s.id === "fx-glue")!.edge).toBe(moved);
|
||||||
|
});
|
||||||
|
|
||||||
it("relocate sector slides everything and keeps adjacency", () => {
|
it("relocate sector slides everything and keeps adjacency", () => {
|
||||||
let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5
|
let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
|
|||||||
@@ -692,7 +692,14 @@
|
|||||||
if (selectedDef.los) return "Choose a target · lit squares are in your sight";
|
if (selectedDef.los) return "Choose a target · lit squares are in your sight";
|
||||||
return `${selectedDef.name} · its panel offers the choices`;
|
return `${selectedDef.name} · its panel offers the choices`;
|
||||||
});
|
});
|
||||||
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
/** The spell's power as the engine weighs it: the NUMBER's face, +1 for
|
||||||
|
* a displayed POWERSTONE, doubled per AMPLIFY riding along. WATERBOLT's
|
||||||
|
* split must total exactly this, or the cast is refused. */
|
||||||
|
const numberTotal = $derived.by(() => {
|
||||||
|
const stone = view?.players.find((p) => p.id === view.you)?.displayed.some((c) => c.cardId === "powerstone") ? 1 : 0;
|
||||||
|
const face = attachedNumber ? cardDef(attachedNumber.cardId).value! + stone : 1;
|
||||||
|
return face * 2 ** attachedMods.filter((m) => m.cardId === "amplify").length;
|
||||||
|
});
|
||||||
|
|
||||||
function dispatch(command: Parameters<typeof net.command>[0]) {
|
function dispatch(command: Parameters<typeof net.command>[0]) {
|
||||||
if (local.active) local.command(command);
|
if (local.active) local.command(command);
|
||||||
@@ -854,6 +861,7 @@
|
|||||||
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
|
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
|
||||||
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
|
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
|
||||||
: [...attachedMods, card];
|
: [...attachedMods, card];
|
||||||
|
wbDamage = numberTotal;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard?.instanceId === card.instanceId) {
|
if (selectedCard?.instanceId === card.instanceId) {
|
||||||
@@ -1372,7 +1380,8 @@
|
|||||||
};
|
};
|
||||||
withMods(cmd);
|
withMods(cmd);
|
||||||
if (selectedCard.cardId === "waterbolt") {
|
if (selectedCard.cardId === "waterbolt") {
|
||||||
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
|
const damage = Math.min(Math.max(0, wbDamage), numberTotal);
|
||||||
|
cmd.params = { damage, knockback: numberTotal - damage };
|
||||||
}
|
}
|
||||||
if (NAMED_CARDS.has(selectedCard.cardId)) {
|
if (NAMED_CARDS.has(selectedCard.cardId)) {
|
||||||
const id = nameToCardId(nameInput);
|
const id = nameToCardId(nameInput);
|
||||||
|
|||||||
Reference in New Issue
Block a user