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
+40 -27
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;
for (const hand of HANDS) {
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 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)) {
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);
if (k >= 2 && (spell.id === 'fireball' || spell.id === 'fire_storm')) foeFirePlan = true;
if (k >= 2 && spell.id === 'ice_storm') foeIcePlan = true;
// 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(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(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], 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;
}