Credibility pass: the illusion tests roll both ways, and the seams from the last batch are sanded
Scope: 66b5c06..HEAD. - The rev-27 illusion tests search seeds for a believed and a doubted roll and assert fixed life totals; seed 42 alone never believed, so the BLUNT pricing went unchecked. - Three docblocks stranded above newly inserted functions are back on their own (openCreatureStack, resolvedBlow, castTrace). - POWER ATTACK's life trade is one helper instead of three copies; the goat's charge distance is one helper for its validator, its ram and its pricing. - The family list loads once into a shared store; the lobby and gallery share one talk filter; Board uses the engine's neighbor(). - A creature's blow shows the card that summoned it by the creature's kind, not by matching the blow's display name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e97deb8d6f
commit
3a6b0ff43e
+26
-20
@@ -2433,17 +2433,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
// Earlier revisions rammed at any range; their ledgers keep it.
|
// Earlier revisions rammed at any range; their ledgers keep it.
|
||||||
validate: (state, _cmd, _caster, target) => {
|
validate: (state, _cmd, _caster, target) => {
|
||||||
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
|
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
|
||||||
const caster = activePlayer(state);
|
const { d, legs } = charge(state, activePlayer(state), target);
|
||||||
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
|
||||||
const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
|
|
||||||
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
|
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
onResolved: (ctx) => {
|
onResolved: (ctx) => {
|
||||||
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
||||||
if ((ctx.state.config.deckRev ?? 1) >= 10) {
|
if ((ctx.state.config.deckRev ?? 1) >= 10) {
|
||||||
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
|
const { d, legs } = charge(ctx.state, ctx.attacker, ctx.defender);
|
||||||
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs));
|
|
||||||
if (d === 0) return;
|
if (d === 0) return;
|
||||||
if (d > legs) {
|
if (d > legs) {
|
||||||
// The target slipped beyond the charge (a counter-teleport, say):
|
// The target slipped beyond the charge (a counter-teleport, say):
|
||||||
@@ -2454,9 +2451,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
ctx.state.turn.movementUsed += d;
|
ctx.state.turn.movementUsed += d;
|
||||||
ctx.attacker.position = { ...ctx.defender.position };
|
ctx.attacker.position = { ...ctx.defender.position };
|
||||||
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d });
|
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),
|
// From rev 26 the ram is priced into the blow itself (ramDistance),
|
||||||
// so STRENGTH, AMPLIFY, and the counters have already weighed it.
|
// so STRENGTH, AMPLIFY, and the counters have already weighed it;
|
||||||
// Older games dealt the bare distance here, past all of them.
|
// earlier revs deal the bare distance here, past all of them.
|
||||||
if ((ctx.state.config.deckRev ?? 1) < 26) resolvedBlow(ctx, d, "goat ram", "physical");
|
if ((ctx.state.config.deckRev ?? 1) < 26) resolvedBlow(ctx, d, "goat ram", "physical");
|
||||||
return;
|
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:
|
/** 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. */
|
* the counteraction is played against this, and the damage is dealt as it. */
|
||||||
function creatureBlowName(kind: CreatureState["kind"]): string {
|
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(
|
function openCreatureStack(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
creature: CreatureState,
|
creature: CreatureState,
|
||||||
@@ -5584,8 +5581,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
if (werr) return err(werr);
|
if (werr) return err(werr);
|
||||||
}
|
}
|
||||||
if (likeAWall && mods.powerAttackPoints > 0) {
|
if (likeAWall && mods.powerAttackPoints > 0) {
|
||||||
caster.life -= mods.powerAttackPoints;
|
payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents);
|
||||||
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
|
|
||||||
}
|
}
|
||||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||||
@@ -5681,8 +5677,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
if (werr) return err(werr);
|
if (werr) return err(werr);
|
||||||
}
|
}
|
||||||
if (mods.powerAttackPoints > 0) {
|
if (mods.powerAttackPoints > 0) {
|
||||||
caster.life -= mods.powerAttackPoints;
|
payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents);
|
||||||
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
|
|
||||||
}
|
}
|
||||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||||
@@ -5773,8 +5768,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
if (werr) return err(werr);
|
if (werr) return err(werr);
|
||||||
}
|
}
|
||||||
if (mods.powerAttackPoints > 0) {
|
if (mods.powerAttackPoints > 0) {
|
||||||
caster.life -= mods.powerAttackPoints;
|
payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents);
|
||||||
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
|
// 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
|
/** The goat's charge: the corridor distance to its mark, and the legs
|
||||||
* EMPATHY — "Any attack done in ANY form against you acts against both you
|
* this turn has left to cover it. */
|
||||||
* and the caster". */
|
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
|
/** 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. */
|
* this turn's legs, or zero when the mark is underfoot or out of reach. */
|
||||||
function ramDistance(state: GameState, attacker: PlayerState, defender: PlayerState): number {
|
function ramDistance(state: GameState, attacker: PlayerState, defender: PlayerState): number {
|
||||||
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
const { d, legs } = charge(state, attacker, defender);
|
||||||
const d = walkingDistance(state, attacker.position, defender.position, chargeReach(state, legs));
|
|
||||||
return d > legs ? 0 : d;
|
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(
|
function resolvedBlow(
|
||||||
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
|
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
|
||||||
): void {
|
): 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. */
|
/** A NUMBER card riding a counteraction sets the raised spell's duration. */
|
||||||
function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number {
|
function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number {
|
||||||
const num = (numberInstanceIds ?? [])
|
const num = (numberInstanceIds ?? [])
|
||||||
|
|||||||
@@ -931,8 +931,8 @@ describe("the imp's fire is a spell (rev 8)", () => {
|
|||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
state = r.state;
|
state = r.state;
|
||||||
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
|
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
|
||||||
// The scorch rides the stack now: a counteraction window, spell-kind,
|
// The scorch rides the stack as a spell-kind counteraction window,
|
||||||
// named for the imp so the counter is played against the scorch, never a punch.
|
// named for the imp: the counter is played against the scorch.
|
||||||
expect(state.stack?.creatureId).toBeTruthy();
|
expect(state.stack?.creatureId).toBeTruthy();
|
||||||
expect(state.stack?.kind).toBe("spell");
|
expect(state.stack?.kind).toBe("spell");
|
||||||
expect(state.stack?.sourceName).toBe("fire imp's scorch");
|
expect(state.stack?.sourceName).toBe("fire imp's scorch");
|
||||||
|
|||||||
@@ -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)", () => {
|
describe("an illusion is priced as the spell it fakes (rev 27)", () => {
|
||||||
/** A faked FIREBALL met by the given counteraction, then both wizards pass. */
|
/** A faked FIREBALL met by the given counteraction, then both wizards pass. */
|
||||||
function fakedFireball(counter: string | null, deckRev?: number) {
|
function fakedFireball(counter: string | null, deckRev?: number, seed = 42) {
|
||||||
let { state } = createGame({ playerIds: ["liar", "mark"], seed: 42, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) });
|
let { state } = createGame({ playerIds: ["liar", "mark"], seed, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const ill = giveCard(state, attacker, "illusionary-attack");
|
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 };
|
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<Record<"believed" | "doubted", ReturnType<typeof fakedFireball>>> = {};
|
||||||
|
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", () => {
|
it("BLUNT halves a believed fake fireball to three, and disbelief costs nothing", () => {
|
||||||
const { events, believed, life } = fakedFireball("blunt");
|
const { believed, doubted } = bothWays("blunt");
|
||||||
expect(believed).not.toBeNull();
|
expect(believed.events).toContainEqual(expect.objectContaining({ type: "attackResolved", incoming: 5 }));
|
||||||
expect(events).toContainEqual(expect.objectContaining({ type: "attackResolved", incoming: 5 }));
|
expect(believed.life).toBe(12);
|
||||||
expect(life).toBe(believed ? 12 : 15);
|
expect(doubted.life).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FULL SHIELD stops the fake outright: nothing to believe in, no die rolled", () => {
|
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", () => {
|
it("uncountered, a believed fake fireball still does its five", () => {
|
||||||
const { believed, life } = fakedFireball(null);
|
expect(bothWays(null).believed.life).toBe(10);
|
||||||
expect(believed).not.toBeNull();
|
|
||||||
expect(life).toBe(believed ? 10 : 15);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("older games dealt a believed illusion's full points past a BLUNT", () => {
|
it("older games dealt a believed illusion's full points past a BLUNT", () => {
|
||||||
const { believed, life } = fakedFireball("blunt", 26);
|
expect(bothWays("blunt", 26).believed.life).toBe(10);
|
||||||
expect(believed).not.toBeNull();
|
|
||||||
expect(life).toBe(believed ? 10 : 15);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+23
-27
@@ -10,7 +10,7 @@
|
|||||||
import Card from "./Card.svelte";
|
import Card from "./Card.svelte";
|
||||||
import Help from "./Help.svelte";
|
import Help from "./Help.svelte";
|
||||||
import { HELP_TABS, type HelpTab } from "./help-state";
|
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 { summarizeMissed } from "./catchup";
|
||||||
import Replay from "./Replay.svelte";
|
import Replay from "./Replay.svelte";
|
||||||
import FxGallery from "./FxGallery.svelte";
|
import FxGallery from "./FxGallery.svelte";
|
||||||
@@ -28,14 +28,8 @@
|
|||||||
|
|
||||||
let name = $state(prefs.wizardName);
|
let name = $state(prefs.wizardName);
|
||||||
/** The other games of Kestrel's Hall, for the front page. */
|
/** The other games of Kestrel's Hall, for the front page. */
|
||||||
let family = $state<FamilyGame[]>([]);
|
const family = $derived(kin.games);
|
||||||
let hall = $state("");
|
const hall = $derived(kin.hall);
|
||||||
$effect(() => {
|
|
||||||
void loadFamily("wizwar").then((f) => {
|
|
||||||
family = f.games;
|
|
||||||
hall = f.hall;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
let joinCode = $state("");
|
let joinCode = $state("");
|
||||||
let claimPhrase = $state("");
|
let claimPhrase = $state("");
|
||||||
/** A /join/<CODE> recruiting link. The visitor lands looking at the
|
/** A /join/<CODE> recruiting link. The visitor lands looking at the
|
||||||
@@ -274,13 +268,15 @@
|
|||||||
let peekToken = $state<string | null>(null);
|
let peekToken = $state<string | null>(null);
|
||||||
/** Ledger chest tapped: the treasure full-size, with whose and who-holds. */
|
/** Ledger chest tapped: the treasure full-size, with whose and who-holds. */
|
||||||
let peekTreasure = $state<{ src: string; note: string } | null>(null);
|
let peekTreasure = $state<{ src: string; note: string } | null>(null);
|
||||||
/** Cardless blows that a card nonetheless made: the stack names the
|
/** Cardless blows that a card nonetheless made: a creature's blow shows
|
||||||
* blow, and this names the card to show for it. */
|
* the card that summoned it (its kind is the card's id), the ward's bite
|
||||||
const BLOW_CARD: Record<string, string> = {
|
* shows WARD. */
|
||||||
"warded treasure": "ward",
|
const blowCard = $derived.by(() => {
|
||||||
"fire imp's scorch": "fire-imp", "troll's blow": "troll", "skeleton's blow": "skeleton",
|
const stack = view?.stack;
|
||||||
"wraith's touch": "wraith", "democratic monster's claw": "democratic-monster",
|
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. */
|
/** Bare-knuckle demolition: click a wall to punch it. */
|
||||||
let punchWallMode = $state(false);
|
let punchWallMode = $state(false);
|
||||||
@@ -1534,6 +1530,8 @@
|
|||||||
}
|
}
|
||||||
/** What the chronicle shows: everything, the game's own lines, or the talk. */
|
/** What the chronicle shows: everything, the game's own lines, or the talk. */
|
||||||
let logFilter = $state<"all" | "game" | "talk">("all");
|
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}");
|
const isTalk = (text: string) => text.startsWith("\u{1F4AC}") || text.startsWith("\u{1F3B2}");
|
||||||
function showLine(line: { text: string }): boolean {
|
function showLine(line: { text: string }): boolean {
|
||||||
return logFilter === "all" ? true : logFilter === "talk" ? isTalk(line.text) : !isTalk(line.text);
|
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
|
/** 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
|
* 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. */
|
* few seconds, so the table can see how the aim was legal. */
|
||||||
|
let castTrace = $state.raw<SightLine | null>(null);
|
||||||
|
let castTraceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace);
|
||||||
|
|
||||||
/** The hour where the keeper lives, in words, kept current by the minute. */
|
/** The hour where the keeper lives, in words, kept current by the minute. */
|
||||||
let minuteTick = $state(0);
|
let minuteTick = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -1795,10 +1797,6 @@
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
let castTrace = $state.raw<SightLine | null>(null);
|
|
||||||
let castTraceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace);
|
|
||||||
|
|
||||||
/** A first game's first turn lights itself; after that only by choice. */
|
/** A first game's first turn lights itself; after that only by choice. */
|
||||||
const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1);
|
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
|
/** 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}
|
{:else if view.stack.defenderId === view.you && view.stack.sourceName}
|
||||||
<!-- A cardless bite with a name — the WARD springing on a grabbed
|
<!-- A cardless bite with a name — the WARD springing on a grabbed
|
||||||
treasure. The card that did it is shown; nobody threw it. -->
|
treasure. The card that did it is shown; nobody threw it. -->
|
||||||
{#if BLOW_CARD[view.stack.sourceName]}
|
{#if blowCard}
|
||||||
<div class="attack-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: BLOW_CARD[view.stack.sourceName]! }} onfaq={(id) => (faqCardId = id)} /></div>
|
<div class="attack-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: blowCard }} onfaq={(id) => (faqCardId = id)} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="attack-power">
|
<div class="attack-power">
|
||||||
{view.stack.sourceName.toUpperCase()} — {view.stack.params?.damage ?? 0} damage inbound unless you answer it
|
{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.</div>
|
back, as long as nobody abandons it.</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Talk before the boards flip: to say when, or to leave a word. -->
|
<!-- Talk before the boards flip: to say when, or to leave a word. -->
|
||||||
{@const lobbyTalk = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
|
|
||||||
<div class="lobby-talk">
|
<div class="lobby-talk">
|
||||||
{#each lobbyTalk as line, i (i)}
|
{#each lobbyTalk as line, i (i)}
|
||||||
<div class="lobby-line">{line.text}</div>
|
<div class="lobby-line">{line.text}</div>
|
||||||
@@ -2880,9 +2877,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{@const galleryTalkLines = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
|
|
||||||
<div class="lobby-talk">
|
<div class="lobby-talk">
|
||||||
{#each galleryTalkLines as line, i (i)}
|
{#each lobbyTalk as line, i (i)}
|
||||||
<div class="lobby-line">{line.text}</div>
|
<div class="lobby-line">{line.text}</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if net.galleryTalk}
|
{#if net.galleryTalk}
|
||||||
@@ -3466,8 +3462,8 @@
|
|||||||
{#if view.stack.attackCard}
|
{#if view.stack.attackCard}
|
||||||
<div class="cast-panel-card"><Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} /></div>
|
<div class="cast-panel-card"><Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} /></div>
|
||||||
{:else if view.stack.sourceName}
|
{:else if view.stack.sourceName}
|
||||||
{#if BLOW_CARD[view.stack.sourceName]}
|
{#if blowCard}
|
||||||
<div class="cast-panel-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: BLOW_CARD[view.stack.sourceName]! }} onfaq={(id) => (faqCardId = id)} /></div>
|
<div class="cast-panel-card"><Card card={{ instanceId: `blow-${view.stack.sourceName}`, cardId: blowCard }} onfaq={(id) => (faqCardId = id)} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="cast-panel-note">{defending ? "" : "your "}{view.stack.sourceName} — it bites for {view.stack.params?.damage ?? 0}</div>
|
<div class="cast-panel-note">{defending ? "" : "your "}{view.stack.sourceName} — it bites for {view.stack.params?.damage ?? 0}</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { Component } from "svelte";
|
import type { Component } from "svelte";
|
||||||
import type { GameView, SightTrace } from "@wizwar/engine";
|
import type { GameView, SightTrace } from "@wizwar/engine";
|
||||||
import type { Side } 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 type { BoardFx } from "./fx";
|
||||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||||
@@ -215,8 +215,7 @@
|
|||||||
}
|
}
|
||||||
if (!punchFrom) return boxes;
|
if (!punchFrom) return boxes;
|
||||||
const beside = (b: { cell: { x: number; y: number }; side: Side }) => {
|
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 }
|
const far = neighbor(b.cell, b.side);
|
||||||
: b.side === "S" ? { x: b.cell.x, y: b.cell.y + 1 } : { x: b.cell.x, y: b.cell.y - 1 };
|
|
||||||
const at = (c: { x: number; y: number }) => c.x === punchFrom!.x && c.y === punchFrom!.y;
|
const at = (c: { x: number; y: number }) => c.x === punchFrom!.x && c.y === punchFrom!.y;
|
||||||
return at(b.cell) || at(far);
|
return at(b.cell) || at(far);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// body, the card library with proper entries, the house rulings, the
|
// 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
|
// 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.
|
// 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 { cardDef } from "@wizwar/engine";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import Card from "./Card.svelte";
|
import Card from "./Card.svelte";
|
||||||
@@ -129,14 +129,8 @@
|
|||||||
else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0;
|
else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0;
|
||||||
});
|
});
|
||||||
/** The other games of Kestrel's Hall, for the about page. */
|
/** The other games of Kestrel's Hall, for the about page. */
|
||||||
let family = $state<FamilyGame[]>([]);
|
const family = $derived(kin.games);
|
||||||
let hall = $state("");
|
const hall = $derived(kin.hall);
|
||||||
$effect(() => {
|
|
||||||
void loadFamily("wizwar").then((f) => {
|
|
||||||
family = f.games;
|
|
||||||
hall = f.hall;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet section(s: RefSection, withSource: boolean)}
|
{#snippet section(s: RefSection, withSource: boolean)}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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<Family> {
|
|
||||||
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: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user