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:
co-authored by
Claude Fable 5.1
parent
527820ee95
commit
5de352db43
+39
-39
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user