A counter-spell aimed at the other wizard is explained, and named when it blocks

Blocked blows now say what blocked them (shield, counter-spell, dispel
magic or protection from evil), the first counter-spell cast at the
other wizard earns a one-time explanation that it protects its subject,
and the spell's description says to cast it on yourself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 16:41:25 -04:00
co-authored by Claude Fable 5.1
parent 5de352db43
commit dd6ff93606
3 changed files with 37 additions and 11 deletions
+12 -1
View File
@@ -289,7 +289,7 @@ describe('haste, time stop, delayed effect and permanency', () => {
// The snap made in the stopped moment still counts toward a missile.
s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'missile' }] });
expect(s.wizards.B.hp).toBe(14);
expect(s.log.some((l) => l.text.includes('shield turns the missile aside'))).toBe(true);
expect(s.log.some((l) => l.text.includes('protection from evil turns the missile aside'))).toBe(true);
});
it('delayed effect banks the next spell until it is released', () => {
@@ -394,4 +394,15 @@ describe('lessons', () => {
s = runSequence(s, 'A', 'SSDD', '----', { casts: [{ hand: 'left', spellId: 'fireball' }] });
expect(s.lastTurn?.lessons.some((l) => l.includes('reused gestures from the earlier Paralysis'))).toBe(true);
});
it('explains a counter-spell aimed at the other wizard, who it then protects', () => {
let s = createGame({ A: 'Black', B: 'White' }, 1);
s = runSequence(s, 'A', 'SFW', '---', { casts: [{ hand: 'left', spellId: 'summon_goblin', monsterTarget: 'B' }] });
expect(s.wizards.B.hp).toBe(14);
// The goblin lands twice more while the counter-spell is made, then the counter-spell shields White from it.
s = runSequence(s, 'A', 'WWS', '---', { casts: [{ hand: 'left', spellId: 'counter_spell', target: 'B' }] });
expect(s.wizards.B.hp).toBe(12);
expect(s.log.some((l) => l.text === "Goblin's blows glance off White's counter-spell.")).toBe(true);
expect(s.lastTurn?.lessons.some((l) => l.includes('protects its subject, not its caster'))).toBe(true);
});
});
+24 -9
View File
@@ -200,7 +200,9 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
overlaps: [] as { name: string; spell: string; earlier: string }[],
broken: [] as { name: string; hand: Hand }[],
curedAndHurt: [] as { name: string; hurt: number; healed: number }[],
newMonsterAttacked: ''
newMonsterAttacked: '',
/** A counter-spell aimed at the other wizard, who is then protected by it. */
counterAimedAway: '' as string
};
// ---- 1. Gestures, after last turn's enchantments have their say ----
@@ -276,7 +278,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
// ---- 2. Which completed spells are actually loosed ----
const casts: ActiveCast[] = [];
const shielded = new Set<TargetId>();
/** Who is shielded this turn, and by what, so a blocked blow can name it. */
const shielded = new Map<TargetId, string>();
for (const id of acting) {
const w = W[id];
const accepted: ActiveCast[] = [];
@@ -364,7 +367,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (dispels.length > 0) {
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.add(d.target);
for (const d of dispels) shielded.set(d.target, 'dispel magic');
for (const id of IDS) clearEnchantments(W[id]);
for (const m of state.monsters) {
clearMonsterEnchantments(m);
@@ -441,11 +444,17 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
// ---- 6. Who is shielded this turn ----
for (const c of live()) {
if (['shield', 'counter_spell', 'dispel_magic', 'protection_from_evil'].includes(c.spell.id)) shielded.add(c.target);
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) {
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.add(id);
for (const m of state.monsters) if (m.protectionTurns > 0) shielded.add(m.id);
for (const id of IDS) 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'}`;
// ---- 7. Summons, then storms and elementals ----
for (const c of live()) {
@@ -731,7 +740,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
case 'missile':
if (isShielded(t)) {
facts.shieldBlocked.add(t);
log(`${beingName(state, t)}'s shield turns the missile aside.`, 'fizzle');
log(`${guard(t)} turns the missile aside.`, 'fizzle');
} else hurt(t, 1, 'a missile');
break;
case 'finger_of_death':
@@ -826,7 +835,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
if (isShielded(target)) {
facts.shieldBlocked.add(target);
log(`${m.name}'s blows glance off ${beingName(state, target)}'s shield.`, 'fizzle');
log(`${m.name}'s blows glance off ${guard(target)}.`, 'fizzle');
} else if (isWizardId(target) && W[target].invisibleTurns > 0 && !frozen(target)) {
log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle');
} else {
@@ -846,7 +855,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
log(`${w.name} stabs at nothing.`, 'fizzle');
} else if (isShielded(target)) {
facts.shieldBlocked.add(target);
log(`${w.name}'s knife skids off ${beingName(state, target)}'s shield.`, 'fizzle');
log(`${w.name}'s knife skids off ${guard(target)}.`, 'fizzle');
} else {
hurt(target, 1, `${w.name}'s knife`);
}
@@ -960,6 +969,12 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
text: `A shield stops only creatures, missiles and stabs, so it did nothing against ${facts.throughShield[0].spell}. Counter-spell is the defence against other spells.`
});
}
if (facts.counterAimedAway) {
lessons.push({
key: 'counter-aim',
text: `A counter-spell protects its subject, not its caster: ${facts.counterAimedAway} cancelled spells aimed at them and shielded them from creatures, missiles and stabs. To stop what the other wizard casts at you, cast the counter-spell on yourself.`
});
}
if (facts.fingerThroughCounter) {
lessons.push({ key: 'counter-finger', text: 'A counter-spell cannot stop finger of death. Only dispel magic can, or an anti-spell before the eight gestures are finished.' });
} else if (facts.counterSmothered > 0) {
+1 -1
View File
@@ -116,7 +116,7 @@ export const SPELLS: Spell[] = [
spell('shield', 'Shield', 'protection', ['P'], 'self', 'Blocks monsters, missiles and stabs against the subject this turn.'),
spell('remove_enchantment', 'Remove enchantment', 'protection', ['P-D-W-P'], 'enemy', 'Ends every enchantment on the subject. Destroys a monster after it attacks.'),
spell('magic_mirror', 'Magic mirror', 'protection', ['C-(w'], 'self', 'Spells cast at the subject this turn rebound on their caster. Undone by a counter-spell.'),
spell('counter_spell', 'Counter-spell', 'protection', ['W-P-P', 'W-W-S'], 'self', 'Other spells cast at the subject this turn fail, and the subject is shielded. Cannot stop finger of death or dispel magic.'),
spell('counter_spell', 'Counter-spell', 'protection', ['W-P-P', 'W-W-S'], 'self', 'Every other spell cast at the subject this turn fails, and the subject is shielded. Cast it on yourself to stop what the other wizard casts at you. Cannot stop finger of death or dispel magic.'),
spell('dispel_magic', 'Dispel magic', 'protection', ['C-D-P-W'], 'self', 'Every other spell this turn fails, all enchantments end, all monsters die after attacking. Shields the subject.'),
spell('raise_dead', 'Raise dead', 'protection', ['D-W-W-F-W-C'], 'self', 'Heals 5 damage and removes all enchantments from the subject.'),
spell('cure_light_wounds', 'Cure light wounds', 'protection', ['D-F-W'], 'self', 'Heals 1 damage.'),