The chronicle's enchantment lines no longer all run image, colon, consequence; each keeps its picture and its exact fact in its own sentence shape, and the about panel's tally heading and two of its closing flourishes are said plainly, since a page of one sentence pattern reads as written by a machine

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-29 10:30:41 -04:00
1 parent f21b2c3b16
commit fa5e9cfc08
3 files changed
+26 -26

No files matched your search

+3 -3
View File
@@ -46,7 +46,7 @@
...Object.entries(t.byGame).filter(([label]) => !told.has(label)).map(([label, n]) => ({ n, label })) ...Object.entries(t.byGame).filter(([label]) => !told.has(label)).map(([label, n]) => ({ n, label }))
]; ];
const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : ''; const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.` }; return { headline, details, note: `${first}The count is of names, so one player under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.` };
}); });
/** The other games of Kestrel's Hall, for the about panel. */ /** The other games of Kestrel's Hall, for the about panel. */
let family = $state<FamilyGame[]>([]); let family = $state<FamilyGame[]>([]);
@@ -342,7 +342,7 @@
{/snippet} {/snippet}
{#snippet aboutTally()} {#snippet aboutTally()}
{#if told} {#if told}
<TallyBoard title="How much love the duel is getting" headline={told.headline} details={told.details} note={told.note} /> <TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
{:else} {:else}
<p class="muted">Counting…</p> <p class="muted">Counting…</p>
{/if} {/if}
@@ -358,7 +358,7 @@
{/if} {/if}
<p class="hosting"> <p class="hosting">
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it. <a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.
</p> </p>
<p class="made">Waving Hands is Richard Bartle's, 1977.</p> <p class="made">Waving Hands is Richard Bartle's, 1977.</p>
</div> </div>
+1 -1
View File
@@ -10,7 +10,7 @@
// What the ledgers add up to: a few counts that are the game's own story, // What the ledgers add up to: a few counts that are the game's own story,
// large, on cards; then the rest in a column of figures; then the fine // large, on cards; then the rest in a column of figures; then the fine
// print. Each game chooses its counts and words. // print. Each game chooses its counts and words.
let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } = let { headline, details, note, title = 'What the ledgers add up to' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
$props(); $props();
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n); const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
+22 -22
View File
@@ -312,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} lowers both hands and shows empty palms: a surrender.`, 'death'); if (w.surrendered) log(`${w.name} lowers both hands and shows empty palms in surrender.`, 'death');
} }
// ---- 2. Which completed spells are actually loosed ---- // ---- 2. Which completed spells are actually loosed ----
@@ -336,7 +336,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
options.find((c) => choice.seqIndex === undefined || c.seqIndex === choice.seqIndex) ?? options[0]; options.find((c) => choice.seqIndex === undefined || c.seqIndex === choice.seqIndex) ?? options[0];
if (!completion) continue; if (!completion) continue;
if (completion.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) { if (completion.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) {
log(`${w.name}'s quick lightning bolt sputters: it has already been used this duel.`, 'fizzle'); log(`${w.name}'s quick lightning bolt sputters and dies, spent earlier in this duel.`, 'fizzle');
continue; continue;
} }
const cells = usedCells(completion, choice.hand, index + 1); const cells = usedCells(completion, choice.hand, index + 1);
@@ -404,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('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'); log('A wave of stillness rolls across the field as the magic is dispelled. 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]);
@@ -476,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} sets hard, like wax cooling: it will not fade.`, 'spell'); log(`${w.name}'s ${chosen.spell.name} sets hard, like wax cooling, and will not fade.`, 'spell');
} }
} }
} }
@@ -545,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('Fire storm meets ice storm: a great hiss of steam, and the two storms cancel. Nothing is harmed.', 'fizzle'); log('Fire storm meets ice storm in a great hiss of steam, and the two cancel. Nothing is harmed.', 'fizzle');
} }
if (fireStorm && elementals('ice_elemental').length > 0) { if (fireStorm && elementals('ice_elemental').length > 0) {
fireStorm = false; fireStorm = false;
@@ -621,14 +621,14 @@ 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' : ''}.`); 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' : ''}.`); log(`The field tilts and swims around ${wiz.name}. One hand will go astray next turn${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[]);
@@ -641,7 +641,7 @@ 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(`A warm, easy trust settles on ${wiz.name}: the ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`); log(`A warm, easy trust comes over ${wiz.name}, whose ${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(`The charm slides off ${target}, finding no mind to hold.`, 'fizzle'); log(`The charm slides off ${target}, finding no mind to hold.`, 'fizzle');
@@ -651,7 +651,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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} turns, blinks, and pads over to ${W[c.caster].name}'s side: it serves a new master.`); log(`${mon.name} turns, blinks, and pads over to ${W[c.caster].name}'s side to serve a new master.`);
} else { } else {
c.nullified = 'not a charmable monster'; c.nullified = 'not a charmable monster';
log(`The charm slides off ${target} and takes no hold.`, 'fizzle'); log(`The charm slides off ${target} and takes no hold.`, 'fizzle');
@@ -662,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(`A cold stiffness creeps into ${wiz.name}'s ${hand} hand: it will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`); log(`A cold stiffness creeps into ${wiz.name}'s ${hand} hand, which 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}'s limbs stiffen: it will stand paralysed next turn.`); log(`${mon.name}'s limbs stiffen, and it will stand paralysed next turn.`);
} else { } else {
c.nullified = 'elementals cannot be paralysed'; c.nullified = 'elementals cannot be paralysed';
} }
@@ -686,7 +686,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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(`A pale circle of light rises around ${target}: 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;
@@ -696,7 +696,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
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(`A warm rosy aura settles over ${target}, who now resists cold.`); log(`A warm rosy aura wraps ${target}, who now resists cold.`);
break; break;
case 'disease': case 'disease':
if (wiz) { if (wiz) {
@@ -707,13 +707,13 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
case 'poison': case 'poison':
if (wiz) { if (wiz) {
wiz.poisonTurns = 6; wiz.poisonTurns = 6;
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}.`); log(`${wiz.name} goes grey about the lips as poison takes 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(`A milky film clouds ${wiz.name}'s eyes: blind${c.permanent ? forever : ' for three turns'}.`); log(`A milky film clouds ${wiz.name}'s eyes and leaves them 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':
@@ -725,25 +725,25 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
case 'haste': case 'haste':
if (wiz) { if (wiz) {
wiz.hasteTurns = duration(4); wiz.hasteTurns = duration(4);
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.`); log(`The air about ${wiz.name} quickens; everyone else seems to move through honey. Hastened${c.permanent ? forever : ' for the next three turns'}, ${wiz.name} makes 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(`The moment catches on ${wiz.name} like a snagged thread: when this turn ends, time will stop, and ${wiz.name} alone will move.`); 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(`A hollow of waiting opens around ${wiz.name}'s hands: the next spell will be caught and held.`); if (!bankedNow.has(wiz.id)) log(`A hollow of waiting opens around ${wiz.name}'s hands, ready to catch and hold the next spell.`);
} 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(`Something steady and unhurried settles on ${wiz.name}: the next enchantment will be permanent.`); if (!extendedNow.has(wiz.id)) log(`Something steady and unhurried takes hold of ${wiz.name}, and 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;
} }
@@ -756,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(`Threads of old magic lift from ${wiz.name} and drift away: every enchantment is gone.`); 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} begins to fray at the edges: it will be unmade once it has attacked.`); log(`${mon.name} begins to fray at the edges, and will be unmade once it has attacked.`);
} }
} }
@@ -820,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 fever breaks, and the flush fades from the skin: the 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':