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
+40
-27
@@ -22,6 +22,7 @@ import {
|
|||||||
MAX_HP,
|
MAX_HP,
|
||||||
enemyOf,
|
enemyOf,
|
||||||
isEnchanted,
|
isEnchanted,
|
||||||
|
opponentsOf,
|
||||||
visibleHistory,
|
visibleHistory,
|
||||||
type CastChoice,
|
type CastChoice,
|
||||||
type GameState,
|
type GameState,
|
||||||
@@ -90,40 +91,52 @@ function hash01(text: string): number {
|
|||||||
return ((h >>> 0) % 1000) / 1000;
|
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 {
|
function assess(state: GameState, me: WizardId, rng: () => number): Situation {
|
||||||
const foe = enemyOf(me);
|
const foe = pickFoe(state, me);
|
||||||
const foeW = state.wizards[foe];
|
const foes = opponentsOf(state, me);
|
||||||
const hist = visibleHistory(state, me, foe);
|
const seqOf = (id: WizardId): Record<Hand, HandTurn[]> => {
|
||||||
const foeSeq: Record<Hand, HandTurn[]> = {
|
const hist = visibleHistory(state, me, id);
|
||||||
left: handSequence(hist, 'left'),
|
return { left: handSequence(hist, 'left'), right: handSequence(hist, 'right') };
|
||||||
right: handSequence(hist, 'right')
|
|
||||||
};
|
};
|
||||||
|
const foeSeq = seqOf(foe);
|
||||||
let threatNow = 0;
|
let threatNow = 0;
|
||||||
let threatNowCounterable = 0;
|
let threatNowCounterable = 0;
|
||||||
let threatNext = 0;
|
let threatNext = 0;
|
||||||
let missileNow = false;
|
let missileNow = false;
|
||||||
let foeFirePlan = false;
|
let foeFirePlan = false;
|
||||||
let foeIcePlan = false;
|
let foeIcePlan = false;
|
||||||
for (const hand of HANDS) {
|
// Danger comes from every opponent, not only the one being attacked.
|
||||||
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) {
|
for (const id of foes.length ? foes : [foe]) {
|
||||||
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
const seq = id === foe ? foeSeq : seqOf(id);
|
||||||
const v = THREAT_VALUE[c.spell.id] ?? 1;
|
const start = state.wizards[id].sequenceStart;
|
||||||
threatNow = Math.max(threatNow, v);
|
for (const hand of HANDS) {
|
||||||
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
|
for (const c of completableIn(seq[hand], start, 1)) {
|
||||||
if (c.spell.id === 'missile') missileNow = true;
|
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
||||||
}
|
const v = THREAT_VALUE[c.spell.id] ?? 1;
|
||||||
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 2)) {
|
threatNow = Math.max(threatNow, v);
|
||||||
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
|
||||||
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
|
if (c.spell.id === 'missile') missileNow = true;
|
||||||
}
|
}
|
||||||
for (const spell of SPELLS) {
|
for (const c of completableIn(seq[hand], start, 2)) {
|
||||||
const k = progressToward(spell.tokens[0], foeSeq[hand], foeW.sequenceStart);
|
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
||||||
if (k >= 2 && (spell.id === 'fireball' || spell.id === 'fire_storm')) foeFirePlan = true;
|
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
|
||||||
if (k >= 2 && spell.id === 'ice_storm') foeIcePlan = true;
|
}
|
||||||
|
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
|
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);
|
.reduce((sum, m) => sum + m.attack, 0);
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
@@ -147,7 +160,7 @@ function assess(state: GameState, me: WizardId, rng: () => number): Situation {
|
|||||||
function baseValue(s: Situation, spell: Spell): number {
|
function baseValue(s: Situation, spell: Spell): number {
|
||||||
const meW = s.state.wizards[s.me];
|
const meW = s.state.wizards[s.me];
|
||||||
const foeW = s.state.wizards[s.foe];
|
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);
|
const foeEnchanted = isEnchanted(foeW);
|
||||||
switch (spell.id) {
|
switch (spell.id) {
|
||||||
case 'fireball':
|
case 'fireball':
|
||||||
@@ -394,7 +407,7 @@ function choosePair(s: Situation, history: TurnGestures[], timeStopped: boolean)
|
|||||||
if (left === 'P' && right === 'P') score -= 100;
|
if (left === 'P' && right === 'P') score -= 100;
|
||||||
if (left === '>' && right === '>') score -= 50;
|
if (left === '>' && right === '>') score -= 50;
|
||||||
if (left === '-' && right === '-') score -= 3;
|
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;
|
score += s.rng() * 0.5;
|
||||||
if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
|
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 timeStopped = state.timeStops[0] === me;
|
||||||
|
|
||||||
const first = choosePair(s, meW.history, timeStopped);
|
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> = {};
|
const monsterOrders: Record<string, TargetId> = {};
|
||||||
for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe;
|
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 };
|
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;
|
return input;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,3 +406,43 @@ describe('lessons', () => {
|
|||||||
expect(s.lastTurn?.lessons.some((l) => l.includes('protects its subject, not its caster'))).toBe(true);
|
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
@@ -31,6 +31,7 @@ import {
|
|||||||
controllerOf,
|
controllerOf,
|
||||||
enemyOf,
|
enemyOf,
|
||||||
findMonster,
|
findMonster,
|
||||||
|
inDuel,
|
||||||
isWizardId,
|
isWizardId,
|
||||||
nextRandom,
|
nextRandom,
|
||||||
type CastChoice,
|
type CastChoice,
|
||||||
@@ -62,7 +63,7 @@ interface ActiveCast {
|
|||||||
nullified?: string;
|
nullified?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IDS: WizardId[] = ['A', 'B'];
|
const REST: TurnInput = { left: '-', right: '-', casts: [] };
|
||||||
|
|
||||||
export function paralysedForm(g: Gesture): Gesture {
|
export function paralysedForm(g: Gesture): Gesture {
|
||||||
if (g === 'C') return 'F';
|
if (g === 'C') return 'F';
|
||||||
@@ -143,9 +144,12 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const state = structuredClone(previous);
|
const state = structuredClone(previous);
|
||||||
if (state.over) return state;
|
if (state.over) return state;
|
||||||
const W = state.wizards;
|
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 actor = state.timeStops.shift() ?? null;
|
||||||
const phase: Phase = actor ? 'timestop' : 'main';
|
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. */
|
/** Time-stop entries and log lines belong to the turn just played. */
|
||||||
const turn = actor ? state.turn - 1 : state.turn;
|
const turn = actor ? state.turn - 1 : state.turn;
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
@@ -161,13 +165,14 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const c = controllerOf(state, id);
|
const c = controllerOf(state, id);
|
||||||
return c !== undefined && frozen(c);
|
return c !== undefined && frozen(c);
|
||||||
};
|
};
|
||||||
|
const isWizard = (id: TargetId | undefined): id is WizardId => isWizardId(state, id);
|
||||||
const isBeing = (id: TargetId | undefined): id is TargetId =>
|
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);
|
const monsterOf = (id: TargetId) => findMonster(state, id);
|
||||||
/** Under time stop, those who cannot move have no resistance to anything. */
|
/** Under time stop, those who cannot move have no resistance to anything. */
|
||||||
const resists = (id: TargetId, type: 'heat' | 'cold'): boolean => {
|
const resists = (id: TargetId, type: 'heat' | 'cold'): boolean => {
|
||||||
if (frozenBeing(id)) return false;
|
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;
|
if (!b) return false;
|
||||||
return type === 'heat' ? b.resistHeat : b.resistCold;
|
return type === 'heat' ? b.resistHeat : b.resistCold;
|
||||||
};
|
};
|
||||||
@@ -175,10 +180,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const summary: TurnSummary = {
|
const summary: TurnSummary = {
|
||||||
turn,
|
turn,
|
||||||
phase,
|
phase,
|
||||||
wizards: {
|
wizards: Object.fromEntries(seats.map((id) => [id, { before: W[id].hp, after: W[id].hp, effects: [] }])),
|
||||||
A: { before: W.A.hp, after: W.A.hp, effects: [] },
|
|
||||||
B: { before: W.B.hp, after: W.B.hp, effects: [] }
|
|
||||||
},
|
|
||||||
events,
|
events,
|
||||||
lessons: []
|
lessons: []
|
||||||
};
|
};
|
||||||
@@ -187,7 +189,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
||||||
};
|
};
|
||||||
const note = (id: TargetId, label: string, delta: number) => {
|
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');
|
if (actor) log(`Time stands still. ${W[actor].name} alone can move.`, 'spell');
|
||||||
/** Facts gathered along the way, turned into explanations at the end. */
|
/** 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];
|
return [prev.left, prev.right];
|
||||||
}
|
}
|
||||||
if (c.charmed) {
|
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 (w.permanent.charmed && !w.permanent.charmedGesture) w.permanent.charmedGesture = g;
|
||||||
if (c.charmed.hand === 'left') left = g;
|
if (c.charmed.hand === 'left') left = g;
|
||||||
else right = 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 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 record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => {
|
||||||
const hiddenFrom: WizardId[] = [];
|
const hiddenFrom = seats.filter(
|
||||||
const foe = enemyOf(w.id);
|
(other) => other !== w.id && (w.invisibleTurns > 0 || W[other].blindTurns > 0 || phase === 'timestop')
|
||||||
if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe);
|
);
|
||||||
if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name);
|
if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name);
|
||||||
for (const hand of HANDS) {
|
for (const hand of HANDS) {
|
||||||
const g = hand === 'left' ? left : right;
|
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');
|
log(`${prefix} left ${lone(left, right)}, right ${lone(right, left)}.`, 'gesture');
|
||||||
};
|
};
|
||||||
/** History indexes of the entries each acting wizard made this turn. */
|
/** 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) {
|
for (const id of acting) {
|
||||||
const w = W[id];
|
const w = W[id];
|
||||||
const inp = inputs[id];
|
const inp = input(id);
|
||||||
w.surrendered = false;
|
w.surrendered = false;
|
||||||
const c = phase === 'main' ? w.constraints : {};
|
const c = phase === 'main' ? w.constraints : {};
|
||||||
const [prevMain, prevHaste] = previousPairs(w);
|
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),
|
left: completedSpells(handSequence(upto, 'left'), w.sequenceStart),
|
||||||
right: completedSpells(handSequence(upto, 'right'), 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) {
|
for (const choice of choices) {
|
||||||
if (accepted.some((a) => a.index === index && a.hand === choice.hand)) continue;
|
if (accepted.some((a) => a.index === index && a.hand === choice.hand)) continue;
|
||||||
const options = completions[choice.hand].filter((c) => c.spell.id === choice.spellId);
|
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');
|
log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle');
|
||||||
continue;
|
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;
|
if (completion.spell.id === 'summon_elemental' && target === NOWHERE) target = id;
|
||||||
const earlier = w.casts.find(
|
const earlier = w.casts.find(
|
||||||
(c) => c.hand === choice.hand && c.index >= 0 && c.index < index && c.index >= index - completion.length + 1
|
(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) {
|
if (release && w.banked) {
|
||||||
const spell = SPELL_BY_ID[w.banked.spellId];
|
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({
|
accepted.push({
|
||||||
caster: id,
|
caster: id,
|
||||||
hand: 'left',
|
hand: 'left',
|
||||||
@@ -358,7 +360,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const destroyedAfterAttack = new Set<string>();
|
const destroyedAfterAttack = new Set<string>();
|
||||||
const orders = new Map<string, TargetId>();
|
const orders = new Map<string, TargetId>();
|
||||||
for (const m of state.monsters) {
|
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 ----
|
// ---- 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');
|
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 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 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) {
|
for (const m of state.monsters) {
|
||||||
clearMonsterEnchantments(m);
|
clearMonsterEnchantments(m);
|
||||||
destroyedAfterAttack.add(m.id);
|
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 === 'shield') shielded.set(c.target, 'shield');
|
||||||
if (c.spell.id === 'counter_spell') shielded.set(c.target, 'counter-spell');
|
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 === '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}`;
|
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');
|
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 isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id);
|
||||||
const guard = (id: TargetId) => `${beingName(state, id)}'s ${shielded.get(id) ?? 'shield'}`;
|
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
|
createdTurn: turn
|
||||||
};
|
};
|
||||||
state.monsters.push(m);
|
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');
|
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');
|
log(`Contradictory enchantments on ${beingName(state, target)} cancel each other out.`, 'fizzle');
|
||||||
}
|
}
|
||||||
for (const c of live()) {
|
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 mon = monsterOf(c.target);
|
||||||
const target = beingName(state, c.target);
|
const target = beingName(state, c.target);
|
||||||
const forever = c.permanent ? ' for the rest of the duel' : '';
|
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;
|
wiz.pending.confusion = true;
|
||||||
if (c.permanent) wiz.permanent.confusion = true;
|
if (c.permanent) wiz.permanent.confusion = true;
|
||||||
} else if (mon) {
|
} 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[]);
|
const t = pick(candidates as TargetId[]);
|
||||||
orders.set(mon.id, t);
|
orders.set(mon.id, t);
|
||||||
log(`${mon.name} is confused and lunges at ${beingName(state, 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':
|
case 'charm_monster':
|
||||||
if (mon && !isElemental(mon.kind)) {
|
if (mon && !isElemental(mon.kind)) {
|
||||||
mon.owner = c.caster;
|
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}.`);
|
log(`${mon.name} now serves ${W[c.caster].name}.`);
|
||||||
} else {
|
} else {
|
||||||
c.nullified = 'not a charmable monster';
|
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 id of extendedNow) W[id].permanencyTurns = 0;
|
||||||
for (const c of live()) {
|
for (const c of live()) {
|
||||||
if (c.spell.id !== 'remove_enchantment' && c.spell.id !== 'raise_dead') continue;
|
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);
|
const mon = monsterOf(c.target);
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
clearEnchantments(wiz);
|
clearEnchantments(wiz);
|
||||||
@@ -745,7 +747,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
break;
|
break;
|
||||||
case 'finger_of_death':
|
case 'finger_of_death':
|
||||||
slain.add(t);
|
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');
|
log(`The finger of death touches ${beingName(state, t)}.`, 'death');
|
||||||
break;
|
break;
|
||||||
case 'lightning_bolt':
|
case 'lightning_bolt':
|
||||||
@@ -773,7 +775,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
break;
|
break;
|
||||||
case 'cure_heavy_wounds':
|
case 'cure_heavy_wounds':
|
||||||
heal(t, 2, '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;
|
W[t].diseaseTurns = null;
|
||||||
log(`${W[t].name}'s disease is cured.`);
|
log(`${W[t].name}'s disease is cured.`);
|
||||||
}
|
}
|
||||||
@@ -784,7 +786,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const everyone = (): TargetId[] => [
|
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)
|
...state.monsters.filter((m) => !destroyedBeforeAttack.has(m.id)).map((m) => m.id)
|
||||||
];
|
];
|
||||||
if (fireStorm) {
|
if (fireStorm) {
|
||||||
@@ -821,7 +823,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
if (phase === 'main') {
|
if (phase === 'main') {
|
||||||
if (resists(id, type)) continue;
|
if (resists(id, type)) continue;
|
||||||
if (isShielded(id)) 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()}`);
|
hurt(id, m.attack, `the ${m.name.toLowerCase()}`);
|
||||||
}
|
}
|
||||||
@@ -836,7 +838,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
if (isShielded(target)) {
|
if (isShielded(target)) {
|
||||||
facts.shieldBlocked.add(target);
|
facts.shieldBlocked.add(target);
|
||||||
log(`${m.name}'s blows glance off ${guard(target)}.`, 'fizzle');
|
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');
|
log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle');
|
||||||
} else {
|
} else {
|
||||||
if (m.createdTurn === turn && phase === 'main') facts.newMonsterAttacked = m.name;
|
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) => {
|
entries[id].forEach((index, half) => {
|
||||||
const g = w.history[index];
|
const g = w.history[index];
|
||||||
if (g.left !== '>' && g.right !== '>') return;
|
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)) {
|
if (target === id || !isBeing(target)) {
|
||||||
log(`${w.name} stabs at nothing.`, 'fizzle');
|
log(`${w.name} stabs at nothing.`, 'fizzle');
|
||||||
} else if (isShielded(target)) {
|
} else if (isShielded(target)) {
|
||||||
@@ -863,7 +865,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 12. Apply the ledger ----
|
// ---- 12. Apply the ledger ----
|
||||||
for (const id of IDS) {
|
for (const id of seats) {
|
||||||
const w = W[id];
|
const w = W[id];
|
||||||
if (!w.alive) continue;
|
if (!w.alive) continue;
|
||||||
const net = (healing.get(id) ?? 0) - (damage.get(id) ?? 0);
|
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 ----
|
// ---- 13. The turn ends ----
|
||||||
if (phase === 'main') {
|
if (phase === 'main') {
|
||||||
for (const id of IDS) {
|
for (const id of seats) {
|
||||||
const w = W[id];
|
const w = W[id];
|
||||||
w.constraints = { ...w.pending, ...w.permanent };
|
w.constraints = { ...w.pending, ...w.permanent };
|
||||||
w.pending = {};
|
w.pending = {};
|
||||||
@@ -931,7 +933,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// A stopped moment: nothing runs down, but what was set in it carries into the next turn.
|
// 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];
|
const w = W[id];
|
||||||
w.constraints = { ...w.constraints, ...w.pending, ...w.permanent };
|
w.constraints = { ...w.constraints, ...w.pending, ...w.permanent };
|
||||||
w.pending = {};
|
w.pending = {};
|
||||||
@@ -942,20 +944,31 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const a = W.A;
|
// A wizard who is out takes their creatures with them: nobody is left to command them.
|
||||||
const b = W.B;
|
state.monsters = state.monsters.filter((m) => inDuel(W[m.owner]));
|
||||||
if (!a.alive && !b.alive) state.over = { winner: null, reason: 'Both wizards fall together. A posthumous draw.' };
|
const standing = seats.filter((id) => inDuel(W[id]));
|
||||||
else if (!a.alive) state.over = { winner: 'B', reason: `${a.name} is dead. ${b.name} wins the duel.` };
|
const fell = (id: WizardId) => (W[id].alive ? `${W[id].name} surrenders` : `${W[id].name} is dead`);
|
||||||
else if (!b.alive) state.over = { winner: 'A', reason: `${b.name} is dead. ${a.name} wins the duel.` };
|
const wonBy = (winner: WizardId) => {
|
||||||
else if (a.surrendered && b.surrendered) state.over = { winner: null, reason: 'Both wizards surrender at once. A draw.' };
|
// Name those who went out this turn; the rest were already gone.
|
||||||
else if (a.surrendered) state.over = { winner: 'B', reason: `${a.name} surrenders. ${b.name} wins the duel.` };
|
const gone = seats.filter((id) => id !== winner && !inDuel(W[id]));
|
||||||
else if (b.surrendered) state.over = { winner: 'A', reason: `${b.name} surrenders. ${a.name} wins the duel.` };
|
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) {
|
if (state.over) {
|
||||||
log(state.over.reason, 'death');
|
log(state.over.reason, 'death');
|
||||||
state.timeStops = [];
|
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 lessons: { key: string; text: string }[] = [];
|
||||||
const through = facts.throughShield.find((h) => facts.shieldBlocked.has(h.target));
|
const through = facts.throughShield.find((h) => facts.shieldBlocked.has(h.target));
|
||||||
if (through) {
|
if (through) {
|
||||||
|
|||||||
+25
-9
@@ -1,7 +1,8 @@
|
|||||||
import type { TurnGestures } from './gestures';
|
import type { TurnGestures } from './gestures';
|
||||||
import type { Gesture, Hand, MonsterKind, SpellId } from './spells';
|
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). */
|
/** A wizard, a monster id, or nowhere (a spell released harmlessly). */
|
||||||
export type TargetId = WizardId | string;
|
export type TargetId = WizardId | string;
|
||||||
export const NOWHERE = 'nowhere';
|
export const NOWHERE = 'nowhere';
|
||||||
@@ -126,6 +127,8 @@ export interface Outcome {
|
|||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
turn: number;
|
turn: number;
|
||||||
|
/** Seats in table order; every wizard in play, whether still standing or not. */
|
||||||
|
seats: WizardId[];
|
||||||
wizards: Record<WizardId, WizardState>;
|
wizards: Record<WizardId, WizardState>;
|
||||||
monsters: Monster[];
|
monsters: Monster[];
|
||||||
log: LogEntry[];
|
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;
|
return w.resistHeat || w.resistCold || w.protectionTurns > 0 || w.invisibleTurns > 0 || w.hasteTurns > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enemyOf(id: WizardId): WizardId {
|
/** Still gesturing: neither dead nor surrendered. */
|
||||||
return id === 'A' ? 'B' : 'A';
|
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 {
|
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 {
|
return {
|
||||||
turn: 0,
|
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: [],
|
monsters: [],
|
||||||
log: [],
|
log: [],
|
||||||
over: null,
|
over: null,
|
||||||
@@ -232,8 +248,8 @@ export function nextRandom(state: GameState): number {
|
|||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWizardId(id: TargetId | undefined): id is WizardId {
|
export function isWizardId(state: GameState, id: TargetId | undefined): id is WizardId {
|
||||||
return id === 'A' || id === 'B';
|
return id !== undefined && id in state.wizards;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findMonster(state: GameState, id: TargetId | undefined): Monster | undefined {
|
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. */
|
/** The wizard who commands a being. */
|
||||||
export function controllerOf(state: GameState, id: TargetId): WizardId | undefined {
|
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;
|
return findMonster(state, id)?.owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function beingName(state: GameState, id: TargetId | undefined): string {
|
export function beingName(state: GameState, id: TargetId | undefined): string {
|
||||||
if (id === undefined || id === NOWHERE) return 'nowhere';
|
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';
|
return findMonster(state, id)?.name ?? 'a vanished creature';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { isElemental, type Spell } from './spells';
|
|||||||
import { enemyOf, isEnchanted, type GameState, type TargetId, type WizardId } from './state';
|
import { enemyOf, isEnchanted, type GameState, type TargetId, type WizardId } from './state';
|
||||||
|
|
||||||
export function likelyTarget(state: GameState, me: WizardId, spell: Spell): TargetId {
|
export function likelyTarget(state: GameState, me: WizardId, spell: Spell): TargetId {
|
||||||
const foe = enemyOf(me);
|
const foe = enemyOf(state, me);
|
||||||
const monsters = state.monsters;
|
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 strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0];
|
||||||
const iceElemental = monsters.find((m) => m.kind === 'ice_elemental');
|
const iceElemental = monsters.find((m) => m.kind === 'ice_elemental');
|
||||||
const fireElemental = monsters.find((m) => m.kind === 'fire_elemental');
|
const fireElemental = monsters.find((m) => m.kind === 'fire_elemental');
|
||||||
|
|||||||
Reference in New Issue
Block a user