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:
co-authored by
Claude Opus 5.5
parent
38e5f8b28d
commit
e1833e4e3c
@@ -214,13 +214,13 @@
|
||||
|
||||
<aside class="rail">
|
||||
{#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 crowded}
|
||||
<Roster {duel} />
|
||||
{:else}
|
||||
{#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}
|
||||
{/if}
|
||||
<TableTalk {duel} />
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</button>
|
||||
{#if open}
|
||||
<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>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { effectsOf } from '$lib/game/status';
|
||||
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>
|
||||
|
||||
<section class="wizard" class:you class:dead={!wizard.alive}>
|
||||
|
||||
@@ -484,3 +484,20 @@ describe('at a table of several wizards', () => {
|
||||
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
@@ -140,6 +140,42 @@ function tick(n: number): number {
|
||||
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 {
|
||||
const state = structuredClone(previous);
|
||||
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) => {
|
||||
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. */
|
||||
const facts = {
|
||||
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 ----
|
||||
const shape = (w: WizardState, c: Constraints, left: Gesture, right: Gesture, prev: HistoryEntry | undefined): [Gesture, Gesture] => {
|
||||
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];
|
||||
}
|
||||
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 (c.charmed.hand === 'left') left = 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) {
|
||||
const g = paralysedForm(w.history[w.history.length - 1][c.paralysedHand]);
|
||||
if (c.paralysedHand === 'left') left = 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 (FEAR_BANNED.includes(left)) left = '-';
|
||||
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) {
|
||||
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 (slip.hand === 'left') left = 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 = '-';
|
||||
return [left, right];
|
||||
@@ -276,7 +312,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
record(w, l2, r2, 'haste');
|
||||
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 ----
|
||||
@@ -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.banked = null;
|
||||
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);
|
||||
}
|
||||
@@ -349,10 +385,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
for (const c of casts) {
|
||||
if (!BLANKET_SPELLS.includes(c.spell.id) && c.target === NOWHERE) {
|
||||
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)) {
|
||||
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);
|
||||
@@ -368,7 +404,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const countered = new Set<TargetId>();
|
||||
const dispels = live().filter((c) => c.spell.id === 'dispel_magic');
|
||||
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 d of dispels) shielded.set(d.target, 'dispel magic');
|
||||
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()) {
|
||||
if (c.spell.id === 'magic_mirror' && countered.has(c.target)) {
|
||||
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));
|
||||
@@ -395,7 +431,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const original = c.caster;
|
||||
c.target = original;
|
||||
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()) {
|
||||
if (UNCOUNTERABLE.includes(c.spell.id)) continue;
|
||||
@@ -430,7 +466,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
chosen.nullified = 'banked';
|
||||
w.banked = { spellId: chosen.spell.id, elemental: chosen.choice.elemental, chosenHand: chosen.choice.chosenHand };
|
||||
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);
|
||||
@@ -440,7 +476,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
if (chosen) {
|
||||
chosen.permanent = true;
|
||||
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);
|
||||
if (targetMonster && isElemental(targetMonster.kind)) {
|
||||
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;
|
||||
}
|
||||
const controller = controllerOf(state, c.target);
|
||||
@@ -497,7 +533,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
state.monsters.push(m);
|
||||
orders.set(m.id, c.choice.monsterTarget ?? enemyOf(state, controller));
|
||||
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');
|
||||
@@ -509,7 +545,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
};
|
||||
if (fireStorm && iceStorm) {
|
||||
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) {
|
||||
fireStorm = false;
|
||||
@@ -557,7 +593,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
for (const extra of same.slice(1)) {
|
||||
destroyedBeforeAttack.add(extra.id);
|
||||
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) {
|
||||
if (ids.size < 2) continue;
|
||||
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()) {
|
||||
const wiz = isWizard(c.target) ? W[c.target] : undefined;
|
||||
@@ -585,17 +621,19 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
if (wiz) {
|
||||
wiz.pending.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);
|
||||
break;
|
||||
case 'confusion':
|
||||
if (wiz) {
|
||||
wiz.pending.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) {
|
||||
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[]);
|
||||
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;
|
||||
case 'charm_person':
|
||||
@@ -603,20 +641,20 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const hand = c.choice.chosenHand ?? pick(HANDS);
|
||||
wiz.pending.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 {
|
||||
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;
|
||||
case 'charm_monster':
|
||||
if (mon && !isElemental(mon.kind)) {
|
||||
mon.owner = 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 {
|
||||
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;
|
||||
case 'paralysis':
|
||||
@@ -624,10 +662,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left';
|
||||
wiz.pending.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)) {
|
||||
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 {
|
||||
c.nullified = 'elementals cannot be paralysed';
|
||||
}
|
||||
@@ -636,76 +674,76 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
if (wiz) {
|
||||
wiz.pending.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';
|
||||
break;
|
||||
case 'anti_spell':
|
||||
if (wiz) {
|
||||
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;
|
||||
case 'protection_from_evil':
|
||||
if (wiz) wiz.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;
|
||||
case 'resist_heat':
|
||||
if (wiz) wiz.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;
|
||||
case 'resist_cold':
|
||||
if (wiz) wiz.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;
|
||||
case 'disease':
|
||||
if (wiz) {
|
||||
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';
|
||||
break;
|
||||
case 'poison':
|
||||
if (wiz) {
|
||||
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';
|
||||
break;
|
||||
case 'blindness':
|
||||
if (wiz) {
|
||||
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');
|
||||
break;
|
||||
case 'invisibility':
|
||||
if (wiz) {
|
||||
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');
|
||||
break;
|
||||
case 'haste':
|
||||
if (wiz) {
|
||||
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';
|
||||
break;
|
||||
case 'time_stop':
|
||||
if (wiz) {
|
||||
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';
|
||||
break;
|
||||
case 'delayed_effect':
|
||||
if (wiz) {
|
||||
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';
|
||||
break;
|
||||
case 'permanency':
|
||||
if (wiz) {
|
||||
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';
|
||||
break;
|
||||
}
|
||||
@@ -718,10 +756,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const mon = monsterOf(c.target);
|
||||
if (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') {
|
||||
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 healing = new Map<TargetId, number>();
|
||||
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);
|
||||
if (isWizard(id) && by && by !== id) W[id].lastHurtBy = by;
|
||||
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) => {
|
||||
healing.set(id, (healing.get(id) ?? 0) + n);
|
||||
@@ -746,32 +785,32 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
if (isShielded(t)) {
|
||||
facts.shieldBlocked.add(t);
|
||||
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;
|
||||
case 'finger_of_death':
|
||||
slain.add(t);
|
||||
if (isWizard(t)) W[t].lastHurtBy = c.caster;
|
||||
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;
|
||||
case 'lightning_bolt':
|
||||
case 'lightning_bolt_quick':
|
||||
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;
|
||||
case '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;
|
||||
case '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;
|
||||
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 {
|
||||
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;
|
||||
case 'cure_light_wounds':
|
||||
@@ -781,7 +820,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
heal(t, 2, 'Cure heavy wounds');
|
||||
if (isWizard(t) && 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;
|
||||
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)
|
||||
];
|
||||
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()) {
|
||||
if (resists(id, 'heat')) 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) {
|
||||
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()) {
|
||||
if (resists(id, 'cold')) 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 (frozen(m.owner)) continue;
|
||||
if (m.constraints.skipAttack && phase === 'main') {
|
||||
log(`${m.name} stands paralysed.`);
|
||||
log(`${m.name} strains against its stiff limbs and cannot move.`);
|
||||
continue;
|
||||
}
|
||||
if (isElemental(m.kind)) {
|
||||
@@ -829,24 +868,24 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
if (isShielded(id)) 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;
|
||||
}
|
||||
const target = orders.get(m.id);
|
||||
m.lastTarget = target;
|
||||
if (!isBeing(target)) {
|
||||
log(`${m.name} has nothing to attack.`);
|
||||
log(`${m.name} prowls about, finding nothing to attack.`);
|
||||
continue;
|
||||
}
|
||||
if (isShielded(target)) {
|
||||
facts.shieldBlocked.add(target);
|
||||
log(`${m.name}'s blows glance off ${guard(target)}.`, 'fizzle');
|
||||
} 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 {
|
||||
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;
|
||||
const target = (half === 0 ? input(id).stabTarget : input(id).second?.stabTarget) ?? enemyOf(state, id);
|
||||
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)) {
|
||||
facts.shieldBlocked.add(target);
|
||||
log(`${w.name}'s knife skids off ${guard(target)}.`, 'fizzle');
|
||||
} 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) {
|
||||
w.hp = 0;
|
||||
w.alive = false;
|
||||
log(`${w.name} falls dead.`, 'death');
|
||||
log(`${w.name} crumples and does not rise.`, 'death');
|
||||
}
|
||||
}
|
||||
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 (destroyedBeforeAttack.has(m.id)) return false;
|
||||
if (destroyedAfterAttack.has(m.id)) {
|
||||
log(`${m.name} is unmade.`, 'death');
|
||||
log(`${m.name} comes apart like smoke and is gone.`, 'death');
|
||||
return false;
|
||||
}
|
||||
if (m.hp <= 0) {
|
||||
log(`${m.name} is destroyed.`, 'death');
|
||||
log(`${m.name} falls and does not get up.`, 'death');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -917,8 +956,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
note(id, 'Disease', -w.hp);
|
||||
w.alive = false;
|
||||
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) {
|
||||
w.poisonTurns -= 1;
|
||||
@@ -926,8 +965,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
note(id, 'Poison', -w.hp);
|
||||
w.alive = false;
|
||||
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) {
|
||||
|
||||
@@ -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('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('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('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.'),
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { WizardState } from './state';
|
||||
const more = (n: number) => (n === Infinity ? 'for good' : `${n} more`);
|
||||
|
||||
/** 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[] = [];
|
||||
if (w.resistHeat) out.push('resists heat');
|
||||
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()}`);
|
||||
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.diseaseTurns !== null) out.push(`diseased, dies in ${w.diseaseTurns}`);
|
||||
if (w.poisonTurns !== null) out.push(`poisoned, dies in ${w.poisonTurns}`);
|
||||
if (w.alive && w.diseaseTurns !== null) out.push(`fevered with disease, ${dies(w.diseaseTurns)}`);
|
||||
if (w.alive && w.poisonTurns !== null) out.push(`poisoned, ${dies(w.poisonTurns)}`);
|
||||
const c = w.constraints;
|
||||
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');
|
||||
@@ -41,8 +43,8 @@ export function statusWords(w: WizardState): string[] {
|
||||
if (w.resistHeat) out.push('resists heat');
|
||||
if (w.resistCold) out.push('resists cold');
|
||||
if (w.hasteTurns > 0) out.push('hastened');
|
||||
if (w.diseaseTurns !== null) out.push(`dies in ${w.diseaseTurns}`);
|
||||
if (w.poisonTurns !== null) out.push(`poisoned`);
|
||||
if (w.diseaseTurns !== null) out.push('fevered');
|
||||
if (w.poisonTurns !== null) out.push('poisoned');
|
||||
const c = w.constraints;
|
||||
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed`);
|
||||
if (c.charmed) out.push('charmed');
|
||||
|
||||
Reference in New Issue
Block a user