SvelteKit 5 single-player version against a heuristic bot. The engine resolves all forty spells simultaneously as the rules describe, the ledger of gestures is the interface, and the duel is saved in the browser. Tester feedback rounds added per-hand planning, threat evidence, a result strip with one-time rule explanations, a phone layout and a structured rules page. Deploys as a static site behind Caddy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1014 lines
39 KiB
TypeScript
1014 lines
39 KiB
TypeScript
// Simultaneous turn resolution, following the precedence rules in
|
|
// docs/waving-hands-rules.txt: dispel magic first, then counter-spells and
|
|
// mirrors, banking and permanency, storm and elemental interplay, summons,
|
|
// 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 {
|
|
BLANKET_SPELLS,
|
|
CONTROL_SPELLS,
|
|
GESTURE_NAMES,
|
|
MAGIC_GESTURES,
|
|
MONSTER_STATS,
|
|
PERMANENCY_EXCLUDED,
|
|
SPELLS,
|
|
SPELL_BY_ID,
|
|
SUMMON_KIND,
|
|
isElemental,
|
|
type Gesture,
|
|
type Hand,
|
|
type MonsterKind,
|
|
type Spell
|
|
} from './spells';
|
|
import {
|
|
MAX_HP,
|
|
NOWHERE,
|
|
beingName,
|
|
controllerOf,
|
|
enemyOf,
|
|
findMonster,
|
|
isWizardId,
|
|
nextRandom,
|
|
type CastChoice,
|
|
type Constraints,
|
|
type GameState,
|
|
type HistoryEntry,
|
|
type LogKind,
|
|
type Monster,
|
|
type Phase,
|
|
type TargetId,
|
|
type TurnInput,
|
|
type TurnSummary,
|
|
type WizardId,
|
|
type WizardState
|
|
} from './state';
|
|
|
|
interface ActiveCast {
|
|
caster: WizardId;
|
|
hand: Hand;
|
|
spell: Spell;
|
|
target: TargetId;
|
|
choice: CastChoice;
|
|
completion?: Completion;
|
|
/** History index of the completing gesture; -1 for a released banked spell. */
|
|
index: number;
|
|
released?: boolean;
|
|
permanent?: boolean;
|
|
/** Why the spell did nothing, once decided. */
|
|
nullified?: string;
|
|
}
|
|
|
|
const IDS: WizardId[] = ['A', 'B'];
|
|
|
|
export function paralysedForm(g: Gesture): Gesture {
|
|
if (g === 'C') return 'F';
|
|
if (g === 'S') return 'D';
|
|
if (g === 'W') return 'P';
|
|
return g;
|
|
}
|
|
|
|
export const FEAR_BANNED: Gesture[] = ['C', 'D', 'F', 'S'];
|
|
|
|
function describe(g: Gesture): string {
|
|
return GESTURE_NAMES[g];
|
|
}
|
|
|
|
function roman(n: number): string {
|
|
const numerals: [number, string][] = [
|
|
[10, 'X'],
|
|
[9, 'IX'],
|
|
[5, 'V'],
|
|
[4, 'IV'],
|
|
[1, 'I']
|
|
];
|
|
let out = '';
|
|
for (const [v, s] of numerals) {
|
|
while (n >= v) {
|
|
out += s;
|
|
n -= v;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function clearEnchantments(w: WizardState): void {
|
|
w.resistHeat = false;
|
|
w.resistCold = false;
|
|
w.protectionTurns = 0;
|
|
w.blindTurns = 0;
|
|
w.invisibleTurns = 0;
|
|
w.diseaseTurns = null;
|
|
w.poisonTurns = null;
|
|
w.hasteTurns = 0;
|
|
w.delayedTurns = 0;
|
|
w.permanencyTurns = 0;
|
|
w.pending = {};
|
|
w.permanent = {};
|
|
}
|
|
|
|
function clearMonsterEnchantments(m: Monster): void {
|
|
m.resistHeat = false;
|
|
m.resistCold = false;
|
|
m.protectionTurns = 0;
|
|
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
|
|
* most recent position.
|
|
*/
|
|
export function previousPairs(w: WizardState): [HistoryEntry | undefined, HistoryEntry | undefined] {
|
|
const last = w.history[w.history.length - 1];
|
|
if (!last) return [undefined, undefined];
|
|
if (last.phase === 'haste') return [w.history[w.history.length - 2], last];
|
|
return [last, last];
|
|
}
|
|
|
|
/** The longest run toward any spell the hand has going. */
|
|
function longestRun(seq: HandTurn[], start: number): number {
|
|
let best = 0;
|
|
for (const spell of SPELLS) for (const tokens of spell.tokens) best = Math.max(best, progressToward(tokens, seq, start));
|
|
return best;
|
|
}
|
|
|
|
function tick(n: number): number {
|
|
return n === Infinity ? n : Math.max(0, n - 1);
|
|
}
|
|
|
|
export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnInput>): GameState {
|
|
const state = structuredClone(previous);
|
|
if (state.over) return state;
|
|
const W = state.wizards;
|
|
const actor = state.timeStops.shift() ?? null;
|
|
const phase: Phase = actor ? 'timestop' : 'main';
|
|
const acting: WizardId[] = actor ? [actor] : IDS;
|
|
/** Time-stop entries and log lines belong to the turn just played. */
|
|
const turn = actor ? state.turn - 1 : state.turn;
|
|
const events: string[] = [];
|
|
const log = (text: string, kind: LogKind = 'info', surface = kind !== 'gesture' && kind !== 'damage') => {
|
|
state.log.push({ turn, kind, text });
|
|
if (surface) events.push(text);
|
|
};
|
|
const rng = () => nextRandom(state);
|
|
const pick = <T>(items: T[]): T => items[Math.floor(rng() * items.length)];
|
|
|
|
const frozen = (id: WizardId) => !acting.includes(id);
|
|
const frozenBeing = (id: TargetId): boolean => {
|
|
const c = controllerOf(state, id);
|
|
return c !== undefined && frozen(c);
|
|
};
|
|
const isBeing = (id: TargetId | undefined): id is TargetId =>
|
|
id !== undefined && id !== NOWHERE && (isWizardId(id) ? W[id].alive : !!findMonster(state, id));
|
|
const monsterOf = (id: TargetId) => findMonster(state, id);
|
|
/** Under time stop, those who cannot move have no resistance to anything. */
|
|
const resists = (id: TargetId, type: 'heat' | 'cold'): boolean => {
|
|
if (frozenBeing(id)) return false;
|
|
const b = isWizardId(id) ? W[id] : monsterOf(id);
|
|
if (!b) return false;
|
|
return type === 'heat' ? b.resistHeat : b.resistCold;
|
|
};
|
|
|
|
const summary: TurnSummary = {
|
|
turn,
|
|
phase,
|
|
wizards: {
|
|
A: { before: W.A.hp, after: W.A.hp, effects: [] },
|
|
B: { before: W.B.hp, after: W.B.hp, effects: [] }
|
|
},
|
|
events,
|
|
lessons: []
|
|
};
|
|
const tidy = (why: string) => {
|
|
const stripped = why.replace(/^(a|an|the) /, '');
|
|
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
|
};
|
|
const note = (id: TargetId, label: string, delta: number) => {
|
|
if (isWizardId(id)) summary.wizards[id].effects.push({ label, delta });
|
|
};
|
|
if (actor) log(`Time stands still. ${W[actor].name} alone can move.`, 'spell');
|
|
/** Facts gathered along the way, turned into explanations at the end. */
|
|
const facts = {
|
|
shieldBlocked: new Set<TargetId>(),
|
|
throughShield: [] as { target: TargetId; spell: string }[],
|
|
counterSmothered: 0,
|
|
fingerThroughCounter: false,
|
|
loneClap: [] as string[],
|
|
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
|
|
};
|
|
|
|
// ---- 1. Gestures, after last turn's enchantments have their say ----
|
|
const shape = (w: WizardState, c: Constraints, left: Gesture, right: Gesture, prev: HistoryEntry | undefined): [Gesture, Gesture] => {
|
|
if (c.amnesia && prev) {
|
|
log(`${w.name}, struck by amnesia, repeats the last gestures.`);
|
|
return [prev.left, prev.right];
|
|
}
|
|
if (c.charmed) {
|
|
const g = c.charmedGesture ?? inputs[c.charmed.by].charmGesture ?? '-';
|
|
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)}.`);
|
|
}
|
|
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)}.`);
|
|
}
|
|
if (c.fear) {
|
|
if (FEAR_BANNED.includes(left)) left = '-';
|
|
if (FEAR_BANNED.includes(right)) right = '-';
|
|
log(`${w.name} is too afraid to snap, point, clap or wriggle.`);
|
|
}
|
|
if (c.confusion) {
|
|
const slip = c.confusionFixed ?? { hand: rng() < 0.5 ? 'left' : 'right', gesture: pick(MAGIC_GESTURES) };
|
|
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.`);
|
|
}
|
|
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 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[]) {
|
|
const g = hand === 'left' ? left : right;
|
|
if (g !== '>' && g !== '-') continue;
|
|
const seq = handSequence(w.history, hand);
|
|
if (longestRun(seq, w.sequenceStart) >= 2) facts.broken.push({ name: w.name, hand });
|
|
}
|
|
w.history.push({ left, right, turn, phase: entryPhase, hiddenFrom });
|
|
if (left === 'P' && right === 'P') w.surrendered = true;
|
|
const prefix = entryPhase === 'haste' ? `${w.name}, hastened, adds` : `${w.name}:`;
|
|
log(`${prefix} left ${lone(left, right)}, right ${lone(right, left)}.`, 'gesture');
|
|
};
|
|
/** History indexes of the entries each acting wizard made this turn. */
|
|
const entries: Record<WizardId, number[]> = { A: [], B: [] };
|
|
for (const id of acting) {
|
|
const w = W[id];
|
|
const inp = inputs[id];
|
|
w.surrendered = false;
|
|
const c = phase === 'main' ? w.constraints : {};
|
|
const [prevMain, prevHaste] = previousPairs(w);
|
|
const [l, r] = shape(w, c, inp.left, inp.right, prevMain);
|
|
record(w, l, r, phase);
|
|
entries[id].push(w.history.length - 1);
|
|
if (phase === 'main' && w.hasteTurns > 0) {
|
|
const second = inp.second ?? { left: '-', right: '-', casts: [] };
|
|
const [l2, r2] = shape(w, c, second.left, second.right, prevHaste);
|
|
record(w, l2, r2, 'haste');
|
|
entries[id].push(w.history.length - 1);
|
|
}
|
|
if (w.surrendered) log(`${w.name} offers both palms in surrender.`, 'death');
|
|
}
|
|
|
|
// ---- 2. Which completed spells are actually loosed ----
|
|
const casts: ActiveCast[] = [];
|
|
const shielded = new Set<TargetId>();
|
|
for (const id of acting) {
|
|
const w = W[id];
|
|
const accepted: ActiveCast[] = [];
|
|
entries[id].forEach((index, half) => {
|
|
const upto = w.history.slice(0, index + 1);
|
|
const completions: Record<Hand, Completion[]> = {
|
|
left: completedSpells(handSequence(upto, 'left'), w.sequenceStart),
|
|
right: completedSpells(handSequence(upto, 'right'), w.sequenceStart)
|
|
};
|
|
const choices = half === 0 ? inputs[id].casts : (inputs[id].second?.casts ?? []);
|
|
for (const choice of choices) {
|
|
if (accepted.some((a) => a.index === index && a.hand === choice.hand)) continue;
|
|
const options = completions[choice.hand].filter((c) => c.spell.id === choice.spellId);
|
|
const completion =
|
|
options.find((c) => choice.seqIndex === undefined || c.seqIndex === choice.seqIndex) ?? options[0];
|
|
if (!completion) continue;
|
|
if (completion.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) {
|
|
log(`${w.name}'s quick lightning bolt sputters: it has already been used this duel.`, 'fizzle');
|
|
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;
|
|
});
|
|
if (clash) {
|
|
log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle');
|
|
continue;
|
|
}
|
|
let target = choice.target ?? (completion.spell.usualTarget === 'self' ? id : enemyOf(id));
|
|
if (completion.spell.id === 'summon_elemental' && target === NOWHERE) target = id;
|
|
const earlier = w.casts.find(
|
|
(c) => c.hand === choice.hand && c.index >= 0 && c.index < index && c.index >= index - completion.length + 1
|
|
);
|
|
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 });
|
|
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') {
|
|
w.usedQuickLightning = true;
|
|
log(`${w.name}'s quick lightning bolt is spent for this duel.`);
|
|
}
|
|
}
|
|
});
|
|
const release = inputs[id].release;
|
|
if (release && w.banked) {
|
|
const spell = SPELL_BY_ID[w.banked.spellId];
|
|
const target = release.target ?? (spell.usualTarget === 'self' ? id : enemyOf(id));
|
|
accepted.push({
|
|
caster: id,
|
|
hand: 'left',
|
|
spell,
|
|
target,
|
|
choice: { hand: 'left', spellId: spell.id, target, elemental: w.banked.elemental, chosenHand: w.banked.chosenHand, monsterTarget: release.monsterTarget },
|
|
index: -1,
|
|
released: true
|
|
});
|
|
w.casts.push({ turn, index: -1, hand: 'left', spellId: spell.id, length: 0, 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');
|
|
}
|
|
casts.push(...accepted);
|
|
}
|
|
|
|
for (const c of casts) {
|
|
if (!BLANKET_SPELLS.includes(c.spell.id) && c.target === NOWHERE) {
|
|
c.nullified = 'released harmlessly';
|
|
log(`${W[c.caster].name} lets the ${c.spell.name} dissipate harmlessly.`, 'fizzle');
|
|
} else if (!BLANKET_SPELLS.includes(c.spell.id) && !isBeing(c.target)) {
|
|
c.nullified = 'target gone';
|
|
log(`${W[c.caster].name}'s ${c.spell.name} finds no target.`, 'fizzle');
|
|
}
|
|
}
|
|
const live = () => casts.filter((c) => !c.nullified);
|
|
|
|
const destroyedBeforeAttack = new Set<string>();
|
|
const destroyedAfterAttack = new Set<string>();
|
|
const orders = new Map<string, TargetId>();
|
|
for (const m of state.monsters) {
|
|
orders.set(m.id, inputs[m.owner]?.monsterOrders?.[m.id] ?? m.constraints.forcedTarget ?? enemyOf(m.owner));
|
|
}
|
|
|
|
// ---- 3. Dispel magic overrides everything ----
|
|
const countered = new Set<TargetId>();
|
|
const dispels = live().filter((c) => c.spell.id === 'dispel_magic');
|
|
if (dispels.length > 0) {
|
|
log('Dispel magic sweeps the field: every other spell fails, all enchantments end, and every monster will fall after this turn.', 'spell');
|
|
for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled';
|
|
for (const d of dispels) shielded.add(d.target);
|
|
for (const id of IDS) clearEnchantments(W[id]);
|
|
for (const m of state.monsters) {
|
|
clearMonsterEnchantments(m);
|
|
destroyedAfterAttack.add(m.id);
|
|
}
|
|
} else {
|
|
// ---- 4. Counter-spells and magic mirrors ----
|
|
for (const c of live()) if (c.spell.id === 'counter_spell') countered.add(c.target);
|
|
for (const c of live()) {
|
|
if (c.spell.id === 'magic_mirror' && countered.has(c.target)) {
|
|
c.nullified = 'countered';
|
|
log(`The magic mirror on ${beingName(state, c.target)} is shattered by a counter-spell.`, 'fizzle');
|
|
}
|
|
}
|
|
const mirrored = new Set(live().filter((c) => c.spell.id === 'magic_mirror').map((c) => c.target));
|
|
for (const c of live()) {
|
|
if (c.spell.id === 'magic_mirror' || c.spell.id === 'counter_spell') continue;
|
|
if (BLANKET_SPELLS.includes(c.spell.id)) continue;
|
|
if (!mirrored.has(c.target)) continue;
|
|
const owner = controllerOf(state, c.target);
|
|
if (owner === c.caster || owner === undefined) continue;
|
|
const original = c.caster;
|
|
c.target = original;
|
|
c.caster = owner;
|
|
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 (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');
|
|
}
|
|
}
|
|
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 ----
|
|
const bankedNow = new Set<WizardId>();
|
|
const extendedNow = new Set<WizardId>();
|
|
for (const id of acting) {
|
|
const w = W[id];
|
|
const own = () => live().filter((c) => c.caster === id && !c.released);
|
|
const delayed = w.delayedTurns > 0 || live().some((c) => c.spell.id === 'delayed_effect' && c.target === id);
|
|
if (delayed && !w.banked) {
|
|
const candidates = own().filter((c) => c.spell.id !== 'delayed_effect');
|
|
const chosen = candidates.find((c) => c.choice.bank) ?? candidates[0];
|
|
if (chosen) {
|
|
chosen.nullified = 'banked';
|
|
w.banked = { spellId: chosen.spell.id, elemental: chosen.choice.elemental, chosenHand: chosen.choice.chosenHand };
|
|
bankedNow.add(id);
|
|
log(`${w.name} banks the ${chosen.spell.name} to release later.`, 'spell');
|
|
}
|
|
}
|
|
const extending = w.permanencyTurns > 0 || live().some((c) => c.spell.id === 'permanency' && c.target === id);
|
|
if (extending) {
|
|
const candidates = own().filter((c) => permanencyEligible(c.spell));
|
|
const chosen = candidates.find((c) => c.choice.permanent) ?? candidates[0];
|
|
if (chosen) {
|
|
chosen.permanent = true;
|
|
extendedNow.add(id);
|
|
log(`${w.name}'s ${chosen.spell.name} is made permanent.`, 'spell');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 5. 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);
|
|
}
|
|
for (const id of IDS) if (W[id].protectionTurns > 0) shielded.add(id);
|
|
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 ----
|
|
for (const c of live()) {
|
|
const kind: MonsterKind | undefined =
|
|
c.spell.id === 'summon_elemental'
|
|
? c.choice.elemental === 'ice'
|
|
? 'ice_elemental'
|
|
: 'fire_elemental'
|
|
: SUMMON_KIND[c.spell.id];
|
|
if (!kind) continue;
|
|
const targetMonster = monsterOf(c.target);
|
|
if (targetMonster && isElemental(targetMonster.kind)) {
|
|
c.nullified = 'cast at an elemental';
|
|
log(`Nothing can be summoned for an elemental; ${W[c.caster].name}'s ${c.spell.name} fails.`, 'fizzle');
|
|
continue;
|
|
}
|
|
const controller = controllerOf(state, c.target);
|
|
if (!controller) continue;
|
|
const stats = MONSTER_STATS[kind];
|
|
const n = (state.summonCounts[kind] ?? 0) + 1;
|
|
state.summonCounts[kind] = n;
|
|
const m: Monster = {
|
|
id: `m${state.nextMonsterId++}`,
|
|
kind,
|
|
name: n === 1 ? stats.name : `${stats.name} ${roman(n)}`,
|
|
hp: stats.hp,
|
|
maxHp: stats.hp,
|
|
attack: stats.attack,
|
|
owner: controller,
|
|
resistHeat: kind === 'fire_elemental',
|
|
resistCold: kind === 'ice_elemental',
|
|
protectionTurns: 0,
|
|
constraints: {},
|
|
pending: {},
|
|
createdTurn: turn
|
|
};
|
|
state.monsters.push(m);
|
|
orders.set(m.id, c.choice.monsterTarget ?? enemyOf(controller));
|
|
log(`A ${stats.name.toLowerCase()} answers ${W[controller].name}'s summons.`, 'spell');
|
|
}
|
|
|
|
let fireStorm = live().some((c) => c.spell.id === 'fire_storm');
|
|
let iceStorm = live().some((c) => c.spell.id === 'ice_storm');
|
|
const elementals = (kind: MonsterKind) => state.monsters.filter((m) => m.kind === kind && !destroyedBeforeAttack.has(m.id));
|
|
const destroyBefore = (m: Monster, why: string) => {
|
|
destroyedBeforeAttack.add(m.id);
|
|
log(`${m.name} is ${why} before it can attack.`, 'death');
|
|
};
|
|
if (fireStorm && iceStorm) {
|
|
fireStorm = iceStorm = false;
|
|
log('The fire storm and the ice storm cancel each other; nothing is harmed.', 'fizzle');
|
|
}
|
|
if (fireStorm && elementals('ice_elemental').length > 0) {
|
|
fireStorm = false;
|
|
for (const m of elementals('ice_elemental')) destroyBefore(m, 'melted by the fire storm, which it quenches');
|
|
}
|
|
if (iceStorm && elementals('fire_elemental').length > 0) {
|
|
iceStorm = false;
|
|
for (const m of elementals('fire_elemental')) destroyBefore(m, 'snuffed by the ice storm, which it dissipates');
|
|
}
|
|
if (fireStorm) for (const m of elementals('fire_elemental')) destroyBefore(m, 'engulfed by the fire storm');
|
|
if (iceStorm) for (const m of elementals('ice_elemental')) destroyBefore(m, 'engulfed by the ice storm');
|
|
const exemptFromIceStorm = new Set<TargetId>();
|
|
for (const c of live()) {
|
|
const tm = monsterOf(c.target);
|
|
if (c.spell.id === 'fireball') {
|
|
if (iceStorm) {
|
|
c.nullified = 'quenched by the ice storm';
|
|
exemptFromIceStorm.add(c.target);
|
|
log(`The fireball and the ice storm cancel around ${beingName(state, c.target)}, who is unharmed by either.`, 'fizzle');
|
|
} else if (tm?.kind === 'ice_elemental') {
|
|
c.nullified = 'spent on the elemental';
|
|
destroyBefore(tm, 'melted by the fireball');
|
|
} else if (tm?.kind === 'fire_elemental') {
|
|
c.nullified = 'no effect on a fire elemental';
|
|
log('The fireball washes harmlessly over the fire elemental.', 'fizzle');
|
|
}
|
|
}
|
|
if (c.spell.id === 'resist_heat' && tm?.kind === 'fire_elemental') {
|
|
c.nullified = 'spent on the elemental';
|
|
destroyBefore(tm, 'unmade by resist heat');
|
|
}
|
|
if (c.spell.id === 'resist_cold' && tm?.kind === 'ice_elemental') {
|
|
c.nullified = 'spent on the elemental';
|
|
destroyBefore(tm, 'unmade by resist cold');
|
|
}
|
|
}
|
|
{
|
|
const fire = elementals('fire_elemental');
|
|
const ice = elementals('ice_elemental');
|
|
if (fire.length > 0 && ice.length > 0) {
|
|
for (const m of [...fire, ...ice]) destroyBefore(m, 'destroyed by its opposite');
|
|
}
|
|
for (const kind of ['fire_elemental', 'ice_elemental'] as MonsterKind[]) {
|
|
const same = elementals(kind);
|
|
for (const extra of same.slice(1)) {
|
|
destroyedBeforeAttack.add(extra.id);
|
|
same[0].hp = same[0].maxHp;
|
|
log(`Two ${MONSTER_STATS[kind].name.toLowerCase()}s merge into one.`, 'info');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 7. Enchantments ----
|
|
const controlByTarget = new Map<TargetId, Set<string>>();
|
|
for (const c of live()) {
|
|
if (!CONTROL_SPELLS.includes(c.spell.id)) continue;
|
|
if (!controlByTarget.has(c.target)) controlByTarget.set(c.target, new Set());
|
|
controlByTarget.get(c.target)!.add(c.spell.id);
|
|
}
|
|
for (const [target, ids] of controlByTarget) {
|
|
if (ids.size < 2) continue;
|
|
for (const c of live()) if (c.target === target && ids.has(c.spell.id)) c.nullified = 'contradictory enchantments';
|
|
log(`Contradictory enchantments on ${beingName(state, target)} cancel each other out.`, 'fizzle');
|
|
}
|
|
for (const c of live()) {
|
|
const wiz = isWizardId(c.target) ? W[c.target] : undefined;
|
|
const mon = monsterOf(c.target);
|
|
const target = beingName(state, c.target);
|
|
const forever = c.permanent ? ' for the rest of the duel' : '';
|
|
const duration = (n: number) => (c.permanent ? Infinity : n);
|
|
switch (c.spell.id) {
|
|
case 'amnesia':
|
|
if (wiz) {
|
|
wiz.pending.amnesia = true;
|
|
if (c.permanent) wiz.permanent.amnesia = true;
|
|
} else if (mon) mon.pending.forcedTarget = orders.get(mon.id);
|
|
break;
|
|
case 'confusion':
|
|
if (wiz) {
|
|
wiz.pending.confusion = true;
|
|
if (c.permanent) wiz.permanent.confusion = true;
|
|
} else if (mon) {
|
|
const candidates = [...IDS.filter((id) => W[id].alive), ...state.monsters.filter((o) => o.id !== mon.id).map((o) => o.id)];
|
|
const t = pick(candidates as TargetId[]);
|
|
orders.set(mon.id, t);
|
|
log(`${mon.name} is confused and lunges at ${beingName(state, t)}.`);
|
|
}
|
|
break;
|
|
case 'charm_person':
|
|
if (wiz) {
|
|
const hand = c.choice.chosenHand ?? pick(['left', 'right'] as Hand[]);
|
|
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'}.`);
|
|
} else {
|
|
c.nullified = 'monsters cannot be charmed as people';
|
|
log(`Charm person has no hold on ${target}.`, 'fizzle');
|
|
}
|
|
break;
|
|
case 'charm_monster':
|
|
if (mon && !isElemental(mon.kind)) {
|
|
mon.owner = c.caster;
|
|
orders.set(mon.id, inputs[c.caster].monsterOrders?.[mon.id] ?? enemyOf(c.caster));
|
|
log(`${mon.name} now serves ${W[c.caster].name}.`);
|
|
} else {
|
|
c.nullified = 'not a charmable monster';
|
|
log(`Charm monster has no hold on ${target}.`, 'fizzle');
|
|
}
|
|
break;
|
|
case 'paralysis':
|
|
if (wiz) {
|
|
const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left';
|
|
wiz.pending.paralysedHand = hand;
|
|
if (c.permanent) wiz.permanent.paralysedHand = hand;
|
|
log(`${wiz.name}'s ${hand} hand will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`);
|
|
} else if (mon && !isElemental(mon.kind)) {
|
|
mon.pending.skipAttack = true;
|
|
log(`${mon.name} will stand paralysed next turn.`);
|
|
} else {
|
|
c.nullified = 'elementals cannot be paralysed';
|
|
}
|
|
break;
|
|
case 'fear':
|
|
if (wiz) {
|
|
wiz.pending.fear = true;
|
|
if (c.permanent) wiz.permanent.fear = true;
|
|
log(`${wiz.name} will be too afraid to snap, point, clap or wriggle${c.permanent ? ' from now on' : ' next turn'}.`);
|
|
} else c.nullified = 'monsters know no fear';
|
|
break;
|
|
case 'anti_spell':
|
|
if (wiz) {
|
|
wiz.sequenceStart = wiz.history.length;
|
|
log(`${wiz.name}'s gestures so far are wiped from the weave; every spell must start afresh.`);
|
|
}
|
|
break;
|
|
case 'protection_from_evil':
|
|
if (wiz) wiz.protectionTurns = duration(4);
|
|
else if (mon) mon.protectionTurns = duration(4);
|
|
log(`${target} is protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`);
|
|
break;
|
|
case 'resist_heat':
|
|
if (wiz) wiz.resistHeat = true;
|
|
else if (mon) mon.resistHeat = true;
|
|
log(`${target} now resists heat.`);
|
|
break;
|
|
case 'resist_cold':
|
|
if (wiz) wiz.resistCold = true;
|
|
else if (mon) mon.resistCold = true;
|
|
log(`${target} now resists cold.`);
|
|
break;
|
|
case 'disease':
|
|
if (wiz) {
|
|
wiz.diseaseTurns = 6;
|
|
log(`${wiz.name} sickens with a deadly disease: six turns to find a cure.`);
|
|
} else c.nullified = 'monsters do not sicken';
|
|
break;
|
|
case 'poison':
|
|
if (wiz) {
|
|
wiz.poisonTurns = 6;
|
|
log(`${wiz.name} is poisoned: six turns to find a cure.`);
|
|
} else c.nullified = 'monsters do not sicken';
|
|
break;
|
|
case 'blindness':
|
|
if (wiz) {
|
|
wiz.blindTurns = duration(4);
|
|
log(`${wiz.name} is struck blind${c.permanent ? forever : ' for three turns'}.`);
|
|
} else if (mon) destroyBefore(mon, 'blinded and unmade');
|
|
break;
|
|
case 'invisibility':
|
|
if (wiz) {
|
|
wiz.invisibleTurns = duration(4);
|
|
log(`${wiz.name} fades from sight${c.permanent ? forever : ' for three turns'}.`);
|
|
} else if (mon) destroyBefore(mon, 'made invisible and unmade');
|
|
break;
|
|
case 'haste':
|
|
if (wiz) {
|
|
wiz.hasteTurns = duration(4);
|
|
log(`${wiz.name} is hastened${c.permanent ? forever : ' for the next three turns'}: two pairs of gestures a turn.`);
|
|
} else c.nullified = 'monsters cannot be hastened';
|
|
break;
|
|
case 'time_stop':
|
|
if (wiz) {
|
|
state.timeStops.push(wiz.id);
|
|
log(`${wiz.name} will act alone in a stopped moment.`);
|
|
} else c.nullified = 'only wizards can stop time';
|
|
break;
|
|
case 'delayed_effect':
|
|
if (wiz) {
|
|
wiz.delayedTurns = bankedNow.has(wiz.id) ? 0 : duration(4);
|
|
if (!bankedNow.has(wiz.id)) log(`${wiz.name}'s next spell will be banked.`);
|
|
} else c.nullified = 'only wizards can delay a spell';
|
|
break;
|
|
case 'permanency':
|
|
if (wiz) {
|
|
wiz.permanencyTurns = extendedNow.has(wiz.id) ? 0 : 4;
|
|
if (!extendedNow.has(wiz.id)) log(`${wiz.name}'s next enchantment will be permanent.`);
|
|
} else c.nullified = 'only a wizard can hold a permanency';
|
|
break;
|
|
}
|
|
}
|
|
for (const id of bankedNow) W[id].delayedTurns = 0;
|
|
for (const id of extendedNow) W[id].permanencyTurns = 0;
|
|
for (const c of live()) {
|
|
if (c.spell.id !== 'remove_enchantment' && c.spell.id !== 'raise_dead') continue;
|
|
const wiz = isWizardId(c.target) ? W[c.target] : undefined;
|
|
const mon = monsterOf(c.target);
|
|
if (wiz) {
|
|
clearEnchantments(wiz);
|
|
log(`Every enchantment on ${wiz.name} is lifted.`);
|
|
} else if (mon && c.spell.id === 'remove_enchantment') {
|
|
destroyedAfterAttack.add(mon.id);
|
|
log(`${mon.name} will be unmade once it has attacked.`);
|
|
}
|
|
}
|
|
|
|
// ---- 8. Damage and healing ----
|
|
const damage = new Map<TargetId, number>();
|
|
const healing = new Map<TargetId, number>();
|
|
const slain = new Set<TargetId>();
|
|
const hurt = (id: TargetId, n: number, why: string) => {
|
|
damage.set(id, (damage.get(id) ?? 0) + n);
|
|
note(id, tidy(why), -n);
|
|
log(`${beingName(state, id)} takes ${n} from ${why}.`, 'damage');
|
|
};
|
|
const heal = (id: TargetId, n: number, label: string) => {
|
|
healing.set(id, (healing.get(id) ?? 0) + n);
|
|
note(id, label, n);
|
|
};
|
|
for (const c of live()) {
|
|
const t = c.target;
|
|
switch (c.spell.id) {
|
|
case 'missile':
|
|
if (isShielded(t)) {
|
|
facts.shieldBlocked.add(t);
|
|
log(`${beingName(state, t)}'s shield turns the missile aside.`, 'fizzle');
|
|
} else hurt(t, 1, 'a missile');
|
|
break;
|
|
case 'finger_of_death':
|
|
slain.add(t);
|
|
note(t, 'Finger of death', -(isWizardId(t) ? W[t].hp : 0));
|
|
log(`The finger of death touches ${beingName(state, t)}.`, 'death');
|
|
break;
|
|
case 'lightning_bolt':
|
|
case 'lightning_bolt_quick':
|
|
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a lightning bolt' });
|
|
hurt(t, 5, 'a lightning bolt');
|
|
break;
|
|
case 'cause_light_wounds':
|
|
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause light wounds' });
|
|
hurt(t, 2, 'cause light wounds');
|
|
break;
|
|
case 'cause_heavy_wounds':
|
|
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause heavy wounds' });
|
|
hurt(t, 3, 'cause heavy wounds');
|
|
break;
|
|
case 'fireball':
|
|
if (resists(t, 'heat')) log(`${beingName(state, t)} shrugs off the fireball.`, 'fizzle');
|
|
else {
|
|
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a fireball' });
|
|
hurt(t, 5, 'a fireball');
|
|
}
|
|
break;
|
|
case 'cure_light_wounds':
|
|
heal(t, 1, 'Cure light wounds');
|
|
break;
|
|
case 'cure_heavy_wounds':
|
|
heal(t, 2, 'Cure heavy wounds');
|
|
if (isWizardId(t) && W[t].diseaseTurns !== null) {
|
|
W[t].diseaseTurns = null;
|
|
log(`${W[t].name}'s disease is cured.`);
|
|
}
|
|
break;
|
|
case 'raise_dead':
|
|
heal(t, 5, 'Raise dead');
|
|
break;
|
|
}
|
|
}
|
|
const everyone = (): TargetId[] => [
|
|
...IDS.filter((id) => W[id].alive),
|
|
...state.monsters.filter((m) => !destroyedBeforeAttack.has(m.id)).map((m) => m.id)
|
|
];
|
|
if (fireStorm) {
|
|
log('A fire storm rages across the field.', 'spell');
|
|
for (const id of everyone()) {
|
|
if (resists(id, 'heat')) continue;
|
|
if (countered.has(id)) continue;
|
|
hurt(id, 5, 'the fire storm');
|
|
}
|
|
}
|
|
if (iceStorm) {
|
|
log('An ice storm howls across the field.', 'spell');
|
|
for (const id of everyone()) {
|
|
if (resists(id, 'cold')) continue;
|
|
if (countered.has(id) || exemptFromIceStorm.has(id)) continue;
|
|
hurt(id, 5, 'the ice storm');
|
|
}
|
|
}
|
|
|
|
// ---- 9. 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;
|
|
if (m.constraints.skipAttack && phase === 'main') {
|
|
log(`${m.name} stands paralysed.`);
|
|
continue;
|
|
}
|
|
if (isElemental(m.kind)) {
|
|
const type = m.kind === 'fire_elemental' ? 'heat' : 'cold';
|
|
for (const id of everyone()) {
|
|
if (id === m.id) continue;
|
|
const other = monsterOf(id);
|
|
if (other && isElemental(other.kind)) continue;
|
|
if (phase === 'main') {
|
|
if (resists(id, type)) continue;
|
|
if (isShielded(id)) continue;
|
|
if (isWizardId(id) && W[id].invisibleTurns > 0) continue;
|
|
}
|
|
hurt(id, m.attack, `the ${m.name.toLowerCase()}`);
|
|
}
|
|
continue;
|
|
}
|
|
const target = orders.get(m.id);
|
|
m.lastTarget = target;
|
|
if (!isBeing(target)) {
|
|
log(`${m.name} has nothing to attack.`);
|
|
continue;
|
|
}
|
|
if (isShielded(target)) {
|
|
facts.shieldBlocked.add(target);
|
|
log(`${m.name}'s blows glance off ${beingName(state, target)}'s shield.`, 'fizzle');
|
|
} else if (isWizardId(target) && W[target].invisibleTurns > 0 && !frozen(target)) {
|
|
log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle');
|
|
} else {
|
|
if (m.createdTurn === turn && phase === 'main') facts.newMonsterAttacked = m.name;
|
|
hurt(target, m.attack, `the ${m.name.toLowerCase()}`);
|
|
}
|
|
}
|
|
|
|
// ---- 10. Stabs ----
|
|
for (const id of acting) {
|
|
const w = W[id];
|
|
entries[id].forEach((index, half) => {
|
|
const g = w.history[index];
|
|
if (g.left !== '>' && g.right !== '>') return;
|
|
const target = (half === 0 ? inputs[id].stabTarget : inputs[id].second?.stabTarget) ?? enemyOf(id);
|
|
if (target === id || !isBeing(target)) {
|
|
log(`${w.name} stabs at nothing.`, 'fizzle');
|
|
} else if (isShielded(target)) {
|
|
facts.shieldBlocked.add(target);
|
|
log(`${w.name}'s knife skids off ${beingName(state, target)}'s shield.`, 'fizzle');
|
|
} else {
|
|
hurt(target, 1, `${w.name}'s knife`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- 11. Apply the ledger ----
|
|
for (const id of IDS) {
|
|
const w = W[id];
|
|
if (!w.alive) continue;
|
|
const net = (healing.get(id) ?? 0) - (damage.get(id) ?? 0);
|
|
if ((healing.get(id) ?? 0) > 0 && (damage.get(id) ?? 0) > 0) {
|
|
facts.curedAndHurt.push({ name: w.name, hurt: damage.get(id) ?? 0, healed: healing.get(id) ?? 0 });
|
|
}
|
|
w.hp = Math.min(MAX_HP, w.hp + net);
|
|
if (slain.has(id)) w.hp = 0;
|
|
if (w.hp <= 0) {
|
|
w.hp = 0;
|
|
w.alive = false;
|
|
log(`${w.name} falls dead.`, 'death');
|
|
}
|
|
}
|
|
state.monsters = state.monsters.filter((m) => {
|
|
m.hp = Math.min(m.maxHp, m.hp + (healing.get(m.id) ?? 0) - (damage.get(m.id) ?? 0));
|
|
if (slain.has(m.id)) m.hp = 0;
|
|
if (destroyedBeforeAttack.has(m.id)) return false;
|
|
if (destroyedAfterAttack.has(m.id)) {
|
|
log(`${m.name} is unmade.`, 'death');
|
|
return false;
|
|
}
|
|
if (m.hp <= 0) {
|
|
log(`${m.name} is destroyed.`, 'death');
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// ---- 12. The turn ends ----
|
|
if (phase === 'main') {
|
|
for (const id of IDS) {
|
|
const w = W[id];
|
|
w.constraints = { ...w.pending, ...w.permanent };
|
|
w.pending = {};
|
|
w.protectionTurns = tick(w.protectionTurns);
|
|
w.blindTurns = tick(w.blindTurns);
|
|
w.invisibleTurns = tick(w.invisibleTurns);
|
|
w.hasteTurns = tick(w.hasteTurns);
|
|
w.delayedTurns = tick(w.delayedTurns);
|
|
w.permanencyTurns = tick(w.permanencyTurns);
|
|
if (w.alive && w.diseaseTurns !== null) {
|
|
w.diseaseTurns -= 1;
|
|
if (w.diseaseTurns <= 0) {
|
|
note(id, 'Disease', -w.hp);
|
|
w.alive = false;
|
|
w.hp = 0;
|
|
log(`${w.name} succumbs to the disease.`, 'death');
|
|
}
|
|
}
|
|
if (w.alive && w.poisonTurns !== null) {
|
|
w.poisonTurns -= 1;
|
|
if (w.poisonTurns <= 0) {
|
|
note(id, 'Poison', -w.hp);
|
|
w.alive = false;
|
|
w.hp = 0;
|
|
log(`${w.name} succumbs to the poison.`, 'death');
|
|
}
|
|
}
|
|
}
|
|
for (const m of state.monsters) {
|
|
m.constraints = m.pending;
|
|
m.pending = {};
|
|
m.protectionTurns = tick(m.protectionTurns);
|
|
}
|
|
} else {
|
|
// A stopped moment: nothing runs down, but what was set in it carries into the next turn.
|
|
for (const id of IDS) {
|
|
const w = W[id];
|
|
w.constraints = { ...w.constraints, ...w.pending, ...w.permanent };
|
|
w.pending = {};
|
|
}
|
|
for (const m of state.monsters) {
|
|
m.constraints = { ...m.constraints, ...m.pending };
|
|
m.pending = {};
|
|
}
|
|
}
|
|
|
|
const a = W.A;
|
|
const b = W.B;
|
|
if (!a.alive && !b.alive) state.over = { winner: null, reason: 'Both wizards fall together. A posthumous draw.' };
|
|
else if (!a.alive) state.over = { winner: 'B', reason: `${a.name} is dead. ${b.name} wins the duel.` };
|
|
else if (!b.alive) state.over = { winner: 'A', reason: `${b.name} is dead. ${a.name} wins the duel.` };
|
|
else if (a.surrendered && b.surrendered) state.over = { winner: null, reason: 'Both wizards surrender at once. A draw.' };
|
|
else if (a.surrendered) state.over = { winner: 'B', reason: `${a.name} surrenders. ${b.name} wins the duel.` };
|
|
else if (b.surrendered) state.over = { winner: 'A', reason: `${b.name} surrenders. ${a.name} wins the duel.` };
|
|
if (state.over) {
|
|
log(state.over.reason, 'death');
|
|
state.timeStops = [];
|
|
}
|
|
|
|
for (const id of IDS) summary.wizards[id].after = W[id].hp;
|
|
const lessons: { key: string; text: string }[] = [];
|
|
const through = facts.throughShield.find((h) => facts.shieldBlocked.has(h.target));
|
|
if (through) {
|
|
lessons.push({
|
|
key: 'shield-scope',
|
|
text: `A shield stops creatures, missiles and stabs. It does not stop ${through.spell}, which is why ${beingName(state, through.target)} was hit anyway.`
|
|
});
|
|
} else if (facts.throughShield.length > 0) {
|
|
lessons.push({
|
|
key: 'shield-scope',
|
|
text: `A shield stops only creatures, missiles and stabs, so it did nothing against ${facts.throughShield[0].spell}. Counter-spell is the defence against other spells.`
|
|
});
|
|
}
|
|
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.' });
|
|
}
|
|
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.` });
|
|
}
|
|
if (facts.overlaps.length > 0) {
|
|
const o = facts.overlaps[0];
|
|
lessons.push({ key: 'overlap', text: `A gesture can serve more than one spell as long as each run is unbroken: ${o.name}'s ${o.spell} reused gestures from the earlier ${o.earlier}.` });
|
|
}
|
|
if (facts.broken.length > 0) {
|
|
const b = facts.broken[0];
|
|
lessons.push({ key: 'broken', text: `A stab or a rest belongs to no spell, so ${b.name}'s ${b.hand} hand has thrown away the run it had going and starts again.` });
|
|
}
|
|
if (facts.curedAndHurt.length > 0) {
|
|
const c = facts.curedAndHurt[0];
|
|
lessons.push({ key: 'cure-timing', text: `Cures land in the same turn as the damage: ${c.name} lost ${c.hurt} and regained ${c.healed} at once.` });
|
|
}
|
|
if (facts.newMonsterAttacked) {
|
|
lessons.push({ key: 'monster-same-turn', text: `A creature attacks on the very turn it is summoned: ${facts.newMonsterAttacked} struck at once. A shield that turn is the only defence.` });
|
|
}
|
|
for (const l of lessons) {
|
|
if (state.lessonsShown.includes(l.key) || summary.lessons.length >= 2) continue;
|
|
state.lessonsShown.push(l.key);
|
|
summary.lessons.push(l.text);
|
|
}
|
|
state.lastTurn = summary;
|
|
if (phase === 'main') state.turn += 1;
|
|
return state;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** The gesture a hand is forced to make this turn, if any is known in advance. */
|
|
export function forcedGesture(w: WizardState, hand: Hand, second = false): Gesture | undefined {
|
|
const [prevMain, prevHaste] = previousPairs(w);
|
|
const prev = second ? prevHaste : prevMain;
|
|
if (!prev) return undefined;
|
|
if (w.constraints.amnesia) return prev[hand];
|
|
if (w.constraints.paralysedHand === hand) return paralysedForm(prev[hand]);
|
|
return undefined;
|
|
}
|