The engine seats any number of wizards

Seats are a list on the state; a spell with no named target goes at the
first opponent still standing; a wizard who dies or surrenders is out
and their creatures go with them; the duel ends when one wizard stands,
or when a surrender and a death coincide, as the rules decide. The bot
attacks the weakest opponent and reads threats from all of them. The
two-seat duel is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 16:50:50 -04:00
co-authored by Claude Fable 5.1
parent cf58193aac
commit 95a1915cae
5 changed files with 166 additions and 84 deletions
+59 -46
View File
@@ -31,6 +31,7 @@ import {
controllerOf,
enemyOf,
findMonster,
inDuel,
isWizardId,
nextRandom,
type CastChoice,
@@ -62,7 +63,7 @@ interface ActiveCast {
nullified?: string;
}
const IDS: WizardId[] = ['A', 'B'];
const REST: TurnInput = { left: '-', right: '-', casts: [] };
export function paralysedForm(g: Gesture): Gesture {
if (g === 'C') return 'F';
@@ -143,9 +144,12 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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] : IDS;
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[] = [];
@@ -161,13 +165,14 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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 && (isWizardId(id) ? W[id].alive : !!findMonster(state, id));
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 = isWizardId(id) ? W[id] : monsterOf(id);
const b = isWizard(id) ? W[id] : monsterOf(id);
if (!b) return false;
return type === 'heat' ? b.resistHeat : b.resistCold;
};
@@ -175,10 +180,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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: [] }
},
wizards: Object.fromEntries(seats.map((id) => [id, { before: W[id].hp, after: W[id].hp, effects: [] }])),
events,
lessons: []
};
@@ -187,7 +189,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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 (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. */
@@ -212,7 +214,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
return [prev.left, prev.right];
}
if (c.charmed) {
const g = c.charmedGesture ?? inputs[c.charmed.by].charmGesture ?? '-';
const g = c.charmedGesture ?? input(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;
@@ -241,9 +243,9 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
};
const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : GESTURE_NAMES[g]);
const record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => {
const hiddenFrom: WizardId[] = [];
const foe = enemyOf(w.id);
if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe);
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;
@@ -257,10 +259,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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: [] };
const entries: Record<WizardId, number[]> = Object.fromEntries(seats.map((id) => [id, []]));
for (const id of acting) {
const w = W[id];
const inp = inputs[id];
const inp = input(id);
w.surrendered = false;
const c = phase === 'main' ? w.constraints : {};
const [prevMain, prevHaste] = previousPairs(w);
@@ -289,7 +291,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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 ?? []);
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);
@@ -306,7 +308,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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));
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
@@ -322,10 +324,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
});
const release = inputs[id].release;
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(id));
const target = release.target ?? (spell.usualTarget === 'self' ? id : enemyOf(state, id));
accepted.push({
caster: id,
hand: 'left',
@@ -358,7 +360,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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));
orders.set(m.id, input(m.owner).monsterOrders?.[m.id] ?? m.constraints.forcedTarget ?? enemyOf(state, m.owner));
}
// ---- 3. Dispel magic overrides everything ----
@@ -368,7 +370,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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 IDS) clearEnchantments(W[id]);
for (const id of seats) clearEnchantments(W[id]);
for (const m of state.monsters) {
clearMonsterEnchantments(m);
destroyedAfterAttack.add(m.id);
@@ -447,11 +449,11 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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' && isWizardId(c.target) && c.target !== c.caster) {
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 IDS) if (W[id].protectionTurns > 0 && !shielded.has(id)) shielded.set(id, 'protection from evil');
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'}`;
@@ -492,7 +494,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
createdTurn: turn
};
state.monsters.push(m);
orders.set(m.id, c.choice.monsterTarget ?? enemyOf(controller));
orders.set(m.id, c.choice.monsterTarget ?? enemyOf(state, controller));
log(`A ${stats.name.toLowerCase()} answers ${W[controller].name}'s summons.`, 'spell');
}
@@ -571,7 +573,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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 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' : '';
@@ -588,7 +590,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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 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)}.`);
@@ -608,7 +610,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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));
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';
@@ -710,7 +712,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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 wiz = isWizard(c.target) ? W[c.target] : undefined;
const mon = monsterOf(c.target);
if (wiz) {
clearEnchantments(wiz);
@@ -745,7 +747,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
break;
case 'finger_of_death':
slain.add(t);
note(t, 'Finger of death', -(isWizardId(t) ? W[t].hp : 0));
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':
@@ -773,7 +775,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
break;
case 'cure_heavy_wounds':
heal(t, 2, 'Cure heavy wounds');
if (isWizardId(t) && W[t].diseaseTurns !== null) {
if (isWizard(t) && W[t].diseaseTurns !== null) {
W[t].diseaseTurns = null;
log(`${W[t].name}'s disease is cured.`);
}
@@ -784,7 +786,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
const everyone = (): TargetId[] => [
...IDS.filter((id) => W[id].alive),
...seats.filter((id) => W[id].alive),
...state.monsters.filter((m) => !destroyedBeforeAttack.has(m.id)).map((m) => m.id)
];
if (fireStorm) {
@@ -821,7 +823,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (phase === 'main') {
if (resists(id, type)) continue;
if (isShielded(id)) continue;
if (isWizardId(id) && W[id].invisibleTurns > 0) continue;
if (isWizard(id) && W[id].invisibleTurns > 0) continue;
}
hurt(id, m.attack, `the ${m.name.toLowerCase()}`);
}
@@ -836,7 +838,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (isShielded(target)) {
facts.shieldBlocked.add(target);
log(`${m.name}'s blows glance off ${guard(target)}.`, 'fizzle');
} else if (isWizardId(target) && W[target].invisibleTurns > 0 && !frozen(target)) {
} 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;
@@ -850,7 +852,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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);
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)) {
@@ -863,7 +865,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
// ---- 12. Apply the ledger ----
for (const id of IDS) {
for (const id of seats) {
const w = W[id];
if (!w.alive) continue;
const net = (healing.get(id) ?? 0) - (damage.get(id) ?? 0);
@@ -895,7 +897,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
// ---- 13. The turn ends ----
if (phase === 'main') {
for (const id of IDS) {
for (const id of seats) {
const w = W[id];
w.constraints = { ...w.pending, ...w.permanent };
w.pending = {};
@@ -931,7 +933,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
} else {
// A stopped moment: nothing runs down, but what was set in it carries into the next turn.
for (const id of IDS) {
for (const id of seats) {
const w = W[id];
w.constraints = { ...w.constraints, ...w.pending, ...w.permanent };
w.pending = {};
@@ -942,20 +944,31 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
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.` };
// 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 IDS) summary.wizards[id].after = W[id].hp;
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) {