diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 01e3dfa..1186126 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -582,7 +582,16 @@ function wallBlastTarget( * when the clockwork can unlock them; the first such door is reported so the * key gets used before the boot. */ -function pathToward( +/** Can a wizard entering the pit at `pit` heading `dir` land beyond it — + * the same test the maze applies: a cell there, no wall between, no stone. */ +function pitLandable(view: GameView, pit: Cell, dir: Side): boolean { + const beyond = neighbor(pit, dir); + return !!view.board.cells[cellKey(beyond)] && + (view.board.edges[edgeKey(pit, dir)] ?? "open") === "open" && + view.squareContents[cellKey(beyond)]?.kind !== "stone"; +} + +export function pathToward( view: GameView, from: Cell, goals: Set, @@ -607,6 +616,11 @@ function pathToward( const k = cellKey(to); if (seen.has(k)) continue; if (view.squareContents[k]?.kind === "stone") continue; + const hazard = view.squareContents[k]?.kind; + // A pit is entered by leaping to the square beyond it in the same + // direction; with nothing to land on, the maze bounces the leaper + // back and charges the stride. Goal or waypoint, that is no road. + if (hazard === "pit" && !pitLandable(view, to, dir)) continue; seen.add(k); cameBy.set(k, { prev: cellKey(c), dir, viaDoor }); if (goals.has(k)) { found = k; break; } @@ -615,7 +629,6 @@ function pathToward( // turn, and stings — a jail with foliage. Only a wizard with no // road at all (throughJails) dives in. "Few will dive into one // unless they are desperate." - const hazard = view.squareContents[k]?.kind; if (hazard === "thornbush" && !opts.throughJails) continue; if (!opts.throughHazards && (hazard === "pit" || hazard === "ooze" || @@ -725,7 +738,8 @@ function overLimit(view: GameView): number { * clogged hand never refreshes; only cards the brain has no play for are * shed (good counters and attacks are hoarded, as a human would). */ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command { - const deficit = tier.draw - (handLimitOf(view) - view.yourHand.length); + const draw = drawUnderCurses(view, tier.draw); + const deficit = draw - (handLimitOf(view) - view.yourHand.length); if (tier.sheds && deficit > 0) { const shed = [...view.yourHand] .filter((c) => discardValue(c, view, style) <= 3) @@ -734,7 +748,16 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle .map((c) => c.instanceId); if (shed.length > 0) return { type: "discard", instanceIds: shed }; } - return { type: "endTurn", draw: tier.draw }; + return { type: "endTurn", draw }; +} + +/** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what + * it can afford, keeping four life in hand, and nothing at all when it + * cannot — a bare hand beats a bare grave. */ +export function drawUnderCurses(view: GameView, draw: number): number { + const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed); + if (!slow) return draw; + return Math.max(0, Math.min(draw, me(view).life - 4)); } function worstCards(view: GameView, n: number, style?: AutomatonStyle): string[] { @@ -1522,8 +1545,13 @@ export function automatonCommand( } } } - // No shot at a wizard: raise a creature to do the walking. - const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId)); + // No shot at a wizard: raise a creature to do the walking. A SHADOW + // drinks a life a turn from its master — no pet for a wizard already + // bleeding, or with little blood to spare. + const bleeding = view.sustained.some((e) => + (e.cardId === "slow-death" || e.cardId === "walking-dead") && e.targetId === you && !e.data?.reversed); + const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId) && + !(c.cardId === "shadow" && (bleeding || self.life <= 6))); if (summon && livingEnemies(view).length > 0) { const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position; const spot = summonSpot(view, near); diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index a3cd94d..0e08cce 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -648,7 +648,9 @@ export type GameEvent = | { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell } | { type: "spellReused"; player: PlayerId; card: CardInstance } | { type: "castAroundCorner"; caster: PlayerId } - | { type: "moveBumped"; player: PlayerId; direction: Side } + /** A stride that went nowhere: into a wall while blind, or into a pit + * with nothing to land on beyond it. */ + | { type: "moveBumped"; player: PlayerId; direction: Side; why?: "pit" } | { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null } | { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string } | { type: "tableTalk"; player: PlayerId; text: string } @@ -4352,7 +4354,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { // Nowhere to land: teeter back where you started. p.position = from; state.turn.movementUsed++; - events.push({ type: "moveBumped", player: p.id, direction }); + events.push({ type: "moveBumped", player: p.id, direction, why: "pit" }); return { ok: true, state, events }; } } diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index e0e6cce..45100f1 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -8,7 +8,7 @@ import { } from "../src/game"; import { cellKey, edgeKey } from "../src/board"; import { sightedCellsFor, viewFor } from "../src/view"; -import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; +import { automatonCommand, automatonFallback, drawUnderCurses, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; import { pushSustained } from "./helpers"; /** Whose input does the maze want right now? */ @@ -1110,3 +1110,83 @@ describe("interception, escape, and hazard sense", () => { expect(applyCommand(state, "bot", cmd!).ok).toBe(true); }); }); + +describe("the clockwork under a curse, and before a pit", () => { + /** A two-seat game advanced to the bot's own turn in round 2. */ + function botsTurn() { + let { state } = createGame({ playerIds: ["human", "bot"], seed: 7, sets: ["basic", "expansion1"] }); + for (let guard = 0; guard < 8; guard++) { + if (state.turn.round >= 2 && actingSeat(state) === "bot") break; + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(`setup: ${r.error}`); + state = r.state; + } + return state; + } + + it("draws only what SLOW DEATH lets it afford", () => { + const state = botsTurn(); + const bot = state.players.find((p) => p.id === "bot")!; + const plain = viewFor(state, "bot"); + expect(drawUnderCurses(plain, 2)).toBe(2); + pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 }); + bot.life = 7; + expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2); + bot.life = 5; + expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1); + bot.life = 4; + expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(0); + }); + + it("raises no SHADOW while bleeding, nor with little blood to spare", () => { + for (const rig of [ + (s: GameState) => pushSustained(s, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 }), + (s: GameState) => { s.players.find((p) => p.id === "bot")!.life = 5; }, + ]) { + let state = botsTurn(); + rig(state); + const bot = state.players.find((p) => p.id === "bot")!; + bot.hand = [{ instanceId: "shadow#T", cardId: "shadow" }]; + // Play the bot's whole turn: nothing it does may be the shadow. + for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) { + const view = viewFor(state, "bot"); + const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage"); + expect(JSON.stringify(cmd)).not.toContain("shadow#T"); + const r = applyCommand(state, "bot", cmd); + if (!r.ok) break; + state = r.state; + } + } + }); + + it("never plans a stride into a pit it cannot leap beyond", () => { + const state = botsTurn(); + const bot = state.players.find((p) => p.id === "bot")!; + const view0 = viewFor(state, "bot"); + // Stand the bot at the head of a straight three-square run, dig a pit + // in the middle square, and wall the far one with stone: the route in + // from here is no route. With the stone gone, the leap is a road again. + const delta = { N: { x: 0, y: -1 }, E: { x: 1, y: 0 }, S: { x: 0, y: 1 }, W: { x: -1, y: 0 } } as const; + for (const k of Object.keys(view0.board.cells)) { + const [x, y] = k.split(",").map(Number) as [number, number]; + for (const dir of ["N", "E", "S", "W"] as const) { + const pit = { x: x + delta[dir].x, y: y + delta[dir].y }; + const beyond = { x: pit.x + delta[dir].x, y: pit.y + delta[dir].y }; + if (!view0.board.cells[cellKey(pit)] || !view0.board.cells[cellKey(beyond)]) continue; + if ((view0.board.edges[edgeKey({ x, y }, dir)] ?? "open") !== "open") continue; + if ((view0.board.edges[edgeKey(pit, dir)] ?? "open") !== "open") continue; + if (state.squareContents[k] || state.squareContents[cellKey(pit)] || state.squareContents[cellKey(beyond)]) continue; + bot.position = { x, y }; + state.squareContents[cellKey(pit)] = { kind: "pit", damage: 0, createdBy: "human" }; + state.squareContents[cellKey(beyond)] = { kind: "stone", damage: 0, createdBy: "human" }; + const blocked = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true }); + expect(blocked === null || blocked.dir !== dir).toBe(true); + delete state.squareContents[cellKey(beyond)]; + const open = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true }); + expect(open?.dir).toBe(dir); + return; + } + } + throw new Error("no straight three-square run on this board"); + }); +}); diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index cb574f6..b83fc39 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -51,6 +51,9 @@ export function humanize(e: GameEvent): string | null { if (e.fullyStopped) return `The attack is completely stopped.`; return null; // the damaged event tells the story case "damaged": { + // The shadow's last drink is told by its own line; the bookkeeping + // blow that follows it has nothing to add. + if (e.amount === 0 && e.source === "shadow upkeep") return null; const soak = e.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", "); return `${e.player} takes ${e.amount} damage (${e.source}${soak ? ` — ${soak}` : ""}) — ${e.lifeAfter} life left.`; } @@ -126,7 +129,9 @@ export function humanize(e: GameEvent): string | null { case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`; case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`; case "castAroundCorner": return `The spell bends around the corner!`; - case "moveBumped": return `${e.player} blunders into a wall!`; + case "moveBumped": return e.why === "pit" + ? `${e.player} leaps the pit — nothing to land on beyond it — and teeters back.` + : `${e.player} blunders into a wall!`; case "attackMisdirected": return e.newTarget ? `${e.attacker}'s blind attack veers off — and hits ${e.newTarget}!` : `${e.attacker}'s blind attack flies off into the darkness.`;