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:
co-authored by
Claude Fable 5.1
parent
cf58193aac
commit
95a1915cae
+59
-46
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user