Rooms take up to twelve seats and the host begins the duel with whoever is seated; a full table begins by itself. The lobby is a roster with a bot seat and the host's begin button. The ledger pins the turn and your own columns and scrolls sideways to the rest; the phone bar shows every wizard's health; a wizard who is out watches the rest; each charmed wizard gets their own gesture; an unnamed spell goes at whoever hurt you last, else the weakest opponent; a stab has a target from the start. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
1046 lines
41 KiB
TypeScript
1046 lines
41 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 { cellsOverlap, completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn } from './gestures';
|
|
import {
|
|
BLANKET_SPELLS,
|
|
CONTROL_SPELLS,
|
|
GESTURES,
|
|
GESTURE_NAMES,
|
|
HANDS,
|
|
MAGIC_GESTURES,
|
|
MONSTER_STATS,
|
|
SPELLS,
|
|
SPELL_BY_ID,
|
|
SUMMON_KIND,
|
|
isElemental,
|
|
permanencyEligible,
|
|
type Gesture,
|
|
type Hand,
|
|
type MonsterKind,
|
|
type Spell,
|
|
type SpellId
|
|
} from './spells';
|
|
import {
|
|
MAX_HP,
|
|
NOWHERE,
|
|
beingName,
|
|
controllerOf,
|
|
enemyOf,
|
|
findMonster,
|
|
inDuel,
|
|
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 REST: TurnInput = { left: '-', right: '-', casts: [] };
|
|
|
|
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'];
|
|
|
|
/** 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][] = [
|
|
[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 = {};
|
|
}
|
|
|
|
/**
|
|
* 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 seats = state.seats;
|
|
/** A seat that is out of the duel makes no gestures; its input, if any, is ignored. */
|
|
const input = (id: WizardId): TurnInput => inputs[id] ?? REST;
|
|
const actor = state.timeStops.shift() ?? null;
|
|
const phase: Phase = actor ? 'timestop' : 'main';
|
|
const acting: WizardId[] = actor ? [actor] : seats.filter((id) => inDuel(W[id]));
|
|
/** 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', hiddenFrom?: WizardId[]) => {
|
|
state.log.push(hiddenFrom?.length ? { turn, kind, text, hiddenFrom } : { 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 isWizard = (id: TargetId | undefined): id is WizardId => isWizardId(state, id);
|
|
const isBeing = (id: TargetId | undefined): id is TargetId =>
|
|
id !== undefined && id !== NOWHERE && (isWizard(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 = isWizard(id) ? W[id] : monsterOf(id);
|
|
if (!b) return false;
|
|
return type === 'heat' ? b.resistHeat : b.resistCold;
|
|
};
|
|
|
|
const summary: TurnSummary = {
|
|
turn,
|
|
phase,
|
|
wizards: Object.fromEntries(seats.map((id) => [id, { before: W[id].hp, after: W[id].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 (isWizard(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: '',
|
|
/** A counter-spell aimed at the other wizard, who is then protected by it. */
|
|
counterAimedAway: '' 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 charmer = input(c.charmed.by);
|
|
const g = c.charmedGesture ?? charmer.charmGestures?.[w.id] ?? charmer.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 ${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 ${GESTURE_NAMES[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 ${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)' : GESTURE_NAMES[g]);
|
|
const record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => {
|
|
const hiddenFrom = seats.filter(
|
|
(other) => other !== w.id && (w.invisibleTurns > 0 || W[other].blindTurns > 0 || phase === 'timestop')
|
|
);
|
|
if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name);
|
|
for (const hand of HANDS) {
|
|
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', false, hiddenFrom);
|
|
};
|
|
/** History indexes of the entries each acting wizard made this turn. */
|
|
const entries: Record<WizardId, number[]> = Object.fromEntries(seats.map((id) => [id, []]));
|
|
for (const id of acting) {
|
|
const w = W[id];
|
|
const inp = input(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[] = [];
|
|
/** Who is shielded this turn, and by what, so a blocked blow can name it. */
|
|
const shielded = new Map<TargetId, string>();
|
|
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 ? input(id).casts : (input(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) => !!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;
|
|
}
|
|
let target = choice.target ?? (completion.spell.usualTarget === 'self' ? id : enemyOf(state, 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, 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 = input(id).release;
|
|
if (release && w.banked) {
|
|
const spell = SPELL_BY_ID[w.banked.spellId];
|
|
const target = release.target ?? (spell.usualTarget === 'self' ? id : enemyOf(state, 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, 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, input(m.owner).monsterOrders?.[m.id] ?? m.constraints.forcedTarget ?? enemyOf(state, 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.set(d.target, 'dispel magic');
|
|
for (const id of seats) 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 (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;
|
|
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;
|
|
}
|
|
|
|
// ---- 5. 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');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 6. Who is shielded this turn ----
|
|
for (const c of live()) {
|
|
if (c.spell.id === 'shield') shielded.set(c.target, 'shield');
|
|
if (c.spell.id === 'counter_spell') shielded.set(c.target, 'counter-spell');
|
|
if (c.spell.id === 'protection_from_evil') shielded.set(c.target, 'protection from evil');
|
|
if (c.spell.id === 'counter_spell' && isWizard(c.target) && c.target !== c.caster) {
|
|
facts.counterAimedAway = `${W[c.caster].name}'s counter-spell on ${W[c.target].name}`;
|
|
}
|
|
}
|
|
for (const id of seats) if (W[id].protectionTurns > 0 && !shielded.has(id)) shielded.set(id, 'protection from evil');
|
|
for (const m of state.monsters) if (m.protectionTurns > 0 && !shielded.has(m.id)) shielded.set(m.id, 'protection from evil');
|
|
const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id);
|
|
const guard = (id: TargetId) => `${beingName(state, id)}'s ${shielded.get(id) ?? 'shield'}`;
|
|
|
|
// ---- 7. 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(state, controller));
|
|
const creature = stats.name.toLowerCase();
|
|
log(`${/^[aeiou]/.test(creature) ? 'An' : 'A'} ${creature} 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');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 8. 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 = isWizard(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 = [...seats.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(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'}.`);
|
|
} 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, input(c.caster).monsterOrders?.[mon.id] ?? enemyOf(state, 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 = isWizard(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.`);
|
|
}
|
|
}
|
|
|
|
// ---- 9. 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, by?: WizardId) => {
|
|
damage.set(id, (damage.get(id) ?? 0) + n);
|
|
if (isWizard(id) && by && by !== id) W[id].lastHurtBy = by;
|
|
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(`${guard(t)} turns the missile aside.`, 'fizzle');
|
|
} else hurt(t, 1, 'a missile', c.caster);
|
|
break;
|
|
case 'finger_of_death':
|
|
slain.add(t);
|
|
if (isWizard(t)) W[t].lastHurtBy = c.caster;
|
|
note(t, 'Finger of death', -(isWizard(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', c.caster);
|
|
break;
|
|
case 'cause_light_wounds':
|
|
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause light wounds' });
|
|
hurt(t, 2, 'cause light wounds', c.caster);
|
|
break;
|
|
case 'cause_heavy_wounds':
|
|
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause heavy wounds' });
|
|
hurt(t, 3, 'cause heavy wounds', c.caster);
|
|
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', c.caster);
|
|
}
|
|
break;
|
|
case 'cure_light_wounds':
|
|
heal(t, 1, 'Cure light wounds');
|
|
break;
|
|
case 'cure_heavy_wounds':
|
|
heal(t, 2, 'Cure heavy wounds');
|
|
if (isWizard(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[] => [
|
|
...seats.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');
|
|
}
|
|
}
|
|
|
|
// ---- 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;
|
|
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 (isWizard(id) && W[id].invisibleTurns > 0) continue;
|
|
}
|
|
hurt(id, m.attack, `the ${m.name.toLowerCase()}`, m.owner);
|
|
}
|
|
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 ${guard(target)}.`, 'fizzle');
|
|
} else if (isWizard(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()}`, m.owner);
|
|
}
|
|
}
|
|
|
|
// ---- 11. 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 ? input(id).stabTarget : input(id).second?.stabTarget) ?? enemyOf(state, 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 ${guard(target)}.`, 'fizzle');
|
|
} else {
|
|
hurt(target, 1, `${w.name}'s knife`, id);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- 12. Apply the ledger ----
|
|
for (const id of seats) {
|
|
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;
|
|
});
|
|
|
|
// ---- 13. The turn ends ----
|
|
if (phase === 'main') {
|
|
for (const id of seats) {
|
|
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 seats) {
|
|
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 = {};
|
|
}
|
|
}
|
|
|
|
// A wizard who is out takes their creatures with them: nobody is left to command them.
|
|
state.monsters = state.monsters.filter((m) => inDuel(W[m.owner]));
|
|
const standing = seats.filter((id) => inDuel(W[id]));
|
|
const fell = (id: WizardId) => (W[id].alive ? `${W[id].name} surrenders` : `${W[id].name} is dead`);
|
|
const wonBy = (winner: WizardId) => {
|
|
// Name those who went out this turn; the rest were already gone.
|
|
const gone = seats.filter((id) => id !== winner && !inDuel(W[id]));
|
|
const thisTurn = gone.filter((id) => inDuel(previous.wizards[id]));
|
|
return { winner, reason: `${(thisTurn.length ? thisTurn : gone).map(fell).join('; ')}. ${W[winner].name} wins the duel.` };
|
|
};
|
|
if (standing.length === 0) {
|
|
// A wizard who surrenders as the last opponent dies is not the loser: the rules let the killing spells count.
|
|
const survivors = seats.filter((id) => W[id].alive);
|
|
if (survivors.length === 1) state.over = wonBy(survivors[0]);
|
|
else if (survivors.length === 0) state.over = { winner: null, reason: 'Every wizard falls together. A posthumous draw.' };
|
|
else state.over = { winner: null, reason: 'Every wizard yields at once. A draw.' };
|
|
} else if (standing.length === 1 && seats.length > 1) {
|
|
state.over = wonBy(standing[0]);
|
|
}
|
|
if (state.over) {
|
|
log(state.over.reason, 'death');
|
|
state.timeStops = [];
|
|
}
|
|
|
|
for (const id of seats) 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.counterAimedAway) {
|
|
lessons.push({
|
|
key: 'counter-aim',
|
|
text: `A counter-spell protects its subject, not its caster: ${facts.counterAimedAway} cancelled spells aimed at them and shielded them from creatures, missiles and stabs. To stop what the other wizard casts at you, cast the counter-spell on yourself.`
|
|
});
|
|
}
|
|
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, 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.` });
|
|
}
|
|
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[] {
|
|
if (w.constraints.amnesia) return [];
|
|
if (w.constraints.paralysedHand === hand) return [];
|
|
if (w.constraints.charmed?.hand === hand) return [];
|
|
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. */
|
|
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;
|
|
}
|