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
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
|
||||
@@ -17,18 +17,19 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You duel a heuristic bot. Choose a gesture per hand, pick which completed spell (if any) each
|
||||
hand casts and at whom, and reveal. Beneath each hand is a list of the spells that hand is
|
||||
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
|
||||
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
|
||||
spell, a countered spell, a one-handed clap, a reused run of gestures) the result strip
|
||||
explains it once per duel; the checkbox under the hands turns this off. Warnings
|
||||
above the hands cite the opponent's gestures that could finish a hostile spell, and selecting
|
||||
a warning lights those gestures up in the ledger. After each reveal a short strip shows what
|
||||
changed each wizard's health and everything else worth knowing, with the full chronicle in
|
||||
the sidebar. The rules are at `/rules`.
|
||||
You duel a heuristic bot. Each turn, choose a gesture per hand, pick which completed spell
|
||||
(if any) each hand casts and at whom, and reveal. The rules are at `/rules`.
|
||||
|
||||
Around the two hands:
|
||||
|
||||
- the ledger of every gesture both wizards have made, with completed spells named;
|
||||
- beneath each hand, the spells it is part-way through, one click from the next gesture, with
|
||||
a pin to keep one in view; every spell-sheet entry can also be planned with either hand;
|
||||
- warnings citing the opponent's gestures that could finish a hostile spell, which light those
|
||||
gestures up in the ledger when selected;
|
||||
- after each reveal, a strip of what changed each wizard's health and everything else worth
|
||||
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 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/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/bot.ts` the opponent
|
||||
- `src/lib/game/duel.svelte.ts` reactive store binding the engine to the UI
|
||||
- `src/lib/components/` ledger, hand pickers, status, chronicle, spell sheet
|
||||
- `src/lib/game/duel.svelte.ts` reactive store binding the engine to the UI, saved to local storage
|
||||
- `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
|
||||
|
||||
|
||||
+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
|
||||
`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
|
||||
Caddyfile pointing at a second directory; the deploy script works unchanged
|
||||
against that host. A separate droplet keeps the two games' uptime and
|
||||
upgrades independent.
|
||||
The zero-cost alternative is a second site block in an existing Caddy
|
||||
server's configuration pointing at a second directory; the deploy script
|
||||
works unchanged against that host. A droplet of its own keeps this site's
|
||||
uptime and upgrades independent of anything else.
|
||||
|
||||
## Current production
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
# The argument is the Caddy site address line: one name, or several
|
||||
# separated by commas. Every name must already resolve to this droplet.
|
||||
# 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
|
||||
# installed.
|
||||
# TLS. There is no application process to install or supervise.
|
||||
set -euo pipefail
|
||||
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
|
||||
# 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
|
||||
# cached for a year; everything else is revalidated so a deploy shows up
|
||||
# on the next load.
|
||||
# cached for a year; every other response is revalidated so a deploy shows
|
||||
# up on the next load. The two header matchers are disjoint, so their order
|
||||
# does not matter.
|
||||
cat > /etc/caddy/Caddyfile <<CADDY
|
||||
$HOST
|
||||
|
||||
@@ -42,8 +42,10 @@ header {
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "no-referrer"
|
||||
}
|
||||
header /_app/immutable/* Cache-Control "public, max-age=31536000, immutable"
|
||||
header /index.html Cache-Control "no-cache"
|
||||
@immutable path /_app/immutable/*
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
@mutable not path /_app/immutable/*
|
||||
header @mutable Cache-Control "no-cache"
|
||||
|
||||
log {
|
||||
output file /var/lib/caddy/access.log {
|
||||
|
||||
+56
-3
@@ -57,12 +57,14 @@ button {
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible,
|
||||
input:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--frost);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
select {
|
||||
select,
|
||||
input[type='text'] {
|
||||
background: var(--slate-deep);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 4px;
|
||||
@@ -70,6 +72,53 @@ select {
|
||||
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 {
|
||||
font-weight: 600;
|
||||
font-variation-settings: 'opsz' 144;
|
||||
@@ -80,8 +129,12 @@ select {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.ember {
|
||||
color: var(--ember);
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
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 { 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();
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
duel.you.constraints.amnesia
|
||||
? 'Amnesia: you must repeat last turn.'
|
||||
: duel.you.constraints.paralysedHand === hand
|
||||
? 'Paralysed in last turn’s position.'
|
||||
? "Paralysed in last turn's position."
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
@@ -34,7 +34,7 @@
|
||||
{#if forced}
|
||||
<p class="locked"><span class="letter big">{glyph(forced, duel.gestureFor(set, other))}</span> {whyForced}</p>
|
||||
{: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}
|
||||
<div class="gestures" role="group" aria-label="{hand} hand gesture{set === 'haste' ? ', second pair' : ''}">
|
||||
{#each GESTURES as g (g)}
|
||||
@@ -55,9 +55,10 @@
|
||||
{#if duel.you.constraints.fear && !duel.timeStopped}
|
||||
<p class="hint">Fear: no clap, digit, fingers or snap this turn.</p>
|
||||
{/if}
|
||||
{#if set === 'main' && duel.showPlans && duel.plans[hand].length === 0}
|
||||
{#if set === 'main' && duel.showPlans}
|
||||
{#if 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>
|
||||
{:else if set === 'main' && duel.showPlans && duel.plans[hand].length > 0}
|
||||
{:else}
|
||||
<ul class="plans" aria-label="{hand} hand, spells under way">
|
||||
{#each plans as plan (plan.spell.id)}
|
||||
<li class:pinned={plan.pinned}>
|
||||
@@ -76,10 +77,11 @@
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if options.length > 0}
|
||||
<div class="cast">
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>Finishes</span>
|
||||
<select value={draft.spellId} onchange={(e) => duel.setSpell(set, hand, e.currentTarget.value as SpellId | '')}>
|
||||
{#each options as c (c.spell.id + c.seqIndex)}
|
||||
@@ -89,7 +91,7 @@
|
||||
</select>
|
||||
</label>
|
||||
{#if spell}
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>at</span>
|
||||
<select bind:value={draft.target}>
|
||||
{#each duel.targetsFor(spell) as t (t.id)}
|
||||
@@ -98,7 +100,7 @@
|
||||
</select>
|
||||
</label>
|
||||
{#if spell.id === 'summon_elemental'}
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>of</span>
|
||||
<select bind:value={draft.elemental}>
|
||||
<option value="fire">fire</option>
|
||||
@@ -107,7 +109,7 @@
|
||||
</label>
|
||||
{/if}
|
||||
{#if spell.id === 'paralysis' || spell.id === 'charm_person'}
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>their</span>
|
||||
<select bind:value={draft.chosenHand}>
|
||||
<option value="left">left hand</option>
|
||||
@@ -172,11 +174,6 @@
|
||||
color: var(--slate-deep);
|
||||
}
|
||||
|
||||
.gesture:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gesture .letter {
|
||||
font-size: 1.25rem;
|
||||
margin-top: 0.05rem;
|
||||
@@ -211,17 +208,6 @@
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0 0;
|
||||
@@ -308,15 +294,6 @@
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--frost);
|
||||
text-decoration: underline;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.summary strong {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -104,13 +104,10 @@
|
||||
{#each ['left', 'right'] as const as hand (hand)}
|
||||
<td class="cell you">
|
||||
{#each duel.activeSets as set (set)}
|
||||
{@const chosen = duel.forcedFor(set, hand) ?? duel.sets[set][hand].gesture}
|
||||
{@const charmed = duel.charmedHand === hand}
|
||||
{@const undecided = duel.chosenGesture(set, hand) === null || duel.charmedHand === hand}
|
||||
{@const cast = duel.chosen[set][hand]}
|
||||
<span class="entry" class:cast={!!cast} class:haste={set === 'haste'}>
|
||||
<span class="letter" class:placeholder={chosen === null || charmed}>
|
||||
{charmed ? '?' : chosen === null ? '·' : glyph(chosen, duel.gestureFor(set, hand === 'left' ? 'right' : 'left'))}
|
||||
</span>
|
||||
<span class="letter" class:placeholder={undecided}>{duel.drawn(set, hand)}</span>
|
||||
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
@@ -251,11 +248,4 @@
|
||||
border-bottom: 2px solid var(--ember);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
|
||||
import { glyph } from '$lib/game/glyphs';
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { HANDS } from '$lib/game/spells';
|
||||
import { MAX_HP } from '$lib/game/state';
|
||||
|
||||
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>
|
||||
|
||||
{#if !duel.state.over}
|
||||
@@ -16,8 +13,9 @@
|
||||
<span><em>{duel.foe.name}</em> {duel.foe.hp}/{MAX_HP}</span>
|
||||
</div>
|
||||
<div class="gestures" aria-label="Chosen gestures">
|
||||
<span class="letter" class:empty={left === null}>{duel.charmedHand === 'left' ? '?' : left === null ? '·' : glyph(left, right ?? '-')}</span>
|
||||
<span class="letter" class:empty={right === null}>{duel.charmedHand === 'right' ? '?' : right === null ? '·' : glyph(right, left ?? '-')}</span>
|
||||
{#each HANDS as hand (hand)}
|
||||
<span class="letter" class:empty={duel.chosenGesture('main', hand) === null}>{duel.drawn('main', hand)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
|
||||
@@ -30,6 +28,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||
@media (max-width: 860px) {
|
||||
.bar {
|
||||
position: fixed;
|
||||
@@ -77,15 +76,7 @@
|
||||
}
|
||||
|
||||
.reveal {
|
||||
background: var(--bone);
|
||||
color: var(--slate-deep);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reveal:disabled {
|
||||
opacity: 0.35;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
{ category: 'damage', title: 'Damage' },
|
||||
{ category: 'enchantment', title: 'Enchantments' }
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
<details class="sheet" id="spell-sheet" bind:open={duel.sheetOpen}>
|
||||
@@ -137,11 +136,6 @@
|
||||
border-color: var(--frost);
|
||||
}
|
||||
|
||||
.planbtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.which {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{#if summary.events?.length}
|
||||
{#if summary.events.length}
|
||||
<ul class="events">
|
||||
{#each summary.events as e, i (i)}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if duel.showLessons && summary.lessons?.length}
|
||||
{#if duel.showLessons && summary.lessons.length}
|
||||
<ul class="lessons">
|
||||
{#each summary.lessons as l, i (i)}
|
||||
<li>{l}</li>
|
||||
@@ -58,7 +58,7 @@
|
||||
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) {
|
||||
.result {
|
||||
scroll-margin-top: 9.5rem;
|
||||
|
||||
+23
-49
@@ -5,6 +5,7 @@
|
||||
// and each named opponent weights the spell book a little differently.
|
||||
|
||||
import {
|
||||
cellsOverlap,
|
||||
completableIn,
|
||||
completedSpells,
|
||||
handSequence,
|
||||
@@ -15,10 +16,11 @@ import {
|
||||
type TurnGestures
|
||||
} from './gestures';
|
||||
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 {
|
||||
MAX_HP,
|
||||
enemyOf,
|
||||
isEnchanted,
|
||||
visibleHistory,
|
||||
type CastChoice,
|
||||
type GameState,
|
||||
@@ -27,27 +29,6 @@ import {
|
||||
type WizardId
|
||||
} 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>> = {
|
||||
missile: 1,
|
||||
@@ -122,16 +103,16 @@ function assess(state: GameState, me: WizardId, rng: () => number): Situation {
|
||||
let missileNow = false;
|
||||
let foeFirePlan = 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)) {
|
||||
if (!HOSTILE.includes(c.spell.id)) continue;
|
||||
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
||||
const v = THREAT_VALUE[c.spell.id] ?? 1;
|
||||
threatNow = Math.max(threatNow, v);
|
||||
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
|
||||
if (c.spell.id === 'missile') missileNow = true;
|
||||
}
|
||||
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);
|
||||
}
|
||||
for (const spell of SPELLS) {
|
||||
@@ -166,8 +147,7 @@ function baseValue(s: Situation, spell: Spell): number {
|
||||
const meW = s.state.wizards[s.me];
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
|
||||
const foeEnchanted =
|
||||
foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
||||
const foeEnchanted = isEnchanted(foeW);
|
||||
switch (spell.id) {
|
||||
case 'fireball':
|
||||
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;
|
||||
case 'anti_spell': {
|
||||
let longest = 0;
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const hand of HANDS) {
|
||||
for (const sp of SPELLS) {
|
||||
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) {
|
||||
const value = spellValue(s, spell);
|
||||
if (value <= 0) continue;
|
||||
spell.tokens.forEach((tokens, i) => {
|
||||
if (spell.id === 'lightning_bolt_quick' && i > 0) return;
|
||||
for (const tokens of spell.tokens) {
|
||||
const k = progressToward(tokens, seq, start);
|
||||
if (k === 0) return;
|
||||
if (k === 0) continue;
|
||||
let v = value * Math.pow(k / tokens.length, 1.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.
|
||||
@@ -309,14 +288,14 @@ function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned
|
||||
progress = v;
|
||||
planned = spell.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return { score: best + progress * 0.9, planned };
|
||||
}
|
||||
|
||||
function bestCasts(s: Situation, completions: Record<Hand, Completion[]>, turnCount: number): CastChoice[] {
|
||||
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]) {
|
||||
const value = spellValue(s, completion.spell);
|
||||
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) {
|
||||
if (chosen.some((c) => c.hand === o.hand)) continue;
|
||||
const cells = usedCells(o.completion, o.hand, turnCount);
|
||||
const clash = chosen.some((c) => {
|
||||
for (const cell of usedCells(c.completion, c.hand, turnCount)) if (cells.has(cell)) return true;
|
||||
return false;
|
||||
});
|
||||
if (clash) continue;
|
||||
if (chosen.some((c) => cellsOverlap(cells, usedCells(c.completion, c.hand, turnCount)))) continue;
|
||||
chosen.push(o);
|
||||
}
|
||||
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];
|
||||
let bestHand: Hand = 'left';
|
||||
let bestK = -1;
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const hand of HANDS) {
|
||||
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);
|
||||
if (k > bestK) {
|
||||
bestK = k;
|
||||
@@ -375,8 +350,7 @@ function targetFor(s: Situation, spell: Spell, choice: CastChoice): void {
|
||||
break;
|
||||
case 'remove_enchantment': {
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
const enchanted = foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
||||
choice.target = enchanted || !strongestFoeMonster ? s.foe : strongestFoeMonster.id;
|
||||
choice.target = isEnchanted(foeW) || !strongestFoeMonster ? s.foe : strongestFoeMonster.id;
|
||||
break;
|
||||
}
|
||||
case 'summon_elemental':
|
||||
@@ -406,11 +380,11 @@ function charmGesture(s: Situation): Gesture {
|
||||
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
|
||||
let best: Gesture = 'P';
|
||||
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 }];
|
||||
let score = 0;
|
||||
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);
|
||||
}
|
||||
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. */
|
||||
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 foe = s.foe;
|
||||
const optionsFor = (hand: Hand): Gesture[] => {
|
||||
if (timeStopped) return ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
|
||||
if (timeStopped) return GESTURES;
|
||||
const forced = forcedGesture(meW, hand);
|
||||
if (forced) return [forced];
|
||||
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 -= 3;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -471,7 +445,7 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
|
||||
const foe = s.foe;
|
||||
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 monsterOrders: Record<string, TargetId> = {};
|
||||
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
|
||||
};
|
||||
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 };
|
||||
}
|
||||
if (meW.banked) {
|
||||
|
||||
+42
-50
@@ -3,6 +3,7 @@
|
||||
import { tick } from 'svelte';
|
||||
import { chooseBotTurn } from './bot';
|
||||
import {
|
||||
cellsOverlap,
|
||||
completableIn,
|
||||
completedSpells,
|
||||
handSequence,
|
||||
@@ -11,19 +12,20 @@ import {
|
||||
type Completion,
|
||||
type HandTurn
|
||||
} from './gestures';
|
||||
import type { Token } from './spells';
|
||||
import { glyph } from './glyphs';
|
||||
import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
|
||||
import {
|
||||
CONTROL_SPELLS,
|
||||
GESTURES,
|
||||
HANDS,
|
||||
PERMANENCY_EXCLUDED,
|
||||
HOSTILE_SPELLS,
|
||||
SPELLS,
|
||||
SPELL_BY_ID,
|
||||
permanencyEligible,
|
||||
type Gesture,
|
||||
type Hand,
|
||||
type Spell,
|
||||
type SpellId
|
||||
type SpellId,
|
||||
type Token
|
||||
} from './spells';
|
||||
import {
|
||||
NOWHERE,
|
||||
@@ -40,34 +42,18 @@ import {
|
||||
export const YOU: WizardId = 'A';
|
||||
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. */
|
||||
export type SetName = 'main' | 'haste';
|
||||
export const SET_NAMES: SetName[] = ['main', 'haste'];
|
||||
|
||||
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 {
|
||||
gesture: Gesture | null;
|
||||
@@ -177,7 +163,7 @@ function loadSaved(): SavedDuel | null {
|
||||
if (!raw) return null;
|
||||
const saved = JSON.parse(raw) as SavedDuel;
|
||||
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 ??= [];
|
||||
if (saved.state.lastTurn) {
|
||||
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
|
||||
* paralysis can finish a spell without the player touching it, so this must run
|
||||
* at the start of every turn, not only after a click.
|
||||
* paralysis can finish a spell without the player touching it, so every turn
|
||||
* starts here as well as every click.
|
||||
*/
|
||||
syncAll(): void {
|
||||
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. */
|
||||
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 {
|
||||
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 {
|
||||
@@ -374,9 +373,7 @@ export class Duel {
|
||||
conflict = $derived.by(() => {
|
||||
const cells = this.planned.map((p) => usedCells(p.completion, p.hand, p.turnCount));
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
for (let j = i + 1; j < cells.length; j++) {
|
||||
for (const cell of cells[i]) if (cells[j].has(cell)) return true;
|
||||
}
|
||||
for (let j = i + 1; j < cells.length; j++) if (cellsOverlap(cells[i], cells[j])) return true;
|
||||
}
|
||||
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)));
|
||||
}
|
||||
|
||||
summary = $derived(this.state.lastTurn);
|
||||
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
|
||||
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
|
||||
threats = $derived.by(() => {
|
||||
@@ -406,7 +404,7 @@ export class Duel {
|
||||
const seq = handSequence(this.foeVisible, hand);
|
||||
for (const turnsAway of [1, 2]) {
|
||||
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;
|
||||
const tokens = c.spell.tokens[c.seqIndex];
|
||||
const done = tokens.slice(0, tokens.length - turnsAway);
|
||||
@@ -449,12 +447,13 @@ export class Duel {
|
||||
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)
|
||||
);
|
||||
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 {
|
||||
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 {
|
||||
@@ -466,14 +465,14 @@ export class Duel {
|
||||
this.pins[hand] = plan.spell.id;
|
||||
const next = plan.remaining[0];
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
||||
this.syncAll();
|
||||
}
|
||||
|
||||
setShowPlans(on: boolean): void {
|
||||
@@ -509,8 +508,6 @@ export class Duel {
|
||||
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'));
|
||||
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
|
||||
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU));
|
||||
@@ -536,12 +533,7 @@ export class Duel {
|
||||
);
|
||||
permanentCandidates = $derived(
|
||||
this.permanencyActive
|
||||
? this.planned.filter(
|
||||
(p) =>
|
||||
p.completion.spell.category === 'enchantment' &&
|
||||
!PERMANENCY_EXCLUDED.includes(p.completion.spell.id) &&
|
||||
p !== this.willBank
|
||||
)
|
||||
? this.planned.filter((p) => permanencyEligible(p.completion.spell) && p !== this.willBank)
|
||||
: []
|
||||
);
|
||||
willExtend = $derived(
|
||||
@@ -562,7 +554,7 @@ export class Duel {
|
||||
choose(set: SetName, hand: Hand, gesture: Gesture): void {
|
||||
const draft = this.sets[set][hand];
|
||||
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. */
|
||||
@@ -586,7 +578,7 @@ export class Duel {
|
||||
hand: p.hand,
|
||||
spellId: spell.id,
|
||||
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 === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Duel } from './duel.svelte';
|
||||
import { resolveTurn } from './resolve';
|
||||
import { createGame, type GameState, type TurnInput } from './state';
|
||||
import { createGame, type GameState } from './state';
|
||||
import type { Gesture } from './spells';
|
||||
|
||||
const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] });
|
||||
import { play } from './test-helpers';
|
||||
|
||||
/** A is one gesture from a lightning bolt and has just been struck by amnesia. */
|
||||
function amnesiaBeforeLightning(): GameState {
|
||||
@@ -12,10 +10,7 @@ function amnesiaBeforeLightning(): GameState {
|
||||
const left = 'DFFD';
|
||||
const foe = '-DPP';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
s = resolveTurn(s, {
|
||||
A: { ...noop(), left: left[i] as Gesture },
|
||||
B: { ...noop(), left: foe[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'amnesia' }] : [] }
|
||||
});
|
||||
s = play(s, { left: left[i] as Gesture }, { left: foe[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'amnesia' }] : [] });
|
||||
}
|
||||
expect(s.wizards.A.constraints.amnesia).toBe(true);
|
||||
return s;
|
||||
@@ -31,21 +26,4 @@ describe('the duel store', () => {
|
||||
duel.reveal();
|
||||
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 { resolveTurn } from './resolve';
|
||||
import { SPELLS, SPELL_BY_ID, parseSequence, type Gesture } from './spells';
|
||||
import { createGame, type GameState, type TurnInput, type WizardId } from './state';
|
||||
|
||||
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;
|
||||
}
|
||||
import { createGame } from './state';
|
||||
import { lcg, noop, play, runSequence } from './test-helpers';
|
||||
|
||||
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.filter((s) => !s.implemented)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parses two-handed tokens', () => {
|
||||
@@ -97,30 +73,40 @@ describe('resolution', () => {
|
||||
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);
|
||||
s = runSequence(s, 'A', 'FSSDD', 'PWPFS', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
||||
expect(s.wizards.B.hp).toBe(10);
|
||||
s = play(s, {}, { left: 'W' });
|
||||
s = play(s, { right: 'S' }, { left: 'W' });
|
||||
s = play(s, { right: 'S' }, { left: 'S', casts: [{ hand: 'left', spellId: 'counter_spell' }] });
|
||||
s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'finger_of_death' }] }, { left: 'W' });
|
||||
expect(s.wizards.B.alive).toBe(true);
|
||||
s = play(s, {}, { left: 'W' });
|
||||
s = play(s, {}, { left: 'S', casts: [{ 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);
|
||||
t = runSequence(t, 'A', 'PWPFSSSD', '--------', () => ({ casts: [{ hand: 'left', spellId: 'finger_of_death' }] }));
|
||||
expect(t.wizards.B.alive).toBe(false);
|
||||
expect(t.over?.winner).toBe('A');
|
||||
// A's F-S-S-D-D and B's W-W-S finish on the same turn.
|
||||
const left = 'FSSDD';
|
||||
const foe = '--WWS';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const last = i === 4;
|
||||
s = play(
|
||||
s,
|
||||
{ left: left[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'fireball' }] : [] },
|
||||
{ left: foe[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'counter_spell' }] : [] }
|
||||
);
|
||||
}
|
||||
expect(s.wizards.B.hp).toBe(15);
|
||||
expect(s.log.some((l) => l.text.includes('smothers the Fireball'))).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
for (let i = 0; i < 7; i++) s = play(s, { left: 'PWPFSSS'[i] as Gesture }, { left: 'C', right: 'C' });
|
||||
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'finger_of_death' }] }, { left: 'D' });
|
||||
const left = 'PWPFSSSD';
|
||||
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.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);
|
||||
const bLeft = '----CDPW';
|
||||
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', () => {
|
||||
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.wizards.B.hp).toBe(14);
|
||||
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', () => {
|
||||
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.
|
||||
expect(s.wizards.B.constraints.amnesia).toBe(true);
|
||||
s = play(s, {}, { left: 'F', right: 'F' });
|
||||
@@ -167,7 +153,7 @@ describe('resolution', () => {
|
||||
|
||||
it('fear bans C, D, F and S next turn', () => {
|
||||
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' });
|
||||
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', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'B', 'WWFP', '----', () => ({ casts: [{ hand: 'left', spellId: 'resist_heat' }] }));
|
||||
s = runSequence(s, 'A', 'FSSDD', '-----', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
||||
s = runSequence(s, 'B', 'WWFP', '----', { casts: [{ hand: 'left', spellId: 'resist_heat' }] });
|
||||
s = runSequence(s, 'A', 'FSSDD', '-----', { casts: [{ hand: 'left', spellId: 'fireball' }] });
|
||||
expect(s.wizards.B.hp).toBe(15);
|
||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
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', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
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', 'WWFP', '----', { casts: [{ hand: 'left', spellId: 'resist_heat' }] });
|
||||
s = runSequence(s, 'A', 'SWWC', '---C', { casts: [{ hand: 'left', spellId: 'fire_storm' }] });
|
||||
expect(s.wizards.A.hp).toBe(15);
|
||||
expect(s.wizards.B.hp).toBe(10);
|
||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
@@ -226,15 +212,15 @@ describe('resolution', () => {
|
||||
|
||||
it('the quick lightning bolt works once per duel', () => {
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
it('disease kills at the end of the sixth turn unless cured', () => {
|
||||
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);
|
||||
for (let i = 0; i < 4; i++) s = play(s, {});
|
||||
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', () => {
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -254,11 +240,7 @@ describe('resolution', () => {
|
||||
describe('bot', () => {
|
||||
it('never surrenders by accident and only casts spells it completed', () => {
|
||||
let s = createGame({ A: 'You', B: 'Bot' }, 7);
|
||||
let rngSeed = 3;
|
||||
const rng = () => {
|
||||
rngSeed = (rngSeed * 1664525 + 1013904223) >>> 0;
|
||||
return rngSeed / 4294967296;
|
||||
};
|
||||
const rng = lcg(3);
|
||||
for (let i = 0; i < 40 && !s.over; i++) {
|
||||
const bot = chooseBotTurn(s, 'B', rng);
|
||||
expect(bot.left === 'P' && bot.right === 'P').toBe(false);
|
||||
@@ -274,7 +256,7 @@ describe('bot', () => {
|
||||
describe('haste, time stop, delayed effect and permanency', () => {
|
||||
it('a hastened wizard makes two pairs of gestures a turn and can cast from both', () => {
|
||||
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);
|
||||
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);
|
||||
@@ -312,9 +294,9 @@ describe('haste, time stop, delayed effect and permanency', () => {
|
||||
|
||||
it('delayed effect banks the next spell until it is released', () => {
|
||||
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);
|
||||
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.A.banked?.spellId).toBe('missile');
|
||||
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', () => {
|
||||
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);
|
||||
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);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
s = play(s, {}, { left: 'F', 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.
|
||||
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' });
|
||||
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: 'F', right: 'W' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('haste under amnesia', () => {
|
||||
it('repeats both of last turn\u2019s pairs', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
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('attacks on the very turn'))).toBe(false);
|
||||
// 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' }] });
|
||||
expect(s.lastTurn?.lessons).toEqual([]);
|
||||
});
|
||||
@@ -410,8 +390,8 @@ describe('lessons', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = play(s, { left: 'C' });
|
||||
expect(s.lastTurn?.lessons[0]).toContain('clap needs both hands');
|
||||
s = runSequence(s, 'A', 'FFF', '---', () => ({ casts: [{ hand: 'left', spellId: 'paralysis' }] }));
|
||||
s = runSequence(s, 'A', 'SSDD', '----', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
||||
s = runSequence(s, 'A', 'FFF', '---', { casts: [{ hand: 'left', spellId: 'paralysis' }] });
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,19 +108,16 @@ export function usedCells(completion: Completion, hand: Hand, turnCount: number)
|
||||
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. */
|
||||
export function castsConflict(
|
||||
a: { completion: Completion; hand: Hand },
|
||||
b: { completion: Completion; hand: Hand },
|
||||
turnCount: number
|
||||
): boolean {
|
||||
const cellsA = usedCells(a.completion, a.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];
|
||||
return cellsOverlap(usedCells(a.completion, a.hand, turnCount), usedCells(b.completion, b.hand, turnCount));
|
||||
}
|
||||
|
||||
+39
-39
@@ -4,22 +4,25 @@
|
||||
// enchantments, damage, monster attacks and stabs, and finally deaths and
|
||||
// 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 {
|
||||
BLANKET_SPELLS,
|
||||
CONTROL_SPELLS,
|
||||
GESTURES,
|
||||
GESTURE_NAMES,
|
||||
HANDS,
|
||||
MAGIC_GESTURES,
|
||||
MONSTER_STATS,
|
||||
PERMANENCY_EXCLUDED,
|
||||
SPELLS,
|
||||
SPELL_BY_ID,
|
||||
SUMMON_KIND,
|
||||
isElemental,
|
||||
permanencyEligible,
|
||||
type Gesture,
|
||||
type Hand,
|
||||
type MonsterKind,
|
||||
type Spell
|
||||
type Spell,
|
||||
type SpellId
|
||||
} from './spells';
|
||||
import {
|
||||
MAX_HP,
|
||||
@@ -70,9 +73,8 @@ export function paralysedForm(g: Gesture): Gesture {
|
||||
|
||||
export const FEAR_BANNED: Gesture[] = ['C', 'D', 'F', 'S'];
|
||||
|
||||
function describe(g: Gesture): string {
|
||||
return GESTURE_NAMES[g];
|
||||
}
|
||||
/** A counter-spell cannot touch these. */
|
||||
const UNCOUNTERABLE: SpellId[] = ['finger_of_death', 'counter_spell', 'magic_mirror'];
|
||||
|
||||
function roman(n: number): string {
|
||||
const numerals: [number, string][] = [
|
||||
@@ -114,10 +116,6 @@ function clearMonsterEnchantments(m: Monster): void {
|
||||
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
|
||||
* 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 }[],
|
||||
broken: [] as { name: string; hand: Hand }[],
|
||||
curedAndHurt: [] as { name: string; hurt: number; healed: number }[],
|
||||
newMonsterAttacked: '' as string
|
||||
newMonsterAttacked: ''
|
||||
};
|
||||
|
||||
// ---- 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 (c.charmed.hand === 'left') left = g;
|
||||
else right = g;
|
||||
log(`${W[c.charmed.by].name} charms ${w.name}'s ${c.charmed.hand} hand into a ${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) {
|
||||
const g = paralysedForm(w.history[w.history.length - 1][c.paralysedHand]);
|
||||
if (c.paralysedHand === 'left') left = g;
|
||||
else right = g;
|
||||
log(`${w.name}'s ${c.paralysedHand} hand is paralysed into a ${describe(g)}.`);
|
||||
log(`${w.name}'s ${c.paralysedHand} hand is paralysed into a ${GESTURE_NAMES[g]}.`);
|
||||
}
|
||||
if (c.fear) {
|
||||
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 (slip.hand === 'left') left = 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 = '-';
|
||||
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 hiddenFrom: WizardId[] = [];
|
||||
const foe = enemyOf(w.id);
|
||||
if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe);
|
||||
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;
|
||||
if (g !== '>' && g !== '-') continue;
|
||||
const seq = handSequence(w.history, hand);
|
||||
@@ -300,11 +298,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
continue;
|
||||
}
|
||||
const cells = usedCells(completion, choice.hand, index + 1);
|
||||
const clash = accepted.some((a) => {
|
||||
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;
|
||||
});
|
||||
const clash = accepted.some((a) => !!a.completion && cellsOverlap(cells, usedCells(a.completion, a.hand, a.index + 1)));
|
||||
if (clash) {
|
||||
log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle');
|
||||
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 });
|
||||
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)}`;
|
||||
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') {
|
||||
@@ -338,7 +332,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
index: -1,
|
||||
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;
|
||||
const at = BLANKET_SPELLS.includes(spell.id) || target === id ? '' : ` at ${beingName(state, target)}`;
|
||||
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');
|
||||
}
|
||||
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 (countered.has(c.target)) {
|
||||
c.nullified = 'countered';
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- 4b. Delayed effect banks a spell; permanency extends one ----
|
||||
// ---- 5. Delayed effect banks a spell; permanency extends one ----
|
||||
const bankedNow = new Set<WizardId>();
|
||||
const extendedNow = new Set<WizardId>();
|
||||
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()) {
|
||||
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);
|
||||
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()) {
|
||||
const kind: MonsterKind | undefined =
|
||||
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>>();
|
||||
for (const c of live()) {
|
||||
if (!CONTROL_SPELLS.includes(c.spell.id)) continue;
|
||||
@@ -586,7 +587,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
break;
|
||||
case 'charm_person':
|
||||
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 };
|
||||
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'}.`);
|
||||
@@ -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 healing = new Map<TargetId, number>();
|
||||
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) {
|
||||
if (destroyedBeforeAttack.has(m.id)) 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) {
|
||||
const w = W[id];
|
||||
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) {
|
||||
const w = W[id];
|
||||
if (!w.alive) continue;
|
||||
@@ -883,7 +884,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
return true;
|
||||
});
|
||||
|
||||
// ---- 12. The turn ends ----
|
||||
// ---- 13. The turn ends ----
|
||||
if (phase === 'main') {
|
||||
for (const id of IDS) {
|
||||
const w = W[id];
|
||||
@@ -962,7 +963,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
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.' });
|
||||
} 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) {
|
||||
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. */
|
||||
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.paralysedHand === hand) return [];
|
||||
if (w.constraints.charmed?.hand === hand) return [];
|
||||
if (w.constraints.fear) return all.filter((g) => !FEAR_BANNED.includes(g));
|
||||
return all;
|
||||
if (w.constraints.fear) return GESTURES.filter((g) => !FEAR_BANNED.includes(g));
|
||||
return GESTURES;
|
||||
}
|
||||
|
||||
/** 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 { resolveTurn } from './resolve';
|
||||
import { createGame } from './state';
|
||||
|
||||
function lcg(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
import { lcg } from './test-helpers';
|
||||
|
||||
describe('bot versus bot', () => {
|
||||
it('sixty duels end without errors and by death, not surrender', () => {
|
||||
|
||||
+45
-16
@@ -81,8 +81,6 @@ export interface Spell {
|
||||
tokens: Token[][];
|
||||
usualTarget: 'self' | 'enemy';
|
||||
summary: string;
|
||||
/** False for spells the engine recognises but does not yet resolve. */
|
||||
implemented: boolean;
|
||||
}
|
||||
|
||||
export function parseSequence(sequence: string): Token[] {
|
||||
@@ -100,8 +98,7 @@ function spell(
|
||||
category: SpellCategory,
|
||||
sequences: string[],
|
||||
usualTarget: 'self' | 'enemy',
|
||||
summary: string,
|
||||
implemented = true
|
||||
summary: string
|
||||
): Spell {
|
||||
return {
|
||||
id,
|
||||
@@ -110,8 +107,7 @@ function spell(
|
||||
sequences,
|
||||
tokens: sequences.map(parseSequence),
|
||||
usualTarget,
|
||||
summary,
|
||||
implemented
|
||||
summary
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('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('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('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('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’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(
|
||||
@@ -176,19 +172,45 @@ export const CONTROL_SPELLS: SpellId[] = [
|
||||
'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 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. */
|
||||
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 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 {
|
||||
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;
|
||||
hand: Hand;
|
||||
spellId: SpellId;
|
||||
length: number;
|
||||
target: TargetId;
|
||||
}
|
||||
|
||||
@@ -171,6 +170,11 @@ export interface TurnInput {
|
||||
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 {
|
||||
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.
|
||||
+23
-79
@@ -6,7 +6,7 @@
|
||||
import SpellSheet from '$lib/components/SpellSheet.svelte';
|
||||
import TurnSummary from '$lib/components/TurnSummary.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 { MAGIC_GESTURES } from '$lib/game/spells';
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
duel.newGame();
|
||||
}
|
||||
|
||||
// Save the duel whenever anything about it changes, and follow the window width.
|
||||
$effect(() => {
|
||||
duel.persist();
|
||||
});
|
||||
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
|
||||
$effect(() => {
|
||||
const query = window.matchMedia('(max-width: 860px)');
|
||||
const query = window.matchMedia(COMPACT_QUERY);
|
||||
const apply = () => (duel.compact = query.matches);
|
||||
apply();
|
||||
query.addEventListener('change', apply);
|
||||
@@ -60,7 +60,7 @@
|
||||
<h1>Waving Hands</h1>
|
||||
{#if renaming}
|
||||
<form class="rename" onsubmit={saveName}>
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>Your name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
|
||||
@@ -71,7 +71,7 @@
|
||||
{:else}
|
||||
<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>.
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -111,9 +111,7 @@
|
||||
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
|
||||
{/if}
|
||||
|
||||
{#if duel.timeStopped}
|
||||
<!-- nobody else moves -->
|
||||
{:else if duel.threats.length}
|
||||
{#if duel.threats.length && !duel.timeStopped}
|
||||
<div class="threats">
|
||||
{#if duel.threatsNow.length}
|
||||
<p>Their hands could finish this turn:</p>
|
||||
@@ -148,7 +146,7 @@
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{:else if !duel.timeStopped}
|
||||
<p class="threats muted">Nothing hostile is one gesture away.</p>
|
||||
{/if}
|
||||
|
||||
@@ -176,7 +174,7 @@
|
||||
|
||||
<div class="orders">
|
||||
{#if duel.stabbing}
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>Stab</span>
|
||||
<select bind:value={duel.stabTarget}>
|
||||
<option value={FOE}>{duel.foe.name}</option>
|
||||
@@ -187,7 +185,7 @@
|
||||
</label>
|
||||
{/if}
|
||||
{#each duel.yourMonsters as m (m.id)}
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>{m.name} attacks</span>
|
||||
<select bind:value={duel.monsterOrders[m.id]}>
|
||||
<option value={FOE}>{duel.foe.name}</option>
|
||||
@@ -198,7 +196,7 @@
|
||||
</label>
|
||||
{/each}
|
||||
{#if duel.bankedSpell}
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
|
||||
<select bind:value={duel.release}>
|
||||
<option value="">not yet</option>
|
||||
@@ -209,7 +207,7 @@
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.bankCandidates.length > 1}
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>Bank</span>
|
||||
<select bind:value={duel.bankPick}>
|
||||
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
|
||||
@@ -219,7 +217,7 @@
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.permanentCandidates.length > 1}
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>Make permanent</span>
|
||||
<select bind:value={duel.permanentPick}>
|
||||
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
|
||||
@@ -229,7 +227,7 @@
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.youCharmedFoe}
|
||||
<label>
|
||||
<label class="field">
|
||||
<span>{duel.foe.name}'s charmed {duel.foe.constraints.charmed?.hand} hand makes</span>
|
||||
<select bind:value={duel.charmGesture}>
|
||||
{#each MAGIC_GESTURES as g (g)}
|
||||
@@ -296,15 +294,9 @@
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
.standfirst .link {
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.8rem;
|
||||
color: var(--frost);
|
||||
text-decoration: underline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.rename {
|
||||
@@ -315,29 +307,8 @@
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.rename label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.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;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.rename input:focus-visible {
|
||||
outline: 2px solid var(--frost);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -378,12 +349,6 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reveal.small {
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
details.next summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
@@ -491,10 +456,6 @@
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.colophon a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.hands {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -508,17 +469,6 @@
|
||||
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 {
|
||||
color: var(--blood);
|
||||
margin-top: 0.8rem;
|
||||
@@ -530,24 +480,13 @@
|
||||
margin: 1rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
.move > .reveal {
|
||||
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) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.reveal:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
.reveal.small {
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.verdict {
|
||||
@@ -577,6 +516,11 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.colophon a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||
@media (max-width: 860px) {
|
||||
.board {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user