Credibility pass: the collapse loses its scaffolding
Blind reviews over the rev-37..40 features and the gate collapse. The scripted surgery's scars come out: bare brace blocks unwrapped (with one hiding an unreachable scrambleHands), the amputated comment healed, the dead events parameter unthreaded from the illusion-sight chain, the laziness probe in perceivedBoard collapsed to the rule it always produced, GameView.deckRev retired with its last reader, the ward's arming stub told honestly, and every comment or test title still citing the retired revision numbering reworded to the rule it guards. Rim mechanics unify on rimWarpMouth and breachRim; the test suite gains plainRimWall and pushSustained and loses its loop-shaped scars and rev-named helpers. No behavior changes; 255 tests pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8a50ff7f36
commit
4a91d56d24
@@ -1318,10 +1318,10 @@ export function automatonCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// IDIOT's curse: the maze no longer forces the feet, so the
|
||||
// clockwork honors the march to its own gold itself — and when a thief
|
||||
// carries that gold, DROP OBJECT (an IDIOT aid) shakes it onto the
|
||||
// floor where it can finally be stood upon.
|
||||
// IDIOT's curse: the engine never forces the feet, so the clockwork
|
||||
// honors the march to its own gold itself — and when a thief carries
|
||||
// that gold, DROP OBJECT (the one attack the curse permits) shakes it
|
||||
// onto the floor where it can finally be stood upon.
|
||||
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
|
||||
if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) {
|
||||
const dropper = inHand(view, "drop-object");
|
||||
@@ -1361,17 +1361,13 @@ export function automatonCommand(
|
||||
pathToward(view, self.position, enemyCells, { canUnlock });
|
||||
// A shimmering illusion on the best crossing costs nothing to doubt:
|
||||
// roll the free test before spending any card on that wall.
|
||||
{
|
||||
const crossing = bestWallCrossing(view, self, objectives, canUnlock);
|
||||
if (crossing && crossing.total < (path?.distance ?? Infinity)) {
|
||||
const k = edgeKey(crossing.cell, crossing.side);
|
||||
if (view.illusionEdges[k] === "untested") {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const flanks = [crossing.cell, neighbor(crossing.cell, crossing.side)];
|
||||
if (flanks.some((c) => cellKey(c) === cellKey(self.position) || sighted.has(cellKey(c)))) {
|
||||
return { type: "testIllusion", cell: crossing.cell, side: crossing.side };
|
||||
}
|
||||
}
|
||||
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
|
||||
if (doubted && doubted.total < (path?.distance ?? Infinity) &&
|
||||
view.illusionEdges[edgeKey(doubted.cell, doubted.side)] === "untested") {
|
||||
const sighted = sightedCellsFor(view);
|
||||
const flanks = [doubted.cell, neighbor(doubted.cell, doubted.side)];
|
||||
if (flanks.some((c) => cellKey(c) === cellKey(self.position) || sighted.has(cellKey(c)))) {
|
||||
return { type: "testIllusion", cell: doubted.cell, side: doubted.side };
|
||||
}
|
||||
}
|
||||
// A wall between here and the gold may be cheaper to remove than to walk
|
||||
|
||||
+125
-167
@@ -21,6 +21,7 @@ import {
|
||||
cellKey,
|
||||
edgeKey,
|
||||
findWarp,
|
||||
type Warp,
|
||||
hasLineOfSight,
|
||||
sightBetween,
|
||||
neighbor,
|
||||
@@ -259,7 +260,6 @@ export interface GameState {
|
||||
wallDamage: Record<string, number>;
|
||||
/** Spells stuck in slime, waiting for the next visitor (by cell key). */
|
||||
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
|
||||
/** Players whose WARD is set to spring. */
|
||||
/** A treasure was just grabbed and its owner holds WARD:
|
||||
* the table waits while they choose to play it "at that time" or not. */
|
||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||
@@ -348,8 +348,8 @@ export function losBlockers(state: GameState): Record<string, true> {
|
||||
return blockers;
|
||||
}
|
||||
|
||||
/** A held-open door is an open doorway to the eye: REMOVE
|
||||
* LOCK's "still considered to block L.O.S." speaks of a CLOSED door, and
|
||||
/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still
|
||||
* considered to block L.O.S." speaks of a CLOSED door, and
|
||||
* the table holds doors open precisely to cast back through them. */
|
||||
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
||||
if (state.heldDoors.length === 0) return board;
|
||||
@@ -377,7 +377,6 @@ function parseEdgeKey(key: string): { cell: Cell; side: Side } {
|
||||
*/
|
||||
function illusionBelief(
|
||||
state: GameState,
|
||||
events: GameEvent[],
|
||||
playerId: PlayerId,
|
||||
key: string,
|
||||
): "believes" | "seesThrough" {
|
||||
@@ -387,35 +386,15 @@ function illusionBelief(
|
||||
return wall.belief[playerId] ?? "believes";
|
||||
}
|
||||
|
||||
/** The board as one player perceives it: believed illusions become walls. */
|
||||
function perceivedBoard(
|
||||
state: GameState,
|
||||
events: GameEvent[],
|
||||
viewerId: PlayerId,
|
||||
sightLine?: { from: Cell; to: Cell },
|
||||
): AssembledBoard {
|
||||
/** The board as one player perceives it: every illusion this viewer has not
|
||||
* seen through — their own excepted — stands as a wall. */
|
||||
function perceivedBoard(state: GameState, viewerId: PlayerId): AssembledBoard {
|
||||
const view = boardView(state);
|
||||
const keys = Object.keys(state.illusionWalls);
|
||||
if (keys.length === 0) return view;
|
||||
const edges = { ...view.edges };
|
||||
for (const key of keys) {
|
||||
const known = state.illusionWalls[key]!.belief[viewerId];
|
||||
const isCreator = state.illusionWalls[key]!.createdBy === viewerId;
|
||||
if (isCreator || known === "seesThrough") continue;
|
||||
if (known === "believes") {
|
||||
edges[key] = "wall";
|
||||
continue;
|
||||
}
|
||||
// Untested: only roll if this sight line would actually cross it.
|
||||
if (sightLine) {
|
||||
const test = { ...view, edges: { [key]: "wall" as const } };
|
||||
const crossesIt = !hasLineOfSight(test, sightLine.from, sightLine.to);
|
||||
if (crossesIt) {
|
||||
if (illusionBelief(state, events, viewerId, key) === "believes") edges[key] = "wall";
|
||||
}
|
||||
} else {
|
||||
edges[key] = "wall"; // no sight context: treat as real until tested
|
||||
}
|
||||
if (illusionBelief(state, viewerId, key) === "believes") edges[key] = "wall";
|
||||
}
|
||||
return { ...view, edges };
|
||||
}
|
||||
@@ -429,9 +408,8 @@ function casterLos(
|
||||
caster: PlayerState,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
events: GameEvent[] = [],
|
||||
): boolean {
|
||||
const board = openHeldDoors(state, perceivedBoard(state, events, caster.id, { from, to }));
|
||||
const board = openHeldDoors(state, perceivedBoard(state, caster.id));
|
||||
const blockers = losBlockers(state);
|
||||
if (sightBetween(board, from, to, blockers)) return true;
|
||||
if (!displays(caster, "visionstone")) return false;
|
||||
@@ -446,12 +424,12 @@ function casterLos(
|
||||
|
||||
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
||||
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean {
|
||||
if (casterLos(state, caster, from, to, events)) return true;
|
||||
if (casterLos(state, caster, from, to)) return true;
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if (casterLos(state, caster, from, mid, events) && casterLos(state, caster, mid, to, events)) {
|
||||
if (casterLos(state, caster, from, mid) && casterLos(state, caster, mid, to)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -594,7 +572,6 @@ export type GameEvent =
|
||||
| { type: "swapFizzled"; player: PlayerId }
|
||||
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
|
||||
| { type: "wardWindow"; owner: PlayerId; victim: PlayerId }
|
||||
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
|
||||
| { type: "chaosShielded"; player: PlayerId }
|
||||
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
|
||||
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
|
||||
@@ -657,7 +634,7 @@ export type Command =
|
||||
| { type: "punch"; targetId: PlayerId }
|
||||
| { type: "punchWall"; cell: Cell; side: Side }
|
||||
| { type: "testIllusion"; cell: Cell; side: Side }
|
||||
| { type: "armWard"; armed: boolean }
|
||||
| { type: "armWard" }
|
||||
| { type: "wardChoice"; play: boolean }
|
||||
| { type: "warpStep" }
|
||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||
@@ -831,6 +808,34 @@ function pairedWarpMouth(
|
||||
return w ? { cell: w.to.cell, side: w.to.side } : null;
|
||||
}
|
||||
|
||||
/** The warp behind (cell, side), when that edge is a rim mouth — an edge
|
||||
* whose far side is off the board but whose corridor wraps around. */
|
||||
function rimWarpMouth(board: AssembledBoard, cell: Cell, side: Side): Warp | undefined {
|
||||
return board.cells[cellKey(neighbor(cell, side))] ? undefined : findWarp(board, cell, side);
|
||||
}
|
||||
|
||||
/**
|
||||
* Break the maze's outer rim clean through at (cell, side): the default
|
||||
* wraparound connects opposite edges, so the matching far rim wall opens
|
||||
* with it and a new warp joins the two mouths. Null when the far side is
|
||||
* not a bare wall (nothing to break through into).
|
||||
*/
|
||||
function breachRim(
|
||||
state: GameState, events: GameEvent[], view: AssembledBoard, cell: Cell, side: Side,
|
||||
): { far: { cell: Cell; side: Side }; farKey: string; farPrior: EdgeState | null } | null {
|
||||
const far = oppositePerimeter(view, cell, side);
|
||||
const farKey = far ? edgeKey(far.cell, far.side) : "";
|
||||
if (!far || (boardView(state).edges[farKey] ?? "open") !== "wall") return null;
|
||||
const farPrior = state.edgeOverrides[farKey] ?? null;
|
||||
state.edgeOverrides[farKey] = "open";
|
||||
state.board.warps.push(
|
||||
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
|
||||
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
|
||||
);
|
||||
events.push({ type: "warpOpened", a: { cell, side }, b: far });
|
||||
return { far, farKey, farPrior };
|
||||
}
|
||||
|
||||
const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect> = {
|
||||
// --- Attacks: damage ------------------------------------------------------
|
||||
fireball: {
|
||||
@@ -946,7 +951,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
||||
const to = ctx.stack.params?.cell;
|
||||
if (!to) return; // pre-rev-35 ambushes carry no destination: fizzle
|
||||
if (!to) return; // an ambush armed without a destination fizzles
|
||||
const from = ctx.defender.position;
|
||||
ctx.defender.position = to;
|
||||
ctx.events.push({
|
||||
@@ -1127,32 +1132,21 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
delete state.createdEdges[pkey];
|
||||
events.push({ type: "wallDestroyed", caster: caster.id, edge: pair, wasDoor: false });
|
||||
}
|
||||
// Breaching the maze's outer rim breaks through BOTH sides — the
|
||||
// default wraparound connects opposite edges, so a new warp opens
|
||||
//.
|
||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||
if (offBoard && !pair) {
|
||||
const far = oppositePerimeter(view, cell, side);
|
||||
const farKey = far ? edgeKey(far.cell, far.side) : "";
|
||||
if (far && (boardView(state).edges[farKey] ?? "open") === "wall") {
|
||||
state.edgeOverrides[farKey] = "open";
|
||||
delete state.createdEdges[farKey];
|
||||
events.push({ type: "wallDestroyed", caster: caster.id, edge: far, wasDoor: false });
|
||||
for (const p of state.players) {
|
||||
if (p.alive && cellKey(p.position) === cellKey(far.cell)) {
|
||||
applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical");
|
||||
}
|
||||
// Breaching the maze's outer rim breaks through BOTH sides.
|
||||
const rim = !view.cells[cellKey(neighbor(cell, side))] && !pair
|
||||
? breachRim(state, events, view, cell, side) : null;
|
||||
if (rim) {
|
||||
delete state.createdEdges[rim.farKey];
|
||||
events.push({ type: "wallDestroyed", caster: caster.id, edge: rim.far, wasDoor: false });
|
||||
for (const p of state.players) {
|
||||
if (p.alive && cellKey(p.position) === cellKey(rim.far.cell)) {
|
||||
applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical");
|
||||
}
|
||||
for (const cr of [...state.creatures]) {
|
||||
if (cellKey(cr.position) === cellKey(far.cell)) {
|
||||
damageCreature(state, events, cr, 4, "collapsing wall");
|
||||
}
|
||||
}
|
||||
for (const cr of [...state.creatures]) {
|
||||
if (cellKey(cr.position) === cellKey(rim.far.cell)) {
|
||||
damageCreature(state, events, cr, 4, "collapsing wall");
|
||||
}
|
||||
state.board.warps.push(
|
||||
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
|
||||
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
|
||||
);
|
||||
events.push({ type: "warpOpened", a: { cell, side }, b: far });
|
||||
}
|
||||
}
|
||||
for (const c of [cell, neighbor(cell, side)]) {
|
||||
@@ -1395,10 +1389,10 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const { cell, side } = cmd.target;
|
||||
const view = boardView(state);
|
||||
// A warp mouth at the maze's rim is a corridor too — the wraparound
|
||||
// connects it to the opposite edge. The fire takes BOTH mouths, so the crossing burns
|
||||
// no matter which rim the wizard steps in from.
|
||||
// connects it to the opposite edge. The fire takes BOTH mouths, so
|
||||
// the crossing burns no matter which rim the wizard steps in from.
|
||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||
const warp = offBoard ? findWarp(view, cell, side) : undefined;
|
||||
const warp = rimWarpMouth(view, cell, side);
|
||||
if (!view.cells[cellKey(cell)] || (offBoard && !warp)) {
|
||||
return "the fire must span a corridor between two spaces";
|
||||
}
|
||||
@@ -1440,7 +1434,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
// A rim warp mouth is a corridor too: the collapse washes
|
||||
// both rims inward (waveFromEdge follows the tunnel).
|
||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||
const warpMouth = offBoard && findWarp(view, cell, side);
|
||||
const warpMouth = rimWarpMouth(view, cell, side);
|
||||
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
||||
return "the wave must span a corridor between two spaces";
|
||||
}
|
||||
@@ -1604,7 +1598,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
// create-wall's brick-over. The illusion hangs on the mouth it was
|
||||
// painted on; the far mouth shows nothing (nothing physical exists).
|
||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||
const warpMouth = offBoard && findWarp(view, cell, side);
|
||||
const warpMouth = rimWarpMouth(view, cell, side);
|
||||
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
||||
return "the illusion must span two spaces on the board";
|
||||
}
|
||||
@@ -1778,26 +1772,16 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
|
||||
state.edgeOverrides[key] = "open";
|
||||
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
|
||||
// A rim wall warped open bores a temporary wraparound: the
|
||||
// far rim wall vanishes with it and a warp runs until turn's end.
|
||||
// (Pre-40 rim casts opened a dead edge and replay so.)
|
||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||
if (offBoard && !findWarp(view, cell, side)) {
|
||||
const far = oppositePerimeter(view, cell, side);
|
||||
const farKey = far ? edgeKey(far.cell, far.side) : "";
|
||||
if (far && (view.edges[farKey] ?? "open") === "wall") {
|
||||
state.tempWarpEdges.push({
|
||||
key: farKey, prior: state.edgeOverrides[farKey] ?? null,
|
||||
unwarp: { a: cell, aSide: side, b: far.cell, bSide: far.side },
|
||||
});
|
||||
state.edgeOverrides[farKey] = "open";
|
||||
events.push({ type: "wallWarpedOpen", player: caster.id, edge: far });
|
||||
state.board.warps.push(
|
||||
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
|
||||
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
|
||||
);
|
||||
events.push({ type: "warpOpened", a: { cell, side }, b: far });
|
||||
}
|
||||
// A rim wall warped open bores a temporary wraparound: the far rim
|
||||
// wall vanishes with it and a warp runs until turn's end.
|
||||
const rim = !view.cells[cellKey(neighbor(cell, side))] && !findWarp(view, cell, side)
|
||||
? breachRim(state, events, view, cell, side) : null;
|
||||
if (rim) {
|
||||
state.tempWarpEdges.push({
|
||||
key: rim.farKey, prior: rim.farPrior,
|
||||
unwarp: { a: cell, aSide: side, b: rim.far.cell, bSide: rim.far.side },
|
||||
});
|
||||
events.push({ type: "wallWarpedOpen", player: caster.id, edge: rim.far });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -1852,16 +1836,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const { cell, side } = cmd.target;
|
||||
const key = edgeKey(cell, side);
|
||||
const view = boardView(state);
|
||||
// No doors through the maze's rim: a rim door would open onto
|
||||
// nothing traversable — the wraparound belongs to breaches, not doors.
|
||||
if (!view.cells[cellKey(neighbor(cell, side))]) {
|
||||
// Cells on both sides: a rim door would open onto nothing
|
||||
// traversable — the wraparound belongs to breaches, not doors.
|
||||
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
|
||||
return "the door must stand between two spaces";
|
||||
}
|
||||
if ((view.edges[key] ?? "open") === "open") {
|
||||
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
|
||||
return "the door must stand between two spaces";
|
||||
}
|
||||
} else if (view.edges[key] !== "wall") {
|
||||
if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") {
|
||||
return "a door goes into a stone wall or an open corridor";
|
||||
}
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
@@ -1969,8 +1949,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (cmd.target?.kind === "edge") {
|
||||
const { cell, side } = cmd.target;
|
||||
const key = edgeKey(cell, side);
|
||||
// Doors are "small entryways in a stone wall" — stone melts as
|
||||
// stone.
|
||||
// Doors are "small entryways in a stone wall": stone melts as stone.
|
||||
const current = view.edges[key];
|
||||
const meltable = current === "wall" || current === "door";
|
||||
if (!meltable) return "that is not a stone wall";
|
||||
@@ -1991,20 +1970,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
// Melting the maze's outer rim breaches it like DESTROY WALL does:
|
||||
// the far rim wall washes out too and a new warp opens, so the
|
||||
// wave below pours through the fresh tunnel onto both rims.
|
||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||
if (offBoard && !pair) {
|
||||
const far = oppositePerimeter(view, cell, side);
|
||||
const farKey = far ? edgeKey(far.cell, far.side) : "";
|
||||
if (far && (boardView(state).edges[farKey] ?? "open") === "wall") {
|
||||
state.edgeOverrides[farKey] = "open";
|
||||
delete state.createdEdges[farKey];
|
||||
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: far });
|
||||
state.board.warps.push(
|
||||
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
|
||||
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
|
||||
);
|
||||
events.push({ type: "warpOpened", a: { cell, side }, b: far });
|
||||
}
|
||||
const rim = !view.cells[cellKey(neighbor(cell, side))] && !pair
|
||||
? breachRim(state, events, view, cell, side) : null;
|
||||
if (rim) {
|
||||
delete state.createdEdges[rim.farKey];
|
||||
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: rim.far });
|
||||
}
|
||||
// "Wall turns into a WATERWALL with a range and damage of 2."
|
||||
waveFromEdge(state, events, cell, side, 2);
|
||||
@@ -2141,7 +2111,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
||||
const to = ctx.stack.params?.cell;
|
||||
if (!to) return; // pre-rev-35 ambushes carry no destination: fizzle
|
||||
if (!to) return; // an ambush armed without a destination fizzles
|
||||
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
|
||||
const from = ctx.defender.position;
|
||||
ctx.defender.position = to;
|
||||
@@ -2217,16 +2187,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped) return;
|
||||
const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
|
||||
{
|
||||
const order = turnOrderFrom(ctx.state, ctx.attacker.id);
|
||||
const queue = order.filter((id) =>
|
||||
id !== ctx.attacker.id && id !== ctx.defender.id &&
|
||||
ctx.state.players.find((p) => p.id === id)!.alive);
|
||||
ctx.state.chaosPending = { casterId: ctx.attacker.id, excluded, queue };
|
||||
finishChaosIfReady(ctx.state, ctx.events);
|
||||
return;
|
||||
}
|
||||
scrambleHands(ctx.state, ctx.events, ctx.attacker.id, excluded);
|
||||
const order = turnOrderFrom(ctx.state, ctx.attacker.id);
|
||||
const queue = order.filter((id) =>
|
||||
id !== ctx.attacker.id && id !== ctx.defender.id &&
|
||||
ctx.state.players.find((p) => p.id === id)!.alive);
|
||||
ctx.state.chaosPending = { casterId: ctx.attacker.id, excluded, queue };
|
||||
finishChaosIfReady(ctx.state, ctx.events);
|
||||
},
|
||||
},
|
||||
"illusionary-attack": {
|
||||
@@ -2263,9 +2229,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped) return;
|
||||
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
|
||||
// "Swap any two carried items": every movable object trades — daggers,
|
||||
// rocks, wands, stones — and a carried TREASURE is an
|
||||
// item too, named by the "treasure" token (never in older ledgers).
|
||||
// "Swap any two carried items": every movable object trades —
|
||||
// daggers, rocks, wands, stones — and a carried TREASURE is an item
|
||||
// too, named by the "treasure" token.
|
||||
const tradable = (id: string) => isMovableObject(id);
|
||||
type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" };
|
||||
const side = (p: PlayerState, id: string): TradeSide | null => {
|
||||
@@ -2430,7 +2396,6 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
// "Signify their new directions by placing the A tokens on one set of
|
||||
// exits & the B tokens on the other" — the two exits you choose become a
|
||||
// pair, and their former partners pair with each other.
|
||||
// Earlier revisions swapped the two exits' destinations instead.
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
const a = cmd.params?.cell;
|
||||
const bT = cmd.target;
|
||||
@@ -2475,7 +2440,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const aim = cmd.target.cell;
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(aim)]) return "off the board";
|
||||
if (!casterLos(state, caster, caster.position, aim, events)) return "no line of sight";
|
||||
if (!casterLos(state, caster, caster.position, aim)) return "no line of sight";
|
||||
state.turn.attackUsed = true;
|
||||
|
||||
const clampToBoard = (c: Cell): Cell => {
|
||||
@@ -2621,11 +2586,11 @@ function terrainEffect(kind: SquareContent["kind"]): NeutralEffect {
|
||||
|
||||
/**
|
||||
* How hard a wave still pushes someone it finds `dist` cells from its
|
||||
* source: the force spent reaching them is gone, so a
|
||||
* range-2 wave throws its adjacent victim 2 spaces but a victim at its far
|
||||
* edge only 1 — and never converts spent force into crush damage.
|
||||
* source: the force spent reaching them is gone, so a range-2 wave throws
|
||||
* its adjacent victim 2 spaces but a victim at its far edge only 1 — and
|
||||
* never converts spent force into crush damage.
|
||||
*/
|
||||
function waveForce(state: GameState, range: number, dist: number): number {
|
||||
function waveForce(range: number, dist: number): number {
|
||||
return range - dist;
|
||||
}
|
||||
|
||||
@@ -2633,8 +2598,7 @@ function waveForce(state: GameState, range: number, dist: number): number {
|
||||
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
|
||||
// A warp mouth's "far side" is the opposite rim: the collapse washes both
|
||||
// mouths inward. A plain edge washes its two adjacent cells apart.
|
||||
const warp = !boardView(state).cells[cellKey(neighbor(cell, side))]
|
||||
? findWarp(boardView(state), cell, side) : undefined;
|
||||
const warp = rimWarpMouth(boardView(state), cell, side);
|
||||
const pushes: { start: Cell; dir: Side }[] = warp
|
||||
? [
|
||||
{ start: cell, dir: opposite(side) },
|
||||
@@ -2647,7 +2611,7 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
|
||||
for (const { start, dir } of pushes) {
|
||||
let probe = start;
|
||||
for (let dist = 0; dist < range; dist++) {
|
||||
const force = waveForce(state, range, dist);
|
||||
const force = waveForce(range, dist);
|
||||
for (const p of state.players) {
|
||||
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
|
||||
}
|
||||
@@ -2675,7 +2639,7 @@ function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range
|
||||
let probe = center;
|
||||
for (let dist = 0; dist < range; dist++) {
|
||||
probe = neighbor(probe, dir);
|
||||
const force = waveForce(state, range, dist);
|
||||
const force = waveForce(range, dist);
|
||||
for (const p of state.players) {
|
||||
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
|
||||
}
|
||||
@@ -2708,7 +2672,7 @@ function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
|
||||
if (!view.cells[cellKey(at)]) return "off the board";
|
||||
if (state.squareContents[cellKey(at)]) return "that square is blocked";
|
||||
if (creatureAt(state, at)) return "a creature is already there";
|
||||
if (!casterLos(state, caster, caster.position, at, events)) return "no line of sight";
|
||||
if (!casterLos(state, caster, caster.position, at)) return "no line of sight";
|
||||
state.turn.attackUsed = true;
|
||||
spawnCreature(state, events, kind, caster.id, at);
|
||||
return null;
|
||||
@@ -2784,9 +2748,9 @@ function washBackN(state: GameState, events: GameEvent[], p: PlayerState, dir: S
|
||||
}
|
||||
}
|
||||
|
||||
/** The wave carries monsters as it carries wizards: washed
|
||||
* back, and crushed a point per space the maze refuses them. Fire imps
|
||||
* never reach this — water destroys them outright. */
|
||||
/** The wave carries monsters as it carries wizards: washed back, and
|
||||
* crushed a point per space the maze refuses them. Fire imps never reach
|
||||
* this — water destroys them outright. */
|
||||
function washBackCreature(state: GameState, events: GameEvent[], c: CreatureState, dir: Side, range: number): void {
|
||||
const view = boardView(state);
|
||||
let moved = 0;
|
||||
@@ -3170,20 +3134,18 @@ function remapState(
|
||||
if (t.position && inSector(t.position)) t.position = mapCell(t.position);
|
||||
}
|
||||
// A moving sector carries everything standing on it.
|
||||
{
|
||||
for (const c of state.creatures) {
|
||||
if (inSector(c.position)) c.position = mapCell(c.position);
|
||||
}
|
||||
for (const t of state.boobytraps) {
|
||||
t.cells = t.cells.map((c) => (inSector(c) ? mapCell(c) : c));
|
||||
t.realKey = mapCellKey(t.realKey);
|
||||
}
|
||||
state.gluedCells = remapRecord(state.gluedCells, mapCellKey);
|
||||
state.openSafes = state.openSafes.map(mapCellKey);
|
||||
for (const w of state.dimWarps) {
|
||||
if (inSector(w.a)) w.a = mapCell(w.a);
|
||||
if (inSector(w.b)) w.b = mapCell(w.b);
|
||||
}
|
||||
for (const c of state.creatures) {
|
||||
if (inSector(c.position)) c.position = mapCell(c.position);
|
||||
}
|
||||
for (const t of state.boobytraps) {
|
||||
t.cells = t.cells.map((c) => (inSector(c) ? mapCell(c) : c));
|
||||
t.realKey = mapCellKey(t.realKey);
|
||||
}
|
||||
state.gluedCells = remapRecord(state.gluedCells, mapCellKey);
|
||||
state.openSafes = state.openSafes.map(mapCellKey);
|
||||
for (const w of state.dimWarps) {
|
||||
if (inSector(w.a)) w.a = mapCell(w.a);
|
||||
if (inSector(w.b)) w.b = mapCell(w.b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3805,7 +3767,6 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
||||
const p = activePlayer(state);
|
||||
const events: GameEvent[] = [];
|
||||
|
||||
|
||||
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
|
||||
// move ... bumping into a wall counts as one space of movement."
|
||||
if (isBlinded(state, p)) {
|
||||
@@ -3820,7 +3781,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
||||
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
||||
return err("the wall shimmers oddly — test your eyes before you can pass");
|
||||
}
|
||||
if (shimmer && illusionBelief(state, events, p.id, key) === "believes") {
|
||||
if (shimmer && illusionBelief(state, p.id, key) === "believes") {
|
||||
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
||||
return err("blocked by wall");
|
||||
}
|
||||
@@ -4300,9 +4261,8 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
||||
}
|
||||
|
||||
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
|
||||
/** Compat stub for stale clients: the Ward has no arming step. */
|
||||
function doArmWard(): CommandResult {
|
||||
// The card is read literally: the Ward is played in the moment of the
|
||||
// grab, never set ahead.
|
||||
return err("the Ward is played in the moment — you will be asked when your treasure is grabbed");
|
||||
}
|
||||
|
||||
@@ -4352,7 +4312,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
|
||||
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
|
||||
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
|
||||
const events: GameEvent[] = [];
|
||||
const board = openHeldDoors(state, perceivedBoard(state, events, p.id));
|
||||
const board = openHeldDoors(state, perceivedBoard(state, p.id));
|
||||
if (!isAdjacentToEdge(p.position, cell, side) &&
|
||||
!losToEdge(board, p.position, cell, side)) {
|
||||
return err("you cannot see that wall from here");
|
||||
@@ -4788,7 +4748,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (effect.requiresLos) {
|
||||
const sighted = mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, target.position, preEvents)
|
||||
: casterLos(state, caster, caster.position, target.position, preEvents);
|
||||
: casterLos(state, caster, caster.position, target.position);
|
||||
if (!sighted) return err("no line of sight to the target");
|
||||
}
|
||||
// Attacking someone breaks any BUDDY pact you swore to them.
|
||||
@@ -6255,20 +6215,18 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
||||
// "If you have SPEED on you while under the influence of a duration
|
||||
// spell, it uses up a turn of that duration" — recipient-counted (FAQ).
|
||||
// Self-cast durations already burned in beginTurnFor.
|
||||
{
|
||||
const surviving: SustainedEffect[] = [];
|
||||
for (const s of state.sustained) {
|
||||
if (s.targetId === p.id && s.casterId !== p.id) {
|
||||
s.remainingTurns--;
|
||||
if (s.remainingTurns <= 0) {
|
||||
expireEffect(state, events, s);
|
||||
continue;
|
||||
}
|
||||
const surviving: SustainedEffect[] = [];
|
||||
for (const s of state.sustained) {
|
||||
if (s.targetId === p.id && s.casterId !== p.id) {
|
||||
s.remainingTurns--;
|
||||
if (s.remainingTurns <= 0) {
|
||||
expireEffect(state, events, s);
|
||||
continue;
|
||||
}
|
||||
surviving.push(s);
|
||||
}
|
||||
state.sustained = surviving;
|
||||
surviving.push(s);
|
||||
}
|
||||
state.sustained = surviving;
|
||||
events.push({ type: "extraTurnStarted", player: p.id });
|
||||
events.push({ type: "turnStarted", player: p.id, round: state.turn.round });
|
||||
return { ok: true, state, events };
|
||||
|
||||
@@ -42,9 +42,6 @@ export interface GameView {
|
||||
winReason: "treasures" | "lastStanding" | null;
|
||||
turn: TurnState;
|
||||
activePlayerId: PlayerId;
|
||||
/** The game's rules revision (a fresh count from 1; gates return if the
|
||||
* rules ever fork again while games are live). */
|
||||
deckRev: number;
|
||||
/** Board with dynamic wall changes already merged in. */
|
||||
board: AssembledBoard;
|
||||
players: PlayerPublicView[];
|
||||
@@ -139,7 +136,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
revealedHands: state.phase === "finished"
|
||||
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
|
||||
: null,
|
||||
deckRev: state.config.deckRev ?? 1,
|
||||
treasures: state.treasures.map((t) => ({ ...t })),
|
||||
deckCount: state.deck.length,
|
||||
discardCount: state.discard.length,
|
||||
|
||||
Reference in New Issue
Block a user