An atmospheric chronicle, and illnesses that name the turn they kill on

Every spell, blow and death in the chronicle now carries an image as
well as its facts, in the spirit of Plotkin's Spellcast but in our own
words. Disease and poison name the turn they will kill on when they
land, give a symptom each turn after, and warn on the last turn a cure
can come; the status panel names the same turn. Timing is unchanged:
as in Spellcast, the casting turn is the first of the six. Amnesia and
confusion now say when they land. Text only, so every ledger replays
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-24 22:45:23 -04:00
co-authored by Claude Opus 5.5
parent 38e5f8b28d
commit e1833e4e3c
7 changed files with 139 additions and 81 deletions
+2 -2
View File
@@ -214,13 +214,13 @@
<aside class="rail"> <aside class="rail">
{#if !duel.spectating} {#if !duel.spectating}
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you /> <WizardStatus turnsPlayed={duel.state.turn} wizard={duel.you} monsters={duel.yourMonsters} you />
{/if} {/if}
{#if crowded} {#if crowded}
<Roster {duel} /> <Roster {duel} />
{:else} {:else}
{#each duel.others as id (id)} {#each duel.others as id (id)}
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} /> <WizardStatus turnsPlayed={duel.state.turn} wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
{/each} {/each}
{/if} {/if}
<TableTalk {duel} /> <TableTalk {duel} />
+1 -1
View File
@@ -43,7 +43,7 @@
</button> </button>
{#if open} {#if open}
<div class="detail"> <div class="detail">
<WizardStatus wizard={w} monsters={duel.state.monsters.filter((m) => m.owner === id)} /> <WizardStatus turnsPlayed={duel.state.turn} wizard={w} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
</div> </div>
{/if} {/if}
</li> </li>
+2 -2
View File
@@ -2,9 +2,9 @@
import { effectsOf } from '$lib/game/status'; import { effectsOf } from '$lib/game/status';
import { MAX_HP, type Monster, type WizardState } from '$lib/game/state'; import { MAX_HP, type Monster, type WizardState } from '$lib/game/state';
let { wizard, monsters, you = false }: { wizard: WizardState; monsters: Monster[]; you?: boolean } = $props(); let { wizard, monsters, you = false, turnsPlayed }: { wizard: WizardState; monsters: Monster[]; you?: boolean; turnsPlayed?: number } = $props();
const effects = $derived(effectsOf(wizard)); const effects = $derived(effectsOf(wizard, turnsPlayed));
</script> </script>
<section class="wizard" class:you class:dead={!wizard.alive}> <section class="wizard" class:you class:dead={!wizard.alive}>
+17
View File
@@ -484,3 +484,20 @@ describe('at a table of several wizards', () => {
expect(s.wizards.B.history.at(-1)?.left).toBe('D'); expect(s.wizards.B.history.at(-1)?.left).toBe('D');
}); });
}); });
describe('the course of a disease', () => {
it('names the turn it kills on, gives a symptom each turn, and kills at the end of the fifth turn after the cast', () => {
let s = createGame({ A: 'Ann', B: 'Bo' }, 1);
s = runSequence(s, 'A', 'DSFFFC', '-----C', { casts: [{ hand: 'left', spellId: 'disease', target: 'B' }] });
expect(s.log.some((l) => l.text.includes('will die at the end of turn 11'))).toBe(true);
for (let turn = 7; turn <= 10; turn++) {
s = play(s, {});
expect(s.wizards.B.alive).toBe(true);
}
expect(s.log.some((l) => l.text.includes('last turn to find a cure'))).toBe(true);
s = play(s, {});
expect(s.turn).toBe(11);
expect(s.wizards.B.alive).toBe(false);
expect(s.over?.winner).toBe('A');
});
});
+109 -70
View File
@@ -140,6 +140,42 @@ function tick(n: number): number {
return n === Infinity ? n : Math.max(0, n - 1); return n === Infinity ? n : Math.max(0, n - 1);
} }
/** What a creature does when it strikes, before the name of the one struck. */
const ATTACKS: Record<MonsterKind, string> = {
goblin: 'darts in and slashes',
ogre: 'swings a heavy fist into',
troll: 'rakes its claws across',
giant: 'brings a vast club down on',
fire_elemental: 'washes searing heat over',
ice_elemental: 'breathes killing cold over'
};
/** How a summoned creature arrives. */
const SUMMONED: Record<MonsterKind, string> = {
goblin: 'The ground cracks and something small and quick climbs out.',
ogre: 'The earth shudders under heavy feet.',
troll: 'A smell of wet stone and old meat fills the air.',
giant: 'A shadow falls across the field, and keeps falling.',
fire_elemental: 'A column of flame spins up out of nothing.',
ice_elemental: 'Frost races across the ground and rises into a shape.'
};
/** The disease's course, by turns left after this one: a symptom each turn, and the last warning. */
const FEVER: Record<number, (name: string) => string> = {
4: (n) => `${n}'s brow is hot and damp with fever.`,
3: (n) => `${n}'s cheeks burn red; the fever is climbing.`,
2: (n) => `${n} sways, slick with sweat, every joint aching.`,
1: (n) => `${n} shakes with chills beneath the fever. This is the last turn to find a cure.`
};
/** The poison's course, by turns left after this one. */
const VENOM: Record<number, (name: string) => string> = {
4: (n) => `${n}'s fingertips have gone cold and dull.`,
3: (n) => `${n} is ashen, and a numbness is creeping up the arms.`,
2: (n) => `${n}'s gestures turn clumsy; the legs are going.`,
1: (n) => `${n} can barely stand, eyes clouding over. This is the last turn to find a cure.`
};
export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnInput>): GameState { export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnInput>): GameState {
const state = structuredClone(previous); const state = structuredClone(previous);
if (state.over) return state; if (state.over) return state;
@@ -191,7 +227,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const note = (id: TargetId, label: string, delta: number) => { const note = (id: TargetId, label: string, delta: number) => {
if (isWizard(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(`The world holds its breath. Dust hangs in the air, and only ${W[actor].name} 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. */
const facts = { const facts = {
shieldBlocked: new Set<TargetId>(), shieldBlocked: new Set<TargetId>(),
@@ -210,7 +246,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
// ---- 1. Gestures, after last turn's enchantments have their say ---- // ---- 1. Gestures, after last turn's enchantments have their say ----
const shape = (w: WizardState, c: Constraints, left: Gesture, right: Gesture, prev: HistoryEntry | undefined): [Gesture, Gesture] => { const shape = (w: WizardState, c: Constraints, left: Gesture, right: Gesture, prev: HistoryEntry | undefined): [Gesture, Gesture] => {
if (c.amnesia && prev) { if (c.amnesia && prev) {
log(`${w.name}, struck by amnesia, repeats the last gestures.`); log(`${w.name}'s thoughts slide away like water; the hands, remembering nothing new, repeat the last gestures.`);
return [prev.left, prev.right]; return [prev.left, prev.right];
} }
if (c.charmed) { if (c.charmed) {
@@ -219,25 +255,25 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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;
log(`${W[c.charmed.by].name} charms ${w.name}'s ${c.charmed.hand} hand into a ${GESTURE_NAMES[g]}.`); log(`${w.name}'s ${c.charmed.hand} hand moves to ${W[c.charmed.by].name}'s will, into a ${GESTURE_NAMES[g]}.`);
} }
if (c.paralysedHand && prev) { if (c.paralysedHand && prev) {
const g = paralysedForm(w.history[w.history.length - 1][c.paralysedHand]); const g = paralysedForm(w.history[w.history.length - 1][c.paralysedHand]);
if (c.paralysedHand === 'left') left = g; if (c.paralysedHand === 'left') left = g;
else right = g; else right = g;
log(`${w.name}'s ${c.paralysedHand} hand is paralysed into a ${GESTURE_NAMES[g]}.`); log(`${w.name}'s ${c.paralysedHand} hand is locked stiff, frozen in a ${GESTURE_NAMES[g]}.`);
} }
if (c.fear) { if (c.fear) {
if (FEAR_BANNED.includes(left)) left = '-'; if (FEAR_BANNED.includes(left)) left = '-';
if (FEAR_BANNED.includes(right)) right = '-'; if (FEAR_BANNED.includes(right)) right = '-';
log(`${w.name} is too afraid to snap, point, clap or wriggle.`); log(`Dread grips ${w.name}; the hands will not snap, point, clap or wriggle.`);
} }
if (c.confusion) { if (c.confusion) {
const slip = c.confusionFixed ?? { hand: rng() < 0.5 ? 'left' : 'right', gesture: pick(MAGIC_GESTURES) }; const slip = c.confusionFixed ?? { hand: rng() < 0.5 ? 'left' : 'right', gesture: pick(MAGIC_GESTURES) };
if (w.permanent.confusion && !w.permanent.confusionFixed) w.permanent.confusionFixed = slip; if (w.permanent.confusion && !w.permanent.confusionFixed) w.permanent.confusionFixed = slip;
if (slip.hand === 'left') left = slip.gesture; if (slip.hand === 'left') left = slip.gesture;
else right = slip.gesture; else right = slip.gesture;
log(`Confused, ${w.name}'s ${slip.hand} hand makes a ${GESTURE_NAMES[slip.gesture]} instead.`); log(`${w.name}'s head swims, and the ${slip.hand} hand makes a ${GESTURE_NAMES[slip.gesture]} instead.`);
} }
if (left === '>' && right === '>') right = '-'; if (left === '>' && right === '>') right = '-';
return [left, right]; return [left, right];
@@ -276,7 +312,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
record(w, l2, r2, 'haste'); record(w, l2, r2, 'haste');
entries[id].push(w.history.length - 1); entries[id].push(w.history.length - 1);
} }
if (w.surrendered) log(`${w.name} offers both palms in surrender.`, 'death'); if (w.surrendered) log(`${w.name} lowers both hands and shows empty palms: a surrender.`, 'death');
} }
// ---- 2. Which completed spells are actually loosed ---- // ---- 2. Which completed spells are actually loosed ----
@@ -341,7 +377,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
w.casts.push({ turn, index: -1, hand: 'left', spellId: spell.id, target }); w.casts.push({ turn, index: -1, hand: 'left', spellId: spell.id, target });
w.banked = null; w.banked = null;
const at = BLANKET_SPELLS.includes(spell.id) || target === id ? '' : ` at ${beingName(state, target)}`; const at = BLANKET_SPELLS.includes(spell.id) || target === id ? '' : ` at ${beingName(state, target)}`;
log(`${w.name} releases the banked ${spell.name}${at}.`, 'spell'); log(`The ${spell.name} ${w.name} had held in reserve uncoils and flies${at}.`, 'spell');
} }
casts.push(...accepted); casts.push(...accepted);
} }
@@ -349,10 +385,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
for (const c of casts) { for (const c of casts) {
if (!BLANKET_SPELLS.includes(c.spell.id) && c.target === NOWHERE) { if (!BLANKET_SPELLS.includes(c.spell.id) && c.target === NOWHERE) {
c.nullified = 'released harmlessly'; c.nullified = 'released harmlessly';
log(`${W[c.caster].name} lets the ${c.spell.name} dissipate harmlessly.`, 'fizzle'); log(`${W[c.caster].name} lets the ${c.spell.name} unravel into the air, harmless.`, 'fizzle');
} else if (!BLANKET_SPELLS.includes(c.spell.id) && !isBeing(c.target)) { } else if (!BLANKET_SPELLS.includes(c.spell.id) && !isBeing(c.target)) {
c.nullified = 'target gone'; c.nullified = 'target gone';
log(`${W[c.caster].name}'s ${c.spell.name} finds no target.`, 'fizzle'); log(`${W[c.caster].name}'s ${c.spell.name} casts about for a target, finds none, and fades.`, 'fizzle');
} }
} }
const live = () => casts.filter((c) => !c.nullified); const live = () => casts.filter((c) => !c.nullified);
@@ -368,7 +404,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const countered = new Set<TargetId>(); const countered = new Set<TargetId>();
const dispels = live().filter((c) => c.spell.id === 'dispel_magic'); const dispels = live().filter((c) => c.spell.id === 'dispel_magic');
if (dispels.length > 0) { 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'); log('A wave of stillness rolls across the field. Dispel magic: every other spell fails, every enchantment ends, and every creature will fade 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 seats) clearEnchantments(W[id]); for (const id of seats) clearEnchantments(W[id]);
@@ -382,7 +418,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
for (const c of live()) { for (const c of live()) {
if (c.spell.id === 'magic_mirror' && countered.has(c.target)) { if (c.spell.id === 'magic_mirror' && countered.has(c.target)) {
c.nullified = 'countered'; c.nullified = 'countered';
log(`The magic mirror on ${beingName(state, c.target)} is shattered by a counter-spell.`, 'fizzle'); log(`The magic mirror around ${beingName(state, c.target)} cracks and falls away in shards, broken by a counter-spell.`, 'fizzle');
} }
} }
const mirrored = new Set(live().filter((c) => c.spell.id === 'magic_mirror').map((c) => c.target)); const mirrored = new Set(live().filter((c) => c.spell.id === 'magic_mirror').map((c) => c.target));
@@ -395,7 +431,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const original = c.caster; const original = c.caster;
c.target = original; c.target = original;
c.caster = owner; c.caster = owner;
log(`${W[owner].name}'s magic mirror turns the ${c.spell.name} back on ${W[original].name}.`, 'spell'); log(`The ${c.spell.name} strikes ${W[owner].name}'s magic mirror and flashes straight back at ${W[original].name}.`, 'spell');
} }
for (const c of live()) { for (const c of live()) {
if (UNCOUNTERABLE.includes(c.spell.id)) continue; if (UNCOUNTERABLE.includes(c.spell.id)) continue;
@@ -430,7 +466,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
chosen.nullified = 'banked'; chosen.nullified = 'banked';
w.banked = { spellId: chosen.spell.id, elemental: chosen.choice.elemental, chosenHand: chosen.choice.chosenHand }; w.banked = { spellId: chosen.spell.id, elemental: chosen.choice.elemental, chosenHand: chosen.choice.chosenHand };
bankedNow.add(id); bankedNow.add(id);
log(`${w.name} banks the ${chosen.spell.name} to release later.`, 'spell'); log(`${w.name} catches the ${chosen.spell.name} before it leaves the hand and holds it in reserve, to loose later.`, 'spell');
} }
} }
const extending = w.permanencyTurns > 0 || live().some((c) => c.spell.id === 'permanency' && c.target === id); const extending = w.permanencyTurns > 0 || live().some((c) => c.spell.id === 'permanency' && c.target === id);
@@ -440,7 +476,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (chosen) { if (chosen) {
chosen.permanent = true; chosen.permanent = true;
extendedNow.add(id); extendedNow.add(id);
log(`${w.name}'s ${chosen.spell.name} is made permanent.`, 'spell'); log(`${w.name}'s ${chosen.spell.name} sets hard, like wax cooling: it will not fade.`, 'spell');
} }
} }
} }
@@ -471,7 +507,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const targetMonster = monsterOf(c.target); const targetMonster = monsterOf(c.target);
if (targetMonster && isElemental(targetMonster.kind)) { if (targetMonster && isElemental(targetMonster.kind)) {
c.nullified = 'cast at an elemental'; c.nullified = 'cast at an elemental';
log(`Nothing can be summoned for an elemental; ${W[c.caster].name}'s ${c.spell.name} fails.`, 'fizzle'); log(`An elemental answers to no master; ${W[c.caster].name}'s ${c.spell.name} calls into emptiness and fails.`, 'fizzle');
continue; continue;
} }
const controller = controllerOf(state, c.target); const controller = controllerOf(state, c.target);
@@ -497,7 +533,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
state.monsters.push(m); state.monsters.push(m);
orders.set(m.id, c.choice.monsterTarget ?? enemyOf(state, controller)); orders.set(m.id, c.choice.monsterTarget ?? enemyOf(state, controller));
const creature = stats.name.toLowerCase(); const creature = stats.name.toLowerCase();
log(`${/^[aeiou]/.test(creature) ? 'An' : 'A'} ${creature} answers ${W[controller].name}'s summons.`, 'spell'); log(`${SUMMONED[kind]} ${/^[aeiou]/.test(creature) ? 'An' : 'A'} ${creature} answers ${W[controller].name}'s summons.`, 'spell');
} }
let fireStorm = live().some((c) => c.spell.id === 'fire_storm'); let fireStorm = live().some((c) => c.spell.id === 'fire_storm');
@@ -509,7 +545,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}; };
if (fireStorm && iceStorm) { if (fireStorm && iceStorm) {
fireStorm = iceStorm = false; fireStorm = iceStorm = false;
log('The fire storm and the ice storm cancel each other; nothing is harmed.', 'fizzle'); log('Fire storm meets ice storm: a great hiss of steam, and the two storms cancel. Nothing is harmed.', 'fizzle');
} }
if (fireStorm && elementals('ice_elemental').length > 0) { if (fireStorm && elementals('ice_elemental').length > 0) {
fireStorm = false; fireStorm = false;
@@ -557,7 +593,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
for (const extra of same.slice(1)) { for (const extra of same.slice(1)) {
destroyedBeforeAttack.add(extra.id); destroyedBeforeAttack.add(extra.id);
same[0].hp = same[0].maxHp; same[0].hp = same[0].maxHp;
log(`Two ${MONSTER_STATS[kind].name.toLowerCase()}s merge into one.`, 'info'); log(`Two ${MONSTER_STATS[kind].name.toLowerCase()}s flow together and become one.`, 'info');
} }
} }
} }
@@ -572,7 +608,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
for (const [target, ids] of controlByTarget) { for (const [target, ids] of controlByTarget) {
if (ids.size < 2) continue; if (ids.size < 2) continue;
for (const c of live()) if (c.target === target && ids.has(c.spell.id)) c.nullified = 'contradictory enchantments'; for (const c of live()) if (c.target === target && ids.has(c.spell.id)) c.nullified = 'contradictory enchantments';
log(`Contradictory enchantments on ${beingName(state, target)} cancel each other out.`, 'fizzle'); log(`The enchantments on ${beingName(state, target)} pull against each other and tear apart; none takes hold.`, 'fizzle');
} }
for (const c of live()) { for (const c of live()) {
const wiz = isWizard(c.target) ? W[c.target] : undefined; const wiz = isWizard(c.target) ? W[c.target] : undefined;
@@ -585,17 +621,19 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (wiz) { if (wiz) {
wiz.pending.amnesia = true; wiz.pending.amnesia = true;
if (c.permanent) wiz.permanent.amnesia = true; if (c.permanent) wiz.permanent.amnesia = true;
log(`A grey haze drifts behind ${wiz.name}'s eyes: next turn the hands will repeat what they just did${c.permanent ? ', and every turn after' : ''}.`);
} else if (mon) mon.pending.forcedTarget = orders.get(mon.id); } else if (mon) mon.pending.forcedTarget = orders.get(mon.id);
break; break;
case 'confusion': case 'confusion':
if (wiz) { if (wiz) {
wiz.pending.confusion = true; wiz.pending.confusion = true;
if (c.permanent) wiz.permanent.confusion = true; if (c.permanent) wiz.permanent.confusion = true;
log(`The field tilts and swims around ${wiz.name}: next turn one hand will go astray${c.permanent ? ', and every turn after' : ''}.`);
} else if (mon) { } else if (mon) {
const candidates = [...seats.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} shakes its head, bewildered, and lunges at ${beingName(state, t)}.`);
} }
break; break;
case 'charm_person': case 'charm_person':
@@ -603,20 +641,20 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const hand = c.choice.chosenHand ?? pick(HANDS); const hand = c.choice.chosenHand ?? pick(HANDS);
wiz.pending.charmed = { hand, by: c.caster }; wiz.pending.charmed = { hand, by: c.caster };
if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster }; if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster };
log(`${wiz.name}'s ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`); log(`A warm, easy trust settles on ${wiz.name}: the ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
} else { } else {
c.nullified = 'monsters cannot be charmed as people'; c.nullified = 'monsters cannot be charmed as people';
log(`Charm person has no hold on ${target}.`, 'fizzle'); log(`The charm slides off ${target}, finding no mind to hold.`, 'fizzle');
} }
break; break;
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, input(c.caster).monsterOrders?.[mon.id] ?? enemyOf(state, 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} turns, blinks, and pads over to ${W[c.caster].name}'s side: it serves a new master.`);
} else { } else {
c.nullified = 'not a charmable monster'; c.nullified = 'not a charmable monster';
log(`Charm monster has no hold on ${target}.`, 'fizzle'); log(`The charm slides off ${target} and takes no hold.`, 'fizzle');
} }
break; break;
case 'paralysis': case 'paralysis':
@@ -624,10 +662,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left'; const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left';
wiz.pending.paralysedHand = hand; wiz.pending.paralysedHand = hand;
if (c.permanent) wiz.permanent.paralysedHand = hand; if (c.permanent) wiz.permanent.paralysedHand = hand;
log(`${wiz.name}'s ${hand} hand will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`); log(`A cold stiffness creeps into ${wiz.name}'s ${hand} hand: it will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`);
} else if (mon && !isElemental(mon.kind)) { } else if (mon && !isElemental(mon.kind)) {
mon.pending.skipAttack = true; mon.pending.skipAttack = true;
log(`${mon.name} will stand paralysed next turn.`); log(`${mon.name}'s limbs stiffen: it will stand paralysed next turn.`);
} else { } else {
c.nullified = 'elementals cannot be paralysed'; c.nullified = 'elementals cannot be paralysed';
} }
@@ -636,76 +674,76 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (wiz) { if (wiz) {
wiz.pending.fear = true; wiz.pending.fear = true;
if (c.permanent) wiz.permanent.fear = true; if (c.permanent) wiz.permanent.fear = true;
log(`${wiz.name} will be too afraid to snap, point, clap or wriggle${c.permanent ? ' from now on' : ' next turn'}.`); log(`A chill of terror runs through ${wiz.name}, who will be too afraid to snap, point, clap or wriggle${c.permanent ? ' from now on' : ' next turn'}.`);
} else c.nullified = 'monsters know no fear'; } else c.nullified = 'monsters know no fear';
break; break;
case 'anti_spell': case 'anti_spell':
if (wiz) { if (wiz) {
wiz.sequenceStart = wiz.history.length; wiz.sequenceStart = wiz.history.length;
log(`${wiz.name}'s gestures so far are wiped from the weave; every spell must start afresh.`); log(`The half-woven magic around ${wiz.name}'s hands flickers and goes out; every spell must start afresh.`);
} }
break; break;
case 'protection_from_evil': case 'protection_from_evil':
if (wiz) wiz.protectionTurns = duration(4); if (wiz) wiz.protectionTurns = duration(4);
else if (mon) mon.protectionTurns = duration(4); else if (mon) mon.protectionTurns = duration(4);
log(`${target} is protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`); log(`A pale circle of light rises around ${target}: protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`);
break; break;
case 'resist_heat': case 'resist_heat':
if (wiz) wiz.resistHeat = true; if (wiz) wiz.resistHeat = true;
else if (mon) mon.resistHeat = true; else if (mon) mon.resistHeat = true;
log(`${target} now resists heat.`); log(`A cool blue aura settles over ${target}, who now resists heat.`);
break; break;
case 'resist_cold': case 'resist_cold':
if (wiz) wiz.resistCold = true; if (wiz) wiz.resistCold = true;
else if (mon) mon.resistCold = true; else if (mon) mon.resistCold = true;
log(`${target} now resists cold.`); log(`A warm rosy aura settles over ${target}, who now resists cold.`);
break; break;
case 'disease': case 'disease':
if (wiz) { if (wiz) {
wiz.diseaseTurns = 6; wiz.diseaseTurns = 6;
log(`${wiz.name} sickens with a deadly disease: six turns to find a cure.`); log(`${wiz.name} coughs, and a fever takes hold. Unless cured, ${wiz.name} will die at the end of turn ${state.turn + 6}.`);
} else c.nullified = 'monsters do not sicken'; } else c.nullified = 'monsters do not sicken';
break; break;
case 'poison': case 'poison':
if (wiz) { if (wiz) {
wiz.poisonTurns = 6; wiz.poisonTurns = 6;
log(`${wiz.name} is poisoned: six turns to find a cure.`); log(`${wiz.name} goes grey about the lips: poison is in the blood. Unless cured, ${wiz.name} will die at the end of turn ${state.turn + 6}.`);
} else c.nullified = 'monsters do not sicken'; } else c.nullified = 'monsters do not sicken';
break; break;
case 'blindness': case 'blindness':
if (wiz) { if (wiz) {
wiz.blindTurns = duration(4); wiz.blindTurns = duration(4);
log(`${wiz.name} is struck blind${c.permanent ? forever : ' for three turns'}.`); log(`A milky film clouds ${wiz.name}'s eyes: blind${c.permanent ? forever : ' for three turns'}.`);
} else if (mon) destroyBefore(mon, 'blinded and unmade'); } else if (mon) destroyBefore(mon, 'blinded and unmade');
break; break;
case 'invisibility': case 'invisibility':
if (wiz) { if (wiz) {
wiz.invisibleTurns = duration(4); wiz.invisibleTurns = duration(4);
log(`${wiz.name} fades from sight${c.permanent ? forever : ' for three turns'}.`); log(`${wiz.name} thins like mist and fades from sight${c.permanent ? forever : ' for three turns'}.`);
} else if (mon) destroyBefore(mon, 'made invisible and unmade'); } else if (mon) destroyBefore(mon, 'made invisible and unmade');
break; break;
case 'haste': case 'haste':
if (wiz) { if (wiz) {
wiz.hasteTurns = duration(4); wiz.hasteTurns = duration(4);
log(`${wiz.name} is hastened${c.permanent ? forever : ' for the next three turns'}: two pairs of gestures a turn.`); log(`The air about ${wiz.name} quickens; everyone else seems to move through honey. Hastened${c.permanent ? forever : ' for the next three turns'}: two pairs of gestures a turn.`);
} else c.nullified = 'monsters cannot be hastened'; } else c.nullified = 'monsters cannot be hastened';
break; break;
case 'time_stop': case 'time_stop':
if (wiz) { if (wiz) {
state.timeStops.push(wiz.id); state.timeStops.push(wiz.id);
log(`${wiz.name} will act alone in a stopped moment.`); log(`The moment catches on ${wiz.name} like a snagged thread: when this turn ends, time will stop, and ${wiz.name} alone will move.`);
} else c.nullified = 'only wizards can stop time'; } else c.nullified = 'only wizards can stop time';
break; break;
case 'delayed_effect': case 'delayed_effect':
if (wiz) { if (wiz) {
wiz.delayedTurns = bankedNow.has(wiz.id) ? 0 : duration(4); wiz.delayedTurns = bankedNow.has(wiz.id) ? 0 : duration(4);
if (!bankedNow.has(wiz.id)) log(`${wiz.name}'s next spell will be banked.`); if (!bankedNow.has(wiz.id)) log(`A hollow of waiting opens around ${wiz.name}'s hands: the next spell will be caught and held.`);
} else c.nullified = 'only wizards can delay a spell'; } else c.nullified = 'only wizards can delay a spell';
break; break;
case 'permanency': case 'permanency':
if (wiz) { if (wiz) {
wiz.permanencyTurns = extendedNow.has(wiz.id) ? 0 : 4; wiz.permanencyTurns = extendedNow.has(wiz.id) ? 0 : 4;
if (!extendedNow.has(wiz.id)) log(`${wiz.name}'s next enchantment will be permanent.`); if (!extendedNow.has(wiz.id)) log(`Something steady and unhurried settles on ${wiz.name}: the next enchantment will be permanent.`);
} else c.nullified = 'only a wizard can hold a permanency'; } else c.nullified = 'only a wizard can hold a permanency';
break; break;
} }
@@ -718,10 +756,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const mon = monsterOf(c.target); const mon = monsterOf(c.target);
if (wiz) { if (wiz) {
clearEnchantments(wiz); clearEnchantments(wiz);
log(`Every enchantment on ${wiz.name} is lifted.`); log(`Threads of old magic lift from ${wiz.name} and drift away: every enchantment is gone.`);
} else if (mon && c.spell.id === 'remove_enchantment') { } else if (mon && c.spell.id === 'remove_enchantment') {
destroyedAfterAttack.add(mon.id); destroyedAfterAttack.add(mon.id);
log(`${mon.name} will be unmade once it has attacked.`); log(`${mon.name} begins to fray at the edges: it will be unmade once it has attacked.`);
} }
} }
@@ -729,11 +767,12 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const damage = new Map<TargetId, number>(); const damage = new Map<TargetId, number>();
const healing = new Map<TargetId, number>(); const healing = new Map<TargetId, number>();
const slain = new Set<TargetId>(); const slain = new Set<TargetId>();
const hurt = (id: TargetId, n: number, why: string, by?: WizardId) => { /** `line` pictures the blow, with % for the one struck; the damage is added after it. */
const hurt = (id: TargetId, n: number, why: string, by?: WizardId, line?: string) => {
damage.set(id, (damage.get(id) ?? 0) + n); damage.set(id, (damage.get(id) ?? 0) + n);
if (isWizard(id) && by && by !== id) W[id].lastHurtBy = by; if (isWizard(id) && by && by !== id) W[id].lastHurtBy = by;
note(id, tidy(why), -n); note(id, tidy(why), -n);
log(`${beingName(state, id)} takes ${n} from ${why}.`, 'damage'); log(`${(line ?? `${tidy(why)} strikes ${beingName(state, id)}`).replace('%', beingName(state, id))}, for ${n}.`, 'damage');
}; };
const heal = (id: TargetId, n: number, label: string) => { const heal = (id: TargetId, n: number, label: string) => {
healing.set(id, (healing.get(id) ?? 0) + n); healing.set(id, (healing.get(id) ?? 0) + n);
@@ -746,32 +785,32 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (isShielded(t)) { if (isShielded(t)) {
facts.shieldBlocked.add(t); facts.shieldBlocked.add(t);
log(`${guard(t)} turns the missile aside.`, 'fizzle'); log(`${guard(t)} turns the missile aside.`, 'fizzle');
} else hurt(t, 1, 'a missile', c.caster); } else hurt(t, 1, 'a missile', c.caster, 'A dart of blue force streaks across and strikes %');
break; break;
case 'finger_of_death': case 'finger_of_death':
slain.add(t); slain.add(t);
if (isWizard(t)) W[t].lastHurtBy = c.caster; if (isWizard(t)) W[t].lastHurtBy = c.caster;
note(t, 'Finger of death', -(isWizard(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(`${W[c.caster].name}'s pointing finger goes black to the knuckle, and death reaches out and touches ${beingName(state, t)}.`, 'death');
break; break;
case 'lightning_bolt': case 'lightning_bolt':
case 'lightning_bolt_quick': case 'lightning_bolt_quick':
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a lightning bolt' }); if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a lightning bolt' });
hurt(t, 5, 'a lightning bolt', c.caster); hurt(t, 5, 'a lightning bolt', c.caster, 'Lightning cracks down on %');
break; break;
case 'cause_light_wounds': case 'cause_light_wounds':
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause light wounds' }); if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause light wounds' });
hurt(t, 2, 'cause light wounds', c.caster); hurt(t, 2, 'cause light wounds', c.caster, 'Thin cuts open across %');
break; break;
case 'cause_heavy_wounds': case 'cause_heavy_wounds':
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause heavy wounds' }); if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause heavy wounds' });
hurt(t, 3, 'cause heavy wounds', c.caster); hurt(t, 3, 'cause heavy wounds', c.caster, 'Deep wounds tear open on %');
break; break;
case 'fireball': case 'fireball':
if (resists(t, 'heat')) log(`${beingName(state, t)} shrugs off the fireball.`, 'fizzle'); if (resists(t, 'heat')) log(`The fireball bursts over ${beingName(state, t)}, and the flames slide off a cool blue aura.`, 'fizzle');
else { else {
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a fireball' }); if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a fireball' });
hurt(t, 5, 'a fireball', c.caster); hurt(t, 5, 'a fireball', c.caster, 'A fireball bursts over %');
} }
break; break;
case 'cure_light_wounds': case 'cure_light_wounds':
@@ -781,7 +820,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
heal(t, 2, 'Cure heavy wounds'); heal(t, 2, 'Cure heavy wounds');
if (isWizard(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 fever breaks, and the flush fades from the skin: the disease is cured.`);
} }
break; break;
case 'raise_dead': case 'raise_dead':
@@ -794,19 +833,19 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
...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) {
log('A fire storm rages across the field.', 'spell'); log('The sky turns orange, and a fire storm roars across the whole field.', 'spell');
for (const id of everyone()) { for (const id of everyone()) {
if (resists(id, 'heat')) continue; if (resists(id, 'heat')) continue;
if (countered.has(id)) continue; if (countered.has(id)) continue;
hurt(id, 5, 'the fire storm'); hurt(id, 5, 'the fire storm', undefined, 'The fire storm engulfs %');
} }
} }
if (iceStorm) { if (iceStorm) {
log('An ice storm howls across the field.', 'spell'); log('The air turns bitter, and an ice storm howls across the whole field.', 'spell');
for (const id of everyone()) { for (const id of everyone()) {
if (resists(id, 'cold')) continue; if (resists(id, 'cold')) continue;
if (countered.has(id) || exemptFromIceStorm.has(id)) continue; if (countered.has(id) || exemptFromIceStorm.has(id)) continue;
hurt(id, 5, 'the ice storm'); hurt(id, 5, 'the ice storm', undefined, 'Hail and sleet flay %');
} }
} }
@@ -815,7 +854,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (destroyedBeforeAttack.has(m.id)) continue; if (destroyedBeforeAttack.has(m.id)) continue;
if (frozen(m.owner)) continue; if (frozen(m.owner)) continue;
if (m.constraints.skipAttack && phase === 'main') { if (m.constraints.skipAttack && phase === 'main') {
log(`${m.name} stands paralysed.`); log(`${m.name} strains against its stiff limbs and cannot move.`);
continue; continue;
} }
if (isElemental(m.kind)) { if (isElemental(m.kind)) {
@@ -829,24 +868,24 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (isShielded(id)) continue; if (isShielded(id)) continue;
if (isWizard(id) && W[id].invisibleTurns > 0) continue; if (isWizard(id) && W[id].invisibleTurns > 0) continue;
} }
hurt(id, m.attack, `the ${m.name.toLowerCase()}`, m.owner); hurt(id, m.attack, `the ${m.name.toLowerCase()}`, m.owner, `${m.name} ${ATTACKS[m.kind]} %`);
} }
continue; continue;
} }
const target = orders.get(m.id); const target = orders.get(m.id);
m.lastTarget = target; m.lastTarget = target;
if (!isBeing(target)) { if (!isBeing(target)) {
log(`${m.name} has nothing to attack.`); log(`${m.name} prowls about, finding nothing to attack.`);
continue; continue;
} }
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 (isWizard(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} swings at the empty air where ${W[target].name} last stood.`, 'fizzle');
} else { } else {
if (m.createdTurn === turn && phase === 'main') facts.newMonsterAttacked = m.name; if (m.createdTurn === turn && phase === 'main') facts.newMonsterAttacked = m.name;
hurt(target, m.attack, `the ${m.name.toLowerCase()}`, m.owner); hurt(target, m.attack, `the ${m.name.toLowerCase()}`, m.owner, `${m.name} ${ATTACKS[m.kind]} %`);
} }
} }
@@ -858,12 +897,12 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (g.left !== '>' && g.right !== '>') return; if (g.left !== '>' && g.right !== '>') return;
const target = (half === 0 ? input(id).stabTarget : input(id).second?.stabTarget) ?? enemyOf(state, 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 the air, and the knife finds nothing.`, 'fizzle');
} else if (isShielded(target)) { } else if (isShielded(target)) {
facts.shieldBlocked.add(target); facts.shieldBlocked.add(target);
log(`${w.name}'s knife skids off ${guard(target)}.`, 'fizzle'); log(`${w.name}'s knife skids off ${guard(target)}.`, 'fizzle');
} else { } else {
hurt(target, 1, `${w.name}'s knife`, id); hurt(target, 1, `${w.name}'s knife`, id, `${w.name}'s knife bites into %`);
} }
}); });
} }
@@ -881,7 +920,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (w.hp <= 0) { if (w.hp <= 0) {
w.hp = 0; w.hp = 0;
w.alive = false; w.alive = false;
log(`${w.name} falls dead.`, 'death'); log(`${w.name} crumples and does not rise.`, 'death');
} }
} }
state.monsters = state.monsters.filter((m) => { state.monsters = state.monsters.filter((m) => {
@@ -889,11 +928,11 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (slain.has(m.id)) m.hp = 0; if (slain.has(m.id)) m.hp = 0;
if (destroyedBeforeAttack.has(m.id)) return false; if (destroyedBeforeAttack.has(m.id)) return false;
if (destroyedAfterAttack.has(m.id)) { if (destroyedAfterAttack.has(m.id)) {
log(`${m.name} is unmade.`, 'death'); log(`${m.name} comes apart like smoke and is gone.`, 'death');
return false; return false;
} }
if (m.hp <= 0) { if (m.hp <= 0) {
log(`${m.name} is destroyed.`, 'death'); log(`${m.name} falls and does not get up.`, 'death');
return false; return false;
} }
return true; return true;
@@ -917,8 +956,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
note(id, 'Disease', -w.hp); note(id, 'Disease', -w.hp);
w.alive = false; w.alive = false;
w.hp = 0; w.hp = 0;
log(`${w.name} succumbs to the disease.`, 'death'); log(`${w.name} sinks to the ground, burning and shaking, and is still. The disease has run its course.`, 'death');
} } else if (FEVER[w.diseaseTurns]) log(FEVER[w.diseaseTurns](w.name));
} }
if (w.alive && w.poisonTurns !== null) { if (w.alive && w.poisonTurns !== null) {
w.poisonTurns -= 1; w.poisonTurns -= 1;
@@ -926,8 +965,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
note(id, 'Poison', -w.hp); note(id, 'Poison', -w.hp);
w.alive = false; w.alive = false;
w.hp = 0; w.hp = 0;
log(`${w.name} succumbs to the poison.`, 'death'); log(`${w.name} folds to the ground, cold and numb, and breathes no more. The poison has done its work.`, 'death');
} } else if (VENOM[w.poisonTurns]) log(VENOM[w.poisonTurns](w.name));
} }
} }
for (const m of state.monsters) { for (const m of state.monsters) {
+1 -1
View File
@@ -148,7 +148,7 @@ export const SPELLS: Spell[] = [
spell('protection_from_evil', 'Protection from evil', 'enchantment', ['W-W-P'], 'self', 'Shields the subject this turn and for the next three.'), spell('protection_from_evil', 'Protection from evil', 'enchantment', ['W-W-P'], 'self', 'Shields the subject this turn and for the next three.'),
spell('resist_heat', 'Resist heat', 'enchantment', ['W-W-F-P'], 'self', 'The subject ignores fireballs, fire storms and fire elementals until dispelled. Destroys a fire elemental.'), spell('resist_heat', 'Resist heat', 'enchantment', ['W-W-F-P'], 'self', 'The subject ignores fireballs, fire storms and fire elementals until dispelled. Destroys a fire elemental.'),
spell('resist_cold', 'Resist cold', 'enchantment', ['S-S-F-P'], 'self', 'The subject ignores ice storms and ice elementals until dispelled. Destroys an ice elemental.'), spell('resist_cold', 'Resist cold', 'enchantment', ['S-S-F-P'], 'self', 'The subject ignores ice storms and ice elementals until dispelled. Destroys an ice elemental.'),
spell('disease', 'Disease', 'enchantment', ['D-S-F-F-F-C'], 'enemy', 'The subject dies at the end of the sixth turn unless cured by cure heavy wounds, remove enchantment or dispel magic.'), spell('disease', 'Disease', 'enchantment', ['D-S-F-F-F-C'], 'enemy', 'A fever takes the subject, who dies at the end of the fifth turn after this one unless cured by cure heavy wounds, remove enchantment or dispel magic.'),
spell('poison', 'Poison', 'enchantment', ['D-W-W-F-W-D'], 'enemy', 'As disease, but cure heavy wounds does not help.'), spell('poison', 'Poison', 'enchantment', ['D-W-W-F-W-D'], 'enemy', 'As disease, but cure heavy wounds does not help.'),
spell('blindness', 'Blindness', 'enchantment', ['D-W-F-F-(d'], 'enemy', 'For three turns the subject cannot see the opponent’s gestures. Destroys a monster.'), spell('blindness', 'Blindness', 'enchantment', ['D-W-F-F-(d'], 'enemy', 'For three turns the subject cannot see the opponent’s gestures. Destroys a monster.'),
spell('invisibility', 'Invisibility', 'enchantment', ['P-P-(w-(s'], 'self', 'For three turns the opponent cannot see the subject’s gestures and monsters cannot attack them. Destroys a monster.'), spell('invisibility', 'Invisibility', 'enchantment', ['P-P-(w-(s'], 'self', 'For three turns the opponent cannot see the subject’s gestures and monsters cannot attack them. Destroys a monster.'),
+7 -5
View File
@@ -7,7 +7,9 @@ import type { WizardState } from './state';
const more = (n: number) => (n === Infinity ? 'for good' : `${n} more`); const more = (n: number) => (n === Infinity ? 'for good' : `${n} more`);
/** Everything on a wizard, each with how long it lasts. */ /** Everything on a wizard, each with how long it lasts. */
export function effectsOf(w: WizardState): string[] { /** `turnsPlayed` names the turn an illness will kill on; without it the count of turns left is given. */
export function effectsOf(w: WizardState, turnsPlayed?: number): string[] {
const dies = (left: number) => (turnsPlayed === undefined ? `dies in ${left}` : `dies at the end of turn ${turnsPlayed + left} unless cured`);
const out: string[] = []; const out: string[] = [];
if (w.resistHeat) out.push('resists heat'); if (w.resistHeat) out.push('resists heat');
if (w.resistCold) out.push('resists cold'); if (w.resistCold) out.push('resists cold');
@@ -18,8 +20,8 @@ export function effectsOf(w: WizardState): string[] {
if (w.banked) out.push(`holding a banked ${SPELL_BY_ID[w.banked.spellId].name.toLowerCase()}`); if (w.banked) out.push(`holding a banked ${SPELL_BY_ID[w.banked.spellId].name.toLowerCase()}`);
else if (w.delayedTurns > 0) out.push(`next spell will be banked, ${more(w.delayedTurns)}`); else if (w.delayedTurns > 0) out.push(`next spell will be banked, ${more(w.delayedTurns)}`);
if (w.permanencyTurns > 0) out.push(`next enchantment will be permanent, ${more(w.permanencyTurns)}`); if (w.permanencyTurns > 0) out.push(`next enchantment will be permanent, ${more(w.permanencyTurns)}`);
if (w.diseaseTurns !== null) out.push(`diseased, dies in ${w.diseaseTurns}`); if (w.alive && w.diseaseTurns !== null) out.push(`fevered with disease, ${dies(w.diseaseTurns)}`);
if (w.poisonTurns !== null) out.push(`poisoned, dies in ${w.poisonTurns}`); if (w.alive && w.poisonTurns !== null) out.push(`poisoned, ${dies(w.poisonTurns)}`);
const c = w.constraints; const c = w.constraints;
if (c.amnesia) out.push(w.permanent.amnesia ? 'amnesia for good' : 'amnesia this turn'); if (c.amnesia) out.push(w.permanent.amnesia ? 'amnesia for good' : 'amnesia this turn');
if (c.confusion) out.push(w.permanent.confusion ? 'confused for good' : 'confused this turn'); if (c.confusion) out.push(w.permanent.confusion ? 'confused for good' : 'confused this turn');
@@ -41,8 +43,8 @@ export function statusWords(w: WizardState): string[] {
if (w.resistHeat) out.push('resists heat'); if (w.resistHeat) out.push('resists heat');
if (w.resistCold) out.push('resists cold'); if (w.resistCold) out.push('resists cold');
if (w.hasteTurns > 0) out.push('hastened'); if (w.hasteTurns > 0) out.push('hastened');
if (w.diseaseTurns !== null) out.push(`dies in ${w.diseaseTurns}`); if (w.diseaseTurns !== null) out.push('fevered');
if (w.poisonTurns !== null) out.push(`poisoned`); if (w.poisonTurns !== null) out.push('poisoned');
const c = w.constraints; const c = w.constraints;
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed`); if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed`);
if (c.charmed) out.push('charmed'); if (c.charmed) out.push('charmed');