Waving Hands: a browser duel after Bartle's 1977 game
SvelteKit 5 single-player version against a heuristic bot. The engine resolves all forty spells simultaneously as the rules describe, the ledger of gestures is the interface, and the duel is saved in the browser. Tester feedback rounds added per-hand planning, threat evidence, a result strip with one-time rule explanations, a phone layout and a structured rules page. Deploys as a static site behind Caddy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
// A heuristic opponent. Each turn it scores every pair of gestures by the
|
||||
// spells they finish, the spells they move toward, and what the human's
|
||||
// visible gestures threaten, then casts the most valuable spells it completed.
|
||||
// Spells it has cast recently are discounted so it does not fall into a rut,
|
||||
// and each named opponent weights the spell book a little differently.
|
||||
|
||||
import {
|
||||
completableIn,
|
||||
completedSpells,
|
||||
handSequence,
|
||||
progressToward,
|
||||
usedCells,
|
||||
type Completion,
|
||||
type HandTurn,
|
||||
type TurnGestures
|
||||
} from './gestures';
|
||||
import { allowedGestures, forcedGesture } from './resolve';
|
||||
import { CONTROL_SPELLS, SPELLS, SPELL_BY_ID, isElemental, type Gesture, type Hand, type Spell, type SpellId } from './spells';
|
||||
import {
|
||||
MAX_HP,
|
||||
enemyOf,
|
||||
visibleHistory,
|
||||
type CastChoice,
|
||||
type GameState,
|
||||
type TargetId,
|
||||
type TurnInput,
|
||||
type WizardId
|
||||
} from './state';
|
||||
|
||||
const HOSTILE: SpellId[] = [
|
||||
'missile',
|
||||
'finger_of_death',
|
||||
'lightning_bolt',
|
||||
'lightning_bolt_quick',
|
||||
'cause_light_wounds',
|
||||
'cause_heavy_wounds',
|
||||
'fireball',
|
||||
'fire_storm',
|
||||
'ice_storm',
|
||||
...CONTROL_SPELLS,
|
||||
'anti_spell',
|
||||
'disease',
|
||||
'poison',
|
||||
'blindness',
|
||||
'summon_goblin',
|
||||
'summon_ogre',
|
||||
'summon_troll',
|
||||
'summon_giant',
|
||||
'summon_elemental'
|
||||
];
|
||||
|
||||
const THREAT_VALUE: Partial<Record<SpellId, number>> = {
|
||||
missile: 1,
|
||||
finger_of_death: 10,
|
||||
lightning_bolt: 5,
|
||||
lightning_bolt_quick: 5,
|
||||
cause_light_wounds: 2,
|
||||
cause_heavy_wounds: 3,
|
||||
fireball: 5,
|
||||
fire_storm: 5,
|
||||
ice_storm: 5,
|
||||
amnesia: 2,
|
||||
confusion: 1.5,
|
||||
charm_person: 2.5,
|
||||
charm_monster: 3,
|
||||
paralysis: 2.5,
|
||||
fear: 1.5,
|
||||
anti_spell: 2,
|
||||
disease: 4,
|
||||
poison: 4,
|
||||
blindness: 3,
|
||||
summon_goblin: 2,
|
||||
summon_ogre: 3,
|
||||
summon_troll: 4,
|
||||
summon_giant: 5,
|
||||
summon_elemental: 4
|
||||
};
|
||||
|
||||
/** Defensive spells whose worth is purely situational, so repeating them is fine. */
|
||||
const NO_REPEAT_DISCOUNT: SpellId[] = ['shield', 'counter_spell', 'magic_mirror', 'dispel_magic', 'cure_light_wounds', 'cure_heavy_wounds'];
|
||||
|
||||
interface Situation {
|
||||
state: GameState;
|
||||
me: WizardId;
|
||||
foe: WizardId;
|
||||
foeSeq: Record<Hand, HandTurn[]>;
|
||||
/** Hostile spells the foe could finish this very turn, by value. */
|
||||
threatNow: number;
|
||||
threatNowCounterable: number;
|
||||
/** Hostile spells the foe could finish next turn. */
|
||||
threatNext: number;
|
||||
foeMonsterDamage: number;
|
||||
missileOrMonsterNow: boolean;
|
||||
foeFirePlan: boolean;
|
||||
foeIcePlan: boolean;
|
||||
iceElemental: boolean;
|
||||
fireElemental: boolean;
|
||||
rng: () => number;
|
||||
}
|
||||
|
||||
/** A stable 0..1 number from a string, so each opponent has its own leanings. */
|
||||
function hash01(text: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return ((h >>> 0) % 1000) / 1000;
|
||||
}
|
||||
|
||||
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')
|
||||
};
|
||||
let threatNow = 0;
|
||||
let threatNowCounterable = 0;
|
||||
let threatNext = 0;
|
||||
let missileNow = false;
|
||||
let foeFirePlan = false;
|
||||
let foeIcePlan = false;
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) {
|
||||
if (!HOSTILE.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.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;
|
||||
}
|
||||
}
|
||||
const foeMonsterDamage = state.monsters
|
||||
.filter((m) => m.owner === foe && !isElemental(m.kind))
|
||||
.reduce((sum, m) => sum + m.attack, 0);
|
||||
return {
|
||||
state,
|
||||
me,
|
||||
foe,
|
||||
foeSeq,
|
||||
threatNow,
|
||||
threatNowCounterable,
|
||||
threatNext,
|
||||
foeMonsterDamage,
|
||||
missileOrMonsterNow: missileNow || foeMonsterDamage > 0,
|
||||
foeFirePlan,
|
||||
foeIcePlan,
|
||||
iceElemental: state.monsters.some((m) => m.kind === 'ice_elemental'),
|
||||
fireElemental: state.monsters.some((m) => m.kind === 'fire_elemental'),
|
||||
rng
|
||||
};
|
||||
}
|
||||
|
||||
/** How much the bot would like to finish this spell right now, before its leanings and recent habits. */
|
||||
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 foeEnchanted =
|
||||
foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
||||
switch (spell.id) {
|
||||
case 'fireball':
|
||||
if (s.iceElemental) return 6;
|
||||
return foeW.resistHeat ? 0.2 : 5;
|
||||
case 'lightning_bolt':
|
||||
return 5;
|
||||
case 'lightning_bolt_quick':
|
||||
return meW.usedQuickLightning ? 0 : 5.2;
|
||||
case 'cause_heavy_wounds':
|
||||
return 3.2;
|
||||
case 'cause_light_wounds':
|
||||
return 2.2;
|
||||
case 'missile':
|
||||
return 1.1;
|
||||
case 'finger_of_death':
|
||||
return 9;
|
||||
case 'summon_goblin':
|
||||
return 2.2;
|
||||
case 'summon_ogre':
|
||||
return 3.2;
|
||||
case 'summon_troll':
|
||||
return 4;
|
||||
case 'summon_giant':
|
||||
return 4.6;
|
||||
case 'summon_elemental':
|
||||
return meW.resistHeat || meW.resistCold ? 4.5 : 0.8;
|
||||
case 'paralysis':
|
||||
return 2.4;
|
||||
case 'amnesia':
|
||||
return 2;
|
||||
case 'confusion':
|
||||
return 1.6;
|
||||
case 'fear':
|
||||
return 1.8;
|
||||
case 'charm_person':
|
||||
return 2.6;
|
||||
case 'charm_monster':
|
||||
return foeMonsters.some((m) => !isElemental(m.kind)) ? 3.5 : 0;
|
||||
case 'anti_spell': {
|
||||
let longest = 0;
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const sp of SPELLS) {
|
||||
longest = Math.max(longest, progressToward(sp.tokens[0], s.foeSeq[hand], foeW.sequenceStart));
|
||||
}
|
||||
}
|
||||
return 1 + longest * 0.6;
|
||||
}
|
||||
case 'disease':
|
||||
return 3.5;
|
||||
case 'poison':
|
||||
return 3.8;
|
||||
case 'blindness':
|
||||
return 3;
|
||||
case 'invisibility':
|
||||
return 2.5 + s.foeMonsterDamage;
|
||||
case 'haste':
|
||||
return meW.hasteTurns > 0 ? 0 : 3.5;
|
||||
case 'time_stop':
|
||||
return 3;
|
||||
case 'delayed_effect':
|
||||
return meW.banked || meW.delayedTurns > 0 ? 0 : 1.5;
|
||||
case 'permanency':
|
||||
return meW.permanencyTurns > 0 ? 0 : 2.5;
|
||||
case 'shield':
|
||||
if (meW.protectionTurns > 0) return 0;
|
||||
if (s.foeMonsterDamage > 0) return 1.5 + s.foeMonsterDamage;
|
||||
if (s.missileOrMonsterNow) return 1.4;
|
||||
return 0.3;
|
||||
case 'counter_spell':
|
||||
return Math.max(0.4, s.threatNowCounterable);
|
||||
case 'magic_mirror':
|
||||
return s.threatNowCounterable >= 3 ? s.threatNowCounterable : 0.3;
|
||||
case 'dispel_magic':
|
||||
return Math.max(0.5, s.foeMonsterDamage + (foeEnchanted ? 2 : 0) + (s.threatNow >= 5 ? s.threatNow : 0));
|
||||
case 'remove_enchantment':
|
||||
return foeEnchanted ? 2.5 : foeMonsters.length > 0 ? 1 + s.foeMonsterDamage : 0.3;
|
||||
case 'protection_from_evil':
|
||||
return meW.protectionTurns > 1 ? 0 : 2 + s.foeMonsterDamage;
|
||||
case 'resist_heat':
|
||||
if (s.fireElemental) return 4;
|
||||
return s.foeFirePlan && !meW.resistHeat ? 3.5 : meW.resistHeat ? 0 : 0.6;
|
||||
case 'resist_cold':
|
||||
if (s.iceElemental) return 4;
|
||||
return s.foeIcePlan && !meW.resistCold ? 3.5 : meW.resistCold ? 0 : 0.5;
|
||||
case 'cure_light_wounds':
|
||||
return meW.hp < MAX_HP ? 1 : 0;
|
||||
case 'cure_heavy_wounds':
|
||||
return meW.hp < MAX_HP - 1 ? 2 : meW.diseaseTurns !== null ? 5 : 0;
|
||||
case 'raise_dead':
|
||||
return meW.hp <= MAX_HP - 5 ? 4 : 0;
|
||||
case 'fire_storm':
|
||||
return meW.resistHeat && !foeW.resistHeat ? 5 : 0;
|
||||
case 'ice_storm':
|
||||
return meW.resistCold && !foeW.resistCold ? 5 : 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function spellValue(s: Situation, spell: Spell): number {
|
||||
let v = baseValue(s, spell);
|
||||
if (v <= 0) return 0;
|
||||
const meW = s.state.wizards[s.me];
|
||||
// Leanings: each opponent favours some spells over others.
|
||||
v *= 0.7 + 0.6 * hash01(meW.name + spell.id);
|
||||
// Habit: a spell cast in the last few turns is worth less again.
|
||||
if (!NO_REPEAT_DISCOUNT.includes(spell.id)) {
|
||||
const recent = meW.casts.filter((c) => c.spellId === spell.id && c.turn >= s.state.turn - 4).length;
|
||||
v /= 1 + recent;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
interface HandEval {
|
||||
score: number;
|
||||
planned?: SpellId;
|
||||
}
|
||||
|
||||
function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned?: SpellId): HandEval {
|
||||
let best = 0;
|
||||
for (const c of completedSpells(seq, start)) {
|
||||
best = Math.max(best, spellValue(s, c.spell));
|
||||
}
|
||||
let progress = 0;
|
||||
let planned: SpellId | undefined;
|
||||
for (const spell of SPELLS) {
|
||||
const value = spellValue(s, spell);
|
||||
if (value <= 0) continue;
|
||||
spell.tokens.forEach((tokens, i) => {
|
||||
if (spell.id === 'lightning_bolt_quick' && i > 0) return;
|
||||
const k = progressToward(tokens, seq, start);
|
||||
if (k === 0) return;
|
||||
let v = value * Math.pow(k / tokens.length, 1.3);
|
||||
if (k >= 2) v += 0.3;
|
||||
// Being one gesture from a counter-spell is worth a lot when a blow is coming next turn.
|
||||
if (spell.id === 'counter_spell' && k === tokens.length - 1) v = Math.max(v, s.threatNext * 0.8);
|
||||
if (spell.id === otherPlanned) v *= 0.4;
|
||||
if (v > progress) {
|
||||
progress = v;
|
||||
planned = spell.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
return { score: best + progress * 0.9, planned };
|
||||
}
|
||||
|
||||
function bestCasts(s: Situation, completions: Record<Hand, Completion[]>, turnCount: number): CastChoice[] {
|
||||
const options: { hand: Hand; completion: Completion; value: number }[] = [];
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const completion of completions[hand]) {
|
||||
const value = spellValue(s, completion.spell);
|
||||
if (value > 0.25) options.push({ hand, completion, value });
|
||||
}
|
||||
}
|
||||
options.sort((a, b) => b.value - a.value);
|
||||
const chosen: typeof options = [];
|
||||
for (const o of options) {
|
||||
if (chosen.some((c) => c.hand === o.hand)) continue;
|
||||
const cells = usedCells(o.completion, o.hand, turnCount);
|
||||
const clash = chosen.some((c) => {
|
||||
for (const cell of usedCells(c.completion, c.hand, turnCount)) if (cells.has(cell)) return true;
|
||||
return false;
|
||||
});
|
||||
if (clash) continue;
|
||||
chosen.push(o);
|
||||
}
|
||||
return chosen.map((o) => toChoice(s, o.hand, o.completion));
|
||||
}
|
||||
|
||||
function foeDangerousHand(s: Situation): Hand {
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
let bestHand: Hand = 'left';
|
||||
let bestK = -1;
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const spell of SPELLS) {
|
||||
if (!HOSTILE.includes(spell.id)) continue;
|
||||
const k = progressToward(spell.tokens[0], s.foeSeq[hand], foeW.sequenceStart);
|
||||
if (k > bestK) {
|
||||
bestK = k;
|
||||
bestHand = hand;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestHand;
|
||||
}
|
||||
|
||||
function targetFor(s: Situation, spell: Spell, choice: CastChoice): void {
|
||||
const meW = s.state.wizards[s.me];
|
||||
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
|
||||
const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0];
|
||||
const iceEl = s.state.monsters.find((m) => m.kind === 'ice_elemental');
|
||||
const fireEl = s.state.monsters.find((m) => m.kind === 'fire_elemental');
|
||||
switch (spell.id) {
|
||||
case 'fireball':
|
||||
choice.target = iceEl ? iceEl.id : s.foe;
|
||||
break;
|
||||
case 'resist_cold':
|
||||
choice.target = iceEl ? iceEl.id : s.me;
|
||||
break;
|
||||
case 'resist_heat':
|
||||
choice.target = fireEl ? fireEl.id : s.me;
|
||||
break;
|
||||
case 'charm_monster':
|
||||
choice.target = strongestFoeMonster?.id ?? s.foe;
|
||||
break;
|
||||
case 'remove_enchantment': {
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
const enchanted = foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
||||
choice.target = enchanted || !strongestFoeMonster ? s.foe : strongestFoeMonster.id;
|
||||
break;
|
||||
}
|
||||
case 'summon_elemental':
|
||||
choice.target = s.me;
|
||||
choice.elemental = meW.resistCold ? 'ice' : 'fire';
|
||||
break;
|
||||
case 'paralysis':
|
||||
case 'charm_person':
|
||||
choice.target = s.foe;
|
||||
choice.chosenHand = foeDangerousHand(s);
|
||||
break;
|
||||
default:
|
||||
choice.target = spell.usualTarget === 'self' ? s.me : s.foe;
|
||||
}
|
||||
if (spell.category === 'summons') choice.monsterTarget = s.foe;
|
||||
}
|
||||
|
||||
function toChoice(s: Situation, hand: Hand, completion: Completion): CastChoice {
|
||||
const choice: CastChoice = { hand, spellId: completion.spell.id, seqIndex: completion.seqIndex };
|
||||
targetFor(s, completion.spell, choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/** The gesture that most disrupts a charmed hand's sequences. */
|
||||
function charmGesture(s: Situation): Gesture {
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
|
||||
let best: Gesture = 'P';
|
||||
let bestScore = Infinity;
|
||||
for (const g of ['C', 'D', 'F', 'P', 'S', 'W'] as Gesture[]) {
|
||||
const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }];
|
||||
let score = 0;
|
||||
for (const spell of SPELLS) {
|
||||
if (!HOSTILE.includes(spell.id)) continue;
|
||||
score += progressToward(spell.tokens[0], seq, foeW.sequenceStart);
|
||||
}
|
||||
if (score < bestScore || (score === bestScore && s.rng() < 0.5)) {
|
||||
bestScore = score;
|
||||
best = g;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
interface PairChoice {
|
||||
left: Gesture;
|
||||
right: Gesture;
|
||||
casts: CastChoice[];
|
||||
}
|
||||
|
||||
/** Pick one pair of gestures given the history so far, and what to cast with them. */
|
||||
function choosePair(s: Situation, history: TurnGestures[], rng: () => number, timeStopped: boolean): PairChoice {
|
||||
const meW = s.state.wizards[s.me];
|
||||
const foe = s.foe;
|
||||
const optionsFor = (hand: Hand): Gesture[] => {
|
||||
if (timeStopped) return ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
|
||||
const forced = forcedGesture(meW, hand);
|
||||
if (forced) return [forced];
|
||||
const allowed = allowedGestures(meW, hand);
|
||||
return allowed.length ? allowed : ['-'];
|
||||
};
|
||||
const start = meW.sequenceStart;
|
||||
let bestPair: { left: Gesture; right: Gesture; score: number } | undefined;
|
||||
for (const left of optionsFor('left')) {
|
||||
for (const right of optionsFor('right')) {
|
||||
const next = [...history, { left, right }];
|
||||
const seqL = handSequence(next, 'left');
|
||||
const seqR = handSequence(next, 'right');
|
||||
const evalL = evaluateHand(s, seqL, start);
|
||||
const evalR = evaluateHand(s, seqR, start, evalL.planned);
|
||||
let score = evalL.score + evalR.score;
|
||||
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;
|
||||
score += rng() * 0.5;
|
||||
if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
|
||||
}
|
||||
}
|
||||
const pair = bestPair ?? { left: '-' as Gesture, right: '-' as Gesture, score: 0 };
|
||||
const next = [...history, { left: pair.left, right: pair.right }];
|
||||
const completions: Record<Hand, Completion[]> = {
|
||||
left: completedSpells(handSequence(next, 'left'), start),
|
||||
right: completedSpells(handSequence(next, 'right'), start)
|
||||
};
|
||||
return { left: pair.left, right: pair.right, casts: bestCasts(s, completions, next.length) };
|
||||
}
|
||||
|
||||
export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number = Math.random): TurnInput {
|
||||
const s = assess(state, me, rng);
|
||||
const meW = state.wizards[me];
|
||||
const foe = s.foe;
|
||||
const timeStopped = state.timeStops[0] === me;
|
||||
|
||||
const first = choosePair(s, meW.history, rng, timeStopped);
|
||||
const weakFoeMonster = state.monsters.find((m) => m.owner === foe && m.hp === 1);
|
||||
const monsterOrders: Record<string, TargetId> = {};
|
||||
for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe;
|
||||
|
||||
const input: TurnInput = {
|
||||
left: first.left,
|
||||
right: first.right,
|
||||
casts: first.casts,
|
||||
stabTarget: weakFoeMonster?.id ?? foe,
|
||||
monsterOrders
|
||||
};
|
||||
if (meW.hasteTurns > 0 && !timeStopped) {
|
||||
const second = choosePair(s, [...meW.history, { left: first.left, right: first.right }], rng, timeStopped);
|
||||
input.second = { left: second.left, right: second.right, casts: second.casts, stabTarget: input.stabTarget };
|
||||
}
|
||||
if (meW.banked) {
|
||||
// Loose a banked spell as soon as it is worth anything; keep a shield for a rainy day.
|
||||
const spell = SPELL_BY_ID[meW.banked.spellId];
|
||||
const worth = baseValue(s, spell);
|
||||
if (worth > 0.5 && (spell.id !== 'shield' || s.missileOrMonsterNow)) {
|
||||
const choice: CastChoice = { hand: 'left', spellId: spell.id };
|
||||
targetFor(s, spell, choice);
|
||||
input.release = { target: choice.target, monsterTarget: choice.monsterTarget };
|
||||
}
|
||||
}
|
||||
if (state.wizards[foe].constraints.charmed?.by === me) input.charmGesture = charmGesture(s);
|
||||
return input;
|
||||
}
|
||||
Reference in New Issue
Block a user