diff --git a/src/lib/game/bot.ts b/src/lib/game/bot.ts index 46df338..71d7dde 100644 --- a/src/lib/game/bot.ts +++ b/src/lib/game/bot.ts @@ -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 = { - left: handSequence(hist, 'left'), - right: handSequence(hist, 'right') + const foe = pickFoe(state, me); + const foes = opponentsOf(state, me); + const seqOf = (id: WizardId): Record => { + 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 = {}; 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; } diff --git a/src/lib/game/engine.test.ts b/src/lib/game/engine.test.ts index 5be617f..64b7681 100644 --- a/src/lib/game/engine.test.ts +++ b/src/lib/game/engine.test.ts @@ -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']); + }); +}); diff --git a/src/lib/game/resolve.ts b/src/lib/game/resolve.ts index b9acd58..423a4e1 100644 --- a/src/lib/game/resolve.ts +++ b/src/lib/game/resolve.ts @@ -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 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 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 [id, { before: W[id].hp, after: W[id].hp, effects: [] }])), events, lessons: [] }; @@ -187,7 +189,7 @@ export function resolveTurn(previous: GameState, inputs: Record { - 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 (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 = { A: [], B: [] }; + const entries: Record = 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 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 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(); 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)); + 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 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 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 [ - ...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 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 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 { 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 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) { diff --git a/src/lib/game/state.ts b/src/lib/game/state.ts index a29a30e..3706c00 100644 --- a/src/lib/game/state.ts +++ b/src/lib/game/state.ts @@ -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; 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, 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'; } diff --git a/src/lib/game/targets.ts b/src/lib/game/targets.ts index 9c15c9f..a9642fe 100644 --- a/src/lib/game/targets.ts +++ b/src/lib/game/targets.ts @@ -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');