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 }>}
{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"}