// 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): 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 = (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(), 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 = { 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(); 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 = { 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(); const destroyedAfterAttack = new Set(); const orders = new Map(); 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(); 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(); const extendedNow = new Set(); 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(); 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>(); 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(); const healing = new Map(); const slain = new Set(); 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; }