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
+27 -14
View File
@@ -22,6 +22,7 @@ import {
MAX_HP,
enemyOf,
isEnchanted,
opponentsOf,
visibleHistory,
type CastChoice,
type GameState,
@@ -90,40 +91,52 @@ function hash01(text: string): number {
return ((h >>> 0) % 1000) / 1000;
}
/** The opponent to attack: the weakest still standing. */
function pickFoe(state: GameState, me: WizardId): WizardId {
const standing = opponentsOf(state, me);
if (standing.length === 0) return enemyOf(state, me);
return [...standing].sort((a, b) => state.wizards[a].hp - state.wizards[b].hp)[0];
}
function assess(state: GameState, me: WizardId, rng: () => number): Situation {
const foe = enemyOf(me);
const foeW = state.wizards[foe];
const hist = visibleHistory(state, me, foe);
const foeSeq: Record<Hand, HandTurn[]> = {
left: handSequence(hist, 'left'),
right: handSequence(hist, 'right')
const foe = pickFoe(state, me);
const foes = opponentsOf(state, me);
const seqOf = (id: WizardId): Record<Hand, HandTurn[]> => {
const hist = visibleHistory(state, me, id);
return { left: handSequence(hist, 'left'), right: handSequence(hist, 'right') };
};
const foeSeq = seqOf(foe);
let threatNow = 0;
let threatNowCounterable = 0;
let threatNext = 0;
let missileNow = false;
let foeFirePlan = false;
let foeIcePlan = false;
// Danger comes from every opponent, not only the one being attacked.
for (const id of foes.length ? foes : [foe]) {
const seq = id === foe ? foeSeq : seqOf(id);
const start = state.wizards[id].sequenceStart;
for (const hand of HANDS) {
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) {
for (const c of completableIn(seq[hand], start, 1)) {
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
const v = THREAT_VALUE[c.spell.id] ?? 1;
threatNow = Math.max(threatNow, v);
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
if (c.spell.id === 'missile') missileNow = true;
}
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 2)) {
for (const c of completableIn(seq[hand], start, 2)) {
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
}
for (const spell of SPELLS) {
const k = progressToward(spell.tokens[0], foeSeq[hand], foeW.sequenceStart);
const k = progressToward(spell.tokens[0], seq[hand], start);
if (k >= 2 && (spell.id === 'fireball' || spell.id === 'fire_storm')) foeFirePlan = true;
if (k >= 2 && spell.id === 'ice_storm') foeIcePlan = true;
}
}
}
const foeMonsterDamage = state.monsters
.filter((m) => m.owner === foe && !isElemental(m.kind))
.filter((m) => m.owner !== me && !isElemental(m.kind))
.reduce((sum, m) => sum + m.attack, 0);
return {
state,
@@ -147,7 +160,7 @@ function assess(state: GameState, me: WizardId, rng: () => number): Situation {
function baseValue(s: Situation, spell: Spell): number {
const meW = s.state.wizards[s.me];
const foeW = s.state.wizards[s.foe];
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
const foeMonsters = s.state.monsters.filter((m) => m.owner !== s.me);
const foeEnchanted = isEnchanted(foeW);
switch (spell.id) {
case 'fireball':
@@ -394,7 +407,7 @@ function choosePair(s: Situation, history: TurnGestures[], timeStopped: boolean)
if (left === 'P' && right === 'P') score -= 100;
if (left === '>' && right === '>') score -= 50;
if (left === '-' && right === '-') score -= 3;
if (left === '>' || right === '>') score += s.state.monsters.some((m) => m.owner === foe && m.hp === 1) ? 1.5 : 0.15;
if (left === '>' || right === '>') score += s.state.monsters.some((m) => m.owner !== s.me && m.hp === 1) ? 1.5 : 0.15;
score += s.rng() * 0.5;
if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
}
@@ -415,7 +428,7 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
const timeStopped = state.timeStops[0] === me;
const first = choosePair(s, meW.history, timeStopped);
const weakFoeMonster = state.monsters.find((m) => m.owner === foe && m.hp === 1);
const weakFoeMonster = state.monsters.find((m) => m.owner !== me && m.hp === 1);
const monsterOrders: Record<string, TargetId> = {};
for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe;
@@ -440,6 +453,6 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
input.release = { target: choice.target, monsterTarget: choice.monsterTarget };
}
}
if (state.wizards[foe].constraints.charmed?.by === me) input.charmGesture = charmGesture(s);
if (state.seats.some((id) => state.wizards[id].constraints.charmed?.by === me)) input.charmGesture = charmGesture(s);
return input;
}
+40
View File
@@ -406,3 +406,43 @@ describe('lessons', () => {
expect(s.lastTurn?.lessons.some((l) => l.includes('protects its subject, not its caster'))).toBe(true);
});
});
describe('more than two wizards', () => {
it('keeps going until one wizard stands, and each sees only what they may', () => {
let s = createGame({ A: 'Black', B: 'White', C: 'Grey' }, 1);
expect(s.seats).toEqual(['A', 'B', 'C']);
// Black hurls a finger of death at Grey while White does nothing; the duel continues.
for (let i = 0; i < 8; i++) {
s = resolveTurn(s, {
A: { ...noop(), left: 'PWPFSSSD'[i] as Gesture, casts: i === 7 ? [{ hand: 'left', spellId: 'finger_of_death', target: 'C' }] : [] },
B: noop(),
C: noop()
});
}
expect(s.wizards.C.alive).toBe(false);
expect(s.over).toBeNull();
// A spell with no named target goes at the first opponent still standing.
s = runSequence(s, 'A', 'SD', '--', { casts: [{ hand: 'left', spellId: 'missile' }] });
expect(s.wizards.B.hp).toBe(14);
// Grey is out and takes no further part: no gestures, no input needed.
expect(s.wizards.C.history).toHaveLength(8);
// White surrenders; Black is the last wizard standing.
s = resolveTurn(s, { A: noop(), B: { ...noop(), left: 'P', right: 'P' } });
expect(s.over).toEqual({ winner: 'A', reason: 'White surrenders. Black wins the duel.' });
});
it('hides an invisible wizard from every opponent and a blind wizard from everything', () => {
let s = createGame({ A: 'Black', B: 'White', C: 'Grey' }, 1);
const left = 'PPWS';
const right = '--WS';
for (let i = 0; i < 4; i++) {
s = resolveTurn(s, {
A: { ...noop(), left: left[i] as Gesture, right: right[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'invisibility' }] : [] },
B: noop(),
C: noop()
});
}
s = resolveTurn(s, { A: { ...noop(), left: 'F' }, B: noop(), C: noop() });
expect(s.wizards.A.history.at(-1)?.hiddenFrom).toEqual(['B', 'C']);
});
});
+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) {
+25 -9
View File
@@ -1,7 +1,8 @@
import type { TurnGestures } from './gestures';
import type { Gesture, Hand, MonsterKind, SpellId } from './spells';
export type WizardId = 'A' | 'B';
/** A seat at the table. The single-player duel uses 'A' for the player and 'B' for the bot. */
export type WizardId = string;
/** A wizard, a monster id, or nowhere (a spell released harmlessly). */
export type TargetId = WizardId | string;
export const NOWHERE = 'nowhere';
@@ -126,6 +127,8 @@ export interface Outcome {
export interface GameState {
turn: number;
/** Seats in table order; every wizard in play, whether still standing or not. */
seats: WizardId[];
wizards: Record<WizardId, WizardState>;
monsters: Monster[];
log: LogEntry[];
@@ -175,8 +178,19 @@ export function isEnchanted(w: WizardState): boolean {
return w.resistHeat || w.resistCold || w.protectionTurns > 0 || w.invisibleTurns > 0 || w.hasteTurns > 0;
}
export function enemyOf(id: WizardId): WizardId {
return id === 'A' ? 'B' : 'A';
/** Still gesturing: neither dead nor surrendered. */
export function inDuel(w: WizardState): boolean {
return w.alive && !w.surrendered;
}
/** The other wizards still in the duel, in table order. */
export function opponentsOf(state: GameState, id: WizardId): WizardId[] {
return state.seats.filter((s) => s !== id && inDuel(state.wizards[s]));
}
/** The wizard a spell goes at when no target is named: the first opponent still standing. */
export function enemyOf(state: GameState, id: WizardId): WizardId {
return opponentsOf(state, id)[0] ?? state.seats.find((s) => s !== id) ?? id;
}
function makeWizard(id: WizardId, name: string): WizardState {
@@ -207,10 +221,12 @@ function makeWizard(id: WizardId, name: string): WizardState {
};
}
export function createGame(names: { A: string; B: string }, seed = Date.now()): GameState {
export function createGame(names: Record<WizardId, string>, seed = Date.now()): GameState {
const seats = Object.keys(names);
return {
turn: 0,
wizards: { A: makeWizard('A', names.A), B: makeWizard('B', names.B) },
seats,
wizards: Object.fromEntries(seats.map((id) => [id, makeWizard(id, names[id])])),
monsters: [],
log: [],
over: null,
@@ -232,8 +248,8 @@ export function nextRandom(state: GameState): number {
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function isWizardId(id: TargetId | undefined): id is WizardId {
return id === 'A' || id === 'B';
export function isWizardId(state: GameState, id: TargetId | undefined): id is WizardId {
return id !== undefined && id in state.wizards;
}
export function findMonster(state: GameState, id: TargetId | undefined): Monster | undefined {
@@ -242,13 +258,13 @@ export function findMonster(state: GameState, id: TargetId | undefined): Monster
/** The wizard who commands a being. */
export function controllerOf(state: GameState, id: TargetId): WizardId | undefined {
if (isWizardId(id)) return id;
if (isWizardId(state, id)) return id;
return findMonster(state, id)?.owner;
}
export function beingName(state: GameState, id: TargetId | undefined): string {
if (id === undefined || id === NOWHERE) return 'nowhere';
if (isWizardId(id)) return state.wizards[id].name;
if (isWizardId(state, id)) return state.wizards[id].name;
return findMonster(state, id)?.name ?? 'a vanished creature';
}
+2 -2
View File
@@ -5,9 +5,9 @@ import { isElemental, type Spell } from './spells';
import { enemyOf, isEnchanted, type GameState, type TargetId, type WizardId } from './state';
export function likelyTarget(state: GameState, me: WizardId, spell: Spell): TargetId {
const foe = enemyOf(me);
const foe = enemyOf(state, me);
const monsters = state.monsters;
const foeMonsters = monsters.filter((m) => m.owner === foe && !isElemental(m.kind));
const foeMonsters = monsters.filter((m) => m.owner !== me && !isElemental(m.kind));
const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0];
const iceElemental = monsters.find((m) => m.kind === 'ice_elemental');
const fireElemental = monsters.find((m) => m.kind === 'fire_elemental');