Credibility pass: one conflict check, one hostile list, one set of chrome
Duplicated logic and styling that had drifted apart is unified: the gesture-sharing check, the hostile spell list, the enchanted test, the plan limits, and the shared button, link, field and disabled styles. Dead plumbing is gone: the always-true implemented flag, the unread cast length, an unreachable guard, an impossible time-stop condition, the unused sequence formatter and utility class, the template placeholder. The counter-spell tests now put a counter-spell in front of a spell, the test helpers live in one file, and the resolver's sections are numbered in order. The deploy script's cache headers now do what its comment says, and the README describes the screen rather than listing features in the order they arrived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
527820ee95
commit
5de352db43
@@ -1,10 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
# Output
|
# Output
|
||||||
.output
|
|
||||||
.vercel
|
|
||||||
.netlify
|
|
||||||
.wrangler
|
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,19 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
You duel a heuristic bot. Choose a gesture per hand, pick which completed spell (if any) each
|
You duel a heuristic bot. Each turn, choose a gesture per hand, pick which completed spell
|
||||||
hand casts and at whom, and reveal. Beneath each hand is a list of the spells that hand is
|
(if any) each hand casts and at whom, and reveal. The rules are at `/rules`.
|
||||||
part-way through; selecting one makes its next gesture, and the diamond pins it. Every entry
|
|
||||||
in the spell sheet offers "Plan with left hand" and "Plan with right hand", showing what that
|
Around the two hands:
|
||||||
hand still has to make, so a duel can start from a chosen spell rather than a guessed gesture.
|
|
||||||
When a rule decides something surprising (a shield that stopped a goblin but not a wound
|
- the ledger of every gesture both wizards have made, with completed spells named;
|
||||||
spell, a countered spell, a one-handed clap, a reused run of gestures) the result strip
|
- beneath each hand, the spells it is part-way through, one click from the next gesture, with
|
||||||
explains it once per duel; the checkbox under the hands turns this off. Warnings
|
a pin to keep one in view; every spell-sheet entry can also be planned with either hand;
|
||||||
above the hands cite the opponent's gestures that could finish a hostile spell, and selecting
|
- warnings citing the opponent's gestures that could finish a hostile spell, which light those
|
||||||
a warning lights those gestures up in the ledger. After each reveal a short strip shows what
|
gestures up in the ledger when selected;
|
||||||
changed each wizard's health and everything else worth knowing, with the full chronicle in
|
- after each reveal, a strip of what changed each wizard's health and everything else worth
|
||||||
the sidebar. The rules are at `/rules`.
|
knowing, and a one-time explanation when a rule decided something surprising;
|
||||||
|
- the full chronicle and the spell sheet in the sidebar.
|
||||||
|
|
||||||
The duel is saved to the browser's local storage after every change, so it survives reading
|
The duel is saved to the browser's local storage after every change, so it survives reading
|
||||||
the rules, a refresh, or a phone putting the tab to sleep. Starting a new duel asks first if
|
the rules, a refresh, or a phone putting the tab to sleep. Starting a new duel asks first if
|
||||||
@@ -38,10 +39,12 @@ one is under way. Nothing is rendered on the server.
|
|||||||
|
|
||||||
- `src/lib/game/spells.ts` the spell book and monster stats
|
- `src/lib/game/spells.ts` the spell book and monster stats
|
||||||
- `src/lib/game/gestures.ts` sequence matching, threat detection, gesture-sharing conflicts
|
- `src/lib/game/gestures.ts` sequence matching, threat detection, gesture-sharing conflicts
|
||||||
|
- `src/lib/game/state.ts` the duel's state and the seeded random source
|
||||||
- `src/lib/game/resolve.ts` simultaneous turn resolution
|
- `src/lib/game/resolve.ts` simultaneous turn resolution
|
||||||
- `src/lib/game/bot.ts` the opponent
|
- `src/lib/game/bot.ts` the opponent
|
||||||
- `src/lib/game/duel.svelte.ts` reactive store binding the engine to the UI
|
- `src/lib/game/duel.svelte.ts` reactive store binding the engine to the UI, saved to local storage
|
||||||
- `src/lib/components/` ledger, hand pickers, status, chronicle, spell sheet
|
- `src/lib/components/` the ledger, hand pickers, status panels, result strip, chronicle, spell sheet, and the sticky bar for phones
|
||||||
|
- `src/routes/rules/` the rules page, rendered from the spell book and the transcription in `docs/`
|
||||||
|
|
||||||
## Simplifications
|
## Simplifications
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -12,10 +12,10 @@ The smallest droplet is enough: `s-1vcpu-512mb-10gb` ($4 a month). The build
|
|||||||
is under half a megabyte and Caddy idles in about 30 MB of memory. Move up to
|
is under half a megabyte and Caddy idles in about 30 MB of memory. Move up to
|
||||||
`s-1vcpu-1gb` only if the site ever gains a server side.
|
`s-1vcpu-1gb` only if the site ever gains a server side.
|
||||||
|
|
||||||
The zero-cost alternative is a second site block in the wizwar droplet's
|
The zero-cost alternative is a second site block in an existing Caddy
|
||||||
Caddyfile pointing at a second directory; the deploy script works unchanged
|
server's configuration pointing at a second directory; the deploy script
|
||||||
against that host. A separate droplet keeps the two games' uptime and
|
works unchanged against that host. A droplet of its own keeps this site's
|
||||||
upgrades independent.
|
uptime and upgrades independent of anything else.
|
||||||
|
|
||||||
## Current production
|
## Current production
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
# The argument is the Caddy site address line: one name, or several
|
# The argument is the Caddy site address line: one name, or several
|
||||||
# separated by commas. Every name must already resolve to this droplet.
|
# separated by commas. Every name must already resolve to this droplet.
|
||||||
# Waving Hands is a static site: Caddy serves the built files and terminates
|
# Waving Hands is a static site: Caddy serves the built files and terminates
|
||||||
# TLS. There is no application process, so nothing but Caddy and rsync is
|
# TLS. There is no application process to install or supervise.
|
||||||
# installed.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
HOST="${1:?usage: setup-droplet.sh <hostname>}"
|
HOST="${1:?usage: setup-droplet.sh <hostname>}"
|
||||||
|
|
||||||
@@ -28,8 +27,9 @@ chmod -R g+rX /opt/waving-hands
|
|||||||
# Caddy vhost: auto-TLS, security headers, static files. SvelteKit writes
|
# Caddy vhost: auto-TLS, security headers, static files. SvelteKit writes
|
||||||
# each route as <route>.html, so /rules is tried as /rules.html before
|
# each route as <route>.html, so /rules is tried as /rules.html before
|
||||||
# falling back to the app shell. Hashed assets under _app/immutable are
|
# falling back to the app shell. Hashed assets under _app/immutable are
|
||||||
# cached for a year; everything else is revalidated so a deploy shows up
|
# cached for a year; every other response is revalidated so a deploy shows
|
||||||
# on the next load.
|
# up on the next load. The two header matchers are disjoint, so their order
|
||||||
|
# does not matter.
|
||||||
cat > /etc/caddy/Caddyfile <<CADDY
|
cat > /etc/caddy/Caddyfile <<CADDY
|
||||||
$HOST
|
$HOST
|
||||||
|
|
||||||
@@ -42,8 +42,10 @@ header {
|
|||||||
X-Frame-Options "DENY"
|
X-Frame-Options "DENY"
|
||||||
Referrer-Policy "no-referrer"
|
Referrer-Policy "no-referrer"
|
||||||
}
|
}
|
||||||
header /_app/immutable/* Cache-Control "public, max-age=31536000, immutable"
|
@immutable path /_app/immutable/*
|
||||||
header /index.html Cache-Control "no-cache"
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
@mutable not path /_app/immutable/*
|
||||||
|
header @mutable Cache-Control "no-cache"
|
||||||
|
|
||||||
log {
|
log {
|
||||||
output file /var/lib/caddy/access.log {
|
output file /var/lib/caddy/access.log {
|
||||||
|
|||||||
+56
-3
@@ -57,12 +57,14 @@ button {
|
|||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
select:focus-visible,
|
select:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
summary:focus-visible {
|
summary:focus-visible {
|
||||||
outline: 2px solid var(--frost);
|
outline: 2px solid var(--frost);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select,
|
||||||
|
input[type='text'] {
|
||||||
background: var(--slate-deep);
|
background: var(--slate-deep);
|
||||||
border: 1px solid var(--rule-strong);
|
border: 1px solid var(--rule-strong);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -70,6 +72,53 @@ select {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
color: var(--bone);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An action written as a word in running text. */
|
||||||
|
.link {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
color: var(--frost);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one button that commits a turn. */
|
||||||
|
.reveal {
|
||||||
|
background: var(--bone);
|
||||||
|
color: var(--slate-deep);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal:hover:not(:disabled) {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An italic prompt beside a control, e.g. "at" before a target list. */
|
||||||
|
.field {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > span {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.letter {
|
.letter {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-variation-settings: 'opsz' 144;
|
font-variation-settings: 'opsz' 144;
|
||||||
@@ -80,8 +129,12 @@ select {
|
|||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ember {
|
.visually-hidden {
|
||||||
color: var(--ember);
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { castKey, tokensText, type Duel, type SetName } from '$lib/game/duel.svelte';
|
import { castKey, tokensText, type Duel, type SetName } from '$lib/game/duel.svelte';
|
||||||
import GestureIcon from './GestureIcon.svelte';
|
|
||||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||||
import { GESTURES, type Hand, type SpellId } from '$lib/game/spells';
|
import { GESTURES, type Hand, type SpellId } from '$lib/game/spells';
|
||||||
|
import GestureIcon from './GestureIcon.svelte';
|
||||||
|
|
||||||
let { duel, hand, set = 'main' }: { duel: Duel; hand: Hand; set?: SetName } = $props();
|
let { duel, hand, set = 'main' }: { duel: Duel; hand: Hand; set?: SetName } = $props();
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
duel.you.constraints.amnesia
|
duel.you.constraints.amnesia
|
||||||
? 'Amnesia: you must repeat last turn.'
|
? 'Amnesia: you must repeat last turn.'
|
||||||
: duel.you.constraints.paralysedHand === hand
|
: duel.you.constraints.paralysedHand === hand
|
||||||
? 'Paralysed in last turn’s position.'
|
? "Paralysed in last turn's position."
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
{#if forced}
|
{#if forced}
|
||||||
<p class="locked"><span class="letter big">{glyph(forced, duel.gestureFor(set, other))}</span> {whyForced}</p>
|
<p class="locked"><span class="letter big">{glyph(forced, duel.gestureFor(set, other))}</span> {whyForced}</p>
|
||||||
{:else if charmed}
|
{:else if charmed}
|
||||||
<p class="locked"><span class="letter big">?</span> Charmed: {duel.foe.name} chooses this hand’s gesture.</p>
|
<p class="locked"><span class="letter big">?</span> Charmed: {duel.foe.name} chooses this hand's gesture.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="gestures" role="group" aria-label="{hand} hand gesture{set === 'haste' ? ', second pair' : ''}">
|
<div class="gestures" role="group" aria-label="{hand} hand gesture{set === 'haste' ? ', second pair' : ''}">
|
||||||
{#each GESTURES as g (g)}
|
{#each GESTURES as g (g)}
|
||||||
@@ -55,31 +55,33 @@
|
|||||||
{#if duel.you.constraints.fear && !duel.timeStopped}
|
{#if duel.you.constraints.fear && !duel.timeStopped}
|
||||||
<p class="hint">Fear: no clap, digit, fingers or snap this turn.</p>
|
<p class="hint">Fear: no clap, digit, fingers or snap this turn.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if set === 'main' && duel.showPlans && duel.plans[hand].length === 0}
|
{#if set === 'main' && duel.showPlans}
|
||||||
<p class="hint">Nothing under way. <button type="button" class="link" onclick={() => duel.openSheet()}>Pick a spell from the sheet</button> to plan with this hand.</p>
|
{#if duel.plans[hand].length === 0}
|
||||||
{:else if set === 'main' && duel.showPlans && duel.plans[hand].length > 0}
|
<p class="hint">Nothing under way. <button type="button" class="link" onclick={() => duel.openSheet()}>Pick a spell from the sheet</button> to plan with this hand.</p>
|
||||||
<ul class="plans" aria-label="{hand} hand, spells under way">
|
{:else}
|
||||||
{#each plans as plan (plan.spell.id)}
|
<ul class="plans" aria-label="{hand} hand, spells under way">
|
||||||
<li class:pinned={plan.pinned}>
|
{#each plans as plan (plan.spell.id)}
|
||||||
<button type="button" class="plan" title="Make the next gesture toward {plan.spell.name}" onclick={() => duel.follow(hand, plan)}>
|
<li class:pinned={plan.pinned}>
|
||||||
<span class="seq">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span><span class="arrow">→</span>{/if}<span class="next">{tokensText(plan.remaining)}</span></span>
|
<button type="button" class="plan" title="Make the next gesture toward {plan.spell.name}" onclick={() => duel.follow(hand, plan)}>
|
||||||
<span class="pname">{plan.spell.name}</span>
|
<span class="seq">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span><span class="arrow">→</span>{/if}<span class="next">{tokensText(plan.remaining)}</span></span>
|
||||||
</button>
|
<span class="pname">{plan.spell.name}</span>
|
||||||
<button type="button" class="pin" aria-pressed={plan.pinned} title={plan.pinned ? 'Unpin' : 'Pin this spell'} onclick={() => duel.pin(hand, plan.spell.id)}>{plan.pinned ? '\u25c6' : '\u25c7'}</button>
|
</button>
|
||||||
</li>
|
<button type="button" class="pin" aria-pressed={plan.pinned} title={plan.pinned ? 'Unpin' : 'Pin this spell'} onclick={() => duel.pin(hand, plan.spell.id)}>{plan.pinned ? '\u25c6' : '\u25c7'}</button>
|
||||||
{/each}
|
</li>
|
||||||
{#if hiddenPlans > 0 || morePlans}
|
{/each}
|
||||||
<li class="more">
|
{#if hiddenPlans > 0 || morePlans}
|
||||||
<button type="button" class="plan" onclick={() => (morePlans = !morePlans)}>{morePlans ? 'Fewer spells' : `More spells (${hiddenPlans})`}</button>
|
<li class="more">
|
||||||
</li>
|
<button type="button" class="plan" onclick={() => (morePlans = !morePlans)}>{morePlans ? 'Fewer spells' : `More spells (${hiddenPlans})`}</button>
|
||||||
{/if}
|
</li>
|
||||||
</ul>
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if options.length > 0}
|
{#if options.length > 0}
|
||||||
<div class="cast">
|
<div class="cast">
|
||||||
<label>
|
<label class="field">
|
||||||
<span>Finishes</span>
|
<span>Finishes</span>
|
||||||
<select value={draft.spellId} onchange={(e) => duel.setSpell(set, hand, e.currentTarget.value as SpellId | '')}>
|
<select value={draft.spellId} onchange={(e) => duel.setSpell(set, hand, e.currentTarget.value as SpellId | '')}>
|
||||||
{#each options as c (c.spell.id + c.seqIndex)}
|
{#each options as c (c.spell.id + c.seqIndex)}
|
||||||
@@ -89,7 +91,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if spell}
|
{#if spell}
|
||||||
<label>
|
<label class="field">
|
||||||
<span>at</span>
|
<span>at</span>
|
||||||
<select bind:value={draft.target}>
|
<select bind:value={draft.target}>
|
||||||
{#each duel.targetsFor(spell) as t (t.id)}
|
{#each duel.targetsFor(spell) as t (t.id)}
|
||||||
@@ -98,7 +100,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if spell.id === 'summon_elemental'}
|
{#if spell.id === 'summon_elemental'}
|
||||||
<label>
|
<label class="field">
|
||||||
<span>of</span>
|
<span>of</span>
|
||||||
<select bind:value={draft.elemental}>
|
<select bind:value={draft.elemental}>
|
||||||
<option value="fire">fire</option>
|
<option value="fire">fire</option>
|
||||||
@@ -107,7 +109,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
{#if spell.id === 'paralysis' || spell.id === 'charm_person'}
|
{#if spell.id === 'paralysis' || spell.id === 'charm_person'}
|
||||||
<label>
|
<label class="field">
|
||||||
<span>their</span>
|
<span>their</span>
|
||||||
<select bind:value={draft.chosenHand}>
|
<select bind:value={draft.chosenHand}>
|
||||||
<option value="left">left hand</option>
|
<option value="left">left hand</option>
|
||||||
@@ -172,11 +174,6 @@
|
|||||||
color: var(--slate-deep);
|
color: var(--slate-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gesture:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gesture .letter {
|
.gesture .letter {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
margin-top: 0.05rem;
|
margin-top: 0.05rem;
|
||||||
@@ -211,17 +208,6 @@
|
|||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cast label {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cast label span {
|
|
||||||
color: var(--bone-dim);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plans {
|
.plans {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0.4rem 0 0;
|
margin: 0.4rem 0 0;
|
||||||
@@ -308,15 +294,6 @@
|
|||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
color: var(--frost);
|
|
||||||
text-decoration: underline;
|
|
||||||
font-size: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary strong {
|
.summary strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -104,13 +104,10 @@
|
|||||||
{#each ['left', 'right'] as const as hand (hand)}
|
{#each ['left', 'right'] as const as hand (hand)}
|
||||||
<td class="cell you">
|
<td class="cell you">
|
||||||
{#each duel.activeSets as set (set)}
|
{#each duel.activeSets as set (set)}
|
||||||
{@const chosen = duel.forcedFor(set, hand) ?? duel.sets[set][hand].gesture}
|
{@const undecided = duel.chosenGesture(set, hand) === null || duel.charmedHand === hand}
|
||||||
{@const charmed = duel.charmedHand === hand}
|
|
||||||
{@const cast = duel.chosen[set][hand]}
|
{@const cast = duel.chosen[set][hand]}
|
||||||
<span class="entry" class:cast={!!cast} class:haste={set === 'haste'}>
|
<span class="entry" class:cast={!!cast} class:haste={set === 'haste'}>
|
||||||
<span class="letter" class:placeholder={chosen === null || charmed}>
|
<span class="letter" class:placeholder={undecided}>{duel.drawn(set, hand)}</span>
|
||||||
{charmed ? '?' : chosen === null ? '·' : glyph(chosen, duel.gestureFor(set, hand === 'left' ? 'right' : 'left'))}
|
|
||||||
</span>
|
|
||||||
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
|
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -251,11 +248,4 @@
|
|||||||
border-bottom: 2px solid var(--ember);
|
border-bottom: 2px solid var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0 0 0 0);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
|
import type { Duel } from '$lib/game/duel.svelte';
|
||||||
import { glyph } from '$lib/game/glyphs';
|
import { HANDS } from '$lib/game/spells';
|
||||||
import { MAX_HP } from '$lib/game/state';
|
import { MAX_HP } from '$lib/game/state';
|
||||||
|
|
||||||
let { duel }: { duel: Duel } = $props();
|
let { duel }: { duel: Duel } = $props();
|
||||||
|
|
||||||
const left = $derived(duel.forcedFor('main', 'left') ?? duel.sets.main.left.gesture);
|
|
||||||
const right = $derived(duel.forcedFor('main', 'right') ?? duel.sets.main.right.gesture);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !duel.state.over}
|
{#if !duel.state.over}
|
||||||
@@ -16,8 +13,9 @@
|
|||||||
<span><em>{duel.foe.name}</em> {duel.foe.hp}/{MAX_HP}</span>
|
<span><em>{duel.foe.name}</em> {duel.foe.hp}/{MAX_HP}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gestures" aria-label="Chosen gestures">
|
<div class="gestures" aria-label="Chosen gestures">
|
||||||
<span class="letter" class:empty={left === null}>{duel.charmedHand === 'left' ? '?' : left === null ? '·' : glyph(left, right ?? '-')}</span>
|
{#each HANDS as hand (hand)}
|
||||||
<span class="letter" class:empty={right === null}>{duel.charmedHand === 'right' ? '?' : right === null ? '·' : glyph(right, left ?? '-')}</span>
|
<span class="letter" class:empty={duel.chosenGesture('main', hand) === null}>{duel.drawn('main', hand)}</span>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||||
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
|
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
|
||||||
@@ -30,6 +28,7 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.bar {
|
.bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -77,15 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reveal {
|
.reveal {
|
||||||
background: var(--bone);
|
|
||||||
color: var(--slate-deep);
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
font-weight: 500;
|
font-size: 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.reveal:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
{ category: 'damage', title: 'Damage' },
|
{ category: 'damage', title: 'Damage' },
|
||||||
{ category: 'enchantment', title: 'Enchantments' }
|
{ category: 'enchantment', title: 'Enchantments' }
|
||||||
];
|
];
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<details class="sheet" id="spell-sheet" bind:open={duel.sheetOpen}>
|
<details class="sheet" id="spell-sheet" bind:open={duel.sheetOpen}>
|
||||||
@@ -26,8 +25,8 @@
|
|||||||
{@const done = duel.completedNow(spell.id)}
|
{@const done = duel.completedNow(spell.id)}
|
||||||
<li class:done class:open={open === spell.id}>
|
<li class:done class:open={open === spell.id}>
|
||||||
<button type="button" class="row" aria-expanded={open === spell.id} onclick={() => (open = open === spell.id ? null : spell.id)}>
|
<button type="button" class="row" aria-expanded={open === spell.id} onclick={() => (open = open === spell.id ? null : spell.id)}>
|
||||||
<span class="seq">{#each spell.sequences as seq, si (seq)}{#if si > 0}<span class="or"> or </span>{/if}{#each seq.split('-') as part, i (i)}{#if i > 0}<span class="dash">-</span>{/if}<span class="tok" class:lit={done || i < k}>{part}</span>{/each}{/each}</span>
|
<span class="seq">{#each spell.sequences as seq, si (seq)}{#if si > 0}<span class="or"> or </span>{/if}{#each seq.split('-') as part, i (i)}{#if i > 0}<span class="dash">-</span>{/if}<span class="tok" class:lit={done || i < k}>{part}</span>{/each}{/each}</span>
|
||||||
<span class="name">{spell.name}</span>
|
<span class="name">{spell.name}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if open === spell.id}
|
{#if open === spell.id}
|
||||||
<p class="about">{spell.summary}</p>
|
<p class="about">{spell.summary}</p>
|
||||||
@@ -137,11 +136,6 @@
|
|||||||
border-color: var(--frost);
|
border-color: var(--frost);
|
||||||
}
|
}
|
||||||
|
|
||||||
.planbtn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.which {
|
.which {
|
||||||
color: var(--frost);
|
color: var(--frost);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,14 +31,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</dl>
|
</dl>
|
||||||
{#if summary.events?.length}
|
{#if summary.events.length}
|
||||||
<ul class="events">
|
<ul class="events">
|
||||||
{#each summary.events as e, i (i)}
|
{#each summary.events as e, i (i)}
|
||||||
<li>{e}</li>
|
<li>{e}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
{#if duel.showLessons && summary.lessons?.length}
|
{#if duel.showLessons && summary.lessons.length}
|
||||||
<ul class="lessons">
|
<ul class="lessons">
|
||||||
{#each summary.lessons as l, i (i)}
|
{#each summary.lessons as l, i (i)}
|
||||||
<li>{l}</li>
|
<li>{l}</li>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On a phone, leave the last two ledger rows in view above the result. */
|
/* On a phone, leave the last two ledger rows in view above the result. Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.result {
|
.result {
|
||||||
scroll-margin-top: 9.5rem;
|
scroll-margin-top: 9.5rem;
|
||||||
|
|||||||
+23
-49
@@ -5,6 +5,7 @@
|
|||||||
// and each named opponent weights the spell book a little differently.
|
// and each named opponent weights the spell book a little differently.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
cellsOverlap,
|
||||||
completableIn,
|
completableIn,
|
||||||
completedSpells,
|
completedSpells,
|
||||||
handSequence,
|
handSequence,
|
||||||
@@ -15,10 +16,11 @@ import {
|
|||||||
type TurnGestures
|
type TurnGestures
|
||||||
} from './gestures';
|
} from './gestures';
|
||||||
import { allowedGestures, forcedGesture } from './resolve';
|
import { allowedGestures, forcedGesture } from './resolve';
|
||||||
import { CONTROL_SPELLS, SPELLS, SPELL_BY_ID, isElemental, type Gesture, type Hand, type Spell, type SpellId } from './spells';
|
import { GESTURES, HANDS, HOSTILE_SPELLS, MAGIC_GESTURES, SPELLS, SPELL_BY_ID, isElemental, type Gesture, type Hand, type Spell, type SpellId } from './spells';
|
||||||
import {
|
import {
|
||||||
MAX_HP,
|
MAX_HP,
|
||||||
enemyOf,
|
enemyOf,
|
||||||
|
isEnchanted,
|
||||||
visibleHistory,
|
visibleHistory,
|
||||||
type CastChoice,
|
type CastChoice,
|
||||||
type GameState,
|
type GameState,
|
||||||
@@ -27,27 +29,6 @@ import {
|
|||||||
type WizardId
|
type WizardId
|
||||||
} from './state';
|
} from './state';
|
||||||
|
|
||||||
const HOSTILE: SpellId[] = [
|
|
||||||
'missile',
|
|
||||||
'finger_of_death',
|
|
||||||
'lightning_bolt',
|
|
||||||
'lightning_bolt_quick',
|
|
||||||
'cause_light_wounds',
|
|
||||||
'cause_heavy_wounds',
|
|
||||||
'fireball',
|
|
||||||
'fire_storm',
|
|
||||||
'ice_storm',
|
|
||||||
...CONTROL_SPELLS,
|
|
||||||
'anti_spell',
|
|
||||||
'disease',
|
|
||||||
'poison',
|
|
||||||
'blindness',
|
|
||||||
'summon_goblin',
|
|
||||||
'summon_ogre',
|
|
||||||
'summon_troll',
|
|
||||||
'summon_giant',
|
|
||||||
'summon_elemental'
|
|
||||||
];
|
|
||||||
|
|
||||||
const THREAT_VALUE: Partial<Record<SpellId, number>> = {
|
const THREAT_VALUE: Partial<Record<SpellId, number>> = {
|
||||||
missile: 1,
|
missile: 1,
|
||||||
@@ -122,16 +103,16 @@ function assess(state: GameState, me: WizardId, rng: () => number): Situation {
|
|||||||
let missileNow = false;
|
let missileNow = false;
|
||||||
let foeFirePlan = false;
|
let foeFirePlan = false;
|
||||||
let foeIcePlan = false;
|
let foeIcePlan = false;
|
||||||
for (const hand of ['left', 'right'] as Hand[]) {
|
for (const hand of HANDS) {
|
||||||
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) {
|
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) {
|
||||||
if (!HOSTILE.includes(c.spell.id)) continue;
|
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
||||||
const v = THREAT_VALUE[c.spell.id] ?? 1;
|
const v = THREAT_VALUE[c.spell.id] ?? 1;
|
||||||
threatNow = Math.max(threatNow, v);
|
threatNow = Math.max(threatNow, v);
|
||||||
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
|
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
|
||||||
if (c.spell.id === 'missile') missileNow = true;
|
if (c.spell.id === 'missile') missileNow = true;
|
||||||
}
|
}
|
||||||
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 2)) {
|
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 2)) {
|
||||||
if (!HOSTILE.includes(c.spell.id)) continue;
|
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
||||||
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
|
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
|
||||||
}
|
}
|
||||||
for (const spell of SPELLS) {
|
for (const spell of SPELLS) {
|
||||||
@@ -166,8 +147,7 @@ function baseValue(s: Situation, spell: Spell): number {
|
|||||||
const meW = s.state.wizards[s.me];
|
const meW = s.state.wizards[s.me];
|
||||||
const foeW = s.state.wizards[s.foe];
|
const foeW = s.state.wizards[s.foe];
|
||||||
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
|
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
|
||||||
const foeEnchanted =
|
const foeEnchanted = isEnchanted(foeW);
|
||||||
foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
|
||||||
switch (spell.id) {
|
switch (spell.id) {
|
||||||
case 'fireball':
|
case 'fireball':
|
||||||
if (s.iceElemental) return 6;
|
if (s.iceElemental) return 6;
|
||||||
@@ -208,7 +188,7 @@ function baseValue(s: Situation, spell: Spell): number {
|
|||||||
return foeMonsters.some((m) => !isElemental(m.kind)) ? 3.5 : 0;
|
return foeMonsters.some((m) => !isElemental(m.kind)) ? 3.5 : 0;
|
||||||
case 'anti_spell': {
|
case 'anti_spell': {
|
||||||
let longest = 0;
|
let longest = 0;
|
||||||
for (const hand of ['left', 'right'] as Hand[]) {
|
for (const hand of HANDS) {
|
||||||
for (const sp of SPELLS) {
|
for (const sp of SPELLS) {
|
||||||
longest = Math.max(longest, progressToward(sp.tokens[0], s.foeSeq[hand], foeW.sequenceStart));
|
longest = Math.max(longest, progressToward(sp.tokens[0], s.foeSeq[hand], foeW.sequenceStart));
|
||||||
}
|
}
|
||||||
@@ -296,10 +276,9 @@ function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned
|
|||||||
for (const spell of SPELLS) {
|
for (const spell of SPELLS) {
|
||||||
const value = spellValue(s, spell);
|
const value = spellValue(s, spell);
|
||||||
if (value <= 0) continue;
|
if (value <= 0) continue;
|
||||||
spell.tokens.forEach((tokens, i) => {
|
for (const tokens of spell.tokens) {
|
||||||
if (spell.id === 'lightning_bolt_quick' && i > 0) return;
|
|
||||||
const k = progressToward(tokens, seq, start);
|
const k = progressToward(tokens, seq, start);
|
||||||
if (k === 0) return;
|
if (k === 0) continue;
|
||||||
let v = value * Math.pow(k / tokens.length, 1.3);
|
let v = value * Math.pow(k / tokens.length, 1.3);
|
||||||
if (k >= 2) v += 0.3;
|
if (k >= 2) v += 0.3;
|
||||||
// Being one gesture from a counter-spell is worth a lot when a blow is coming next turn.
|
// Being one gesture from a counter-spell is worth a lot when a blow is coming next turn.
|
||||||
@@ -309,14 +288,14 @@ function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned
|
|||||||
progress = v;
|
progress = v;
|
||||||
planned = spell.id;
|
planned = spell.id;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
return { score: best + progress * 0.9, planned };
|
return { score: best + progress * 0.9, planned };
|
||||||
}
|
}
|
||||||
|
|
||||||
function bestCasts(s: Situation, completions: Record<Hand, Completion[]>, turnCount: number): CastChoice[] {
|
function bestCasts(s: Situation, completions: Record<Hand, Completion[]>, turnCount: number): CastChoice[] {
|
||||||
const options: { hand: Hand; completion: Completion; value: number }[] = [];
|
const options: { hand: Hand; completion: Completion; value: number }[] = [];
|
||||||
for (const hand of ['left', 'right'] as Hand[]) {
|
for (const hand of HANDS) {
|
||||||
for (const completion of completions[hand]) {
|
for (const completion of completions[hand]) {
|
||||||
const value = spellValue(s, completion.spell);
|
const value = spellValue(s, completion.spell);
|
||||||
if (value > 0.25) options.push({ hand, completion, value });
|
if (value > 0.25) options.push({ hand, completion, value });
|
||||||
@@ -327,11 +306,7 @@ function bestCasts(s: Situation, completions: Record<Hand, Completion[]>, turnCo
|
|||||||
for (const o of options) {
|
for (const o of options) {
|
||||||
if (chosen.some((c) => c.hand === o.hand)) continue;
|
if (chosen.some((c) => c.hand === o.hand)) continue;
|
||||||
const cells = usedCells(o.completion, o.hand, turnCount);
|
const cells = usedCells(o.completion, o.hand, turnCount);
|
||||||
const clash = chosen.some((c) => {
|
if (chosen.some((c) => cellsOverlap(cells, usedCells(c.completion, c.hand, turnCount)))) continue;
|
||||||
for (const cell of usedCells(c.completion, c.hand, turnCount)) if (cells.has(cell)) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (clash) continue;
|
|
||||||
chosen.push(o);
|
chosen.push(o);
|
||||||
}
|
}
|
||||||
return chosen.map((o) => toChoice(s, o.hand, o.completion));
|
return chosen.map((o) => toChoice(s, o.hand, o.completion));
|
||||||
@@ -341,9 +316,9 @@ function foeDangerousHand(s: Situation): Hand {
|
|||||||
const foeW = s.state.wizards[s.foe];
|
const foeW = s.state.wizards[s.foe];
|
||||||
let bestHand: Hand = 'left';
|
let bestHand: Hand = 'left';
|
||||||
let bestK = -1;
|
let bestK = -1;
|
||||||
for (const hand of ['left', 'right'] as Hand[]) {
|
for (const hand of HANDS) {
|
||||||
for (const spell of SPELLS) {
|
for (const spell of SPELLS) {
|
||||||
if (!HOSTILE.includes(spell.id)) continue;
|
if (!HOSTILE_SPELLS.includes(spell.id)) continue;
|
||||||
const k = progressToward(spell.tokens[0], s.foeSeq[hand], foeW.sequenceStart);
|
const k = progressToward(spell.tokens[0], s.foeSeq[hand], foeW.sequenceStart);
|
||||||
if (k > bestK) {
|
if (k > bestK) {
|
||||||
bestK = k;
|
bestK = k;
|
||||||
@@ -375,8 +350,7 @@ function targetFor(s: Situation, spell: Spell, choice: CastChoice): void {
|
|||||||
break;
|
break;
|
||||||
case 'remove_enchantment': {
|
case 'remove_enchantment': {
|
||||||
const foeW = s.state.wizards[s.foe];
|
const foeW = s.state.wizards[s.foe];
|
||||||
const enchanted = foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
choice.target = isEnchanted(foeW) || !strongestFoeMonster ? s.foe : strongestFoeMonster.id;
|
||||||
choice.target = enchanted || !strongestFoeMonster ? s.foe : strongestFoeMonster.id;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'summon_elemental':
|
case 'summon_elemental':
|
||||||
@@ -406,11 +380,11 @@ function charmGesture(s: Situation): Gesture {
|
|||||||
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
|
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
|
||||||
let best: Gesture = 'P';
|
let best: Gesture = 'P';
|
||||||
let bestScore = Infinity;
|
let bestScore = Infinity;
|
||||||
for (const g of ['C', 'D', 'F', 'P', 'S', 'W'] as Gesture[]) {
|
for (const g of MAGIC_GESTURES) {
|
||||||
const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }];
|
const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }];
|
||||||
let score = 0;
|
let score = 0;
|
||||||
for (const spell of SPELLS) {
|
for (const spell of SPELLS) {
|
||||||
if (!HOSTILE.includes(spell.id)) continue;
|
if (!HOSTILE_SPELLS.includes(spell.id)) continue;
|
||||||
score += progressToward(spell.tokens[0], seq, foeW.sequenceStart);
|
score += progressToward(spell.tokens[0], seq, foeW.sequenceStart);
|
||||||
}
|
}
|
||||||
if (score < bestScore || (score === bestScore && s.rng() < 0.5)) {
|
if (score < bestScore || (score === bestScore && s.rng() < 0.5)) {
|
||||||
@@ -428,11 +402,11 @@ interface PairChoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Pick one pair of gestures given the history so far, and what to cast with them. */
|
/** Pick one pair of gestures given the history so far, and what to cast with them. */
|
||||||
function choosePair(s: Situation, history: TurnGestures[], rng: () => number, timeStopped: boolean): PairChoice {
|
function choosePair(s: Situation, history: TurnGestures[], timeStopped: boolean): PairChoice {
|
||||||
const meW = s.state.wizards[s.me];
|
const meW = s.state.wizards[s.me];
|
||||||
const foe = s.foe;
|
const foe = s.foe;
|
||||||
const optionsFor = (hand: Hand): Gesture[] => {
|
const optionsFor = (hand: Hand): Gesture[] => {
|
||||||
if (timeStopped) return ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
|
if (timeStopped) return GESTURES;
|
||||||
const forced = forcedGesture(meW, hand);
|
const forced = forcedGesture(meW, hand);
|
||||||
if (forced) return [forced];
|
if (forced) return [forced];
|
||||||
const allowed = allowedGestures(meW, hand);
|
const allowed = allowedGestures(meW, hand);
|
||||||
@@ -452,7 +426,7 @@ function choosePair(s: Situation, history: TurnGestures[], rng: () => number, ti
|
|||||||
if (left === '>' && right === '>') score -= 50;
|
if (left === '>' && right === '>') score -= 50;
|
||||||
if (left === '-' && right === '-') score -= 3;
|
if (left === '-' && right === '-') score -= 3;
|
||||||
if (left === '>' || right === '>') score += s.state.monsters.some((m) => m.owner === foe && m.hp === 1) ? 1.5 : 0.15;
|
if (left === '>' || right === '>') score += s.state.monsters.some((m) => m.owner === foe && m.hp === 1) ? 1.5 : 0.15;
|
||||||
score += rng() * 0.5;
|
score += s.rng() * 0.5;
|
||||||
if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
|
if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,7 +445,7 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
|
|||||||
const foe = s.foe;
|
const foe = s.foe;
|
||||||
const timeStopped = state.timeStops[0] === me;
|
const timeStopped = state.timeStops[0] === me;
|
||||||
|
|
||||||
const first = choosePair(s, meW.history, rng, timeStopped);
|
const first = choosePair(s, meW.history, timeStopped);
|
||||||
const weakFoeMonster = state.monsters.find((m) => m.owner === foe && m.hp === 1);
|
const weakFoeMonster = state.monsters.find((m) => m.owner === foe && m.hp === 1);
|
||||||
const monsterOrders: Record<string, TargetId> = {};
|
const monsterOrders: Record<string, TargetId> = {};
|
||||||
for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe;
|
for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe;
|
||||||
@@ -484,7 +458,7 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
|
|||||||
monsterOrders
|
monsterOrders
|
||||||
};
|
};
|
||||||
if (meW.hasteTurns > 0 && !timeStopped) {
|
if (meW.hasteTurns > 0 && !timeStopped) {
|
||||||
const second = choosePair(s, [...meW.history, { left: first.left, right: first.right }], rng, timeStopped);
|
const second = choosePair(s, [...meW.history, { left: first.left, right: first.right }], timeStopped);
|
||||||
input.second = { left: second.left, right: second.right, casts: second.casts, stabTarget: input.stabTarget };
|
input.second = { left: second.left, right: second.right, casts: second.casts, stabTarget: input.stabTarget };
|
||||||
}
|
}
|
||||||
if (meW.banked) {
|
if (meW.banked) {
|
||||||
|
|||||||
+42
-50
@@ -3,6 +3,7 @@
|
|||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { chooseBotTurn } from './bot';
|
import { chooseBotTurn } from './bot';
|
||||||
import {
|
import {
|
||||||
|
cellsOverlap,
|
||||||
completableIn,
|
completableIn,
|
||||||
completedSpells,
|
completedSpells,
|
||||||
handSequence,
|
handSequence,
|
||||||
@@ -11,19 +12,20 @@ import {
|
|||||||
type Completion,
|
type Completion,
|
||||||
type HandTurn
|
type HandTurn
|
||||||
} from './gestures';
|
} from './gestures';
|
||||||
import type { Token } from './spells';
|
import { glyph } from './glyphs';
|
||||||
import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
|
import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
|
||||||
import {
|
import {
|
||||||
CONTROL_SPELLS,
|
|
||||||
GESTURES,
|
GESTURES,
|
||||||
HANDS,
|
HANDS,
|
||||||
PERMANENCY_EXCLUDED,
|
HOSTILE_SPELLS,
|
||||||
SPELLS,
|
SPELLS,
|
||||||
SPELL_BY_ID,
|
SPELL_BY_ID,
|
||||||
|
permanencyEligible,
|
||||||
type Gesture,
|
type Gesture,
|
||||||
type Hand,
|
type Hand,
|
||||||
type Spell,
|
type Spell,
|
||||||
type SpellId
|
type SpellId,
|
||||||
|
type Token
|
||||||
} from './spells';
|
} from './spells';
|
||||||
import {
|
import {
|
||||||
NOWHERE,
|
NOWHERE,
|
||||||
@@ -40,34 +42,18 @@ import {
|
|||||||
export const YOU: WizardId = 'A';
|
export const YOU: WizardId = 'A';
|
||||||
export const FOE: WizardId = 'B';
|
export const FOE: WizardId = 'B';
|
||||||
|
|
||||||
|
/** Narrow layouts: shorter lists and a sticky bar. The stylesheets in +page, MobileBar and TurnSummary use the same width. */
|
||||||
|
export const COMPACT_QUERY = '(max-width: 860px)';
|
||||||
|
|
||||||
|
/** How many spells under way to list beneath a hand, with and without a pinned one. */
|
||||||
|
const PLAN_LIMITS = { wide: 7, compact: 3, compactPinned: 4 };
|
||||||
|
|
||||||
/** The first pair of gestures each turn, and the extra pair a hastened wizard makes. */
|
/** The first pair of gestures each turn, and the extra pair a hastened wizard makes. */
|
||||||
export type SetName = 'main' | 'haste';
|
export type SetName = 'main' | 'haste';
|
||||||
export const SET_NAMES: SetName[] = ['main', 'haste'];
|
export const SET_NAMES: SetName[] = ['main', 'haste'];
|
||||||
|
|
||||||
const NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
|
const NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
|
||||||
|
|
||||||
const HOSTILE: SpellId[] = [
|
|
||||||
'missile',
|
|
||||||
'finger_of_death',
|
|
||||||
'lightning_bolt',
|
|
||||||
'lightning_bolt_quick',
|
|
||||||
'cause_light_wounds',
|
|
||||||
'cause_heavy_wounds',
|
|
||||||
'fireball',
|
|
||||||
'fire_storm',
|
|
||||||
'ice_storm',
|
|
||||||
...CONTROL_SPELLS,
|
|
||||||
'anti_spell',
|
|
||||||
'disease',
|
|
||||||
'poison',
|
|
||||||
'blindness',
|
|
||||||
'remove_enchantment',
|
|
||||||
'summon_goblin',
|
|
||||||
'summon_ogre',
|
|
||||||
'summon_troll',
|
|
||||||
'summon_giant',
|
|
||||||
'summon_elemental'
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface HandDraft {
|
export interface HandDraft {
|
||||||
gesture: Gesture | null;
|
gesture: Gesture | null;
|
||||||
@@ -177,7 +163,7 @@ function loadSaved(): SavedDuel | null {
|
|||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const saved = JSON.parse(raw) as SavedDuel;
|
const saved = JSON.parse(raw) as SavedDuel;
|
||||||
if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null;
|
if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null;
|
||||||
// Fields added since the save was written.
|
// Saves at this version may lack fields that came later; the engine expects them present.
|
||||||
saved.state.lessonsShown ??= [];
|
saved.state.lessonsShown ??= [];
|
||||||
if (saved.state.lastTurn) {
|
if (saved.state.lastTurn) {
|
||||||
saved.state.lastTurn.events ??= [];
|
saved.state.lastTurn.events ??= [];
|
||||||
@@ -245,8 +231,8 @@ export class Duel {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Choose each hand's default cast from its gestures. A hand bound by amnesia or
|
* Choose each hand's default cast from its gestures. A hand bound by amnesia or
|
||||||
* paralysis can finish a spell without the player touching it, so this must run
|
* paralysis can finish a spell without the player touching it, so every turn
|
||||||
* at the start of every turn, not only after a click.
|
* starts here as well as every click.
|
||||||
*/
|
*/
|
||||||
syncAll(): void {
|
syncAll(): void {
|
||||||
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
||||||
@@ -316,8 +302,21 @@ export class Duel {
|
|||||||
/** True when the bot's hand is yours to command this turn. */
|
/** True when the bot's hand is yours to command this turn. */
|
||||||
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped);
|
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped);
|
||||||
|
|
||||||
|
/** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */
|
||||||
|
chosenGesture(set: SetName, hand: Hand): Gesture | null {
|
||||||
|
return this.forcedFor(set, hand) ?? this.sets[set][hand].gesture;
|
||||||
|
}
|
||||||
|
|
||||||
gestureFor(set: SetName, hand: Hand): Gesture {
|
gestureFor(set: SetName, hand: Hand): Gesture {
|
||||||
return this.forcedFor(set, hand) ?? this.sets[set][hand].gesture ?? '-';
|
return this.chosenGesture(set, hand) ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a hand's gesture is written in the ledger and the sticky bar before the reveal. */
|
||||||
|
drawn(set: SetName, hand: Hand): string {
|
||||||
|
if (this.charmedHand === hand) return '?';
|
||||||
|
const g = this.chosenGesture(set, hand);
|
||||||
|
if (g === null) return '\u00b7';
|
||||||
|
return glyph(g, this.gestureFor(set, hand === 'left' ? 'right' : 'left'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private entry(set: SetName): HistoryEntry {
|
private entry(set: SetName): HistoryEntry {
|
||||||
@@ -374,9 +373,7 @@ export class Duel {
|
|||||||
conflict = $derived.by(() => {
|
conflict = $derived.by(() => {
|
||||||
const cells = this.planned.map((p) => usedCells(p.completion, p.hand, p.turnCount));
|
const cells = this.planned.map((p) => usedCells(p.completion, p.hand, p.turnCount));
|
||||||
for (let i = 0; i < cells.length; i++) {
|
for (let i = 0; i < cells.length; i++) {
|
||||||
for (let j = i + 1; j < cells.length; j++) {
|
for (let j = i + 1; j < cells.length; j++) if (cellsOverlap(cells[i], cells[j])) return true;
|
||||||
for (const cell of cells[i]) if (cells[j].has(cell)) return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -398,6 +395,7 @@ export class Duel {
|
|||||||
return this.activeSets.some((set) => HANDS.some((h) => this.completions[set][h].some((c) => c.spell.id === id)));
|
return this.activeSets.some((set) => HANDS.some((h) => this.completions[set][h].some((c) => c.spell.id === id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summary = $derived(this.state.lastTurn);
|
||||||
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
|
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
|
||||||
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
|
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
|
||||||
threats = $derived.by(() => {
|
threats = $derived.by(() => {
|
||||||
@@ -406,7 +404,7 @@ export class Duel {
|
|||||||
const seq = handSequence(this.foeVisible, hand);
|
const seq = handSequence(this.foeVisible, hand);
|
||||||
for (const turnsAway of [1, 2]) {
|
for (const turnsAway of [1, 2]) {
|
||||||
for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) {
|
for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) {
|
||||||
if (!HOSTILE.includes(c.spell.id)) continue;
|
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
||||||
if (c.spell.id === 'lightning_bolt_quick' && this.foe.usedQuickLightning) continue;
|
if (c.spell.id === 'lightning_bolt_quick' && this.foe.usedQuickLightning) continue;
|
||||||
const tokens = c.spell.tokens[c.seqIndex];
|
const tokens = c.spell.tokens[c.seqIndex];
|
||||||
const done = tokens.slice(0, tokens.length - turnsAway);
|
const done = tokens.slice(0, tokens.length - turnsAway);
|
||||||
@@ -449,12 +447,13 @@ export class Duel {
|
|||||||
const all = [...best.values()].sort(
|
const all = [...best.values()].sort(
|
||||||
(a, b) => Number(b.pinned) - Number(a.pinned) || a.remaining.length - b.remaining.length || b.done.length - a.done.length || a.spell.name.localeCompare(b.spell.name)
|
(a, b) => Number(b.pinned) - Number(a.pinned) || a.remaining.length - b.remaining.length || b.done.length - a.done.length || a.spell.name.localeCompare(b.spell.name)
|
||||||
);
|
);
|
||||||
return all.slice(0, pinned ? 7 : 6);
|
return all.slice(0, PLAN_LIMITS.wide);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many plans to show before "More spells" on a narrow screen. */
|
/** How many plans to show before "More spells". */
|
||||||
planLimit(hand: Hand): number {
|
planLimit(hand: Hand): number {
|
||||||
return this.compact ? (this.pins[hand] ? 4 : 3) : 7;
|
if (!this.compact) return PLAN_LIMITS.wide;
|
||||||
|
return this.pins[hand] ? PLAN_LIMITS.compactPinned : PLAN_LIMITS.compact;
|
||||||
}
|
}
|
||||||
|
|
||||||
pin(hand: Hand, id: SpellId): void {
|
pin(hand: Hand, id: SpellId): void {
|
||||||
@@ -466,14 +465,14 @@ export class Duel {
|
|||||||
this.pins[hand] = plan.spell.id;
|
this.pins[hand] = plan.spell.id;
|
||||||
const next = plan.remaining[0];
|
const next = plan.remaining[0];
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
if (!this.forcedFor('main', hand) && this.charmedHand !== hand && this.allowed[hand].includes(next.gesture)) {
|
if (this.handFree(hand) && this.allowed[hand].includes(next.gesture)) {
|
||||||
this.sets.main[hand].gesture = next.gesture;
|
this.sets.main[hand].gesture = next.gesture;
|
||||||
}
|
}
|
||||||
const other: Hand = hand === 'left' ? 'right' : 'left';
|
const other: Hand = hand === 'left' ? 'right' : 'left';
|
||||||
if (next.both && !this.forcedFor('main', other) && this.charmedHand !== other && this.allowed[other].includes(next.gesture)) {
|
if (next.both && this.handFree(other) && this.allowed[other].includes(next.gesture)) {
|
||||||
this.sets.main[other].gesture = next.gesture;
|
this.sets.main[other].gesture = next.gesture;
|
||||||
}
|
}
|
||||||
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
this.syncAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowPlans(on: boolean): void {
|
setShowPlans(on: boolean): void {
|
||||||
@@ -509,8 +508,6 @@ export class Duel {
|
|||||||
document.getElementById('spell-sheet')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
document.getElementById('spell-sheet')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
summary = $derived(this.state.lastTurn);
|
|
||||||
|
|
||||||
surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P'));
|
surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P'));
|
||||||
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
|
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
|
||||||
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU));
|
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU));
|
||||||
@@ -536,12 +533,7 @@ export class Duel {
|
|||||||
);
|
);
|
||||||
permanentCandidates = $derived(
|
permanentCandidates = $derived(
|
||||||
this.permanencyActive
|
this.permanencyActive
|
||||||
? this.planned.filter(
|
? this.planned.filter((p) => permanencyEligible(p.completion.spell) && p !== this.willBank)
|
||||||
(p) =>
|
|
||||||
p.completion.spell.category === 'enchantment' &&
|
|
||||||
!PERMANENCY_EXCLUDED.includes(p.completion.spell.id) &&
|
|
||||||
p !== this.willBank
|
|
||||||
)
|
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
willExtend = $derived(
|
willExtend = $derived(
|
||||||
@@ -562,7 +554,7 @@ export class Duel {
|
|||||||
choose(set: SetName, hand: Hand, gesture: Gesture): void {
|
choose(set: SetName, hand: Hand, gesture: Gesture): void {
|
||||||
const draft = this.sets[set][hand];
|
const draft = this.sets[set][hand];
|
||||||
draft.gesture = draft.gesture === gesture ? null : gesture;
|
draft.gesture = draft.gesture === gesture ? null : gesture;
|
||||||
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
this.syncAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep the selected spell valid for the current gestures; default to the longest completion. */
|
/** Keep the selected spell valid for the current gestures; default to the longest completion. */
|
||||||
@@ -586,7 +578,7 @@ export class Duel {
|
|||||||
hand: p.hand,
|
hand: p.hand,
|
||||||
spellId: spell.id,
|
spellId: spell.id,
|
||||||
seqIndex: p.completion.seqIndex,
|
seqIndex: p.completion.seqIndex,
|
||||||
target: p.draft.target || (spell.usualTarget === 'self' ? YOU : FOE)
|
target: p.draft.target || defaultTarget(spell)
|
||||||
};
|
};
|
||||||
if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental;
|
if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental;
|
||||||
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;
|
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { Duel } from './duel.svelte';
|
import { Duel } from './duel.svelte';
|
||||||
import { resolveTurn } from './resolve';
|
import { createGame, type GameState } from './state';
|
||||||
import { createGame, type GameState, type TurnInput } from './state';
|
|
||||||
import type { Gesture } from './spells';
|
import type { Gesture } from './spells';
|
||||||
|
import { play } from './test-helpers';
|
||||||
const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] });
|
|
||||||
|
|
||||||
/** A is one gesture from a lightning bolt and has just been struck by amnesia. */
|
/** A is one gesture from a lightning bolt and has just been struck by amnesia. */
|
||||||
function amnesiaBeforeLightning(): GameState {
|
function amnesiaBeforeLightning(): GameState {
|
||||||
@@ -12,10 +10,7 @@ function amnesiaBeforeLightning(): GameState {
|
|||||||
const left = 'DFFD';
|
const left = 'DFFD';
|
||||||
const foe = '-DPP';
|
const foe = '-DPP';
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
s = resolveTurn(s, {
|
s = play(s, { left: left[i] as Gesture }, { left: foe[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'amnesia' }] : [] });
|
||||||
A: { ...noop(), left: left[i] as Gesture },
|
|
||||||
B: { ...noop(), left: foe[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'amnesia' }] : [] }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
expect(s.wizards.A.constraints.amnesia).toBe(true);
|
expect(s.wizards.A.constraints.amnesia).toBe(true);
|
||||||
return s;
|
return s;
|
||||||
@@ -31,21 +26,4 @@ describe('the duel store', () => {
|
|||||||
duel.reveal();
|
duel.reveal();
|
||||||
expect(duel.foe.hp).toBe(10);
|
expect(duel.foe.hp).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('chooses a default cast at the start of the next turn as well', () => {
|
|
||||||
let s = createGame({ A: 'You', B: 'Foe' }, 1);
|
|
||||||
// A's left hand made D-F-F-D; B's amnesia lands as A makes the fourth gesture.
|
|
||||||
const left = 'DFFD';
|
|
||||||
const foe = '-DPP';
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
s = resolveTurn(s, { A: { ...noop(), left: left[i] as Gesture }, B: { ...noop(), left: foe[i] as Gesture } });
|
|
||||||
}
|
|
||||||
const duel = new Duel(s);
|
|
||||||
duel.choose('main', 'left', 'D');
|
|
||||||
duel.choose('main', 'right', '-');
|
|
||||||
// The bot is replaced by a scripted foe for this test: resolve directly, then hand the store the result.
|
|
||||||
const next = resolveTurn(s, { A: { ...noop(), left: 'D' }, B: { ...noop(), left: 'P', casts: [{ hand: 'left', spellId: 'amnesia' }] } });
|
|
||||||
const fresh = new Duel(next);
|
|
||||||
expect(fresh.sets.main.left.spellId).toBe('lightning_bolt');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-74
@@ -3,36 +3,12 @@ import { chooseBotTurn } from './bot';
|
|||||||
import { completedSpells, handSequence, castsConflict, completableIn, progressToward } from './gestures';
|
import { completedSpells, handSequence, castsConflict, completableIn, progressToward } from './gestures';
|
||||||
import { resolveTurn } from './resolve';
|
import { resolveTurn } from './resolve';
|
||||||
import { SPELLS, SPELL_BY_ID, parseSequence, type Gesture } from './spells';
|
import { SPELLS, SPELL_BY_ID, parseSequence, type Gesture } from './spells';
|
||||||
import { createGame, type GameState, type TurnInput, type WizardId } from './state';
|
import { createGame } from './state';
|
||||||
|
import { lcg, noop, play, runSequence } from './test-helpers';
|
||||||
const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] });
|
|
||||||
|
|
||||||
function play(state: GameState, a: Partial<TurnInput>, b: Partial<TurnInput> = {}): GameState {
|
|
||||||
return resolveTurn(state, { A: { ...noop(), ...a }, B: { ...noop(), ...b } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Feed gesture strings turn by turn; the caster casts every spell it completes on the final turn. */
|
|
||||||
function runSequence(
|
|
||||||
state: GameState,
|
|
||||||
who: WizardId,
|
|
||||||
left: string,
|
|
||||||
right: string,
|
|
||||||
finalCasts: (turnIndex: number) => Partial<TurnInput>
|
|
||||||
): GameState {
|
|
||||||
const L = left.split('');
|
|
||||||
const R = right.split('');
|
|
||||||
for (let i = 0; i < L.length; i++) {
|
|
||||||
const extra = i === L.length - 1 ? finalCasts(i) : {};
|
|
||||||
const inp = { left: L[i] as Gesture, right: R[i] as Gesture, ...extra };
|
|
||||||
state = who === 'A' ? play(state, inp) : play(state, {}, inp);
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('spell table', () => {
|
describe('spell table', () => {
|
||||||
it('has 40 spells, all of them resolved', () => {
|
it('has the forty spells of the rules', () => {
|
||||||
expect(SPELLS).toHaveLength(40);
|
expect(SPELLS).toHaveLength(40);
|
||||||
expect(SPELLS.filter((s) => !s.implemented)).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses two-handed tokens', () => {
|
it('parses two-handed tokens', () => {
|
||||||
@@ -97,30 +73,40 @@ describe('resolution', () => {
|
|||||||
expect(s.wizards.B.hp).toBe(14);
|
expect(s.wizards.B.hp).toBe(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a counter-spell stops a fireball but not a finger of death', () => {
|
it('a counter-spell smothers a fireball cast at its subject that turn', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'FSSDD', 'PWPFS', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
// A's F-S-S-D-D and B's W-W-S finish on the same turn.
|
||||||
expect(s.wizards.B.hp).toBe(10);
|
const left = 'FSSDD';
|
||||||
s = play(s, {}, { left: 'W' });
|
const foe = '--WWS';
|
||||||
s = play(s, { right: 'S' }, { left: 'W' });
|
for (let i = 0; i < 5; i++) {
|
||||||
s = play(s, { right: 'S' }, { left: 'S', casts: [{ hand: 'left', spellId: 'counter_spell' }] });
|
const last = i === 4;
|
||||||
s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'finger_of_death' }] }, { left: 'W' });
|
s = play(
|
||||||
expect(s.wizards.B.alive).toBe(true);
|
s,
|
||||||
s = play(s, {}, { left: 'W' });
|
{ left: left[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'fireball' }] : [] },
|
||||||
s = play(s, {}, { left: 'S', casts: [{ hand: 'left', spellId: 'counter_spell' }] });
|
{ left: foe[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'counter_spell' }] : [] }
|
||||||
expect(s.wizards.B.hp).toBe(10);
|
);
|
||||||
// Now the real finger of death lands through a counter-spell.
|
}
|
||||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
expect(s.wizards.B.hp).toBe(15);
|
||||||
t = runSequence(t, 'A', 'PWPFSSSD', '--------', () => ({ casts: [{ hand: 'left', spellId: 'finger_of_death' }] }));
|
expect(s.log.some((l) => l.text.includes('smothers the Fireball'))).toBe(true);
|
||||||
expect(t.wizards.B.alive).toBe(false);
|
|
||||||
expect(t.over?.winner).toBe('A');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counter-spell smothers a finger of death only via dispel magic', () => {
|
it('a counter-spell cannot stop a finger of death', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
for (let i = 0; i < 7; i++) s = play(s, { left: 'PWPFSSS'[i] as Gesture }, { left: 'C', right: 'C' });
|
const left = 'PWPFSSSD';
|
||||||
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'finger_of_death' }] }, { left: 'D' });
|
const foe = '-----WWS';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const last = i === 7;
|
||||||
|
s = play(
|
||||||
|
s,
|
||||||
|
{ left: left[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'finger_of_death' }] : [] },
|
||||||
|
{ left: foe[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'counter_spell' }] : [] }
|
||||||
|
);
|
||||||
|
}
|
||||||
expect(s.wizards.B.alive).toBe(false);
|
expect(s.wizards.B.alive).toBe(false);
|
||||||
|
expect(s.over?.winner).toBe('A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispel magic is the one thing that stops a finger of death', () => {
|
||||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
const bLeft = '----CDPW';
|
const bLeft = '----CDPW';
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
@@ -136,7 +122,7 @@ describe('resolution', () => {
|
|||||||
|
|
||||||
it('a summoned goblin attacks the same turn and dies to a stab', () => {
|
it('a summoned goblin attacks the same turn and dies to a stab', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'SFW', '---', () => ({ casts: [{ hand: 'left', spellId: 'summon_goblin', monsterTarget: 'B' }] }));
|
s = runSequence(s, 'A', 'SFW', '---', { casts: [{ hand: 'left', spellId: 'summon_goblin', monsterTarget: 'B' }] });
|
||||||
expect(s.monsters).toHaveLength(1);
|
expect(s.monsters).toHaveLength(1);
|
||||||
expect(s.wizards.B.hp).toBe(14);
|
expect(s.wizards.B.hp).toBe(14);
|
||||||
s = play(s, {}, { left: '>', right: 'P', stabTarget: 'm1', casts: [{ hand: 'right', spellId: 'shield' }] });
|
s = play(s, {}, { left: '>', right: 'P', stabTarget: 'm1', casts: [{ hand: 'right', spellId: 'shield' }] });
|
||||||
@@ -146,7 +132,7 @@ describe('resolution', () => {
|
|||||||
|
|
||||||
it('amnesia forces last turn’s gestures and P+P is a surrender', () => {
|
it('amnesia forces last turn’s gestures and P+P is a surrender', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'DPP', 'W--', () => ({ casts: [{ hand: 'left', spellId: 'amnesia' }] }));
|
s = runSequence(s, 'A', 'DPP', 'W--', { casts: [{ hand: 'left', spellId: 'amnesia' }] });
|
||||||
// B was idle, so amnesia makes B repeat nothing with both hands.
|
// B was idle, so amnesia makes B repeat nothing with both hands.
|
||||||
expect(s.wizards.B.constraints.amnesia).toBe(true);
|
expect(s.wizards.B.constraints.amnesia).toBe(true);
|
||||||
s = play(s, {}, { left: 'F', right: 'F' });
|
s = play(s, {}, { left: 'F', right: 'F' });
|
||||||
@@ -167,7 +153,7 @@ describe('resolution', () => {
|
|||||||
|
|
||||||
it('fear bans C, D, F and S next turn', () => {
|
it('fear bans C, D, F and S next turn', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'SWD', '---', () => ({ casts: [{ hand: 'left', spellId: 'fear' }] }));
|
s = runSequence(s, 'A', 'SWD', '---', { casts: [{ hand: 'left', spellId: 'fear' }] });
|
||||||
s = play(s, {}, { left: 'D', right: 'W' });
|
s = play(s, {}, { left: 'D', right: 'W' });
|
||||||
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: '-', right: 'W' });
|
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: '-', right: 'W' });
|
||||||
});
|
});
|
||||||
@@ -184,8 +170,8 @@ describe('resolution', () => {
|
|||||||
|
|
||||||
it('resist heat makes a fireball harmless and a magic mirror reflects it', () => {
|
it('resist heat makes a fireball harmless and a magic mirror reflects it', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'B', 'WWFP', '----', () => ({ casts: [{ hand: 'left', spellId: 'resist_heat' }] }));
|
s = runSequence(s, 'B', 'WWFP', '----', { casts: [{ hand: 'left', spellId: 'resist_heat' }] });
|
||||||
s = runSequence(s, 'A', 'FSSDD', '-----', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
s = runSequence(s, 'A', 'FSSDD', '-----', { casts: [{ hand: 'left', spellId: 'fireball' }] });
|
||||||
expect(s.wizards.B.hp).toBe(15);
|
expect(s.wizards.B.hp).toBe(15);
|
||||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
@@ -207,8 +193,8 @@ describe('resolution', () => {
|
|||||||
|
|
||||||
it('fire storm and ice storm cancel; fire storm spares the heat-resistant', () => {
|
it('fire storm and ice storm cancel; fire storm spares the heat-resistant', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'WWFP', '----', () => ({ casts: [{ hand: 'left', spellId: 'resist_heat' }] }));
|
s = runSequence(s, 'A', 'WWFP', '----', { casts: [{ hand: 'left', spellId: 'resist_heat' }] });
|
||||||
s = runSequence(s, 'A', 'SWWC', '---C', () => ({ casts: [{ hand: 'left', spellId: 'fire_storm' }] }));
|
s = runSequence(s, 'A', 'SWWC', '---C', { casts: [{ hand: 'left', spellId: 'fire_storm' }] });
|
||||||
expect(s.wizards.A.hp).toBe(15);
|
expect(s.wizards.A.hp).toBe(15);
|
||||||
expect(s.wizards.B.hp).toBe(10);
|
expect(s.wizards.B.hp).toBe(10);
|
||||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
@@ -226,15 +212,15 @@ describe('resolution', () => {
|
|||||||
|
|
||||||
it('the quick lightning bolt works once per duel', () => {
|
it('the quick lightning bolt works once per duel', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'WDDC', '---C', () => ({ casts: [{ hand: 'left', spellId: 'lightning_bolt_quick' }] }));
|
s = runSequence(s, 'A', 'WDDC', '---C', { casts: [{ hand: 'left', spellId: 'lightning_bolt_quick' }] });
|
||||||
expect(s.wizards.B.hp).toBe(10);
|
expect(s.wizards.B.hp).toBe(10);
|
||||||
s = runSequence(s, 'A', 'WDDC', '---C', () => ({ casts: [{ hand: 'left', spellId: 'lightning_bolt_quick' }] }));
|
s = runSequence(s, 'A', 'WDDC', '---C', { casts: [{ hand: 'left', spellId: 'lightning_bolt_quick' }] });
|
||||||
expect(s.wizards.B.hp).toBe(10);
|
expect(s.wizards.B.hp).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disease kills at the end of the sixth turn unless cured', () => {
|
it('disease kills at the end of the sixth turn unless cured', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'DSFFFC', '-----C', () => ({ casts: [{ hand: 'left', spellId: 'disease' }] }));
|
s = runSequence(s, 'A', 'DSFFFC', '-----C', { casts: [{ hand: 'left', spellId: 'disease' }] });
|
||||||
expect(s.wizards.B.diseaseTurns).toBe(5);
|
expect(s.wizards.B.diseaseTurns).toBe(5);
|
||||||
for (let i = 0; i < 4; i++) s = play(s, {});
|
for (let i = 0; i < 4; i++) s = play(s, {});
|
||||||
expect(s.wizards.B.alive).toBe(true);
|
expect(s.wizards.B.alive).toBe(true);
|
||||||
@@ -244,9 +230,9 @@ describe('resolution', () => {
|
|||||||
|
|
||||||
it('protection from evil covers four turns and dispel magic ends it', () => {
|
it('protection from evil covers four turns and dispel magic ends it', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'B', 'WWP', '---', () => ({ casts: [{ hand: 'left', spellId: 'protection_from_evil' }] }));
|
s = runSequence(s, 'B', 'WWP', '---', { casts: [{ hand: 'left', spellId: 'protection_from_evil' }] });
|
||||||
expect(s.wizards.B.protectionTurns).toBe(3);
|
expect(s.wizards.B.protectionTurns).toBe(3);
|
||||||
s = runSequence(s, 'A', 'SD', '--', () => ({ casts: [{ hand: 'left', spellId: 'missile' }] }));
|
s = runSequence(s, 'A', 'SD', '--', { casts: [{ hand: 'left', spellId: 'missile' }] });
|
||||||
expect(s.wizards.B.hp).toBe(15);
|
expect(s.wizards.B.hp).toBe(15);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -254,11 +240,7 @@ describe('resolution', () => {
|
|||||||
describe('bot', () => {
|
describe('bot', () => {
|
||||||
it('never surrenders by accident and only casts spells it completed', () => {
|
it('never surrenders by accident and only casts spells it completed', () => {
|
||||||
let s = createGame({ A: 'You', B: 'Bot' }, 7);
|
let s = createGame({ A: 'You', B: 'Bot' }, 7);
|
||||||
let rngSeed = 3;
|
const rng = lcg(3);
|
||||||
const rng = () => {
|
|
||||||
rngSeed = (rngSeed * 1664525 + 1013904223) >>> 0;
|
|
||||||
return rngSeed / 4294967296;
|
|
||||||
};
|
|
||||||
for (let i = 0; i < 40 && !s.over; i++) {
|
for (let i = 0; i < 40 && !s.over; i++) {
|
||||||
const bot = chooseBotTurn(s, 'B', rng);
|
const bot = chooseBotTurn(s, 'B', rng);
|
||||||
expect(bot.left === 'P' && bot.right === 'P').toBe(false);
|
expect(bot.left === 'P' && bot.right === 'P').toBe(false);
|
||||||
@@ -274,7 +256,7 @@ describe('bot', () => {
|
|||||||
describe('haste, time stop, delayed effect and permanency', () => {
|
describe('haste, time stop, delayed effect and permanency', () => {
|
||||||
it('a hastened wizard makes two pairs of gestures a turn and can cast from both', () => {
|
it('a hastened wizard makes two pairs of gestures a turn and can cast from both', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'PWPWWC', '-----C', () => ({ casts: [{ hand: 'left', spellId: 'haste' }] }));
|
s = runSequence(s, 'A', 'PWPWWC', '-----C', { casts: [{ hand: 'left', spellId: 'haste' }] });
|
||||||
expect(s.wizards.A.hasteTurns).toBe(3);
|
expect(s.wizards.A.hasteTurns).toBe(3);
|
||||||
s = play(s, { left: 'S', right: 'S', second: { left: 'D', right: 'D', casts: [{ hand: 'left', spellId: 'missile' }, { hand: 'right', spellId: 'missile' }] } });
|
s = play(s, { left: 'S', right: 'S', second: { left: 'D', right: 'D', casts: [{ hand: 'left', spellId: 'missile' }, { hand: 'right', spellId: 'missile' }] } });
|
||||||
expect(s.wizards.A.history).toHaveLength(8);
|
expect(s.wizards.A.history).toHaveLength(8);
|
||||||
@@ -312,9 +294,9 @@ describe('haste, time stop, delayed effect and permanency', () => {
|
|||||||
|
|
||||||
it('delayed effect banks the next spell until it is released', () => {
|
it('delayed effect banks the next spell until it is released', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'DWSSSP', '------', () => ({ casts: [{ hand: 'left', spellId: 'delayed_effect' }] }));
|
s = runSequence(s, 'A', 'DWSSSP', '------', { casts: [{ hand: 'left', spellId: 'delayed_effect' }] });
|
||||||
expect(s.wizards.A.delayedTurns).toBe(3);
|
expect(s.wizards.A.delayedTurns).toBe(3);
|
||||||
s = runSequence(s, 'A', 'SD', '--', () => ({ casts: [{ hand: 'left', spellId: 'missile' }] }));
|
s = runSequence(s, 'A', 'SD', '--', { casts: [{ hand: 'left', spellId: 'missile' }] });
|
||||||
expect(s.wizards.B.hp).toBe(15);
|
expect(s.wizards.B.hp).toBe(15);
|
||||||
expect(s.wizards.A.banked?.spellId).toBe('missile');
|
expect(s.wizards.A.banked?.spellId).toBe('missile');
|
||||||
expect(s.wizards.A.delayedTurns).toBe(0);
|
expect(s.wizards.A.delayedTurns).toBe(0);
|
||||||
@@ -342,22 +324,20 @@ describe('haste, time stop, delayed effect and permanency', () => {
|
|||||||
|
|
||||||
it('permanency makes the next enchantment last for the rest of the duel', () => {
|
it('permanency makes the next enchantment last for the rest of the duel', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = runSequence(s, 'A', 'SPFPSDW', '-------', () => ({ casts: [{ hand: 'left', spellId: 'permanency' }] }));
|
s = runSequence(s, 'A', 'SPFPSDW', '-------', { casts: [{ hand: 'left', spellId: 'permanency' }] });
|
||||||
expect(s.wizards.A.permanencyTurns).toBe(3);
|
expect(s.wizards.A.permanencyTurns).toBe(3);
|
||||||
s = runSequence(s, 'A', 'SWD', '---', () => ({ casts: [{ hand: 'left', spellId: 'fear' }] }));
|
s = runSequence(s, 'A', 'SWD', '---', { casts: [{ hand: 'left', spellId: 'fear' }] });
|
||||||
expect(s.wizards.A.permanencyTurns).toBe(0);
|
expect(s.wizards.A.permanencyTurns).toBe(0);
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
s = play(s, {}, { left: 'F', right: 'W' });
|
s = play(s, {}, { left: 'F', right: 'W' });
|
||||||
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: '-', right: 'W' });
|
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: '-', right: 'W' });
|
||||||
}
|
}
|
||||||
// Only a dispel magic can lift it now: B cannot point a digit to cast remove enchantment.
|
// Only a dispel magic can lift it now: B cannot point a digit to cast remove enchantment.
|
||||||
s = runSequence(s, 'A', 'CDPW', 'C---', () => ({ casts: [{ hand: 'left', spellId: 'dispel_magic' }] }));
|
s = runSequence(s, 'A', 'CDPW', 'C---', { casts: [{ hand: 'left', spellId: 'dispel_magic' }] });
|
||||||
s = play(s, {}, { left: 'F', right: 'W' });
|
s = play(s, {}, { left: 'F', right: 'W' });
|
||||||
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: 'F', right: 'W' });
|
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: 'F', right: 'W' });
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('haste under amnesia', () => {
|
|
||||||
it('repeats both of last turn\u2019s pairs', () => {
|
it('repeats both of last turn\u2019s pairs', () => {
|
||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
@@ -401,7 +381,7 @@ describe('lessons', () => {
|
|||||||
expect(s.lastTurn?.lessons.some((l) => l.includes('does not stop cause light wounds'))).toBe(true);
|
expect(s.lastTurn?.lessons.some((l) => l.includes('does not stop cause light wounds'))).toBe(true);
|
||||||
expect(s.lastTurn?.lessons.some((l) => l.includes('attacks on the very turn'))).toBe(false);
|
expect(s.lastTurn?.lessons.some((l) => l.includes('attacks on the very turn'))).toBe(false);
|
||||||
// The same situation again teaches nothing new.
|
// The same situation again teaches nothing new.
|
||||||
s = runSequence(s, 'A', 'WFP', '---', () => ({ casts: [{ hand: 'left', spellId: 'cause_light_wounds' }] }));
|
s = runSequence(s, 'A', 'WFP', '---', { casts: [{ hand: 'left', spellId: 'cause_light_wounds' }] });
|
||||||
s = play(s, {}, { left: 'P', casts: [{ hand: 'left', spellId: 'shield' }] });
|
s = play(s, {}, { left: 'P', casts: [{ hand: 'left', spellId: 'shield' }] });
|
||||||
expect(s.lastTurn?.lessons).toEqual([]);
|
expect(s.lastTurn?.lessons).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -410,8 +390,8 @@ describe('lessons', () => {
|
|||||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
s = play(s, { left: 'C' });
|
s = play(s, { left: 'C' });
|
||||||
expect(s.lastTurn?.lessons[0]).toContain('clap needs both hands');
|
expect(s.lastTurn?.lessons[0]).toContain('clap needs both hands');
|
||||||
s = runSequence(s, 'A', 'FFF', '---', () => ({ casts: [{ hand: 'left', spellId: 'paralysis' }] }));
|
s = runSequence(s, 'A', 'FFF', '---', { casts: [{ hand: 'left', spellId: 'paralysis' }] });
|
||||||
s = runSequence(s, 'A', 'SSDD', '----', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
s = runSequence(s, 'A', 'SSDD', '----', { casts: [{ hand: 'left', spellId: 'fireball' }] });
|
||||||
expect(s.lastTurn?.lessons.some((l) => l.includes('reused gestures from the earlier Paralysis'))).toBe(true);
|
expect(s.lastTurn?.lessons.some((l) => l.includes('reused gestures from the earlier Paralysis'))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,19 +108,16 @@ export function usedCells(completion: Completion, hand: Hand, turnCount: number)
|
|||||||
return cells;
|
return cells;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cellsOverlap(a: Set<string>, b: Set<string>): boolean {
|
||||||
|
for (const cell of b) if (a.has(cell)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** No gesture may complete more than one spell. */
|
/** No gesture may complete more than one spell. */
|
||||||
export function castsConflict(
|
export function castsConflict(
|
||||||
a: { completion: Completion; hand: Hand },
|
a: { completion: Completion; hand: Hand },
|
||||||
b: { completion: Completion; hand: Hand },
|
b: { completion: Completion; hand: Hand },
|
||||||
turnCount: number
|
turnCount: number
|
||||||
): boolean {
|
): boolean {
|
||||||
const cellsA = usedCells(a.completion, a.hand, turnCount);
|
return cellsOverlap(usedCells(a.completion, a.hand, turnCount), usedCells(b.completion, b.hand, turnCount));
|
||||||
for (const cell of usedCells(b.completion, b.hand, turnCount)) {
|
|
||||||
if (cellsA.has(cell)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatSequence(spell: Spell, seqIndex = 0): string {
|
|
||||||
return spell.sequences[seqIndex];
|
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-39
@@ -4,22 +4,25 @@
|
|||||||
// enchantments, damage, monster attacks and stabs, and finally deaths and
|
// enchantments, damage, monster attacks and stabs, and finally deaths and
|
||||||
// surrenders. A time stop resolves as a turn in which one wizard acts alone.
|
// surrenders. A time stop resolves as a turn in which one wizard acts alone.
|
||||||
|
|
||||||
import { completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn } from './gestures';
|
import { cellsOverlap, completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn } from './gestures';
|
||||||
import {
|
import {
|
||||||
BLANKET_SPELLS,
|
BLANKET_SPELLS,
|
||||||
CONTROL_SPELLS,
|
CONTROL_SPELLS,
|
||||||
|
GESTURES,
|
||||||
GESTURE_NAMES,
|
GESTURE_NAMES,
|
||||||
|
HANDS,
|
||||||
MAGIC_GESTURES,
|
MAGIC_GESTURES,
|
||||||
MONSTER_STATS,
|
MONSTER_STATS,
|
||||||
PERMANENCY_EXCLUDED,
|
|
||||||
SPELLS,
|
SPELLS,
|
||||||
SPELL_BY_ID,
|
SPELL_BY_ID,
|
||||||
SUMMON_KIND,
|
SUMMON_KIND,
|
||||||
isElemental,
|
isElemental,
|
||||||
|
permanencyEligible,
|
||||||
type Gesture,
|
type Gesture,
|
||||||
type Hand,
|
type Hand,
|
||||||
type MonsterKind,
|
type MonsterKind,
|
||||||
type Spell
|
type Spell,
|
||||||
|
type SpellId
|
||||||
} from './spells';
|
} from './spells';
|
||||||
import {
|
import {
|
||||||
MAX_HP,
|
MAX_HP,
|
||||||
@@ -70,9 +73,8 @@ export function paralysedForm(g: Gesture): Gesture {
|
|||||||
|
|
||||||
export const FEAR_BANNED: Gesture[] = ['C', 'D', 'F', 'S'];
|
export const FEAR_BANNED: Gesture[] = ['C', 'D', 'F', 'S'];
|
||||||
|
|
||||||
function describe(g: Gesture): string {
|
/** A counter-spell cannot touch these. */
|
||||||
return GESTURE_NAMES[g];
|
const UNCOUNTERABLE: SpellId[] = ['finger_of_death', 'counter_spell', 'magic_mirror'];
|
||||||
}
|
|
||||||
|
|
||||||
function roman(n: number): string {
|
function roman(n: number): string {
|
||||||
const numerals: [number, string][] = [
|
const numerals: [number, string][] = [
|
||||||
@@ -114,10 +116,6 @@ function clearMonsterEnchantments(m: Monster): void {
|
|||||||
m.pending = {};
|
m.pending = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function permanencyEligible(spell: Spell): boolean {
|
|
||||||
return spell.category === 'enchantment' && !PERMANENCY_EXCLUDED.includes(spell.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last turn's gestures as [first pair, second pair]: a wizard who was hastened
|
* Last turn's gestures as [first pair, second pair]: a wizard who was hastened
|
||||||
* repeats both pairs under amnesia, and each hand stays paralysed in its
|
* repeats both pairs under amnesia, and each hand stays paralysed in its
|
||||||
@@ -202,7 +200,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
overlaps: [] as { name: string; spell: string; earlier: string }[],
|
overlaps: [] as { name: string; spell: string; earlier: string }[],
|
||||||
broken: [] as { name: string; hand: Hand }[],
|
broken: [] as { name: string; hand: Hand }[],
|
||||||
curedAndHurt: [] as { name: string; hurt: number; healed: number }[],
|
curedAndHurt: [] as { name: string; hurt: number; healed: number }[],
|
||||||
newMonsterAttacked: '' as string
|
newMonsterAttacked: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- 1. Gestures, after last turn's enchantments have their say ----
|
// ---- 1. Gestures, after last turn's enchantments have their say ----
|
||||||
@@ -216,13 +214,13 @@ 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 ${describe(g)}.`);
|
log(`${W[c.charmed.by].name} charms ${w.name}'s ${c.charmed.hand} hand 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 ${describe(g)}.`);
|
log(`${w.name}'s ${c.paralysedHand} hand is paralysed into a ${GESTURE_NAMES[g]}.`);
|
||||||
}
|
}
|
||||||
if (c.fear) {
|
if (c.fear) {
|
||||||
if (FEAR_BANNED.includes(left)) left = '-';
|
if (FEAR_BANNED.includes(left)) left = '-';
|
||||||
@@ -234,18 +232,18 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
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 ${describe(slip.gesture)} instead.`);
|
log(`Confused, ${w.name}'s ${slip.hand} hand makes a ${GESTURE_NAMES[slip.gesture]} instead.`);
|
||||||
}
|
}
|
||||||
if (left === '>' && right === '>') right = '-';
|
if (left === '>' && right === '>') right = '-';
|
||||||
return [left, right];
|
return [left, right];
|
||||||
};
|
};
|
||||||
const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : describe(g));
|
const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : GESTURE_NAMES[g]);
|
||||||
const record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => {
|
const record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => {
|
||||||
const hiddenFrom: WizardId[] = [];
|
const hiddenFrom: WizardId[] = [];
|
||||||
const foe = enemyOf(w.id);
|
const foe = enemyOf(w.id);
|
||||||
if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe);
|
if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe);
|
||||||
if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name);
|
if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name);
|
||||||
for (const hand of ['left', 'right'] as Hand[]) {
|
for (const hand of HANDS) {
|
||||||
const g = hand === 'left' ? left : right;
|
const g = hand === 'left' ? left : right;
|
||||||
if (g !== '>' && g !== '-') continue;
|
if (g !== '>' && g !== '-') continue;
|
||||||
const seq = handSequence(w.history, hand);
|
const seq = handSequence(w.history, hand);
|
||||||
@@ -300,11 +298,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cells = usedCells(completion, choice.hand, index + 1);
|
const cells = usedCells(completion, choice.hand, index + 1);
|
||||||
const clash = accepted.some((a) => {
|
const clash = accepted.some((a) => !!a.completion && cellsOverlap(cells, usedCells(a.completion, a.hand, a.index + 1)));
|
||||||
if (!a.completion) return false;
|
|
||||||
for (const cell of usedCells(a.completion, a.hand, a.index + 1)) if (cells.has(cell)) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (clash) {
|
if (clash) {
|
||||||
log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle');
|
log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle');
|
||||||
continue;
|
continue;
|
||||||
@@ -316,7 +310,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
);
|
);
|
||||||
if (earlier) facts.overlaps.push({ name: w.name, spell: completion.spell.name, earlier: SPELL_BY_ID[earlier.spellId].name });
|
if (earlier) facts.overlaps.push({ name: w.name, spell: completion.spell.name, earlier: SPELL_BY_ID[earlier.spellId].name });
|
||||||
accepted.push({ caster: id, hand: choice.hand, spell: completion.spell, target, choice, completion, index });
|
accepted.push({ caster: id, hand: choice.hand, spell: completion.spell, target, choice, completion, index });
|
||||||
w.casts.push({ turn, index, hand: choice.hand, spellId: completion.spell.id, length: completion.length, target });
|
w.casts.push({ turn, index, hand: choice.hand, spellId: completion.spell.id, target });
|
||||||
const at = BLANKET_SPELLS.includes(completion.spell.id) || target === id ? '' : ` at ${beingName(state, target)}`;
|
const at = BLANKET_SPELLS.includes(completion.spell.id) || target === id ? '' : ` at ${beingName(state, target)}`;
|
||||||
log(`${w.name} casts ${completion.spell.name}${at} (${completion.spell.sequences[completion.seqIndex]}, ${choice.hand} hand).`, 'spell', false);
|
log(`${w.name} casts ${completion.spell.name}${at} (${completion.spell.sequences[completion.seqIndex]}, ${choice.hand} hand).`, 'spell', false);
|
||||||
if (completion.spell.id === 'lightning_bolt_quick') {
|
if (completion.spell.id === 'lightning_bolt_quick') {
|
||||||
@@ -338,7 +332,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
index: -1,
|
index: -1,
|
||||||
released: true
|
released: true
|
||||||
});
|
});
|
||||||
w.casts.push({ turn, index: -1, hand: 'left', spellId: spell.id, length: 0, 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(`${w.name} releases the banked ${spell.name}${at}.`, 'spell');
|
||||||
@@ -398,18 +392,25 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
log(`${W[owner].name}'s magic mirror turns the ${c.spell.name} back on ${W[original].name}.`, 'spell');
|
log(`${W[owner].name}'s magic mirror turns the ${c.spell.name} back on ${W[original].name}.`, 'spell');
|
||||||
}
|
}
|
||||||
for (const c of live()) {
|
for (const c of live()) {
|
||||||
if (['dispel_magic', 'finger_of_death', 'counter_spell', 'magic_mirror'].includes(c.spell.id)) continue;
|
if (UNCOUNTERABLE.includes(c.spell.id)) continue;
|
||||||
if (BLANKET_SPELLS.includes(c.spell.id)) continue;
|
if (BLANKET_SPELLS.includes(c.spell.id)) continue;
|
||||||
if (countered.has(c.target)) {
|
if (countered.has(c.target)) {
|
||||||
c.nullified = 'countered';
|
c.nullified = 'countered';
|
||||||
facts.counterSmothered += 1;
|
facts.counterSmothered += 1;
|
||||||
log(`A counter-spell on ${beingName(state, c.target)} smothers the ${c.spell.name}.`, 'fizzle');
|
const subject = beingName(state, c.target);
|
||||||
|
const own = c.caster === c.target;
|
||||||
|
log(
|
||||||
|
c.spell.id === 'shield'
|
||||||
|
? `A counter-spell on ${subject} smothers the Shield, and shields ${subject} itself.`
|
||||||
|
: `A counter-spell on ${subject} smothers ${own ? 'their own ' : 'the '}${c.spell.name}.`,
|
||||||
|
'fizzle'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (live().some((c) => c.spell.id === 'finger_of_death' && countered.has(c.target))) facts.fingerThroughCounter = true;
|
if (live().some((c) => c.spell.id === 'finger_of_death' && countered.has(c.target))) facts.fingerThroughCounter = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 4b. Delayed effect banks a spell; permanency extends one ----
|
// ---- 5. Delayed effect banks a spell; permanency extends one ----
|
||||||
const bankedNow = new Set<WizardId>();
|
const bankedNow = new Set<WizardId>();
|
||||||
const extendedNow = new Set<WizardId>();
|
const extendedNow = new Set<WizardId>();
|
||||||
for (const id of acting) {
|
for (const id of acting) {
|
||||||
@@ -438,7 +439,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 5. Who is shielded this turn ----
|
// ---- 6. Who is shielded this turn ----
|
||||||
for (const c of live()) {
|
for (const c of live()) {
|
||||||
if (['shield', 'counter_spell', 'dispel_magic', 'protection_from_evil'].includes(c.spell.id)) shielded.add(c.target);
|
if (['shield', 'counter_spell', 'dispel_magic', 'protection_from_evil'].includes(c.spell.id)) shielded.add(c.target);
|
||||||
}
|
}
|
||||||
@@ -446,7 +447,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
for (const m of state.monsters) if (m.protectionTurns > 0) shielded.add(m.id);
|
for (const m of state.monsters) if (m.protectionTurns > 0) shielded.add(m.id);
|
||||||
const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id);
|
const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id);
|
||||||
|
|
||||||
// ---- 6. Summons, then storms and elementals ----
|
// ---- 7. Summons, then storms and elementals ----
|
||||||
for (const c of live()) {
|
for (const c of live()) {
|
||||||
const kind: MonsterKind | undefined =
|
const kind: MonsterKind | undefined =
|
||||||
c.spell.id === 'summon_elemental'
|
c.spell.id === 'summon_elemental'
|
||||||
@@ -548,7 +549,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 7. Enchantments ----
|
// ---- 8. Enchantments ----
|
||||||
const controlByTarget = new Map<TargetId, Set<string>>();
|
const controlByTarget = new Map<TargetId, Set<string>>();
|
||||||
for (const c of live()) {
|
for (const c of live()) {
|
||||||
if (!CONTROL_SPELLS.includes(c.spell.id)) continue;
|
if (!CONTROL_SPELLS.includes(c.spell.id)) continue;
|
||||||
@@ -586,7 +587,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
break;
|
break;
|
||||||
case 'charm_person':
|
case 'charm_person':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
const hand = c.choice.chosenHand ?? pick(['left', 'right'] as Hand[]);
|
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(`${wiz.name}'s ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
|
||||||
@@ -711,7 +712,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 8. Damage and healing ----
|
// ---- 9. Damage and healing ----
|
||||||
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>();
|
||||||
@@ -794,7 +795,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 9. Monsters attack, including those slain this very turn ----
|
// ---- 10. Monsters attack, including those slain this very turn ----
|
||||||
for (const m of state.monsters) {
|
for (const m of state.monsters) {
|
||||||
if (destroyedBeforeAttack.has(m.id)) continue;
|
if (destroyedBeforeAttack.has(m.id)) continue;
|
||||||
if (frozen(m.owner)) continue;
|
if (frozen(m.owner)) continue;
|
||||||
@@ -834,7 +835,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 10. Stabs ----
|
// ---- 11. Stabs ----
|
||||||
for (const id of acting) {
|
for (const id of acting) {
|
||||||
const w = W[id];
|
const w = W[id];
|
||||||
entries[id].forEach((index, half) => {
|
entries[id].forEach((index, half) => {
|
||||||
@@ -852,7 +853,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 11. Apply the ledger ----
|
// ---- 12. Apply the ledger ----
|
||||||
for (const id of IDS) {
|
for (const id of IDS) {
|
||||||
const w = W[id];
|
const w = W[id];
|
||||||
if (!w.alive) continue;
|
if (!w.alive) continue;
|
||||||
@@ -883,7 +884,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- 12. The turn ends ----
|
// ---- 13. The turn ends ----
|
||||||
if (phase === 'main') {
|
if (phase === 'main') {
|
||||||
for (const id of IDS) {
|
for (const id of IDS) {
|
||||||
const w = W[id];
|
const w = W[id];
|
||||||
@@ -962,7 +963,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
if (facts.fingerThroughCounter) {
|
if (facts.fingerThroughCounter) {
|
||||||
lessons.push({ key: 'counter-finger', text: 'A counter-spell cannot stop finger of death. Only dispel magic can, or an anti-spell before the eight gestures are finished.' });
|
lessons.push({ key: 'counter-finger', text: 'A counter-spell cannot stop finger of death. Only dispel magic can, or an anti-spell before the eight gestures are finished.' });
|
||||||
} else if (facts.counterSmothered > 0) {
|
} else if (facts.counterSmothered > 0) {
|
||||||
lessons.push({ key: 'counter-scope', text: 'A counter-spell stops every other spell cast at its subject that turn and shields them as well. It cannot stop finger of death or dispel magic.' });
|
lessons.push({ key: 'counter-scope', text: 'A counter-spell stops every other spell cast at its subject that turn, even the subject\'s own, and shields them as well. It cannot stop finger of death or dispel magic.' });
|
||||||
}
|
}
|
||||||
if (facts.loneClap.length > 0) {
|
if (facts.loneClap.length > 0) {
|
||||||
lessons.push({ key: 'lone-clap', text: `A clap needs both hands at once. ${facts.loneClap[0]}'s single-handed clap counts as nothing, and that hand's spells start from scratch.` });
|
lessons.push({ key: 'lone-clap', text: `A clap needs both hands at once. ${facts.loneClap[0]}'s single-handed clap counts as nothing, and that hand's spells start from scratch.` });
|
||||||
@@ -994,12 +995,11 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
|
|
||||||
/** Gestures a hand may choose this turn, given last turn's enchantments. */
|
/** Gestures a hand may choose this turn, given last turn's enchantments. */
|
||||||
export function allowedGestures(w: WizardState, hand: Hand): Gesture[] {
|
export function allowedGestures(w: WizardState, hand: Hand): Gesture[] {
|
||||||
const all: Gesture[] = ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
|
|
||||||
if (w.constraints.amnesia) return [];
|
if (w.constraints.amnesia) return [];
|
||||||
if (w.constraints.paralysedHand === hand) return [];
|
if (w.constraints.paralysedHand === hand) return [];
|
||||||
if (w.constraints.charmed?.hand === hand) return [];
|
if (w.constraints.charmed?.hand === hand) return [];
|
||||||
if (w.constraints.fear) return all.filter((g) => !FEAR_BANNED.includes(g));
|
if (w.constraints.fear) return GESTURES.filter((g) => !FEAR_BANNED.includes(g));
|
||||||
return all;
|
return GESTURES;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The gesture a hand is forced to make this turn, if any is known in advance. */
|
/** The gesture a hand is forced to make this turn, if any is known in advance. */
|
||||||
|
|||||||
@@ -2,14 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { chooseBotTurn } from './bot';
|
import { chooseBotTurn } from './bot';
|
||||||
import { resolveTurn } from './resolve';
|
import { resolveTurn } from './resolve';
|
||||||
import { createGame } from './state';
|
import { createGame } from './state';
|
||||||
|
import { lcg } from './test-helpers';
|
||||||
function lcg(seed: number) {
|
|
||||||
let s = seed >>> 0;
|
|
||||||
return () => {
|
|
||||||
s = (s * 1664525 + 1013904223) >>> 0;
|
|
||||||
return s / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('bot versus bot', () => {
|
describe('bot versus bot', () => {
|
||||||
it('sixty duels end without errors and by death, not surrender', () => {
|
it('sixty duels end without errors and by death, not surrender', () => {
|
||||||
|
|||||||
+45
-16
@@ -81,8 +81,6 @@ export interface Spell {
|
|||||||
tokens: Token[][];
|
tokens: Token[][];
|
||||||
usualTarget: 'self' | 'enemy';
|
usualTarget: 'self' | 'enemy';
|
||||||
summary: string;
|
summary: string;
|
||||||
/** False for spells the engine recognises but does not yet resolve. */
|
|
||||||
implemented: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSequence(sequence: string): Token[] {
|
export function parseSequence(sequence: string): Token[] {
|
||||||
@@ -100,8 +98,7 @@ function spell(
|
|||||||
category: SpellCategory,
|
category: SpellCategory,
|
||||||
sequences: string[],
|
sequences: string[],
|
||||||
usualTarget: 'self' | 'enemy',
|
usualTarget: 'self' | 'enemy',
|
||||||
summary: string,
|
summary: string
|
||||||
implemented = true
|
|
||||||
): Spell {
|
): Spell {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -110,8 +107,7 @@ function spell(
|
|||||||
sequences,
|
sequences,
|
||||||
tokens: sequences.map(parseSequence),
|
tokens: sequences.map(parseSequence),
|
||||||
usualTarget,
|
usualTarget,
|
||||||
summary,
|
summary
|
||||||
implemented
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +154,8 @@ export const SPELLS: Spell[] = [
|
|||||||
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.'),
|
||||||
spell('haste', 'Haste', 'enchantment', ['P-W-P-W-W-C'], 'self', 'For the next three turns the subject makes two pairs of gestures a turn, both taking effect together.'),
|
spell('haste', 'Haste', 'enchantment', ['P-W-P-W-W-C'], 'self', 'For the next three turns the subject makes two pairs of gestures a turn, both taking effect together.'),
|
||||||
spell('time_stop', 'Time stop', 'enchantment', ['S-P-P-C'], 'self', 'The subject at once takes an extra turn nobody else can see or resist.'),
|
spell('time_stop', 'Time stop', 'enchantment', ['S-P-P-C'], 'self', 'The subject at once takes an extra turn nobody else can see or resist.'),
|
||||||
spell('delayed_effect', 'Delayed effect', 'enchantment', ['D-W-S-S-S-P'], 'self', 'The subject\u2019s next spell (this turn or the next three) is banked, to be released whenever they choose.'),
|
spell('delayed_effect', 'Delayed effect', 'enchantment', ['D-W-S-S-S-P'], 'self', 'The subject’s next spell (this turn or the next three) is banked, to be released whenever they choose.'),
|
||||||
spell('permanency', 'Permanency', 'enchantment', ['S-P-F-P-S-D-W'], 'self', 'The subject\u2019s next enchantment (this turn or the next three) lasts for the rest of the duel.')
|
spell('permanency', 'Permanency', 'enchantment', ['S-P-F-P-S-D-W'], 'self', 'The subject’s next enchantment (this turn or the next three) lasts for the rest of the duel.')
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SPELL_BY_ID: Record<SpellId, Spell> = Object.fromEntries(
|
export const SPELL_BY_ID: Record<SpellId, Spell> = Object.fromEntries(
|
||||||
@@ -176,19 +172,45 @@ export const CONTROL_SPELLS: SpellId[] = [
|
|||||||
'fear'
|
'fear'
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Enchantments a permanency can extend for the rest of the duel. */
|
/** Enchantments a permanency cannot extend. */
|
||||||
export const PERMANENCY_EXCLUDED: SpellId[] = ['anti_spell', 'disease', 'poison', 'time_stop', 'permanency'];
|
export const PERMANENCY_EXCLUDED: SpellId[] = ['anti_spell', 'disease', 'poison', 'time_stop', 'permanency'];
|
||||||
|
|
||||||
|
export function permanencyEligible(spell: Spell): boolean {
|
||||||
|
return spell.category === 'enchantment' && !PERMANENCY_EXCLUDED.includes(spell.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spells worth warning about, or defending against, when the other wizard's hands approach them. */
|
||||||
|
export const HOSTILE_SPELLS: SpellId[] = [
|
||||||
|
'missile',
|
||||||
|
'finger_of_death',
|
||||||
|
'lightning_bolt',
|
||||||
|
'lightning_bolt_quick',
|
||||||
|
'cause_light_wounds',
|
||||||
|
'cause_heavy_wounds',
|
||||||
|
'fireball',
|
||||||
|
'fire_storm',
|
||||||
|
'ice_storm',
|
||||||
|
'amnesia',
|
||||||
|
'confusion',
|
||||||
|
'charm_person',
|
||||||
|
'charm_monster',
|
||||||
|
'paralysis',
|
||||||
|
'fear',
|
||||||
|
'anti_spell',
|
||||||
|
'disease',
|
||||||
|
'poison',
|
||||||
|
'blindness',
|
||||||
|
'remove_enchantment',
|
||||||
|
'summon_goblin',
|
||||||
|
'summon_ogre',
|
||||||
|
'summon_troll',
|
||||||
|
'summon_giant',
|
||||||
|
'summon_elemental'
|
||||||
|
];
|
||||||
|
|
||||||
/** Spells that affect everyone rather than one subject. */
|
/** Spells that affect everyone rather than one subject. */
|
||||||
export const BLANKET_SPELLS: SpellId[] = ['fire_storm', 'ice_storm'];
|
export const BLANKET_SPELLS: SpellId[] = ['fire_storm', 'ice_storm'];
|
||||||
|
|
||||||
export const SUMMON_KIND: Partial<Record<SpellId, MonsterKind>> = {
|
|
||||||
summon_goblin: 'goblin',
|
|
||||||
summon_ogre: 'ogre',
|
|
||||||
summon_troll: 'troll',
|
|
||||||
summon_giant: 'giant'
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MonsterKind = 'goblin' | 'ogre' | 'troll' | 'giant' | 'fire_elemental' | 'ice_elemental';
|
export type MonsterKind = 'goblin' | 'ogre' | 'troll' | 'giant' | 'fire_elemental' | 'ice_elemental';
|
||||||
|
|
||||||
export const MONSTER_STATS: Record<MonsterKind, { name: string; hp: number; attack: number }> = {
|
export const MONSTER_STATS: Record<MonsterKind, { name: string; hp: number; attack: number }> = {
|
||||||
@@ -203,3 +225,10 @@ export const MONSTER_STATS: Record<MonsterKind, { name: string; hp: number; atta
|
|||||||
export function isElemental(kind: MonsterKind): boolean {
|
export function isElemental(kind: MonsterKind): boolean {
|
||||||
return kind === 'fire_elemental' || kind === 'ice_elemental';
|
return kind === 'fire_elemental' || kind === 'ice_elemental';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SUMMON_KIND: Partial<Record<SpellId, MonsterKind>> = {
|
||||||
|
summon_goblin: 'goblin',
|
||||||
|
summon_ogre: 'ogre',
|
||||||
|
summon_troll: 'troll',
|
||||||
|
summon_giant: 'giant'
|
||||||
|
};
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export interface CastRecord {
|
|||||||
index: number;
|
index: number;
|
||||||
hand: Hand;
|
hand: Hand;
|
||||||
spellId: SpellId;
|
spellId: SpellId;
|
||||||
length: number;
|
|
||||||
target: TargetId;
|
target: TargetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +170,11 @@ export interface TurnInput {
|
|||||||
charmGesture?: Gesture;
|
charmGesture?: Gesture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Under any lasting enchantment a remove enchantment or dispel magic would strip. */
|
||||||
|
export function isEnchanted(w: WizardState): boolean {
|
||||||
|
return w.resistHeat || w.resistCold || w.protectionTurns > 0 || w.invisibleTurns > 0 || w.hasteTurns > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function enemyOf(id: WizardId): WizardId {
|
export function enemyOf(id: WizardId): WizardId {
|
||||||
return id === 'A' ? 'B' : 'A';
|
return id === 'A' ? 'B' : 'A';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { resolveTurn } from './resolve';
|
||||||
|
import type { Gesture } from './spells';
|
||||||
|
import type { GameState, TurnInput, WizardId } from './state';
|
||||||
|
|
||||||
|
export const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] });
|
||||||
|
|
||||||
|
/** One turn with partial inputs for each wizard; anything unspecified is a rest. */
|
||||||
|
export function play(state: GameState, a: Partial<TurnInput>, b: Partial<TurnInput> = {}): GameState {
|
||||||
|
return resolveTurn(state, { A: { ...noop(), ...a }, B: { ...noop(), ...b } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed one wizard gesture strings turn by turn while the other rests, casting on the final turn. */
|
||||||
|
export function runSequence(
|
||||||
|
state: GameState,
|
||||||
|
who: WizardId,
|
||||||
|
left: string,
|
||||||
|
right: string,
|
||||||
|
finalCasts: Partial<TurnInput> = {}
|
||||||
|
): GameState {
|
||||||
|
for (let i = 0; i < left.length; i++) {
|
||||||
|
const input = { left: left[i] as Gesture, right: right[i] as Gesture, ...(i === left.length - 1 ? finalCasts : {}) };
|
||||||
|
state = who === 'A' ? play(state, input) : play(state, {}, input);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small linear congruential generator, so bot decisions in tests are repeatable. */
|
||||||
|
export function lcg(seed: number): () => number {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s * 1664525 + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
+36
-92
@@ -6,7 +6,7 @@
|
|||||||
import SpellSheet from '$lib/components/SpellSheet.svelte';
|
import SpellSheet from '$lib/components/SpellSheet.svelte';
|
||||||
import TurnSummary from '$lib/components/TurnSummary.svelte';
|
import TurnSummary from '$lib/components/TurnSummary.svelte';
|
||||||
import WizardStatus from '$lib/components/WizardStatus.svelte';
|
import WizardStatus from '$lib/components/WizardStatus.svelte';
|
||||||
import { duel, FOE, YOU, NAME_MAX, castKey, tokensText } from '$lib/game/duel.svelte';
|
import { COMPACT_QUERY, duel, FOE, YOU, NAME_MAX, castKey, tokensText } from '$lib/game/duel.svelte';
|
||||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||||
import { MAGIC_GESTURES } from '$lib/game/spells';
|
import { MAGIC_GESTURES } from '$lib/game/spells';
|
||||||
|
|
||||||
@@ -34,12 +34,12 @@
|
|||||||
duel.newGame();
|
duel.newGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the duel whenever anything about it changes, and follow the window width.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
duel.persist();
|
duel.persist();
|
||||||
});
|
});
|
||||||
|
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const query = window.matchMedia('(max-width: 860px)');
|
const query = window.matchMedia(COMPACT_QUERY);
|
||||||
const apply = () => (duel.compact = query.matches);
|
const apply = () => (duel.compact = query.matches);
|
||||||
apply();
|
apply();
|
||||||
query.addEventListener('change', apply);
|
query.addEventListener('change', apply);
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<h1>Waving Hands</h1>
|
<h1>Waving Hands</h1>
|
||||||
{#if renaming}
|
{#if renaming}
|
||||||
<form class="rename" onsubmit={saveName}>
|
<form class="rename" onsubmit={saveName}>
|
||||||
<label>
|
<label class="field">
|
||||||
<span>Your name</span>
|
<span>Your name</span>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
|
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="standfirst">
|
<p class="standfirst">
|
||||||
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
|
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
|
||||||
{#if duel.state.turn === 0 && !duel.timeStopped}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if}
|
{#if duel.state.turn === 0}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -111,9 +111,7 @@
|
|||||||
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
|
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if duel.timeStopped}
|
{#if duel.threats.length && !duel.timeStopped}
|
||||||
<!-- nobody else moves -->
|
|
||||||
{:else if duel.threats.length}
|
|
||||||
<div class="threats">
|
<div class="threats">
|
||||||
{#if duel.threatsNow.length}
|
{#if duel.threatsNow.length}
|
||||||
<p>Their hands could finish this turn:</p>
|
<p>Their hands could finish this turn:</p>
|
||||||
@@ -132,23 +130,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if duel.threatsNext.length}
|
{#if duel.threatsNext.length}
|
||||||
<details class="next" open={!duel.compact}>
|
<details class="next" open={!duel.compact}>
|
||||||
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
|
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
|
||||||
<ul>
|
<ul>
|
||||||
{#each duel.threatsNext as t (t.hand + t.spell.id)}
|
{#each duel.threatsNext as t (t.hand + t.spell.id)}
|
||||||
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from && duel.highlight.to === t.to}
|
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from && duel.highlight.to === t.to}
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
|
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
|
||||||
<span class="thand">{t.hand}</span>
|
<span class="thand">{t.hand}</span>
|
||||||
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
|
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
|
||||||
<span class="tname">{t.spell.name}</span>
|
<span class="tname">{t.spell.name}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if !duel.timeStopped}
|
||||||
<p class="threats muted">Nothing hostile is one gesture away.</p>
|
<p class="threats muted">Nothing hostile is one gesture away.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -176,7 +174,7 @@
|
|||||||
|
|
||||||
<div class="orders">
|
<div class="orders">
|
||||||
{#if duel.stabbing}
|
{#if duel.stabbing}
|
||||||
<label>
|
<label class="field">
|
||||||
<span>Stab</span>
|
<span>Stab</span>
|
||||||
<select bind:value={duel.stabTarget}>
|
<select bind:value={duel.stabTarget}>
|
||||||
<option value={FOE}>{duel.foe.name}</option>
|
<option value={FOE}>{duel.foe.name}</option>
|
||||||
@@ -187,7 +185,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
{#each duel.yourMonsters as m (m.id)}
|
{#each duel.yourMonsters as m (m.id)}
|
||||||
<label>
|
<label class="field">
|
||||||
<span>{m.name} attacks</span>
|
<span>{m.name} attacks</span>
|
||||||
<select bind:value={duel.monsterOrders[m.id]}>
|
<select bind:value={duel.monsterOrders[m.id]}>
|
||||||
<option value={FOE}>{duel.foe.name}</option>
|
<option value={FOE}>{duel.foe.name}</option>
|
||||||
@@ -198,7 +196,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
{#if duel.bankedSpell}
|
{#if duel.bankedSpell}
|
||||||
<label>
|
<label class="field">
|
||||||
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
|
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
|
||||||
<select bind:value={duel.release}>
|
<select bind:value={duel.release}>
|
||||||
<option value="">not yet</option>
|
<option value="">not yet</option>
|
||||||
@@ -209,7 +207,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
{#if duel.bankCandidates.length > 1}
|
{#if duel.bankCandidates.length > 1}
|
||||||
<label>
|
<label class="field">
|
||||||
<span>Bank</span>
|
<span>Bank</span>
|
||||||
<select bind:value={duel.bankPick}>
|
<select bind:value={duel.bankPick}>
|
||||||
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
|
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
|
||||||
@@ -219,7 +217,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
{#if duel.permanentCandidates.length > 1}
|
{#if duel.permanentCandidates.length > 1}
|
||||||
<label>
|
<label class="field">
|
||||||
<span>Make permanent</span>
|
<span>Make permanent</span>
|
||||||
<select bind:value={duel.permanentPick}>
|
<select bind:value={duel.permanentPick}>
|
||||||
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
|
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
|
||||||
@@ -229,7 +227,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
{#if duel.youCharmedFoe}
|
{#if duel.youCharmedFoe}
|
||||||
<label>
|
<label class="field">
|
||||||
<span>{duel.foe.name}'s charmed {duel.foe.constraints.charmed?.hand} hand makes</span>
|
<span>{duel.foe.name}'s charmed {duel.foe.constraints.charmed?.hand} hand makes</span>
|
||||||
<select bind:value={duel.charmGesture}>
|
<select bind:value={duel.charmGesture}>
|
||||||
{#each MAGIC_GESTURES as g (g)}
|
{#each MAGIC_GESTURES as g (g)}
|
||||||
@@ -296,15 +294,9 @@
|
|||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.standfirst .link {
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--frost);
|
|
||||||
text-decoration: underline;
|
|
||||||
vertical-align: baseline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rename {
|
.rename {
|
||||||
@@ -315,29 +307,8 @@
|
|||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rename label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: var(--bone-dim);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rename input {
|
.rename input {
|
||||||
font: inherit;
|
|
||||||
font-style: normal;
|
|
||||||
color: var(--bone);
|
|
||||||
background: var(--slate-deep);
|
|
||||||
border: 1px solid var(--rule-strong);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
width: 14em;
|
width: 14em;
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rename input:focus-visible {
|
|
||||||
outline: 2px solid var(--frost);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -378,12 +349,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal.small {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
details.next summary {
|
details.next summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -491,10 +456,6 @@
|
|||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.colophon a {
|
|
||||||
color: var(--frost);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hands {
|
.hands {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -508,17 +469,6 @@
|
|||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders label {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orders label span {
|
|
||||||
color: var(--bone-dim);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
@@ -530,24 +480,13 @@
|
|||||||
margin: 1rem 0 0.6rem;
|
margin: 1rem 0 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal {
|
.move > .reveal {
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
background: var(--bone);
|
|
||||||
color: var(--slate-deep);
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.6rem 1.4rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal:hover:not(:disabled) {
|
.reveal.small {
|
||||||
background: #fff;
|
padding: 0.4rem 0.9rem;
|
||||||
}
|
font-size: 1rem;
|
||||||
|
|
||||||
.reveal:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.verdict {
|
.verdict {
|
||||||
@@ -577,6 +516,11 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.colophon a {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.board {
|
.board {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user