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
+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) {