Receipts under resolved attacks; the cast line clears; the table holds still; teleport wraps
Each resolved attack now folds a receipt under its line, read off the engine's own record: the blow as it came in, what each counter left of it in the order they were weighed, what landed, and whose life moved. The resolution event carries the incoming damage and duration and the trail of counters for it. Hotseat chronicles carry the same. The line a cast was accepted on stayed on the board: its timer compared a reactive proxy to the object it held. Kept raw, it clears. The board jumped while walking. The "sending" note added a line beside End turn and took it away again, and the refusal toast stood in the flow above the table for five seconds; both now float and move nothing. Teleport reaches through the lettered openings in the engine, but the board dimmed the squares beyond them. The eligibility map follows the warps as the engine does. 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
7d0c1c68a3
commit
648f6ab8cd
@@ -661,7 +661,10 @@ export type GameEvent =
|
||||
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
|
||||
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance }
|
||||
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" | "outran" }
|
||||
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean }
|
||||
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean;
|
||||
/** The blow before any counter, and what each counter left of it, in
|
||||
* the order they were weighed — the receipt the table reads. */
|
||||
incoming?: number; incomingDuration?: number; trail?: CounterStep[] }
|
||||
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
|
||||
soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] }
|
||||
| { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" | "soulstone" }
|
||||
@@ -946,6 +949,17 @@ interface ResolutionContext {
|
||||
stack: CastStack;
|
||||
}
|
||||
|
||||
/** One counter's mark on the blow: the damage, the half sent back, and
|
||||
* the duration left after it was weighed (unchanged when nullified). */
|
||||
export interface CounterStep {
|
||||
cardId: string;
|
||||
player: PlayerId;
|
||||
nullified: boolean;
|
||||
damage: number;
|
||||
reflected: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface DamagePipeline {
|
||||
damage: number;
|
||||
duration: number;
|
||||
@@ -6304,6 +6318,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
// Whether this attack even tries to wound: utility attacks (DROP OBJECT,
|
||||
// TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped.
|
||||
const dealsDamage = base > 0;
|
||||
const trail: CounterStep[] = [];
|
||||
const receipt = { incoming: base, incomingDuration: baseDuration, trail };
|
||||
const pipe: DamagePipeline = {
|
||||
damage: base,
|
||||
duration: baseDuration,
|
||||
@@ -6322,18 +6338,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
}
|
||||
}
|
||||
for (const counter of stack.counters) {
|
||||
trail.push({ cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration });
|
||||
const mark = trail[trail.length - 1]!;
|
||||
const weigh = () => { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; };
|
||||
if (counter.nullified) continue;
|
||||
if (isNumberCard(counter.card.cardId)) {
|
||||
// SHIELDSTONE number counter: reduce point AND duration effects.
|
||||
const v = numberValue(counter.card.cardId);
|
||||
pipe.damage = Math.max(0, pipe.damage - v);
|
||||
pipe.duration = Math.max(0, pipe.duration - v);
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") {
|
||||
@@ -6343,6 +6364,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
pipe.damage = 0;
|
||||
pipe.duration = 0;
|
||||
pipe.fullyStopped = true;
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
if (stack.creatureId &&
|
||||
@@ -6355,10 +6377,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
} else {
|
||||
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
|
||||
}
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
const ce = CARD_EFFECTS[counter.card.cardId];
|
||||
if (ce && ce.kind === "counter") ce.apply(pipe);
|
||||
weigh();
|
||||
}
|
||||
|
||||
// A surviving teleport counter whisks the defender away before anything lands.
|
||||
@@ -6390,7 +6414,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
});
|
||||
}
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
type: "attackResolved", ...receipt,
|
||||
attacker: attacker.id,
|
||||
defender: defender.id,
|
||||
attackCardId: attackId,
|
||||
@@ -6422,7 +6446,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
reflectedBase: { damage: pipe.damage, duration: pipe.duration },
|
||||
};
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
type: "attackResolved", ...receipt,
|
||||
attacker: attacker.id,
|
||||
defender: defender.id,
|
||||
attackCardId: attackId,
|
||||
@@ -6512,7 +6536,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
type: "attackResolved", ...receipt,
|
||||
attacker: attacker.id,
|
||||
defender: defender.id,
|
||||
attackCardId: attackId,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// The server sends this after every state change; clients never see the
|
||||
// deck order or other players' hands.
|
||||
|
||||
import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { neighbor, SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import {
|
||||
boardView,
|
||||
@@ -516,7 +516,9 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
|
||||
|
||||
if (cardId === "teleport") {
|
||||
// Up to four spaces, walls and objects ignored; not into solid stone.
|
||||
// BFS over existing cells, matching the engine's wallIgnoringDistance.
|
||||
// BFS over existing cells, matching the engine's wallIgnoringDistance —
|
||||
// the maze wraps for teleporters as it does for walkers, a warp mouth
|
||||
// being one step like any doorway.
|
||||
const out = new Set<string>();
|
||||
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
|
||||
const queue = [me.position];
|
||||
@@ -524,10 +526,15 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
|
||||
const cur = queue.shift()!;
|
||||
const d = dist.get(key(cur.x, cur.y))!;
|
||||
if (d >= 4) continue;
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
||||
const n = { x: cur.x + dx, y: cur.y + dy };
|
||||
for (const side of SIDES) {
|
||||
let n = neighbor(cur, side);
|
||||
if (!view.board.cells[key(n.x, n.y)]) {
|
||||
const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side);
|
||||
if (!w) continue;
|
||||
n = w.to.cell;
|
||||
}
|
||||
const nk = key(n.x, n.y);
|
||||
if (!view.board.cells[nk] || dist.has(nk)) continue;
|
||||
if (dist.has(nk)) continue;
|
||||
dist.set(nk, d + 1);
|
||||
queue.push(n);
|
||||
if (view.squareContents[nk]?.kind !== "stone") out.add(nk);
|
||||
|
||||
@@ -770,3 +770,19 @@ describe("attacks aimed at a bush or the ooze", () => {
|
||||
if (!refused.ok) expect(refused.error).toMatch(/beside/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport's reach wraps through the board's openings", () => {
|
||||
it("offers the square beyond a warp mouth, as the engine allows it", () => {
|
||||
const state = toRound2(newGame().state);
|
||||
const me = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
const warp = view.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
giveCard(state, me.id, "teleport");
|
||||
const cells = eligibleCellsFor(viewFor(state, me.id), "teleport")!;
|
||||
const beyond = cellKey(warp.to.cell);
|
||||
expect(cells.has(beyond)).toBe(true);
|
||||
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: warp.to.cell } });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user