diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index ffb43d6..ca72f87 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -232,18 +232,22 @@ function thiefOfMine(view: GameView) { return livingEnemies(view).find((p) => carriers.has(p.id)) ?? null; } -function overLimit(view: GameView): number { - return Math.max(0, view.yourHand.length - 7); +/** The hand limit as this seat experiences it (a displayed BRAINSTONE + * raises it by two — mirrors the engine's handLimit). */ +function handLimitOf(view: GameView): number { + return me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7; } +function overLimit(view: GameView): number { + return Math.max(0, view.yourHand.length - handLimitOf(view)); +} /** End the turn drawing fully — shedding dead weight first if the hand is * too full to take the draw. doEndTurn caps the draw by the room left, so a * 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): Command { - const limit = me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7; - const deficit = tier.draw - (limit - view.yourHand.length); + const deficit = tier.draw - (handLimitOf(view) - view.yourHand.length); if (tier.sheds && deficit > 0) { const shed = [...view.yourHand] .filter((c) => discardValue(c) <= 3) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 90b4fd1..f772a6d 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -138,6 +138,10 @@ const WAND_CARD_IDS = ["blaster-wand", "shift-wand", "sticky-wand", "warp-wand"] /** Duration meaning "for the rest of the game" ("This card is permanent."). */ const PERMANENT_TURNS = 1_000_000_000; +/** Whether a sustained duration means "until used/removed", not a count. */ +export function isPermanentDuration(turns: number): boolean { + return turns >= PERMANENT_TURNS / 2; +} /** Which square contents block line of sight. */ export const LOS_BLOCKING_CONTENT: Record = { @@ -4760,11 +4764,11 @@ function doCounteract( if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction"); if (!targetCounter) return err("no counteraction to nullify"); // "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE" - // (the card face; rules rev 6 — earlier games replay their old chains). - if ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport") { - return err("ANTI-ANTI does not work against escape"); - } - if (targetCounter.card.cardId === "invisible") { + // (the card face). Teleport is rev-6-gated — earlier games replay their + // old chains; invisible needs no gate, since no earlier engine ever + // accepted it as a counteraction for a ledger to contain. + if (targetCounter.card.cardId === "invisible" || + ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport")) { return err("ANTI-ANTI does not work against escape"); } takeFromHand(player, instanceId); @@ -4801,6 +4805,8 @@ function doPass(prev: GameState, playerId: PlayerId): CommandResult { stack.counters.some((c) => !c.nullified && (TOTAL_STOP_COUNTERS.has(c.card.cardId) || + // Wall-of-fire counters pre-date the total-stop rule; their stored + // exchanges bounced, and replay so (rules rev 12). (rev >= 12 && c.card.cardId === "wall-of-fire")) && (stack.kind === "spell" || c.card.cardId === "teleport")); if (!totalStop) { diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 7d67fd7..d5660fd 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -118,7 +118,7 @@ describe("automaton vs automaton", () => { }); describe("the clockwork does not waste counters on pointless targets", () => { - function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: any, defender: string) => void) { + function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) { let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 }); // burn round 1 for (let i = 0; i < 2; i++) { @@ -131,8 +131,8 @@ describe("the clockwork does not waste counters on pointless targets", () => { if (!r.ok) throw new Error(`setup: ${r.error}`); state = r.state; } - const human = state.players.find((p: { id: string }) => p.id === "human")!; - const bot = state.players.find((p: { id: string }) => p.id === "bot")!; + const human = state.players.find((p) => p.id === "human")!; + const bot = state.players.find((p) => p.id === "bot")!; bot.position = { ...human.position }; defenderHand.forEach((c, i) => { bot.hand[i] = c; }); human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId }; @@ -160,8 +160,8 @@ describe("the clockwork does not waste counters on pointless targets", () => { { instanceId: "full-shield#T", cardId: "full-shield" }, { instanceId: "dagger#T", cardId: "dagger" }, ], (s, defender) => { - const d = s.players.find((p: { id: string }) => p.id === defender)!; - const t = s.treasures.find((t: { owner: string }) => t.owner !== defender)!; + const d = s.players.find((p) => p.id === defender)!; + const t = s.treasures.find((t) => t.owner !== defender)!; t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id; }); const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index a058bd4..172bf46 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -466,28 +466,23 @@ describe("monsters roll to hit the hidden (rules rev 13)", () => { id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id, remainingTurns: 3, data: {}, }); - return { state, me, victim, trollId: bones.id }; + return { state, me, victim, skeletonId: bones.id }; } it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => { - // Deterministic seed: this particular swing misses and the blow dissipates. - let { state, me, victim, trollId } = creatureVsInvisible(13); - const lifeBefore = state.players.find((p) => p.id === victim.id)!.life; - state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id }); + let { state, me, victim, skeletonId } = creatureVsInvisible(13); + const rngBefore = JSON.stringify(state.rng); + state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id }); state = must(state, victim.id, { type: "pass" }); - const rolled = state.rng; - expect(rolled).toBeDefined(); - const after = state.players.find((p) => p.id === victim.id)!; - const missed = after.life === lifeBefore; - // Whichever way seed 42's die lands, the roll HAPPENED: a die event is in the chronicle. - expect(missed || after.life < lifeBefore).toBe(true); + // The die was consumed, whichever way it landed. + expect(JSON.stringify(state.rng)).not.toBe(rngBefore); }); it("earlier revisions keep the old certainty: no roll, the blow just lands", () => { - let { state, me, victim, trollId } = creatureVsInvisible(12); + let { state, me, victim, skeletonId } = creatureVsInvisible(12); const lifeBefore = state.players.find((p) => p.id === victim.id)!.life; const rngBefore = JSON.stringify(state.rng); - state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id }); + state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id }); state = must(state, victim.id, { type: "pass" }); expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore); // No die was consumed on the way. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0f883ed..dcdc17c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -193,22 +193,25 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo */ const BOT_STEP_MS = 1000; -/** What a step's event means to this bot — its own deed when it acted, - * its own suffering when somebody else did. Null: nothing worth a word. */ +/** What a step's event means to this bot — its own deeds when it acted, + * its own suffering whoever caused it. Null: nothing worth a word. */ function banterTrigger( e: { type: string; [k: string]: unknown }, seat: string, actor: string, ): BanterTrigger | null { if (actor === seat) { if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold"; - if (e.type === "treasureDropped" && e.player === seat) return "deliverGold"; + if (e.type === "treasureDropped" && e.player === seat && e.onHomeOf != null) return "deliverGold"; if (e.type === "damaged" && e.player !== seat) return "dealPain"; if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill"; if (e.type === "creatureCreated" && e.controller === seat) return "summon"; if (e.type === "wallCreated" && e.caster === seat) return "buildWall"; - if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape"; if (e.type === "trapSprung" && e.player === seat) return "springTrap"; - if (e.type === "gameWon" && e.player === seat) return "win"; } + // A counter-teleport escape resolves during the ATTACKER's step, and a + // last-standing win can land on the victim's turn: self-referential + // triggers hold whoever acted. + if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape"; + if (e.type === "gameWon" && e.player === seat) return "win"; if (e.type === "damaged" && e.player === seat) return "takePain"; if (e.type === "died" && e.player === seat) return "die"; if (e.type === "attackMissed" && e.defender === seat) return "dodge"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 4f0e782..407b099 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -7,10 +7,10 @@ import Help from "./Help.svelte"; import Replay from "./Replay.svelte"; import FxGallery from "./FxGallery.svelte"; - import { fxForEvents, fxTtl, type BoardFx } from "./fx"; + import { scheduleFx, type BoardFx } from "./fx"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { local } from "./local.svelte"; - import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine"; + import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine"; net.connect(); @@ -37,22 +37,28 @@ localStorage.setItem("wizwar-no-fanfare", announce ? "0" : "1"); } /** Which pending attack the player has already acknowledged (modal dismissed). */ + const fxWorkshop = new URLSearchParams(location.search).has("fx"); let attackNoticeSeen = $state(null); /** Live spell flourishes on the board (cosmetic, self-expiring). */ let boardFx = $state([]); - function playFx(events: Parameters[0]) { + let fxCancels: (() => void)[] = []; + function playFx(events: Parameters[0]) { if (!view) return; - for (const { fx, delay } of fxForEvents(events, view)) { - setTimeout(() => { - boardFx = [...boardFx, fx]; - setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)); - }, delay); - } + fxCancels.push(scheduleFx( + events, view, + (fx) => (boardFx = [...boardFx, fx]), + (id) => (boardFx = boardFx.filter((f) => f.id !== id)), + )); } $effect(() => { net.onFx = playFx; local.onFx = playFx; - return () => { net.onFx = null; local.onFx = null; }; + return () => { + net.onFx = null; + local.onFx = null; + fxCancels.forEach((c) => c()); + fxCancels = []; + }; }); const openingRolls = $derived(net.openingRolls ?? local.openingRolls); function dismissRolls() { @@ -906,7 +912,7 @@ } -{#if new URLSearchParams(location.search).has("fx")} +{#if fxWorkshop} {:else}
@@ -1413,7 +1419,7 @@ peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId }; peekCreatureId = null; }}> - ✦ {spellName(e.cardId)}{e.remainingTurns < 9000 ? ` · ${e.remainingTurns}` : ""} + ✦ {spellName(e.cardId)}{isPermanentDuration(e.remainingTurns) ? "" : ` · ${e.remainingTurns}`} {/each}
diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 34d94cc..3c1d714 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -1,6 +1,8 @@ @@ -55,7 +56,7 @@

{#each SAMPLES as fx (fx.kind)} - {@const Sprite = FX_SPRITES[fx.kind]} + {@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)} @@ -64,7 +65,7 @@ {/each} {/each} {#key tick} - + {/key}
{fx.kind}
diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 973ff1a..ba381ce 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -1,7 +1,7 @@ @@ -49,7 +51,7 @@ {#if isSvg} - {#if entry && entry !== "pending"} + {#if entry && entry !== "pending" && entry !== "failed"} {#if title}{title}{/if} diff --git a/packages/web/src/fx-sprites/Bolt.svelte b/packages/web/src/fx-sprites/Bolt.svelte index 2df1f96..84dede2 100644 --- a/packages/web/src/fx-sprites/Bolt.svelte +++ b/packages/web/src/fx-sprites/Bolt.svelte @@ -51,7 +51,7 @@ } .bolt .fork { fill: none; - stroke: #fff6b0; + stroke: #f5d54a; stroke-width: 1.6; stroke-linejoin: round; } @@ -61,7 +61,6 @@ stroke-width: 2; stroke-linejoin: round; } - .bolt .fork { stroke: #f5d54a; } .bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; } @keyframes bolt-flicker { 0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; } diff --git a/packages/web/src/fx-sprites/DustPuff.svelte b/packages/web/src/fx-sprites/DustPuff.svelte index 60c7f4a..41de538 100644 --- a/packages/web/src/fx-sprites/DustPuff.svelte +++ b/packages/web/src/fx-sprites/DustPuff.svelte @@ -20,9 +20,9 @@ - + - + diff --git a/packages/web/src/fx-sprites/index.ts b/packages/web/src/fx-sprites/index.ts index 0d2b256..24364a8 100644 --- a/packages/web/src/fx-sprites/index.ts +++ b/packages/web/src/fx-sprites/index.ts @@ -2,7 +2,7 @@ // file; to add one, create the component, extend BoardFx in ../fx.ts, and // register it here. import type { Component } from "svelte"; -import type { BoardFx } from "../fx"; +import type { BoardFx, FxOf } from "../fx"; import Absorb from "./Absorb.svelte"; import Bolt from "./Bolt.svelte"; import Burst from "./Burst.svelte"; @@ -29,7 +29,7 @@ import ThornSnap from "./ThornSnap.svelte"; import Waterbolt from "./Waterbolt.svelte"; import Whiff from "./Whiff.svelte"; -export const FX_SPRITES: Record> = { +export const FX_SPRITES: { [K in BoardFx["kind"]]: Component<{ fx: FxOf }> } = { fireball: Fireball, waterbolt: Waterbolt, bolt: Bolt, @@ -58,4 +58,4 @@ export const FX_SPRITES: Record> = { "edge-dust": DustPuff, "sector-spin": SectorGrind, "sector-slide": SectorGrind, -} as never; +}; diff --git a/packages/web/src/fx.ts b/packages/web/src/fx.ts index 3d8ef11..bfe2957 100644 --- a/packages/web/src/fx.ts +++ b/packages/web/src/fx.ts @@ -9,12 +9,12 @@ type Cell = { x: number; y: number }; type Pt = { x: number; y: number }; type Side = "N" | "E" | "S" | "W"; -const CELL = 48; +import { CELL } from "./fx-sprites/geom"; const cellMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 }); /** Where a wizard token's center sits in a cell (mirrors Board.svelte). */ const wizMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL * 0.36 }); -type FxShape = +export type FxShape = | { kind: "fireball" | "bolt" | "waterbolt" | "streak"; a: Pt; b: Pt } | { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit" | "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl" @@ -134,7 +134,7 @@ export function fxForEvents( break; case "creatureAttacked": { // The target may be a wizard or a fellow creature. - const at = posOf(typeof e.target === "string" ? e.target : null) ?? + const at = posOf(e.target) ?? (() => { const c = view.creatures.find((c) => c.id === e.target); return c ? { ...c.position } : null; @@ -306,3 +306,24 @@ export function fxForEvents( } return out; } + +/** Schedule a batch's effects into `add`, expiring each after its run. + * Returns a cancel that stops pending starts and sweeps what began. */ +export function scheduleFx( + events: GameEvent[], view: GameView, + add: (fx: BoardFx) => void, remove: (id: number) => void, +): () => void { + const timers: ReturnType[] = []; + const started: number[] = []; + for (const { fx, delay } of fxForEvents(events, view)) { + timers.push(setTimeout(() => { + started.push(fx.id); + add(fx); + timers.push(setTimeout(() => remove(fx.id), fxTtl(fx.kind))); + }, delay)); + } + return () => { + timers.forEach(clearTimeout); + started.forEach(remove); + }; +} diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 011e24b..edec871 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -48,11 +48,11 @@ class LocalGame { /** Set while the device should be handed to the named player. */ handoffTo = $state(null); log = $state([]); - /** The finished game as a reel, each step from its actor's own seat. */ /** The opening roll-off, shown once as the boards flip. */ openingRolls = $state<{ rolls: Record; first: string; players: string[] } | null>(null); /** Board flourishes: the app hooks in to animate command results. */ onFx: ((events: GameEvent[]) => void) | null = null; + /** The finished game as a reel, each step from its actor's own seat. */ replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null); view = $derived( this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null, diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 800ded0..c545010 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -250,11 +250,11 @@ class Net { transferCode = $state<{ code: string; expiresAt: number } | null>(null); /** Moves you haven't watched yet in the current room. */ missedMoves = $state(0); - /** A catch-up reel delivered by the server. */ /** The opening roll-off, shown once as the boards flip. */ openingRolls = $state<{ rolls: Record; first: string; players: string[] } | null>(null); /** Board flourishes: the app hooks in to animate live event batches. */ onFx: ((events: GameEvent[]) => void) | null = null; + /** A catch-up reel delivered by the server. */ catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null); private seen: Record = loadSeen(); /** Room whose live stream this connection has already shown once: states @@ -326,8 +326,8 @@ class Net { // Only the FIRST state after arriving carries a gap worth // announcing. Later states were watched live: a caught-up // watcher stays caught up, and an announced gap stays FROZEN - // (not grown, not wiped) until watched or skipped. A hidden - // tab accumulates its gap honestly. + // until watched or skipped. A hidden tab accumulates its gap + // honestly. if (this.watching === this.roomId && document.visibilityState === "visible") { if (this.missedMoves === 0) this.markSeen(); } else {