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
+39 -39
View File
@@ -4,22 +4,25 @@
// enchantments, damage, monster attacks and stabs, and finally deaths and
// surrenders. A time stop resolves as a turn in which one wizard acts alone.
import { completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn } from './gestures';
import { cellsOverlap, completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn } from './gestures';
import {
BLANKET_SPELLS,
CONTROL_SPELLS,
GESTURES,
GESTURE_NAMES,
HANDS,
MAGIC_GESTURES,
MONSTER_STATS,
PERMANENCY_EXCLUDED,
SPELLS,
SPELL_BY_ID,
SUMMON_KIND,
isElemental,
permanencyEligible,
type Gesture,
type Hand,
type MonsterKind,
type Spell
type Spell,
type SpellId
} from './spells';
import {
MAX_HP,
@@ -70,9 +73,8 @@ export function paralysedForm(g: Gesture): Gesture {
export const FEAR_BANNED: Gesture[] = ['C', 'D', 'F', 'S'];
function describe(g: Gesture): string {
return GESTURE_NAMES[g];
}
/** A counter-spell cannot touch these. */
const UNCOUNTERABLE: SpellId[] = ['finger_of_death', 'counter_spell', 'magic_mirror'];
function roman(n: number): string {
const numerals: [number, string][] = [
@@ -114,10 +116,6 @@ function clearMonsterEnchantments(m: Monster): void {
m.pending = {};
}
function permanencyEligible(spell: Spell): boolean {
return spell.category === 'enchantment' && !PERMANENCY_EXCLUDED.includes(spell.id);
}
/**
* Last turn's gestures as [first pair, second pair]: a wizard who was hastened
* repeats both pairs under amnesia, and each hand stays paralysed in its
@@ -202,7 +200,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
overlaps: [] as { name: string; spell: string; earlier: string }[],
broken: [] as { name: string; hand: Hand }[],
curedAndHurt: [] as { name: string; hurt: number; healed: number }[],
newMonsterAttacked: '' as string
newMonsterAttacked: ''
};
// ---- 1. Gestures, after last turn's enchantments have their say ----
@@ -216,13 +214,13 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (w.permanent.charmed && !w.permanent.charmedGesture) w.permanent.charmedGesture = g;
if (c.charmed.hand === 'left') left = g;
else right = g;
log(`${W[c.charmed.by].name} charms ${w.name}'s ${c.charmed.hand} hand into a ${describe(g)}.`);
log(`${W[c.charmed.by].name} charms ${w.name}'s ${c.charmed.hand} hand into a ${GESTURE_NAMES[g]}.`);
}
if (c.paralysedHand && prev) {
const g = paralysedForm(w.history[w.history.length - 1][c.paralysedHand]);
if (c.paralysedHand === 'left') left = g;
else right = g;
log(`${w.name}'s ${c.paralysedHand} hand is paralysed into a ${describe(g)}.`);
log(`${w.name}'s ${c.paralysedHand} hand is paralysed into a ${GESTURE_NAMES[g]}.`);
}
if (c.fear) {
if (FEAR_BANNED.includes(left)) left = '-';
@@ -234,18 +232,18 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (w.permanent.confusion && !w.permanent.confusionFixed) w.permanent.confusionFixed = slip;
if (slip.hand === 'left') left = slip.gesture;
else right = slip.gesture;
log(`Confused, ${w.name}'s ${slip.hand} hand makes a ${describe(slip.gesture)} instead.`);
log(`Confused, ${w.name}'s ${slip.hand} hand makes a ${GESTURE_NAMES[slip.gesture]} instead.`);
}
if (left === '>' && right === '>') right = '-';
return [left, right];
};
const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : describe(g));
const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : GESTURE_NAMES[g]);
const record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => {
const hiddenFrom: WizardId[] = [];
const foe = enemyOf(w.id);
if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe);
if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name);
for (const hand of ['left', 'right'] as Hand[]) {
for (const hand of HANDS) {
const g = hand === 'left' ? left : right;
if (g !== '>' && g !== '-') continue;
const seq = handSequence(w.history, hand);
@@ -300,11 +298,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
continue;
}
const cells = usedCells(completion, choice.hand, index + 1);
const clash = accepted.some((a) => {
if (!a.completion) return false;
for (const cell of usedCells(a.completion, a.hand, a.index + 1)) if (cells.has(cell)) return true;
return false;
});
const clash = accepted.some((a) => !!a.completion && cellsOverlap(cells, usedCells(a.completion, a.hand, a.index + 1)));
if (clash) {
log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle');
continue;
@@ -316,7 +310,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
);
if (earlier) facts.overlaps.push({ name: w.name, spell: completion.spell.name, earlier: SPELL_BY_ID[earlier.spellId].name });
accepted.push({ caster: id, hand: choice.hand, spell: completion.spell, target, choice, completion, index });
w.casts.push({ turn, index, hand: choice.hand, spellId: completion.spell.id, length: completion.length, target });
w.casts.push({ turn, index, hand: choice.hand, spellId: completion.spell.id, target });
const at = BLANKET_SPELLS.includes(completion.spell.id) || target === id ? '' : ` at ${beingName(state, target)}`;
log(`${w.name} casts ${completion.spell.name}${at} (${completion.spell.sequences[completion.seqIndex]}, ${choice.hand} hand).`, 'spell', false);
if (completion.spell.id === 'lightning_bolt_quick') {
@@ -338,7 +332,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
index: -1,
released: true
});
w.casts.push({ turn, index: -1, hand: 'left', spellId: spell.id, length: 0, target });
w.casts.push({ turn, index: -1, hand: 'left', spellId: spell.id, target });
w.banked = null;
const at = BLANKET_SPELLS.includes(spell.id) || target === id ? '' : ` at ${beingName(state, target)}`;
log(`${w.name} releases the banked ${spell.name}${at}.`, 'spell');
@@ -398,18 +392,25 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
log(`${W[owner].name}'s magic mirror turns the ${c.spell.name} back on ${W[original].name}.`, 'spell');
}
for (const c of live()) {
if (['dispel_magic', 'finger_of_death', 'counter_spell', 'magic_mirror'].includes(c.spell.id)) continue;
if (UNCOUNTERABLE.includes(c.spell.id)) continue;
if (BLANKET_SPELLS.includes(c.spell.id)) continue;
if (countered.has(c.target)) {
c.nullified = 'countered';
facts.counterSmothered += 1;
log(`A counter-spell on ${beingName(state, c.target)} smothers the ${c.spell.name}.`, 'fizzle');
const subject = beingName(state, c.target);
const own = c.caster === c.target;
log(
c.spell.id === 'shield'
? `A counter-spell on ${subject} smothers the Shield, and shields ${subject} itself.`
: `A counter-spell on ${subject} smothers ${own ? 'their own ' : 'the '}${c.spell.name}.`,
'fizzle'
);
}
}
if (live().some((c) => c.spell.id === 'finger_of_death' && countered.has(c.target))) facts.fingerThroughCounter = true;
}
// ---- 4b. Delayed effect banks a spell; permanency extends one ----
// ---- 5. Delayed effect banks a spell; permanency extends one ----
const bankedNow = new Set<WizardId>();
const extendedNow = new Set<WizardId>();
for (const id of acting) {
@@ -438,7 +439,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 5. Who is shielded this turn ----
// ---- 6. Who is shielded this turn ----
for (const c of live()) {
if (['shield', 'counter_spell', 'dispel_magic', 'protection_from_evil'].includes(c.spell.id)) shielded.add(c.target);
}
@@ -446,7 +447,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
for (const m of state.monsters) if (m.protectionTurns > 0) shielded.add(m.id);
const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id);
// ---- 6. Summons, then storms and elementals ----
// ---- 7. Summons, then storms and elementals ----
for (const c of live()) {
const kind: MonsterKind | undefined =
c.spell.id === 'summon_elemental'
@@ -548,7 +549,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 7. Enchantments ----
// ---- 8. Enchantments ----
const controlByTarget = new Map<TargetId, Set<string>>();
for (const c of live()) {
if (!CONTROL_SPELLS.includes(c.spell.id)) continue;
@@ -586,7 +587,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
break;
case 'charm_person':
if (wiz) {
const hand = c.choice.chosenHand ?? pick(['left', 'right'] as Hand[]);
const hand = c.choice.chosenHand ?? pick(HANDS);
wiz.pending.charmed = { hand, by: c.caster };
if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster };
log(`${wiz.name}'s ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
@@ -711,7 +712,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 8. Damage and healing ----
// ---- 9. Damage and healing ----
const damage = new Map<TargetId, number>();
const healing = new Map<TargetId, number>();
const slain = new Set<TargetId>();
@@ -794,7 +795,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 9. Monsters attack, including those slain this very turn ----
// ---- 10. Monsters attack, including those slain this very turn ----
for (const m of state.monsters) {
if (destroyedBeforeAttack.has(m.id)) continue;
if (frozen(m.owner)) continue;
@@ -834,7 +835,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 10. Stabs ----
// ---- 11. Stabs ----
for (const id of acting) {
const w = W[id];
entries[id].forEach((index, half) => {
@@ -852,7 +853,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
});
}
// ---- 11. Apply the ledger ----
// ---- 12. Apply the ledger ----
for (const id of IDS) {
const w = W[id];
if (!w.alive) continue;
@@ -883,7 +884,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
return true;
});
// ---- 12. The turn ends ----
// ---- 13. The turn ends ----
if (phase === 'main') {
for (const id of IDS) {
const w = W[id];
@@ -962,7 +963,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (facts.fingerThroughCounter) {
lessons.push({ key: 'counter-finger', text: 'A counter-spell cannot stop finger of death. Only dispel magic can, or an anti-spell before the eight gestures are finished.' });
} else if (facts.counterSmothered > 0) {
lessons.push({ key: 'counter-scope', text: 'A counter-spell stops every other spell cast at its subject that turn and shields them as well. It cannot stop finger of death or dispel magic.' });
lessons.push({ key: 'counter-scope', text: 'A counter-spell stops every other spell cast at its subject that turn, even the subject\'s own, and shields them as well. It cannot stop finger of death or dispel magic.' });
}
if (facts.loneClap.length > 0) {
lessons.push({ key: 'lone-clap', text: `A clap needs both hands at once. ${facts.loneClap[0]}'s single-handed clap counts as nothing, and that hand's spells start from scratch.` });
@@ -994,12 +995,11 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
/** Gestures a hand may choose this turn, given last turn's enchantments. */
export function allowedGestures(w: WizardState, hand: Hand): Gesture[] {
const all: Gesture[] = ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
if (w.constraints.amnesia) return [];
if (w.constraints.paralysedHand === hand) return [];
if (w.constraints.charmed?.hand === hand) return [];
if (w.constraints.fear) return all.filter((g) => !FEAR_BANNED.includes(g));
return all;
if (w.constraints.fear) return GESTURES.filter((g) => !FEAR_BANNED.includes(g));
return GESTURES;
}
/** The gesture a hand is forced to make this turn, if any is known in advance. */