diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 07d3f82..59568b5 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -2433,17 +2433,14 @@ const CARD_EFFECTS: Record // Earlier revisions rammed at any range; their ledgers keep it. validate: (state, _cmd, _caster, target) => { if ((state.config.deckRev ?? 1) < 10 || !target) return null; - const caster = activePlayer(state); - const legs = state.turn.movementAllowance - state.turn.movementUsed; - const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs)); + const { d, legs } = charge(state, activePlayer(state), target); if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn"; return null; }, onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; if ((ctx.state.config.deckRev ?? 1) >= 10) { - const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed; - const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs)); + const { d, legs } = charge(ctx.state, ctx.attacker, ctx.defender); if (d === 0) return; if (d > legs) { // The target slipped beyond the charge (a counter-teleport, say): @@ -2454,9 +2451,9 @@ const CARD_EFFECTS: Record ctx.state.turn.movementUsed += d; ctx.attacker.position = { ...ctx.defender.position }; ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d }); - // Rev 26: the ram was priced into the blow itself (ramDistance), - // so STRENGTH, AMPLIFY, and the counters have already weighed it. - // Older games dealt the bare distance here, past all of them. + // From rev 26 the ram is priced into the blow itself (ramDistance), + // so STRENGTH, AMPLIFY, and the counters have already weighed it; + // earlier revs deal the bare distance here, past all of them. if ((ctx.state.config.deckRev ?? 1) < 26) resolvedBlow(ctx, d, "goat ram", "physical"); return; } @@ -3357,7 +3354,6 @@ function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerSt }; } -/** A creature's blow opens a counteraction window like any attack. */ /** What a creature's blow is called on the stack and in the chronicle: * the counteraction is played against this, and the damage is dealt as it. */ function creatureBlowName(kind: CreatureState["kind"]): string { @@ -3369,6 +3365,7 @@ function creatureBlowName(kind: CreatureState["kind"]): string { } } +/** A creature's blow opens a counteraction window like any attack. */ function openCreatureStack( state: GameState, creature: CreatureState, @@ -5584,8 +5581,7 @@ function doCast(prev: GameState, cmd: Extract): Comma if (werr) return err(werr); } if (likeAWall && mods.powerAttackPoints > 0) { - caster.life -= mods.powerAttackPoints; - wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance }); + payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents); } consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); if (state.turn.attackUsed) state.turn.secondAttackUsed = true; @@ -5681,8 +5677,7 @@ function doCast(prev: GameState, cmd: Extract): Comma if (werr) return err(werr); } if (mods.powerAttackPoints > 0) { - caster.life -= mods.powerAttackPoints; - wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance }); + payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents); } consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); if (state.turn.attackUsed) state.turn.secondAttackUsed = true; @@ -5773,8 +5768,7 @@ function doCast(prev: GameState, cmd: Extract): Comma if (werr) return err(werr); } if (mods.powerAttackPoints > 0) { - caster.life -= mods.powerAttackPoints; - wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance }); + payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents); } // BLIND: casts at others fly in a rolled direction. "Misdirected spells @@ -6051,17 +6045,23 @@ function checkAmbushes( } } -/** Damage dealt inside an attack's own resolution hook still honors - * EMPATHY — "Any attack done in ANY form against you acts against both you - * and the caster". */ +/** The goat's charge: the corridor distance to its mark, and the legs + * this turn has left to cover it. */ +function charge(state: GameState, attacker: PlayerState, defender: PlayerState): { d: number; legs: number } { + const legs = state.turn.movementAllowance - state.turn.movementUsed; + return { d: walkingDistance(state, attacker.position, defender.position, chargeReach(state, legs)), legs }; +} + /** How far the goat charges: the corridor distance to its mark within * this turn's legs, or zero when the mark is underfoot or out of reach. */ function ramDistance(state: GameState, attacker: PlayerState, defender: PlayerState): number { - const legs = state.turn.movementAllowance - state.turn.movementUsed; - const d = walkingDistance(state, attacker.position, defender.position, chargeReach(state, legs)); + const { d, legs } = charge(state, attacker, defender); return d > legs ? 0 : d; } +/** Damage dealt inside an attack's own resolution hook still honors + * EMPATHY — "Any attack done in ANY form against you acts against both you + * and the caster". */ function resolvedBlow( ctx: ResolutionContext, dmg: number, source: string, kind?: "physical", ): void { @@ -6072,6 +6072,12 @@ function resolvedBlow( } } +/** POWER ATTACK: the caster pays in life for the extra points. */ +function payPowerAttack(state: GameState, caster: PlayerState, points: number, events: GameEvent[]): void { + caster.life -= points; + events.push({ type: "lifeTraded", player: caster.id, points, newAllowance: state.turn.movementAllowance }); +} + /** A NUMBER card riding a counteraction sets the raised spell's duration. */ function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number { const num = (numberInstanceIds ?? []) diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 4572d17..cb93e56 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -931,8 +931,8 @@ describe("the imp's fire is a spell (rev 8)", () => { if (!r.ok) throw new Error(r.error); state = r.state; if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 }); - // The scorch rides the stack now: a counteraction window, spell-kind, - // named for the imp so the counter is played against the scorch, never a punch. + // The scorch rides the stack as a spell-kind counteraction window, + // named for the imp: the counter is played against the scorch. expect(state.stack?.creatureId).toBeTruthy(); expect(state.stack?.kind).toBe("spell"); expect(state.stack?.sourceName).toBe("fire imp's scorch"); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index f38a348..22f8084 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -942,8 +942,8 @@ describe("the goat's ram is a physical blow like any other (rev 26)", () => { describe("an illusion is priced as the spell it fakes (rev 27)", () => { /** A faked FIREBALL met by the given counteraction, then both wizards pass. */ - function fakedFireball(counter: string | null, deckRev?: number) { - let { state } = createGame({ playerIds: ["liar", "mark"], seed: 42, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) }); + function fakedFireball(counter: string | null, deckRev?: number, seed = 42) { + let { state } = createGame({ playerIds: ["liar", "mark"], seed, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) }); state = toRound2(state); const { attacker, defender } = faceOff(state); const ill = giveCard(state, attacker, "illusionary-attack"); @@ -965,11 +965,22 @@ describe("an illusion is priced as the spell it fakes (rev 27)", () => { return { events: done.events, believed, life: done.state.players.find((p) => p.id === defender)!.life }; } + /** The first seed whose die comes up each way: one believed, one disbelieved. */ + function bothWays(counter: string | null, deckRev?: number) { + const found: Partial>> = {}; + for (let seed = 1; seed <= 40 && !(found.believed && found.doubted); seed++) { + const r = fakedFireball(counter, deckRev, seed); + expect(r.believed).not.toBeNull(); + found[r.believed ? "believed" : "doubted"] ??= r; + } + return { believed: found.believed!, doubted: found.doubted! }; + } + it("BLUNT halves a believed fake fireball to three, and disbelief costs nothing", () => { - const { events, believed, life } = fakedFireball("blunt"); - expect(believed).not.toBeNull(); - expect(events).toContainEqual(expect.objectContaining({ type: "attackResolved", incoming: 5 })); - expect(life).toBe(believed ? 12 : 15); + const { believed, doubted } = bothWays("blunt"); + expect(believed.events).toContainEqual(expect.objectContaining({ type: "attackResolved", incoming: 5 })); + expect(believed.life).toBe(12); + expect(doubted.life).toBe(15); }); it("FULL SHIELD stops the fake outright: nothing to believe in, no die rolled", () => { @@ -980,14 +991,10 @@ describe("an illusion is priced as the spell it fakes (rev 27)", () => { }); it("uncountered, a believed fake fireball still does its five", () => { - const { believed, life } = fakedFireball(null); - expect(believed).not.toBeNull(); - expect(life).toBe(believed ? 10 : 15); + expect(bothWays(null).believed.life).toBe(10); }); it("older games dealt a believed illusion's full points past a BLUNT", () => { - const { believed, life } = fakedFireball("blunt", 26); - expect(believed).not.toBeNull(); - expect(life).toBe(believed ? 10 : 15); + expect(bothWays("blunt", 26).believed.life).toBe(10); }); }); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index a35d3d7..36dfcc2 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -10,7 +10,7 @@ import Card from "./Card.svelte"; import Help from "./Help.svelte"; import { HELP_TABS, type HelpTab } from "./help-state"; - import { loadFamily, type FamilyGame } from "./family"; + import { family as kin } from "./family.svelte"; import { summarizeMissed } from "./catchup"; import Replay from "./Replay.svelte"; import FxGallery from "./FxGallery.svelte"; @@ -28,14 +28,8 @@ let name = $state(prefs.wizardName); /** The other games of Kestrel's Hall, for the front page. */ - let family = $state([]); - let hall = $state(""); - $effect(() => { - void loadFamily("wizwar").then((f) => { - family = f.games; - hall = f.hall; - }); - }); + const family = $derived(kin.games); + const hall = $derived(kin.hall); let joinCode = $state(""); let claimPhrase = $state(""); /** A /join/ recruiting link. The visitor lands looking at the @@ -274,13 +268,15 @@ let peekToken = $state(null); /** Ledger chest tapped: the treasure full-size, with whose and who-holds. */ let peekTreasure = $state<{ src: string; note: string } | null>(null); - /** Cardless blows that a card nonetheless made: the stack names the - * blow, and this names the card to show for it. */ - const BLOW_CARD: Record = { - "warded treasure": "ward", - "fire imp's scorch": "fire-imp", "troll's blow": "troll", "skeleton's blow": "skeleton", - "wraith's touch": "wraith", "democratic monster's claw": "democratic-monster", - }; + /** Cardless blows that a card nonetheless made: a creature's blow shows + * the card that summoned it (its kind is the card's id), the ward's bite + * shows WARD. */ + const blowCard = $derived.by(() => { + const stack = view?.stack; + if (!stack) return null; + const creature = stack.creatureId ? view!.creatures.find((c) => c.id === stack.creatureId) : undefined; + return creature?.kind ?? (stack.sourceName === "warded treasure" ? "ward" : null); + }); /** Bare-knuckle demolition: click a wall to punch it. */ let punchWallMode = $state(false); @@ -1534,6 +1530,8 @@ } /** What the chronicle shows: everything, the game's own lines, or the talk. */ let logFilter = $state<"all" | "game" | "talk">("all"); + /** The last few lines of talk, for the lobby and the gallery before the boards flip. */ + const lobbyTalk = $derived(net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)); const isTalk = (text: string) => text.startsWith("\u{1F4AC}") || text.startsWith("\u{1F3B2}"); function showLine(line: { text: string }): boolean { return logFilter === "all" ? true : logFilter === "talk" ? isTalk(line.text) : !isTalk(line.text); @@ -1778,6 +1776,10 @@ /** The attack on the stack draws its sight line; a cast that resolved * at once (a creation, a curse) draws the line it was accepted on for a * few seconds, so the table can see how the aim was legal. */ + let castTrace = $state.raw(null); + let castTraceTimer: ReturnType | null = null; + const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace); + /** The hour where the keeper lives, in words, kept current by the minute. */ let minuteTick = $state(0); $effect(() => { @@ -1795,10 +1797,6 @@ } catch { return null; } }); - let castTrace = $state.raw(null); - let castTraceTimer: ReturnType | null = null; - const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace); - /** A first game's first turn lights itself; after that only by choice. */ const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1); /** The step light: with no card in hand, your square and the squares a @@ -2463,8 +2461,8 @@ {:else if view.stack.defenderId === view.you && view.stack.sourceName} - {#if BLOW_CARD[view.stack.sourceName]} -
(faqCardId = id)} />
+ {#if blowCard} +
(faqCardId = id)} />
{/if}
{view.stack.sourceName.toUpperCase()} — {view.stack.params?.damage ?? 0} damage inbound unless you answer it @@ -2848,7 +2846,6 @@ back, as long as nobody abandons it.
{/if} - {@const lobbyTalk = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
{#each lobbyTalk as line, i (i)}
{line.text}
@@ -2880,9 +2877,8 @@
{/if} {:else} - {@const galleryTalkLines = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)} -
- {#each galleryTalkLines as line, i (i)} +
+ {#each lobbyTalk as line, i (i)}
{line.text}
{/each} {#if net.galleryTalk} @@ -3466,8 +3462,8 @@ {#if view.stack.attackCard}
(faqCardId = id)} />
{:else if view.stack.sourceName} - {#if BLOW_CARD[view.stack.sourceName]} -
(faqCardId = id)} />
+ {#if blowCard} +
(faqCardId = id)} />
{/if}
{defending ? "" : "your "}{view.stack.sourceName} — it bites for {view.stack.params?.damage ?? 0}
{:else} diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 32b923d..5008221 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -2,7 +2,7 @@ import type { Component } from "svelte"; import type { GameView, SightTrace } from "@wizwar/engine"; import type { Side } from "@wizwar/engine"; - import { edgeKey, fearCells } from "@wizwar/engine"; + import { edgeKey, fearCells, neighbor } from "@wizwar/engine"; import type { BoardFx } from "./fx"; import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; @@ -215,8 +215,7 @@ } if (!punchFrom) return boxes; const beside = (b: { cell: { x: number; y: number }; side: Side }) => { - const far = b.side === "E" ? { x: b.cell.x + 1, y: b.cell.y } : b.side === "W" ? { x: b.cell.x - 1, y: b.cell.y } - : b.side === "S" ? { x: b.cell.x, y: b.cell.y + 1 } : { x: b.cell.x, y: b.cell.y - 1 }; + const far = neighbor(b.cell, b.side); const at = (c: { x: number; y: number }) => c.x === punchFrom!.x && c.y === punchFrom!.y; return at(b.cell) || at(far); }; diff --git a/packages/web/src/Help.svelte b/packages/web/src/Help.svelte index 6ce5a56..a000135 100644 --- a/packages/web/src/Help.svelte +++ b/packages/web/src/Help.svelte @@ -3,7 +3,7 @@ // body, the card library with proper entries, the house rulings, the // story of the table, and the tally. Every section and card has a link // a player can send; the desk remembers where the reader was. - import { loadFamily, type FamilyGame } from "./family"; + import { family as kin } from "./family.svelte"; import { cardDef } from "@wizwar/engine"; import { tick } from "svelte"; import Card from "./Card.svelte"; @@ -129,14 +129,8 @@ else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0; }); /** The other games of Kestrel's Hall, for the about page. */ - let family = $state([]); - let hall = $state(""); - $effect(() => { - void loadFamily("wizwar").then((f) => { - family = f.games; - hall = f.hall; - }); - }); + const family = $derived(kin.games); + const hall = $derived(kin.hall); {#snippet section(s: RefSection, withSource: boolean)} diff --git a/packages/web/src/family.svelte.ts b/packages/web/src/family.svelte.ts new file mode 100644 index 0000000..7fcb1a2 --- /dev/null +++ b/packages/web/src/family.svelte.ts @@ -0,0 +1,28 @@ +// The other games of Kestrel's Hall, from the family list the kit keeps +// (family.json, one copy shipped with every game), so a player who liked one +// table can find the next, and the hall's front door. This game is left out +// of its own list. Fetched once, shared by every page that shows it. + +export interface FamilyGame { + slug: string; + name: string; + url: string; + pitch: string; + players: string; + origin: string; +} + +/** The hall's front door and every game but this one; empty until the list arrives. */ +export const family = $state<{ hall: string; games: FamilyGame[] }>({ hall: "", games: [] }); + +void (async () => { + try { + const res = await fetch("/family.json", { cache: "no-cache" }); + if (!res.ok) return; + const data = (await res.json()) as { hall?: string; games?: FamilyGame[] }; + family.hall = data.hall ?? ""; + family.games = (data.games ?? []).filter((g) => g.slug !== "wizwar"); + } catch { + // No list, no neighbors shown. + } +})(); diff --git a/packages/web/src/family.ts b/packages/web/src/family.ts deleted file mode 100644 index cc83776..0000000 --- a/packages/web/src/family.ts +++ /dev/null @@ -1,30 +0,0 @@ -// The other games of Kestrel's Hall, from the family list the kit keeps -// (family.json, one copy shipped with every game), so a player who liked one -// table can find the next, and the hall's front door. This game is left out -// of its own list. - -export interface FamilyGame { - slug: string; - name: string; - url: string; - pitch: string; - players: string; - origin: string; -} - -/** The family list: the hall's front door and every game but this one. */ -export interface Family { - hall: string; - games: FamilyGame[]; -} - -export async function loadFamily(self: string): Promise { - try { - const res = await fetch('/family.json', { cache: 'no-cache' }); - if (!res.ok) return { hall: '', games: [] }; - const data = (await res.json()) as { hall?: string; games?: FamilyGame[] }; - return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) }; - } catch { - return { hall: '', games: [] }; - } -}