Credibility pass: one conflict check, one hostile list, one set of chrome

Duplicated logic and styling that had drifted apart is unified: the
gesture-sharing check, the hostile spell list, the enchanted test, the
plan limits, and the shared button, link, field and disabled styles.
Dead plumbing is gone: the always-true implemented flag, the unread
cast length, an unreachable guard, an impossible time-stop condition,
the unused sequence formatter and utility class, the template
placeholder. The counter-spell tests now put a counter-spell in front
of a spell, the test helpers live in one file, and the resolver's
sections are numbered in order. The deploy script's cache headers now
do what its comment says, and the README describes the screen rather
than listing features in the order they arrived.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 16:37:54 -04:00
co-authored by Claude Fable 5.1
parent 527820ee95
commit 5de352db43
22 changed files with 415 additions and 485 deletions
+42 -50
View File
@@ -3,6 +3,7 @@
import { tick } from 'svelte';
import { chooseBotTurn } from './bot';
import {
cellsOverlap,
completableIn,
completedSpells,
handSequence,
@@ -11,19 +12,20 @@ import {
type Completion,
type HandTurn
} from './gestures';
import type { Token } from './spells';
import { glyph } from './glyphs';
import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
import {
CONTROL_SPELLS,
GESTURES,
HANDS,
PERMANENCY_EXCLUDED,
HOSTILE_SPELLS,
SPELLS,
SPELL_BY_ID,
permanencyEligible,
type Gesture,
type Hand,
type Spell,
type SpellId
type SpellId,
type Token
} from './spells';
import {
NOWHERE,
@@ -40,34 +42,18 @@ import {
export const YOU: WizardId = 'A';
export const FOE: WizardId = 'B';
/** Narrow layouts: shorter lists and a sticky bar. The stylesheets in +page, MobileBar and TurnSummary use the same width. */
export const COMPACT_QUERY = '(max-width: 860px)';
/** How many spells under way to list beneath a hand, with and without a pinned one. */
const PLAN_LIMITS = { wide: 7, compact: 3, compactPinned: 4 };
/** The first pair of gestures each turn, and the extra pair a hastened wizard makes. */
export type SetName = 'main' | 'haste';
export const SET_NAMES: SetName[] = ['main', 'haste'];
const NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
const HOSTILE: SpellId[] = [
'missile',
'finger_of_death',
'lightning_bolt',
'lightning_bolt_quick',
'cause_light_wounds',
'cause_heavy_wounds',
'fireball',
'fire_storm',
'ice_storm',
...CONTROL_SPELLS,
'anti_spell',
'disease',
'poison',
'blindness',
'remove_enchantment',
'summon_goblin',
'summon_ogre',
'summon_troll',
'summon_giant',
'summon_elemental'
];
export interface HandDraft {
gesture: Gesture | null;
@@ -177,7 +163,7 @@ function loadSaved(): SavedDuel | null {
if (!raw) return null;
const saved = JSON.parse(raw) as SavedDuel;
if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null;
// Fields added since the save was written.
// Saves at this version may lack fields that came later; the engine expects them present.
saved.state.lessonsShown ??= [];
if (saved.state.lastTurn) {
saved.state.lastTurn.events ??= [];
@@ -245,8 +231,8 @@ export class Duel {
/**
* Choose each hand's default cast from its gestures. A hand bound by amnesia or
* paralysis can finish a spell without the player touching it, so this must run
* at the start of every turn, not only after a click.
* paralysis can finish a spell without the player touching it, so every turn
* starts here as well as every click.
*/
syncAll(): void {
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
@@ -316,8 +302,21 @@ export class Duel {
/** True when the bot's hand is yours to command this turn. */
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped);
/** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */
chosenGesture(set: SetName, hand: Hand): Gesture | null {
return this.forcedFor(set, hand) ?? this.sets[set][hand].gesture;
}
gestureFor(set: SetName, hand: Hand): Gesture {
return this.forcedFor(set, hand) ?? this.sets[set][hand].gesture ?? '-';
return this.chosenGesture(set, hand) ?? '-';
}
/** How a hand's gesture is written in the ledger and the sticky bar before the reveal. */
drawn(set: SetName, hand: Hand): string {
if (this.charmedHand === hand) return '?';
const g = this.chosenGesture(set, hand);
if (g === null) return '\u00b7';
return glyph(g, this.gestureFor(set, hand === 'left' ? 'right' : 'left'));
}
private entry(set: SetName): HistoryEntry {
@@ -374,9 +373,7 @@ export class Duel {
conflict = $derived.by(() => {
const cells = this.planned.map((p) => usedCells(p.completion, p.hand, p.turnCount));
for (let i = 0; i < cells.length; i++) {
for (let j = i + 1; j < cells.length; j++) {
for (const cell of cells[i]) if (cells[j].has(cell)) return true;
}
for (let j = i + 1; j < cells.length; j++) if (cellsOverlap(cells[i], cells[j])) return true;
}
return false;
});
@@ -398,6 +395,7 @@ export class Duel {
return this.activeSets.some((set) => HANDS.some((h) => this.completions[set][h].some((c) => c.spell.id === id)));
}
summary = $derived(this.state.lastTurn);
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
threats = $derived.by(() => {
@@ -406,7 +404,7 @@ export class Duel {
const seq = handSequence(this.foeVisible, hand);
for (const turnsAway of [1, 2]) {
for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) {
if (!HOSTILE.includes(c.spell.id)) continue;
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
if (c.spell.id === 'lightning_bolt_quick' && this.foe.usedQuickLightning) continue;
const tokens = c.spell.tokens[c.seqIndex];
const done = tokens.slice(0, tokens.length - turnsAway);
@@ -449,12 +447,13 @@ export class Duel {
const all = [...best.values()].sort(
(a, b) => Number(b.pinned) - Number(a.pinned) || a.remaining.length - b.remaining.length || b.done.length - a.done.length || a.spell.name.localeCompare(b.spell.name)
);
return all.slice(0, pinned ? 7 : 6);
return all.slice(0, PLAN_LIMITS.wide);
}
/** How many plans to show before "More spells" on a narrow screen. */
/** How many plans to show before "More spells". */
planLimit(hand: Hand): number {
return this.compact ? (this.pins[hand] ? 4 : 3) : 7;
if (!this.compact) return PLAN_LIMITS.wide;
return this.pins[hand] ? PLAN_LIMITS.compactPinned : PLAN_LIMITS.compact;
}
pin(hand: Hand, id: SpellId): void {
@@ -466,14 +465,14 @@ export class Duel {
this.pins[hand] = plan.spell.id;
const next = plan.remaining[0];
if (!next) return;
if (!this.forcedFor('main', hand) && this.charmedHand !== hand && this.allowed[hand].includes(next.gesture)) {
if (this.handFree(hand) && this.allowed[hand].includes(next.gesture)) {
this.sets.main[hand].gesture = next.gesture;
}
const other: Hand = hand === 'left' ? 'right' : 'left';
if (next.both && !this.forcedFor('main', other) && this.charmedHand !== other && this.allowed[other].includes(next.gesture)) {
if (next.both && this.handFree(other) && this.allowed[other].includes(next.gesture)) {
this.sets.main[other].gesture = next.gesture;
}
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
this.syncAll();
}
setShowPlans(on: boolean): void {
@@ -509,8 +508,6 @@ export class Duel {
document.getElementById('spell-sheet')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
summary = $derived(this.state.lastTurn);
surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P'));
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU));
@@ -536,12 +533,7 @@ export class Duel {
);
permanentCandidates = $derived(
this.permanencyActive
? this.planned.filter(
(p) =>
p.completion.spell.category === 'enchantment' &&
!PERMANENCY_EXCLUDED.includes(p.completion.spell.id) &&
p !== this.willBank
)
? this.planned.filter((p) => permanencyEligible(p.completion.spell) && p !== this.willBank)
: []
);
willExtend = $derived(
@@ -562,7 +554,7 @@ export class Duel {
choose(set: SetName, hand: Hand, gesture: Gesture): void {
const draft = this.sets[set][hand];
draft.gesture = draft.gesture === gesture ? null : gesture;
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
this.syncAll();
}
/** Keep the selected spell valid for the current gestures; default to the longest completion. */
@@ -586,7 +578,7 @@ export class Duel {
hand: p.hand,
spellId: spell.id,
seqIndex: p.completion.seqIndex,
target: p.draft.target || (spell.usualTarget === 'self' ? YOU : FOE)
target: p.draft.target || defaultTarget(spell)
};
if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental;
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;