A crowded table: keyed threats, a compact roster, a ledger selector, host-only starts
Threats are keyed by seat as well as hand and spell, so two opponents building the same spell no longer crash the board (regression test). Past three wizards the rail lists the others on a line each with health, condition and whether they have moved; a row of names turns the ledger toward any wizard, as do pressing a warning or choosing a target; each seat keeps one colour across the ledger, the warnings and the rail. The result strip folds untouched wizards into one line. A full table no longer begins by itself: the host begins at every size and may send a bot away. Target lists carry health and condition, and an intent sentence says what the reveal will do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c24fccaa35
commit
5c79a65319
@@ -3,11 +3,13 @@
|
||||
import HandPicker from './HandPicker.svelte';
|
||||
import Ledger from './Ledger.svelte';
|
||||
import MobileBar from './MobileBar.svelte';
|
||||
import Roster from './Roster.svelte';
|
||||
import SpellSheet from './SpellSheet.svelte';
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import TurnSummary from './TurnSummary.svelte';
|
||||
import WizardStatus from './WizardStatus.svelte';
|
||||
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
|
||||
import { seatColor } from '$lib/game/colors';
|
||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||
import { MAGIC_GESTURES } from '$lib/game/spells';
|
||||
|
||||
@@ -16,6 +18,8 @@
|
||||
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
|
||||
const outcome = $derived(duel.state.over);
|
||||
const won = $derived(outcome?.winner === duel.me);
|
||||
/** Past three wizards the rail lists the others on a line each. */
|
||||
const crowded = $derived(duel.others.length >= 3);
|
||||
</script>
|
||||
|
||||
<main class="board">
|
||||
@@ -58,11 +62,11 @@
|
||||
{#if duel.threatsNow.length}
|
||||
<p>Their hands could finish this turn:</p>
|
||||
<ul>
|
||||
{#each duel.threatsNow as t (t.hand + t.spell.id)}
|
||||
{#each duel.threatsNow as t (duel.threatKey(t))}
|
||||
{@const active = duel.isHighlighted(t)}
|
||||
<li>
|
||||
<button type="button" class="threat soon" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
|
||||
<span class="thand">{duel.others.length > 1 ? `${duel.state.wizards[t.who].name}, ` : ''}{t.hand}</span>
|
||||
<span class="thand">{#if duel.others.length > 1}<span class="dot" style="background: {seatColor(duel.state, t.who)}"></span>{`${duel.state.wizards[t.who].name}, `}{/if}{t.hand}</span>
|
||||
<span class="tseq">{tokensText(t.done)} → <strong>{tokensText(t.remaining)}</strong></span>
|
||||
<span class="tname">{t.spell.name}</span>
|
||||
</button>
|
||||
@@ -74,11 +78,11 @@
|
||||
<details class="next" open={!duel.compact}>
|
||||
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
|
||||
<ul>
|
||||
{#each duel.threatsNext as t (t.hand + t.spell.id)}
|
||||
{#each duel.threatsNext as t (duel.threatKey(t))}
|
||||
{@const active = duel.isHighlighted(t)}
|
||||
<li>
|
||||
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
|
||||
<span class="thand">{duel.others.length > 1 ? `${duel.state.wizards[t.who].name}, ` : ''}{t.hand}</span>
|
||||
<span class="thand">{#if duel.others.length > 1}<span class="dot" style="background: {seatColor(duel.state, t.who)}"></span>{`${duel.state.wizards[t.who].name}, `}{/if}{t.hand}</span>
|
||||
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
|
||||
<span class="tname">{t.spell.name}</span>
|
||||
</button>
|
||||
@@ -118,9 +122,9 @@
|
||||
{#if duel.stabbing}
|
||||
<label class="field">
|
||||
<span>Stab</span>
|
||||
<select bind:value={duel.stabTarget}>
|
||||
<select bind:value={duel.stabTarget} onchange={() => duel.focusOn(duel.stabTarget)}>
|
||||
{#each duel.others as id (id)}
|
||||
<option value={id}>{duel.state.wizards[id].name}</option>
|
||||
<option value={id}>{crowded ? duel.targetLabel(id) : duel.state.wizards[id].name}</option>
|
||||
{/each}
|
||||
{#each foeMonsters as m (m.id)}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
@@ -131,9 +135,9 @@
|
||||
{#each duel.yourMonsters as m (m.id)}
|
||||
<label class="field">
|
||||
<span>{m.name} attacks</span>
|
||||
<select bind:value={duel.monsterOrders[m.id]}>
|
||||
<select bind:value={duel.monsterOrders[m.id]} onchange={() => duel.focusOn(duel.monsterOrders[m.id])}>
|
||||
{#each duel.others as id (id)}
|
||||
<option value={id}>{duel.state.wizards[id].name}</option>
|
||||
<option value={id}>{crowded ? duel.targetLabel(id) : duel.state.wizards[id].name}</option>
|
||||
{/each}
|
||||
{#each foeMonsters as fm (fm.id)}
|
||||
<option value={fm.id}>{fm.name}</option>
|
||||
@@ -187,6 +191,9 @@
|
||||
{#if duel.remote?.error}
|
||||
<p class="warning">{duel.remote.error}</p>
|
||||
{/if}
|
||||
{#if duel.intentText && !duel.moved}
|
||||
<p class="intent">{duel.intentText}</p>
|
||||
{/if}
|
||||
{#if duel.moved}
|
||||
<p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p>
|
||||
{/if}
|
||||
@@ -209,9 +216,13 @@
|
||||
{#if !duel.spectating}
|
||||
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
|
||||
{/if}
|
||||
{#each duel.others as id (id)}
|
||||
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
|
||||
{/each}
|
||||
{#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)} />
|
||||
{/each}
|
||||
{/if}
|
||||
<TableTalk {duel} />
|
||||
<SpellSheet {duel} />
|
||||
<Chronicle log={duel.state.log} />
|
||||
@@ -252,6 +263,21 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.intent {
|
||||
margin: 0.6rem 0 0.2rem;
|
||||
font-style: italic;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.move {
|
||||
padding: 0.6rem 0 1.5rem;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
{#if spell}
|
||||
<label class="field">
|
||||
<span>at</span>
|
||||
<select bind:value={draft.target}>
|
||||
<select bind:value={draft.target} onchange={() => duel.focusOn(draft.target)}>
|
||||
{#each duel.targetsFor(spell) as t (t.id)}
|
||||
<option value={t.id}>{t.label}</option>
|
||||
{/each}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { seatColor } from '$lib/game/colors';
|
||||
import { glyph } from '$lib/game/glyphs';
|
||||
import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
|
||||
import { hiddenFromViewer, type HistoryEntry, type WizardId } from '$lib/game/state';
|
||||
@@ -71,15 +72,41 @@
|
||||
void turns;
|
||||
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
||||
});
|
||||
|
||||
const crowded = $derived(duel.others.length >= 3);
|
||||
|
||||
// The focused wizard's columns slide in just past the pinned ones.
|
||||
$effect(() => {
|
||||
const who = duel.focus;
|
||||
void duel.focusTick;
|
||||
if (!who || !scroller) return;
|
||||
const th = scroller.querySelector<HTMLElement>(`th[data-who="${who}"]`);
|
||||
const pinned = scroller.querySelector<HTMLElement>('thead th.pin-right');
|
||||
if (!th) return;
|
||||
const left = pinned ? pinned.offsetLeft + pinned.offsetWidth : 0;
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
scroller.scrollTo({ left: th.offsetLeft - left, behavior: reduce ? 'auto' : 'smooth' });
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if crowded}
|
||||
<nav class="seats" aria-label="Wizards in the ledger">
|
||||
<span class="muted">Turn the ledger toward</span>
|
||||
{#each duel.others as who (who)}
|
||||
{@const w = duel.state.wizards[who]}
|
||||
<button type="button" class="seat" class:focused={duel.focus === who} class:gone={!w.alive || w.surrendered} onclick={() => duel.focusOn(who)}>
|
||||
<span class="dot" style="background: {seatColor(duel.state, who)}"></span>{w.name}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
<div class="scroller" bind:this={scroller}>
|
||||
<table class="ledger">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
|
||||
{#each columns as who (who)}
|
||||
<th class:you={who === duel.me} class:foe={who !== duel.me} class:gone={!duel.state.wizards[who].alive || duel.state.wizards[who].surrendered} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me} (you){/if}</th>
|
||||
<th data-who={who} class:you={who === duel.me} class:pin-left={who === duel.me} class:foe={who !== duel.me} class:focused={duel.focus === who} class:gone={!duel.state.wizards[who].alive || duel.state.wizards[who].surrendered} colspan="2" scope="colgroup">{#if crowded && who !== duel.me}<span class="dot" style="background: {seatColor(duel.state, who)}"></span>{/if}{duel.state.wizards[who].name}{#if who === duel.me} (you){/if}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
<tr class="hands">
|
||||
@@ -144,6 +171,50 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.seats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem 0.4rem;
|
||||
padding: 0.2rem 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.seat {
|
||||
background: none;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.6rem 0.1rem 0.45rem;
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.seat:hover {
|
||||
border-color: var(--bone-dim);
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.seat.focused {
|
||||
border-color: var(--frost);
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.seat.gone {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
thead th.focused {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.scroller {
|
||||
max-height: clamp(170px, 30vh, 400px);
|
||||
overflow: auto;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
// A crowded table's rail: every other wizard on one line, with the one
|
||||
// you are looking at opened out. Your own panel stays in full above.
|
||||
import WizardStatus from './WizardStatus.svelte';
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { seatColor } from '$lib/game/colors';
|
||||
import { MAX_HP, type WizardId } from '$lib/game/state';
|
||||
import { statusWords } from '$lib/game/status';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
const waitingOn = $derived(duel.remote?.waitingOn ?? []);
|
||||
|
||||
function toggle(id: WizardId) {
|
||||
if (duel.focus === id) duel.focus = null;
|
||||
else duel.focusOn(id);
|
||||
}
|
||||
|
||||
function stateOf(id: WizardId): string {
|
||||
if (!duel.remote?.started || duel.state.over) return '';
|
||||
const w = duel.state.wizards[id];
|
||||
if (!w.alive || w.surrendered) return 'out';
|
||||
const bot = duel.remote.seats.find((s) => s.id === id)?.bot;
|
||||
if (bot) return '';
|
||||
return waitingOn.includes(id) ? 'writing' : 'moved';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="roster" aria-label="The other wizards">
|
||||
<h2>At the table</h2>
|
||||
<ul>
|
||||
{#each duel.others as id (id)}
|
||||
{@const w = duel.state.wizards[id]}
|
||||
{@const open = duel.focus === id}
|
||||
{@const words = statusWords(w)}
|
||||
<li class:open class:gone={!w.alive || w.surrendered}>
|
||||
<button type="button" class="row" aria-expanded={open} onclick={() => toggle(id)}>
|
||||
<span class="dot" style="background: {seatColor(duel.state, id)}"></span>
|
||||
<span class="name">{w.name}</span>
|
||||
<span class="hp">{w.hp}/{MAX_HP}</span>
|
||||
<span class="words">{words.join(', ')}</span>
|
||||
<span class="turn muted">{stateOf(id)}</span>
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="detail">
|
||||
<WizardStatus wizard={w} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.roster {
|
||||
padding: 0.75rem 0 0.4rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
li.gone .name {
|
||||
text-decoration: line-through;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto minmax(0, 1.4fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.2rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone);
|
||||
text-align: left;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.row:hover,
|
||||
li.open .row {
|
||||
background: var(--slate);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hp {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.words {
|
||||
color: var(--frost);
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.turn {
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 0 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.detail :global(.wizard) {
|
||||
border-bottom: 0;
|
||||
padding-top: 0.2rem;
|
||||
}
|
||||
|
||||
.detail :global(h2) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { listText, type Duel } from '$lib/game/duel.svelte';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
const summary = $derived(duel.summary);
|
||||
const touched = $derived(summary ? duel.state.seats.filter((who) => summary.wizards[who].effects.length > 0) : []);
|
||||
const untouched = $derived(summary ? duel.state.seats.filter((who) => summary.wizards[who].effects.length === 0) : []);
|
||||
const nameOf = (who: string) => (who === duel.me ? 'You' : duel.state.wizards[who].name);
|
||||
|
||||
function signed(n: number): string {
|
||||
return n > 0 ? `+${n}` : String(n);
|
||||
@@ -14,22 +17,24 @@
|
||||
<section class="result" id="turn-result" aria-label="What the last reveal did">
|
||||
<h2>{summary.phase === 'timestop' ? 'In the stopped moment' : `Turn ${summary.turn + 1}`}</h2>
|
||||
<dl>
|
||||
{#each duel.state.seats as who (who)}
|
||||
{#each touched as who (who)}
|
||||
{@const w = summary.wizards[who]}
|
||||
<div>
|
||||
<dt>{who === duel.me ? 'You' : duel.state.wizards[who].name}</dt>
|
||||
<dt>{nameOf(who)}</dt>
|
||||
<dd>
|
||||
{#if w.effects.length === 0}
|
||||
<span class="muted">untouched</span>
|
||||
{:else}
|
||||
{#each w.effects as e, i (i)}
|
||||
<span class="effect" class:hurt={e.delta < 0} class:healed={e.delta > 0}>{e.label} {signed(e.delta)}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
{#each w.effects as e, i (i)}
|
||||
<span class="effect" class:hurt={e.delta < 0} class:healed={e.delta > 0}>{e.label} {signed(e.delta)}</span>
|
||||
{/each}
|
||||
<span class="hp" class:changed={w.before !== w.after}>health {w.before}{#if w.before !== w.after} → {w.after}{/if}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
{#if untouched.length}
|
||||
<div>
|
||||
<dt class="muted">{touched.length === 0 ? 'Nobody' : untouched.length > 2 ? `${untouched.length} others` : listText(untouched.map(nameOf))}</dt>
|
||||
<dd><span class="muted">{touched.length === 0 ? 'No health changes.' : 'untouched'}</span></dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
{#if summary.events.length}
|
||||
<ul class="events">
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { SPELL_BY_ID } from '$lib/game/spells';
|
||||
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();
|
||||
|
||||
const more = (n: number) => (n === Infinity ? 'for good' : `${n} more`);
|
||||
|
||||
const effects = $derived.by(() => {
|
||||
const out: string[] = [];
|
||||
if (wizard.resistHeat) out.push('resists heat');
|
||||
if (wizard.resistCold) out.push('resists cold');
|
||||
if (wizard.protectionTurns > 0) out.push(`protected from evil, ${more(wizard.protectionTurns)}`);
|
||||
if (wizard.blindTurns > 0) out.push(`blind, ${more(wizard.blindTurns)}`);
|
||||
if (wizard.invisibleTurns > 0) out.push(`invisible, ${more(wizard.invisibleTurns)}`);
|
||||
if (wizard.hasteTurns > 0) out.push(`hastened, ${more(wizard.hasteTurns)}`);
|
||||
if (wizard.banked) out.push(`holding a banked ${SPELL_BY_ID[wizard.banked.spellId].name.toLowerCase()}`);
|
||||
else if (wizard.delayedTurns > 0) out.push(`next spell will be banked, ${more(wizard.delayedTurns)}`);
|
||||
if (wizard.permanencyTurns > 0) out.push(`next enchantment will be permanent, ${more(wizard.permanencyTurns)}`);
|
||||
if (wizard.diseaseTurns !== null) out.push(`diseased, dies in ${wizard.diseaseTurns}`);
|
||||
if (wizard.poisonTurns !== null) out.push(`poisoned, dies in ${wizard.poisonTurns}`);
|
||||
const c = wizard.constraints;
|
||||
if (c.amnesia) out.push(wizard.permanent.amnesia ? 'amnesia for good' : 'amnesia this turn');
|
||||
if (c.confusion) out.push(wizard.permanent.confusion ? 'confused for good' : 'confused this turn');
|
||||
if (c.fear) out.push(wizard.permanent.fear ? 'afraid for good' : 'afraid this turn');
|
||||
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed${wizard.permanent.paralysedHand ? ' for good' : ''}`);
|
||||
if (c.charmed) out.push(`${c.charmed.hand} hand charmed${wizard.permanent.charmed ? ' for good' : ''}`);
|
||||
if (wizard.usedQuickLightning) out.push('quick lightning spent');
|
||||
return out;
|
||||
});
|
||||
const effects = $derived(effectsOf(wizard));
|
||||
</script>
|
||||
|
||||
<section class="wizard" class:you class:dead={!wizard.alive}>
|
||||
|
||||
@@ -87,6 +87,7 @@ export const api = {
|
||||
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
|
||||
addBot: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/bot`, { token }),
|
||||
begin: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/begin`, { token }),
|
||||
unseat: (roomId: string, token: string, seat: WizardId) => call<RoomView>('POST', `/api/rooms/${roomId}/unseat`, { token, seat }),
|
||||
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
||||
/** The gallery's view: no token, no seat, nothing any wizard could not see. */
|
||||
watch: (roomId: string) => call<RoomView>('GET', `/api/rooms/${roomId}`),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// One colour per seat, the same in the roster, the ledger and the threats,
|
||||
// so a name met in one place is known in the next. Twelve hues spaced round
|
||||
// the wheel at the palette's own saturation, so none shouts.
|
||||
|
||||
import type { GameState, WizardId } from './state';
|
||||
|
||||
const HUES = [28, 200, 96, 330, 52, 260, 160, 8, 220, 120, 300, 70];
|
||||
|
||||
export function seatColor(state: GameState, id: WizardId): string {
|
||||
const i = Math.max(0, state.seats.indexOf(id));
|
||||
return `hsl(${HUES[i % HUES.length]} 55% 62%)`;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from './gestures';
|
||||
import { glyph } from './glyphs';
|
||||
import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
|
||||
import { statusWords } from './status';
|
||||
import { likelyTarget } from './targets';
|
||||
import {
|
||||
GESTURES,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
createGame,
|
||||
enemyOf,
|
||||
inDuel,
|
||||
isWizardId,
|
||||
visibleHistory,
|
||||
type CastChoice,
|
||||
type GameState,
|
||||
@@ -249,6 +251,10 @@ export class Duel {
|
||||
sheetOpen = $state(false);
|
||||
/** Opponent gestures to light up in the ledger. */
|
||||
highlight = $state<Highlight | null>(null);
|
||||
/** The wizard the ledger and the roster are turned toward, at a crowded table. */
|
||||
focus = $state<WizardId | null>(null);
|
||||
/** Bumped each time focus is asked for, so asking for the same wizard twice scrolls twice. */
|
||||
focusTick = $state(0);
|
||||
/** A turn is on its way to the server. */
|
||||
sending = $state(false);
|
||||
|
||||
@@ -493,6 +499,21 @@ export class Duel {
|
||||
const h = this.highlight;
|
||||
const same = h && h.who === t.who && h.hand === t.hand && h.from === t.from && h.to === t.to;
|
||||
this.highlight = same ? null : { who: t.who, hand: t.hand, from: t.from, to: t.to };
|
||||
if (!same) this.focusOn(t.who);
|
||||
}
|
||||
|
||||
/** One key per threat: the same spell on the same hand can be under way at several seats. */
|
||||
threatKey(t: Threat): string {
|
||||
return `${t.who}:${t.hand}:${t.spell.id}`;
|
||||
}
|
||||
|
||||
/** Turn the ledger and the roster toward a wizard; a creature's owner stands in for the creature. */
|
||||
focusOn(target: TargetId | ''): void {
|
||||
if (!target) return;
|
||||
const who = isWizardId(this.state, target) ? target : this.state.monsters.find((m) => m.id === target)?.owner;
|
||||
if (!who || who === this.me) return;
|
||||
this.focus = who;
|
||||
this.focusTick += 1;
|
||||
}
|
||||
|
||||
/** Whether a threat is the one lit up in the ledger. */
|
||||
@@ -587,6 +608,24 @@ export class Duel {
|
||||
}
|
||||
|
||||
surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P'));
|
||||
/** What the reveal will do, in a sentence: "Shield yourself; Missile at Lucan; stab Wynne." */
|
||||
intentText = $derived.by(() => {
|
||||
const name = (id: TargetId | '') => {
|
||||
if (!id || id === this.me) return 'yourself';
|
||||
if (id === NOWHERE) return 'nowhere';
|
||||
if (isWizardId(this.state, id)) return this.state.wizards[id].name;
|
||||
return this.state.monsters.find((m) => m.id === id)?.name ?? id;
|
||||
};
|
||||
const parts: string[] = [];
|
||||
for (const p of this.planned) {
|
||||
const at = p.draft.target || this.defaultTarget(p.completion.spell);
|
||||
parts.push(at === this.me || !at ? `${p.completion.spell.name} yourself` : `${p.completion.spell.name} at ${name(at)}`);
|
||||
}
|
||||
if (this.stabbing) parts.push(`stab ${name(this.stabTarget)}`);
|
||||
if (!parts.length) return '';
|
||||
const text = parts.join('; ');
|
||||
return text.charAt(0).toUpperCase() + text.slice(1) + '.';
|
||||
});
|
||||
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
|
||||
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === this.me));
|
||||
ready = $derived(
|
||||
@@ -627,10 +666,18 @@ export class Duel {
|
||||
return spell ? likelyTarget(this.state, this.me, spell) : '';
|
||||
}
|
||||
|
||||
/** A wizard's name with what a caster needs to know: health and the conditions that matter. */
|
||||
targetLabel(id: WizardId): string {
|
||||
const w = this.state.wizards[id];
|
||||
const words = statusWords(w);
|
||||
return [w.name, `${w.hp} HP`, ...words].join(' · ');
|
||||
}
|
||||
|
||||
/** Every possible subject, the likeliest first, creatures marked by whose they are. */
|
||||
targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
|
||||
const list: { id: TargetId; label: string }[] = [{ id: this.me, label: 'yourself' }];
|
||||
for (const id of this.others) list.push({ id, label: this.state.wizards[id].name });
|
||||
const crowded = this.others.length > 1;
|
||||
for (const id of this.others) list.push({ id, label: crowded ? this.targetLabel(id) : this.state.wizards[id].name });
|
||||
for (const m of this.state.monsters) {
|
||||
list.push({ id: m.id, label: `${m.name} (${m.owner === this.me ? 'yours' : `${this.state.wizards[m.owner].name}'s`})` });
|
||||
}
|
||||
@@ -798,6 +845,17 @@ export class Duel {
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a bot away before the duel begins; only the host may. */
|
||||
async unseat(id: WizardId): Promise<void> {
|
||||
const r = this.remote;
|
||||
if (!r) return;
|
||||
try {
|
||||
this.apply(await api.unseat(r.roomId, r.token, id));
|
||||
} catch (e) {
|
||||
r.error = e instanceof Error ? e.message : 'The bot could not be sent away.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Seat the bot in an empty chair. */
|
||||
async addBot(): Promise<void> {
|
||||
const r = this.remote;
|
||||
@@ -863,11 +921,12 @@ export class Duel {
|
||||
return remoteFrom(seated.view, seated.token);
|
||||
}
|
||||
|
||||
/** A room with the bot already seated: a duel the server keeps, so it can be replayed and seen. */
|
||||
/** A room with the bot seated and the duel begun: a duel the server keeps, so it can be replayed and seen. */
|
||||
static async createBotRoom(name: string): Promise<Remote> {
|
||||
const seated = await api.create(name, 2);
|
||||
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
||||
return remoteFrom(await api.addBot(seated.view.roomId, seated.token), seated.token);
|
||||
await api.addBot(seated.view.roomId, seated.token);
|
||||
return remoteFrom(await api.begin(seated.view.roomId, seated.token), seated.token);
|
||||
}
|
||||
|
||||
static async joinRoom(roomId: string, name: string): Promise<Remote> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Duel } from './duel.svelte';
|
||||
import { resolveTurn } from './resolve';
|
||||
import { createGame, type GameState } from './state';
|
||||
import type { Gesture } from './spells';
|
||||
import { play } from './test-helpers';
|
||||
import { noop, play } from './test-helpers';
|
||||
|
||||
/** A is one gesture from a lightning bolt and has just been struck by amnesia. */
|
||||
function amnesiaBeforeLightning(): GameState {
|
||||
@@ -27,3 +28,24 @@ describe('the duel store', () => {
|
||||
expect(duel.foe.hp).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threats at a crowded table', () => {
|
||||
it('keys each opponent apart when two build the same spell with the same hand', () => {
|
||||
let s = createGame({ A: 'You', B: 'Bryn', C: 'Cass' }, 1);
|
||||
s = resolveTurn(s, { A: noop(), B: { ...noop(), left: 'S' }, C: { ...noop(), left: 'S' } });
|
||||
const duel = new Duel(s);
|
||||
const missiles = duel.threatsNow.filter((t) => t.spell.id === 'missile');
|
||||
expect(missiles.map((t) => t.who).sort()).toEqual(['B', 'C']);
|
||||
const keys = duel.threats.map((t) => duel.threatKey(t));
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('labels targets with health and condition', () => {
|
||||
let s = createGame({ A: 'You', B: 'Bryn', C: 'Cass' }, 1);
|
||||
s = resolveTurn(s, { A: noop(), B: noop(), C: noop() });
|
||||
s.wizards.C.hp = 8;
|
||||
s.wizards.C.invisibleTurns = 2;
|
||||
const duel = new Duel(s);
|
||||
expect(duel.targetLabel('C')).toBe('Cass · 8 HP · invisible');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// A wizard's condition in words: the long form for their panel, the short
|
||||
// form for a crowded roster and a target list.
|
||||
|
||||
import { SPELL_BY_ID } from './spells';
|
||||
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[] {
|
||||
const out: string[] = [];
|
||||
if (w.resistHeat) out.push('resists heat');
|
||||
if (w.resistCold) out.push('resists cold');
|
||||
if (w.protectionTurns > 0) out.push(`protected from evil, ${more(w.protectionTurns)}`);
|
||||
if (w.blindTurns > 0) out.push(`blind, ${more(w.blindTurns)}`);
|
||||
if (w.invisibleTurns > 0) out.push(`invisible, ${more(w.invisibleTurns)}`);
|
||||
if (w.hasteTurns > 0) out.push(`hastened, ${more(w.hasteTurns)}`);
|
||||
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}`);
|
||||
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');
|
||||
if (c.fear) out.push(w.permanent.fear ? 'afraid for good' : 'afraid this turn');
|
||||
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed${w.permanent.paralysedHand ? ' for good' : ''}`);
|
||||
if (c.charmed) out.push(`${c.charmed.hand} hand charmed${w.permanent.charmed ? ' for good' : ''}`);
|
||||
if (w.usedQuickLightning) out.push('quick lightning spent');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The few words that matter when choosing a target or scanning a roster. */
|
||||
export function statusWords(w: WizardState): string[] {
|
||||
if (!w.alive) return ['dead'];
|
||||
if (w.surrendered) return ['yielded'];
|
||||
const out: string[] = [];
|
||||
if (w.invisibleTurns > 0) out.push('invisible');
|
||||
if (w.blindTurns > 0) out.push('blind');
|
||||
if (w.protectionTurns > 0) out.push('protected');
|
||||
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`);
|
||||
const c = w.constraints;
|
||||
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed`);
|
||||
if (c.charmed) out.push('charmed');
|
||||
if (c.amnesia) out.push('amnesia');
|
||||
if (c.confusion) out.push('confused');
|
||||
if (c.fear) out.push('afraid');
|
||||
if (w.banked) out.push('holding a spell');
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user