A credibility pass over the whole repo: the board's mechanics in board.ts, one copy of each helper and stylesheet rule, the kit's plumbing this game never used removed, and the ops scripts counting traffic through one parser
The engine and the bot no longer import each other: geometry, movement
and captures live in src/lib/game/board.ts and both use it. The escape
test, the four directions, the king's neighbours and the enumeration of
a side's moves each exist once; the tally counts by the named end
sentences rather than by regex over them; exports nobody imports are
exports no more. The tests pin the bot's opening move and the three-
beside-the-throne capture they named but never exercised.
In the client: .small, the word-as-button, the × that dismisses, the
visually-hidden rule and the frame of the reading pages are in app.css
once; the preferences panel and the report slip share one modal shape;
the room store drops the simultaneous-round fields this game never read
and gains seatEmpty and seatUnheld, which the lobby and the join page
read instead of three spellings of their own. The wire shapes for
reports and the tally are declared in view.ts for both sides. The
artwork component is re-indented for the top level it lives at.
On the server and in deploy: the plaintext-token fallback and its
migration script guarded ledgers this game never wrote; the seat line
now requires the hash and the start line the rules revision. The route
table lists every route. The visitors digest and the nightly rollup
count Caddy's log through deploy/traffic.py, and the rollup writes the
finished-games count it had been computing behind "and False". The
reports digest is one program, deploy/report-digest.ts, that
pull-reports.sh and the desk skill both use. The deploy README, the
visitors skill and the reports skill no longer describe a browser-only
game, a /play route or a rules text in docs/; conventions.md carries
the kit's Preferences and tally sections.
Kit-shared files touched, to port back: server/src/{index,rooms,store,
tally,reports}.ts, deploy/{deploy.sh,replay-ledgers.ts,pull-reports.sh,
traffic.py,report-digest.ts,*-rollup.sh,*-visitors.sh,*-pulse.sh},
src/lib/net/{client.ts,room.svelte.ts,view.ts}, Preferences.svelte,
ReportSlip.svelte, TableTalk.svelte.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6d041e3a63
commit
ee5690ebda
+65
-1
@@ -4,7 +4,6 @@
|
||||
/* Sea-dark iron, driftwood bone, a coal ember, and northern ice. */
|
||||
--slate: #1f2426;
|
||||
--slate-deep: #14181a;
|
||||
--slate-raised: #2a3134;
|
||||
--bone: #e6dfcf;
|
||||
--bone-dim: #9ea59e;
|
||||
--bone-faint: #626b66;
|
||||
@@ -94,6 +93,31 @@ button:disabled {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* The same in the running colour, underlined only under the pointer: a rail of moves. */
|
||||
.link.plain {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link.plain:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* The × that sends a housecarl away or forgets a game. */
|
||||
.dismiss {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-faint);
|
||||
font-size: 1.1rem;
|
||||
padding: 0 0.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dismiss:hover {
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
/* The one button that commits a move. */
|
||||
.commit {
|
||||
background: var(--bone);
|
||||
@@ -134,6 +158,10 @@ button:disabled {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
}
|
||||
@@ -151,6 +179,42 @@ button:disabled {
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A page of reading beside the game: the rules, the guide. */
|
||||
.reading {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem var(--gutter) 4rem;
|
||||
}
|
||||
|
||||
.reading .back {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.reading h1 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 300;
|
||||
margin: 0.6rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.reading .lede {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.reading section {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.reading h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.reading .word {
|
||||
margin-top: 2rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#121a20" />
|
||||
<meta name="theme-color" content="#14181a" />
|
||||
<title>Hnefatafl</title>
|
||||
<meta name="description" content="Hnefatafl, the Viking board game, free in the browser: play a housecarl or a friend, by Copenhagen rules or Linnaeus's Tablut." />
|
||||
<link rel="canonical" href="https://tafl.kestrelsnest.social/" />
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
// move. Above it, a strip says whose move it is and what the side is
|
||||
// for; beside it, the record of moves, each one a click from being shown
|
||||
// on the board. Copenhagen is a newly crafted oak reenactment set;
|
||||
// Tablut evokes an excavated stone board and worn counters. This is an
|
||||
// artistic treatment, not a claim to reconstruct a particular find.
|
||||
// Tablut evokes an excavated stone board and worn counters. Both looks
|
||||
// are invented, not reconstructions of any find.
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import BoardArtwork from './BoardArtwork.svelte';
|
||||
import { beyond, cellOf, cornersOf, movesFrom, rulesetOf, sideOf, throneOf, type Move, type Side } from '$lib/game';
|
||||
import BoardArtwork, { type Token } from './BoardArtwork.svelte';
|
||||
import { beyond, cellOf, cornersOf, movesFrom, pieceWord, rulesetOf, sideOf, squareName, throneOf, type Move, type Side } from '$lib/game';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
import { prefs, reducedMotion } from '$lib/prefs.svelte';
|
||||
|
||||
@@ -35,26 +35,15 @@
|
||||
const objective = (side: Side) => (side === 'attackers' ? 'Take the king.' : set.escape === 'corner' ? 'Get the crowned king to a corner.' : 'Get the crowned king to any edge square.');
|
||||
const sideOfPly = (ply: number): Side => (ply % 2 === 1 ? 'attackers' : 'defenders');
|
||||
|
||||
// --- Coordinates ---------------------------------------------------------
|
||||
const label = (i: number) => {
|
||||
const c = cellOf(size, i);
|
||||
return `${String.fromCharCode(97 + c.x)}${size - c.y}`;
|
||||
};
|
||||
const pieceWord = (p: string) => (p === 'k' ? 'the king' : p === 'a' ? 'an attacker' : 'a defender');
|
||||
const label = (i: number) => squareName(size, i);
|
||||
|
||||
// --- Tokens: pieces with a life of their own, so a move slides and a capture fades ---
|
||||
interface Token {
|
||||
id: number;
|
||||
piece: string;
|
||||
at: number;
|
||||
dying: boolean;
|
||||
}
|
||||
let tokens = $state<Token[]>([]);
|
||||
/** How many of the game's moves the tokens have played through. */
|
||||
let shown = $state(0);
|
||||
let shownSet = $state('');
|
||||
let animating = $state(false);
|
||||
const calm = $derived(prefs.motion === 'reduced' || reducedMotion());
|
||||
const calm = $derived(reducedMotion());
|
||||
const showCoords = $derived(prefs.coordinates !== false);
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
|
||||
|
||||
@@ -234,7 +223,7 @@
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
|
||||
<div class="frame">
|
||||
<BoardArtwork {g} {tokens} {showCoords} {selected} {targets} {shownSquares} {shownCaptured} {yourMove} {calm} {tap} {key} />
|
||||
<BoardArtwork {g} {tokens} {showCoords} {selected} {targets} {shownSquares} {shownCaptured} {yourMove} {calm} {tap} {key} />
|
||||
</div>
|
||||
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
||||
|
||||
@@ -266,7 +255,7 @@
|
||||
{@const ply = g.moves.length - i}
|
||||
{@const side = sideOfPly(ply)}
|
||||
<li class:mine={mySide === side} class:reviewed={reviewing === ply}>
|
||||
<button type="button" class="link" onclick={() => (reviewing = reviewing === ply ? null : ply)}>
|
||||
<button type="button" class="link plain" onclick={() => (reviewing = reviewing === ply ? null : ply)}>
|
||||
{nameOfSide(side)}: {label(m.from)} to {label(m.to)}{#if m.captured.length}, <strong>taking {m.captured.map(label).join(', ')}</strong>{/if}
|
||||
</button>
|
||||
</li>
|
||||
@@ -350,7 +339,6 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
.how {
|
||||
max-width: 620px;
|
||||
margin: 0.5rem auto 0;
|
||||
@@ -402,8 +390,7 @@
|
||||
background: #ece4d0;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
.sides p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -424,27 +411,4 @@
|
||||
color: var(--ember);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
<script module lang="ts">
|
||||
import type { Piece } from '$lib/game';
|
||||
|
||||
/** A piece with a life of its own, so a move slides and a capture fades. */
|
||||
export interface Token {
|
||||
id: number;
|
||||
piece: Piece;
|
||||
at: number;
|
||||
dying: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Shared by the interactive board, the hall, and the generated share artwork.
|
||||
import { cellOf, cornersOf, isEdge, rulesetOf, throneOf, type State } from '$lib/game';
|
||||
import { cellOf, cornersOf, isEdge, pieceWord, rulesetOf, squareName, throneOf, type State } from '$lib/game';
|
||||
let { g, tokens, showCoords = false, selected = null, targets = [], shownSquares = [], shownCaptured = [], yourMove = false, calm = true, decorative = false, tap = () => {}, key = () => {} }: {
|
||||
g: State;
|
||||
tokens?: {id: number; piece: string; at: number; dying: boolean}[];
|
||||
tokens?: Token[];
|
||||
showCoords?: boolean; selected?: number | null; targets?: number[]; shownSquares?: number[]; shownCaptured?: number[];
|
||||
yourMove?: boolean; calm?: boolean; decorative?: boolean;
|
||||
tap?: (i: number) => void | Promise<void>; key?: (e: KeyboardEvent, i: number) => void;
|
||||
@@ -22,17 +34,13 @@
|
||||
const PAD_B = $derived(showCoords ? 16 : 6);
|
||||
const width = $derived(PAD_L + size * CELL + PAD_R);
|
||||
const height = $derived(PAD_T + size * CELL + PAD_B);
|
||||
const label = (i: number) => {
|
||||
const c = cellOf(size, i);
|
||||
return `${String.fromCharCode(97 + c.x)}${size - c.y}`;
|
||||
};
|
||||
const pieceWord = (p: string) => (p === 'k' ? 'the king' : p === 'a' ? 'an attacker' : 'a defender');
|
||||
const label = (i: number) => squareName(size, i);
|
||||
|
||||
// --- Age: the Tablut board came out of a long hall. A few chipped corners
|
||||
// and two hairline cracks, placed by a fixed hand so the board is the same
|
||||
// board every visit; nothing here moves or changes with play.
|
||||
const chips = $derived.by(() => {
|
||||
if (!worn) return [] as { x: number; y: number; r: number; d: string }[];
|
||||
if (!worn) return [] as string[];
|
||||
const spots = [
|
||||
[0, 0, 0], [size - 1, 2, 1], [3, size - 1, 2], [0, size - 3, 3], [size - 4, 0, 1]
|
||||
] as const;
|
||||
@@ -43,7 +51,7 @@
|
||||
const sy = corner >= 2 ? -1 : 1;
|
||||
const a = 5 + ((cx * 7 + cy * 3) % 4);
|
||||
const b = 4 + ((cx * 5 + cy) % 3);
|
||||
return { x, y, r: 0, d: `M${x} ${y} l${sx * a} 0 l${-sx * (a - b)} ${sy * b} l${-sx * b} ${sy * (a - b + 1)} z` };
|
||||
return `M${x} ${y} l${sx * a} 0 l${-sx * (a - b)} ${sy * b} l${-sx * b} ${sy * (a - b + 1)} z`;
|
||||
});
|
||||
});
|
||||
const cracks = $derived.by(() => {
|
||||
@@ -55,131 +63,135 @@
|
||||
`M${w} ${h - 62} l-12 3 -6 -2 -9 6 -7 1 -5 6 -10 3 m22 -10 -2 -7 -5 -4`
|
||||
];
|
||||
});
|
||||
|
||||
</script>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="artwork" data-set={set.id} class:calm viewBox="0 0 {width} {height}" role={decorative ? "img" : "grid"} aria-label={decorative ? `${set.name} board in its opening position` : "The board"}>
|
||||
<defs>
|
||||
<linearGradient id={`${artId}-surface`} x2="0.85" y2="1">
|
||||
<stop stop-color="var(--surface-light)" />
|
||||
<stop offset="0.48" stop-color="var(--wood)" />
|
||||
<stop offset="1" stop-color="var(--surface-dark)" />
|
||||
</linearGradient>
|
||||
<filter id={`${artId}-grain`} x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency={worn ? '0.48' : '0.025 0.5'} numOctaves="3" seed="7" result="noise" />
|
||||
<feColorMatrix in="noise" type="saturate" values="0" />
|
||||
<feComponentTransfer result="soft"><feFuncA type="linear" slope={worn ? 0.2 : 0.13} /></feComponentTransfer>
|
||||
<feBlend in="SourceGraphic" in2="soft" mode="multiply" />
|
||||
</filter>
|
||||
<filter id={`${artId}-worn`} x="-5%" y="-5%" width="110%" height="110%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="2" seed="3" result="warp" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="warp" scale="1.15" xChannelSelector="R" yChannelSelector="G" />
|
||||
</filter>
|
||||
<filter id={`${artId}-mottle`} x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.025" numOctaves="3" seed="11" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.20 0 0 0 0 0.17 0 0 0 0 0.12 0.28 0 0 0 0" />
|
||||
<feComposite in2="SourceGraphic" operator="in" />
|
||||
</filter>
|
||||
{#each ['attacker', 'defender', 'king'] as kind (kind)}
|
||||
<radialGradient id={`${artId}-${kind}`} cx="32%" cy="24%" r="80%">
|
||||
<stop stop-color={`var(--${kind}-light)`} />
|
||||
<stop offset={worn ? "0.85" : "0.65"} stop-color={`var(--${kind})`} />
|
||||
<stop offset="1" stop-color={`var(--${kind}${worn ? "" : "-rim"})`} />
|
||||
</radialGradient>
|
||||
{/each}
|
||||
</defs>
|
||||
<rect class="wood" x="0.7" y="0.7" width={width - 1.4} height={height - 1.4} rx={worn ? 3 : 6} fill={`url(#${artId}-surface)`} filter={`url(#${artId}-grain)`} />
|
||||
<rect class="board-rim" x="2" y="2" width={width - 4} height={height - 4} rx={worn ? 2 : 5} />
|
||||
{#if worn}
|
||||
<rect class="stain" x="2" y="2" width={width - 4} height={height - 4} rx="3" filter={`url(#${artId}-mottle)`} />
|
||||
{:else}
|
||||
{#each [0.22, 0.51, 0.79] as part (part)}
|
||||
<path class="join" d={`M${PAD_L + size * CELL * part} 3 v${height - 6}`} />
|
||||
{/each}
|
||||
{/if}
|
||||
<g class="surface-wear" aria-hidden="true">
|
||||
{#each chips as chip (chip.d)}<path class="chip" d={chip.d} />{/each}
|
||||
{#each cracks as crack (crack)}
|
||||
<path class="crack-light" d={crack} transform="translate(0.35 0.4)" />
|
||||
<path class="crack" d={crack} />
|
||||
{/each}
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="artwork" data-set={set.id} class:calm viewBox="0 0 {width} {height}" role={decorative ? "img" : "grid"} aria-label={decorative ? `${set.name} board in its opening position` : "The board"}>
|
||||
<defs>
|
||||
<linearGradient id={`${artId}-surface`} x2="0.85" y2="1">
|
||||
<stop stop-color="var(--surface-light)" />
|
||||
<stop offset="0.48" stop-color="var(--wood)" />
|
||||
<stop offset="1" stop-color="var(--surface-dark)" />
|
||||
</linearGradient>
|
||||
<filter id={`${artId}-grain`} x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency={worn ? '0.48' : '0.025 0.5'} numOctaves="3" seed="7" result="noise" />
|
||||
<feColorMatrix in="noise" type="saturate" values="0" />
|
||||
<feComponentTransfer result="soft"><feFuncA type="linear" slope={worn ? 0.2 : 0.13} /></feComponentTransfer>
|
||||
<feBlend in="SourceGraphic" in2="soft" mode="multiply" />
|
||||
</filter>
|
||||
<filter id={`${artId}-worn`} x="-5%" y="-5%" width="110%" height="110%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="2" seed="3" result="warp" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="warp" scale="1.15" xChannelSelector="R" yChannelSelector="G" />
|
||||
</filter>
|
||||
<filter id={`${artId}-mottle`} x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.025" numOctaves="3" seed="11" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.20 0 0 0 0 0.17 0 0 0 0 0.12 0.28 0 0 0 0" />
|
||||
<feComposite in2="SourceGraphic" operator="in" />
|
||||
</filter>
|
||||
{#each ['attacker', 'defender', 'king'] as kind (kind)}
|
||||
<radialGradient id={`${artId}-${kind}`} cx="32%" cy="24%" r="80%">
|
||||
<stop stop-color={`var(--${kind}-light)`} />
|
||||
<stop offset={worn ? "0.85" : "0.65"} stop-color={`var(--${kind})`} />
|
||||
<stop offset="1" stop-color={`var(--${kind}${worn ? "" : "-rim"})`} />
|
||||
</radialGradient>
|
||||
{/each}
|
||||
</defs>
|
||||
<rect class="wood" x="0.7" y="0.7" width={width - 1.4} height={height - 1.4} rx={worn ? 3 : 6} fill={`url(#${artId}-surface)`} filter={`url(#${artId}-grain)`} />
|
||||
<rect class="board-rim" x="2" y="2" width={width - 4} height={height - 4} rx={worn ? 2 : 5} />
|
||||
{#if worn}
|
||||
<rect class="stain" x="2" y="2" width={width - 4} height={height - 4} rx="3" filter={`url(#${artId}-mottle)`} />
|
||||
{:else}
|
||||
{#each [0.22, 0.51, 0.79] as part (part)}
|
||||
<path class="join" d={`M${PAD_L + size * CELL * part} 3 v${height - 6}`} />
|
||||
{/each}
|
||||
{/if}
|
||||
<g class="surface-wear" aria-hidden="true">
|
||||
{#each chips as chip (chip)}<path class="chip" d={chip} />{/each}
|
||||
{#each cracks as crack (crack)}
|
||||
<path class="crack-light" d={crack} transform="translate(0.35 0.4)" />
|
||||
<path class="crack" d={crack} />
|
||||
{/each}
|
||||
</g>
|
||||
{#each showCoords ? Array.from({ length: size }, (_, n) => n) : [] as n (n)}
|
||||
<text class="coord" x={PAD_L + n * CELL + CELL / 2} y={height - 4} text-anchor="middle">{String.fromCharCode(97 + n)}</text>
|
||||
<text class="coord" x={PAD_L - 4} y={PAD_T + n * CELL + CELL / 2 + 3} text-anchor="end">{size - n}</text>
|
||||
{/each}
|
||||
<g>
|
||||
{#each g.cells as piece, i (i)}
|
||||
{@const c = cellOf(size, i)}
|
||||
{@const x = PAD_L + c.x * CELL}
|
||||
{@const y = PAD_T + c.y * CELL}
|
||||
<!-- Interactive squares have gridcell roles; illustrations have no tab stops. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<rect
|
||||
class="square"
|
||||
class:throne={i === throne}
|
||||
class:corner={corners.includes(i)}
|
||||
class:last={shownSquares.includes(i)}
|
||||
class:target={targets.includes(i)}
|
||||
class:fallen={shownCaptured.includes(i)}
|
||||
class:edge={set.escape === 'edge' && isEdge(size, i)}
|
||||
data-i={i}
|
||||
{x}
|
||||
{y}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
role={decorative ? undefined : "gridcell"}
|
||||
aria-label={`${label(i)}${piece === '.' ? '' : `, ${pieceWord(piece)}`}${targets.includes(i) ? ', a legal move' : ''}`}
|
||||
aria-selected={decorative ? undefined : selected === i}
|
||||
tabindex={decorative ? undefined : yourMove ? 0 : -1}
|
||||
onclick={decorative ? undefined : () => tap(i)}
|
||||
onkeydown={decorative ? undefined : (e) => key(e, i)}
|
||||
/>
|
||||
{#if targets.includes(i)}
|
||||
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
|
||||
{/if}
|
||||
{/each}
|
||||
{#each [throne, ...corners] as place (place)}
|
||||
{@const c = cellOf(size, place)}
|
||||
<g class="inlay" transform={`translate(${PAD_L + c.x * CELL + 20} ${PAD_T + c.y * CELL + 20})`} aria-hidden="true">
|
||||
<path d="M0 -15 L15 0 0 15 -15 0 Z" />
|
||||
{#if !worn}<path d="M-7 0 H7 M0 -7 V7" />{/if}
|
||||
</g>
|
||||
{/each}
|
||||
{#each artworkTokens as t (t.id)}
|
||||
{@const c = cellOf(size, t.at)}
|
||||
{@const cx = PAD_L + c.x * CELL + CELL / 2}
|
||||
{@const cy = PAD_T + c.y * CELL + CELL / 2}
|
||||
<g
|
||||
class="piece {t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'}"
|
||||
class:selected={selected === t.at}
|
||||
class:dying={t.dying}
|
||||
style="transform: translate({cx}px, {cy}px)"
|
||||
pointer-events="none"
|
||||
>
|
||||
<ellipse class="shadow" cx="1" cy="3" rx="14" ry="12.5" />
|
||||
<g filter={worn ? `url(#${artId}-worn)` : undefined}>
|
||||
<circle class="base" cy="1.2" r="13.6" />
|
||||
<ellipse class="body" rx="13.2" ry={worn ? 12.6 + (t.id % 3) * 0.25 : 13.2} fill={`url(#${artId}-${t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'})`} />
|
||||
<path class="bevel-light" d="M-11.5 3 A12 12 0 0 1 8 -9" />
|
||||
<path class="bevel-dark" d="M11.5 -3 A12 12 0 0 1 -7 9.8" />
|
||||
<circle class="turned" r="10.2" />
|
||||
{#if worn}
|
||||
{#each [0, 1, 2, 3, 4] as pit (pit)}
|
||||
<circle class="pit" cx={((t.id * 7 + pit * 11) % 17) - 8} cy={((t.id * 3 + pit * 7) % 15) - 7} r={0.18 + (pit % 3) * 0.1} />
|
||||
{/each}
|
||||
<path class="counter-wear" transform={`rotate(${(t.id * 137) % 360})`} d="M-10 -6 l2 1 M4 10 l2 -1 M-5 -9 l3 0.6" />
|
||||
{/if}
|
||||
{#if t.piece === 'k'}
|
||||
<path class="crown-gleam" transform="translate(0.25 0.55)" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
|
||||
<path class="crown" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
|
||||
{/if}
|
||||
</g>
|
||||
{#each showCoords ? Array.from({ length: size }, (_, n) => n) : [] as n (n)}
|
||||
<text class="coord" x={PAD_L + n * CELL + CELL / 2} y={height - 4} text-anchor="middle">{String.fromCharCode(97 + n)}</text>
|
||||
<text class="coord" x={PAD_L - 4} y={PAD_T + n * CELL + CELL / 2 + 3} text-anchor="end">{size - n}</text>
|
||||
{/each}
|
||||
<g>
|
||||
{#each g.cells as piece, i (i)}
|
||||
{@const c = cellOf(size, i)}
|
||||
{@const x = PAD_L + c.x * CELL}
|
||||
{@const y = PAD_T + c.y * CELL}
|
||||
<!-- Interactive squares have gridcell roles; illustrations have no tab stops. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<rect
|
||||
class="square"
|
||||
class:throne={i === throne}
|
||||
class:corner={corners.includes(i)}
|
||||
class:last={shownSquares.includes(i)}
|
||||
class:target={targets.includes(i)}
|
||||
class:fallen={shownCaptured.includes(i)}
|
||||
class:edge={set.escape === 'edge' && isEdge(size, i)}
|
||||
data-i={i}
|
||||
{x}
|
||||
{y}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
role={decorative ? undefined : "gridcell"}
|
||||
aria-label={`${label(i)}${piece === '.' ? '' : `, ${pieceWord(piece)}`}${targets.includes(i) ? ', a legal move' : ''}`}
|
||||
aria-selected={decorative ? undefined : selected === i}
|
||||
tabindex={decorative ? undefined : yourMove ? 0 : -1}
|
||||
onclick={decorative ? undefined : () => tap(i)}
|
||||
onkeydown={decorative ? undefined : (e) => key(e, i)}
|
||||
/>
|
||||
{#if targets.includes(i)}
|
||||
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
|
||||
{/if}
|
||||
{/each}
|
||||
{#each [throne, ...corners] as place (place)}
|
||||
{@const c = cellOf(size, place)}
|
||||
<g class="inlay" transform={`translate(${PAD_L + c.x * CELL + 20} ${PAD_T + c.y * CELL + 20})`} aria-hidden="true">
|
||||
<path d="M0 -15 L15 0 0 15 -15 0 Z" />
|
||||
{#if !worn}<path d="M-7 0 H7 M0 -7 V7" />{/if}
|
||||
</g>
|
||||
{/each}
|
||||
{#each artworkTokens as t (t.id)}
|
||||
{@const c = cellOf(size, t.at)}
|
||||
{@const cx = PAD_L + c.x * CELL + CELL / 2}
|
||||
{@const cy = PAD_T + c.y * CELL + CELL / 2}
|
||||
<g
|
||||
class="piece {t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'}"
|
||||
class:selected={selected === t.at}
|
||||
class:dying={t.dying}
|
||||
style="transform: translate({cx}px, {cy}px)"
|
||||
pointer-events="none"
|
||||
>
|
||||
<ellipse class="shadow" cx="1" cy="3" rx="14" ry="12.5" />
|
||||
<g filter={worn ? `url(#${artId}-worn)` : undefined}>
|
||||
<circle class="base" cy="1.2" r="13.6" />
|
||||
<ellipse class="body" rx="13.2" ry={worn ? 12.6 + (t.id % 3) * 0.25 : 13.2} fill={`url(#${artId}-${t.piece === 'a' ? 'attacker' : t.piece === 'k' ? 'king' : 'defender'})`} />
|
||||
<path class="bevel-light" d="M-11.5 3 A12 12 0 0 1 8 -9" />
|
||||
<path class="bevel-dark" d="M11.5 -3 A12 12 0 0 1 -7 9.8" />
|
||||
<circle class="turned" r="10.2" />
|
||||
{#if worn}
|
||||
{#each [0, 1, 2, 3, 4] as pit (pit)}
|
||||
<circle class="pit" cx={((t.id * 7 + pit * 11) % 17) - 8} cy={((t.id * 3 + pit * 7) % 15) - 7} r={0.18 + (pit % 3) * 0.1} />
|
||||
{/each}
|
||||
<path class="counter-wear" transform={`rotate(${(t.id * 137) % 360})`} d="M-10 -6 l2 1 M4 10 l2 -1 M-5 -9 l3 0.6" />
|
||||
{/if}
|
||||
{#if t.piece === 'k'}
|
||||
<path class="crown-gleam" transform="translate(0.25 0.55)" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
|
||||
<path class="crown" d="M-8 4 V-5 L-4 0 0 -7 4 0 8 -5 V4 Z M-7 6 H7" />
|
||||
{/if}
|
||||
</g>
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.artwork {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
/* Copenhagen: a crafted oak board, polished counters and brass inlay. */
|
||||
--surface-light: #bd935c;
|
||||
--surface-dark: #79502d;
|
||||
@@ -226,11 +238,6 @@
|
||||
--king-rim: #7d6636;
|
||||
--crown: #5a4a2a;
|
||||
}
|
||||
.artwork {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wood {
|
||||
stroke: var(--surface-dark);
|
||||
@@ -308,41 +315,155 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.board-rim {
|
||||
fill: none;
|
||||
stroke: var(--rim-light);
|
||||
stroke-width: 0.7;
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.join {
|
||||
fill: none;
|
||||
stroke: #50351f;
|
||||
stroke-width: 0.6;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.surface-wear {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.crack-light {
|
||||
fill: none;
|
||||
stroke: #e1d6bd;
|
||||
stroke-width: 0.55;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.inlay {
|
||||
fill: none;
|
||||
stroke: var(--rim-light);
|
||||
stroke-width: 0.8;
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.artwork[data-set='tablut'] .inlay {
|
||||
stroke: #494638;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.piece {
|
||||
--piece-rim: var(--defender-rim);
|
||||
transition:
|
||||
transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.piece.attacker {
|
||||
--piece-rim: var(--attacker-rim);
|
||||
}
|
||||
|
||||
.piece.king {
|
||||
--piece-rim: var(--king-rim);
|
||||
}
|
||||
|
||||
.piece.dying {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.board-rim { fill: none; stroke: var(--rim-light); stroke-width: 0.7; opacity: 0.65; pointer-events: none; }
|
||||
.join { fill: none; stroke: #50351f; stroke-width: 0.6; opacity: 0.18; pointer-events: none; }
|
||||
.surface-wear { pointer-events: none; }
|
||||
.crack-light { fill: none; stroke: #e1d6bd; stroke-width: 0.55; opacity: 0.45; }
|
||||
.inlay { fill: none; stroke: var(--rim-light); stroke-width: 0.8; opacity: 0.75; pointer-events: none; }
|
||||
.artwork[data-set='tablut'] .inlay { stroke: #494638; opacity: 0.45; }
|
||||
.piece .shadow {
|
||||
fill: #16130e;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.piece { --piece-rim: var(--defender-rim); }
|
||||
.piece.attacker { --piece-rim: var(--attacker-rim); }
|
||||
.piece.king { --piece-rim: var(--king-rim); }
|
||||
.piece .shadow { fill: #16130e; opacity: 0.28; }
|
||||
.piece .base { fill: var(--piece-rim); }
|
||||
.piece .body { stroke: var(--piece-rim); stroke-width: 0.65; }
|
||||
.bevel-light { fill: none; stroke: #fff1cc; stroke-width: 0.9; opacity: 0.48; stroke-linecap: round; }
|
||||
.bevel-dark { fill: none; stroke: #211d16; stroke-width: 0.8; opacity: 0.3; stroke-linecap: round; }
|
||||
.turned { fill: none; stroke: var(--piece-rim); stroke-width: 0.45; opacity: 0.35; }
|
||||
.counter-wear { fill: none; stroke: #534b37; stroke-width: 0.5; opacity: 0.3; stroke-linecap: round; }
|
||||
.attacker .counter-wear { stroke: #d6c6a3; opacity: 0.23; }
|
||||
.artwork[data-set='tablut'] .bevel-light { opacity: 0.25; stroke-width: 1.1; }
|
||||
.artwork[data-set='tablut'] .turned { display: none; }
|
||||
.pit { fill: #544b38; opacity: 0.3; }
|
||||
.attacker .pit { fill: #d6c6a3; opacity: 0.2; }
|
||||
.piece .crown { fill: var(--crown); stroke: var(--crown); stroke-width: 0.7; stroke-linejoin: round; }
|
||||
.piece .crown-gleam { fill: #f8e5b2; stroke: #f8e5b2; stroke-width: 0.9; opacity: 0.7; }
|
||||
.piece.selected .body { stroke: var(--frost); stroke-width: 2.5; }
|
||||
.piece .base {
|
||||
fill: var(--piece-rim);
|
||||
}
|
||||
|
||||
.calm .piece, .calm .square { transition: none; }
|
||||
.piece .body {
|
||||
stroke: var(--piece-rim);
|
||||
stroke-width: 0.65;
|
||||
}
|
||||
|
||||
.bevel-light {
|
||||
fill: none;
|
||||
stroke: #fff1cc;
|
||||
stroke-width: 0.9;
|
||||
opacity: 0.48;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.bevel-dark {
|
||||
fill: none;
|
||||
stroke: #211d16;
|
||||
stroke-width: 0.8;
|
||||
opacity: 0.3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.turned {
|
||||
fill: none;
|
||||
stroke: var(--piece-rim);
|
||||
stroke-width: 0.45;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.counter-wear {
|
||||
fill: none;
|
||||
stroke: #534b37;
|
||||
stroke-width: 0.5;
|
||||
opacity: 0.3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.attacker .counter-wear {
|
||||
stroke: #d6c6a3;
|
||||
opacity: 0.23;
|
||||
}
|
||||
|
||||
.artwork[data-set='tablut'] .bevel-light {
|
||||
opacity: 0.25;
|
||||
stroke-width: 1.1;
|
||||
}
|
||||
|
||||
.artwork[data-set='tablut'] .turned {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pit {
|
||||
fill: #544b38;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.attacker .pit {
|
||||
fill: #d6c6a3;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.piece .crown {
|
||||
fill: var(--crown);
|
||||
stroke: var(--crown);
|
||||
stroke-width: 0.7;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.piece .crown-gleam {
|
||||
fill: #f8e5b2;
|
||||
stroke: #f8e5b2;
|
||||
stroke-width: 0.9;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.piece.selected .body {
|
||||
stroke: var(--frost);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.calm .piece,
|
||||
.calm .square {
|
||||
transition: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,18 +6,16 @@
|
||||
import { otherGames, type FamilyGame } from '$lib/family';
|
||||
import Preferences from './Preferences.svelte';
|
||||
import { prefs, setPref } from '$lib/prefs.svelte';
|
||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client';
|
||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
|
||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
import { unreadTalk } from '$lib/net/talk';
|
||||
import type { RoomView } from '$lib/net/view';
|
||||
import type { State } from '$lib/game';
|
||||
import { game, createGame } from '$lib/game';
|
||||
import type { Report, RoomView, Tally } from '$lib/net/view';
|
||||
import { createGame, game, type State } from '$lib/game';
|
||||
import BoardArtwork from './BoardArtwork.svelte';
|
||||
|
||||
let name = $state(playerName());
|
||||
let code = $state('');
|
||||
/** The host's choices for the next table, from the game's declared options. */
|
||||
// A new table starts from the preferences' table defaults; the hall's selectors set the same keys.
|
||||
/** The host's choices for the next table: the preferences' table defaults, which the selectors below also set. */
|
||||
const options = $derived<Record<string, string>>(
|
||||
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
|
||||
);
|
||||
@@ -85,7 +83,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The desk is asked once per visit for every seat this browser holds.
|
||||
// The desk is asked for every seat this browser holds, again whenever that set changes.
|
||||
$effect(() => {
|
||||
const held = seats;
|
||||
if (!held.length) return;
|
||||
@@ -166,8 +164,8 @@
|
||||
<h1>Hnefatafl</h1>
|
||||
<p class="pitch">The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.</p>
|
||||
<p class="tag">Hnefatafl was played across the Norse world for a thousand years before chess. Every piece moves like a rook and is taken by being sandwiched; the attackers must capture the king, the king must reach safety. Play a housecarl, or open a table for a friend and play by turns. Copenhagen rules on the big board, or Tablut, the older game Linnaeus wrote down in 1732.</p>
|
||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a> · <button type="button" class="link-like" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
||||
<Preferences bind:open={prefsOpen} />
|
||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
||||
<Preferences bind:open={prefsOpen} />
|
||||
</div>
|
||||
|
||||
<div class="lid-play">
|
||||
@@ -179,7 +177,7 @@
|
||||
<div class="options">
|
||||
{#each game.options as o (o.key)}
|
||||
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
||||
<div class="option-block">
|
||||
<div>
|
||||
<label class="option">
|
||||
<span>{o.label}</span>
|
||||
<select value={options[o.key]} onchange={(e) => setPref(`table.${o.key}`, e.currentTarget.value)}>
|
||||
@@ -201,7 +199,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
|
||||
<form class="joinform claimform" onsubmit={claim}>
|
||||
<form class="joinform" onsubmit={claim}>
|
||||
<span class="or">or bring a seat from another device</span>
|
||||
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||
<button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button>
|
||||
@@ -222,7 +220,7 @@
|
||||
<span class="ledger-code">{held.roomId}</span>
|
||||
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
|
||||
</a>
|
||||
<button type="button" class="ledger-forget" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
|
||||
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if reports.length}
|
||||
@@ -384,10 +382,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ways {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -425,7 +419,6 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
|
||||
/* the games ledger */
|
||||
.ledger {
|
||||
grid-area: ledger;
|
||||
@@ -486,18 +479,6 @@
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.ledger-forget {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-faint);
|
||||
font-size: 1.1rem;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.ledger-forget:hover {
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.reports-head {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
@@ -588,6 +569,7 @@
|
||||
font-size: 0.85rem;
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
.family {
|
||||
margin: 0.4rem 0 0.8rem 1.2rem;
|
||||
padding: 0;
|
||||
@@ -596,6 +578,7 @@
|
||||
.family li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -614,6 +597,7 @@
|
||||
margin: 0.2rem 0 0;
|
||||
max-width: 22em;
|
||||
}
|
||||
|
||||
.still {
|
||||
width: 100%;
|
||||
max-width: 18rem;
|
||||
@@ -637,13 +621,4 @@
|
||||
.tally dd {
|
||||
margin: 0;
|
||||
}
|
||||
.link-like {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
|
||||
let { room, link }: { room: Room; link: string } = $props();
|
||||
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
|
||||
|
||||
const v = $derived(room.view);
|
||||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||||
const seatFree = $derived(v.seats.length < v.size);
|
||||
/** A seat is free for a newcomer once the held ones are counted. */
|
||||
const seatOpen = $derived(v.seats.length + v.expected.length < v.size);
|
||||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||||
let copied = $state(false);
|
||||
|
||||
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
||||
let minute = $state(0);
|
||||
@@ -31,22 +27,12 @@
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1600);
|
||||
} catch {
|
||||
// The link is on screen to copy by hand.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="lobby">
|
||||
{#if room.spectating}
|
||||
<h2>At the table</h2>
|
||||
{#if seatFree}
|
||||
{#if room.seatEmpty}
|
||||
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
|
||||
{:else}
|
||||
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
|
||||
@@ -62,7 +48,7 @@
|
||||
<li>
|
||||
<span class="seat-name">{s.name}</span>
|
||||
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'a housecarl' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost}
|
||||
<button type="button" class="unseat" title="Send this housecarl away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
|
||||
<button type="button" class="dismiss" title="Send this housecarl away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#each v.expected as name (name)}
|
||||
@@ -78,7 +64,7 @@
|
||||
{/if}
|
||||
{#if !room.spectating}
|
||||
<div class="ways">
|
||||
{#if seatFree && room.isHost}
|
||||
{#if room.seatEmpty && room.isHost}
|
||||
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a housecarl</button>
|
||||
{/if}
|
||||
{#if room.isHost}
|
||||
@@ -94,13 +80,13 @@
|
||||
Let the Peanut Gallery talk at the table
|
||||
</label>
|
||||
{/if}
|
||||
{#if v.keeper && !v.challenge && !keeperSeated && seatOpen}
|
||||
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
|
||||
<div class="keeper">
|
||||
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
|
||||
<p class="muted small">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
|
||||
<p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
|
||||
</div>
|
||||
{:else if v.challenge}
|
||||
<p class="muted small">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a housecarl, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
|
||||
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a housecarl, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
@@ -148,20 +134,6 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.unseat {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-faint);
|
||||
font-size: 1.1rem;
|
||||
padding: 0 0.3rem;
|
||||
margin-left: 0.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.unseat:hover {
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.ways {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -203,8 +175,7 @@
|
||||
max-width: 38em;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
.note {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
// The preferences panel: every declared setting, kit and game, as a
|
||||
// control. Opened from the masthead; closes with its button or Escape.
|
||||
// control. Opened from the masthead; closes with its button, a click
|
||||
// outside it, or Escape.
|
||||
import { PREFERENCES, prefs, setPref } from '$lib/prefs.svelte';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
@@ -13,29 +14,30 @@
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if open}
|
||||
<div class="scrim" role="presentation" onclick={() => (open = false)}></div>
|
||||
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
|
||||
<h2 id="prefs-title">Preferences</h2>
|
||||
<p class="muted small">Kept in this browser, and nowhere else.</p>
|
||||
{#each PREFERENCES as p (p.key)}
|
||||
<div class="pref">
|
||||
{#if p.kind === 'toggle'}
|
||||
<label class="row">
|
||||
<input type="checkbox" checked={prefs[p.key] === true} onchange={(e) => setPref(p.key, e.currentTarget.checked)} />
|
||||
<span>{p.label}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<label class="row">
|
||||
<span>{p.label}</span>
|
||||
<select value={prefs[p.key]} onchange={(e) => setPref(p.key, e.currentTarget.value)}>
|
||||
{#each p.choices ?? [] as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if p.note}<p class="muted small note">{p.note}</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="quiet" onclick={() => (open = false)}>Done</button>
|
||||
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
|
||||
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
|
||||
<h2 id="prefs-title">Preferences</h2>
|
||||
<p class="muted small">Kept in this browser, and nowhere else.</p>
|
||||
{#each PREFERENCES as p (p.key)}
|
||||
<div class="pref">
|
||||
{#if p.kind === 'toggle'}
|
||||
<label class="row">
|
||||
<input type="checkbox" checked={prefs[p.key] === true} onchange={(e) => setPref(p.key, e.currentTarget.checked)} />
|
||||
<span>{p.label}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<label class="row">
|
||||
<span>{p.label}</span>
|
||||
<select value={prefs[p.key]} onchange={(e) => setPref(p.key, e.currentTarget.value)}>
|
||||
{#each p.choices ?? [] as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if p.note}<p class="muted small note">{p.note}</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="quiet" onclick={() => (open = false)}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -43,22 +45,20 @@
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 21;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(92vw, 26rem);
|
||||
max-height: 90vh;
|
||||
width: min(100%, 26rem);
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
background: var(--slate);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 1.2rem 1.4rem 1.4rem;
|
||||
}
|
||||
|
||||
@@ -87,10 +87,6 @@
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
@@ -38,12 +38,11 @@
|
||||
|
||||
async function send(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const r = room;
|
||||
if (!happened.trim() || busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const { id } = await api.report(r.roomId, r.token, happened.trim(), expected.trim());
|
||||
const { id } = await api.report(room.roomId, room.token, happened.trim(), expected.trim());
|
||||
if (image) await api.reportImage(id, image).catch(() => (error = 'The report went; the picture did not.'));
|
||||
sent = true;
|
||||
setTimeout(close, 2200);
|
||||
@@ -75,7 +74,7 @@
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
|
||||
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
|
||||
</label>
|
||||
<p class="muted small">The room code and the round ride along; the keeper can replay the game to this moment.</p>
|
||||
<p class="muted small note">The room code and the round ride along; the keeper can replay the game to this moment.</p>
|
||||
{#if error}<p class="warning">{error}</p>{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
|
||||
@@ -119,7 +118,6 @@
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
|
||||
.picture input {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -132,13 +130,11 @@
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
.note {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -152,14 +148,4 @@
|
||||
gap: 1rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-dim);
|
||||
text-decoration: underline dotted;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
|
||||
</form>
|
||||
{:else if room.spectating}
|
||||
<p class="muted small">The gallery listens; the host has not let it talk.</p>
|
||||
<p class="muted small note">The gallery listens; the host has not let it talk.</p>
|
||||
{:else}
|
||||
<form onsubmit={say}>
|
||||
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
|
||||
@@ -125,8 +125,7 @@
|
||||
flex: 0 1 8rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
.note {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// The board without the game: the squares and their geometry, how a piece
|
||||
// moves, and what a move takes. Everything here works on a bare array of
|
||||
// pieces under a ruleset, so the engine and the bot share one account of
|
||||
// the rules and neither needs the other.
|
||||
|
||||
import type { Cell, Ruleset } from './rules';
|
||||
|
||||
/** One square: empty, an attacker, a defender, or the king. */
|
||||
export type Piece = '.' | 'a' | 'd' | 'k';
|
||||
export type Side = 'attackers' | 'defenders';
|
||||
/** A move as a seat submits it: from one square to another. */
|
||||
export type Input = { from: number; to: number };
|
||||
|
||||
export function at(size: number, c: Cell): number {
|
||||
return c.y * size + c.x;
|
||||
}
|
||||
|
||||
export function cellOf(size: number, i: number): Cell {
|
||||
return { x: i % size, y: Math.floor(i / size) };
|
||||
}
|
||||
|
||||
export function throneOf(size: number): number {
|
||||
const mid = (size - 1) / 2;
|
||||
return mid * size + mid;
|
||||
}
|
||||
|
||||
export function cornersOf(size: number): number[] {
|
||||
return [0, size - 1, size * (size - 1), size * size - 1];
|
||||
}
|
||||
|
||||
export function isEdge(size: number, i: number): boolean {
|
||||
const { x, y } = cellOf(size, i);
|
||||
return x === 0 || y === 0 || x === size - 1 || y === size - 1;
|
||||
}
|
||||
|
||||
export const DIRECTIONS = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1]
|
||||
] as const;
|
||||
|
||||
export function neighbors(size: number, i: number): number[] {
|
||||
const { x, y } = cellOf(size, i);
|
||||
const out: number[] = [];
|
||||
if (y > 0) out.push(i - size);
|
||||
if (y < size - 1) out.push(i + size);
|
||||
if (x > 0) out.push(i - 1);
|
||||
if (x < size - 1) out.push(i + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The square on the far side of `mid` from `from`, or -1 off the board. */
|
||||
export function beyond(size: number, from: number, mid: number): number {
|
||||
const a = cellOf(size, from);
|
||||
const b = cellOf(size, mid);
|
||||
const c = { x: b.x + (b.x - a.x), y: b.y + (b.y - a.y) };
|
||||
if (c.x < 0 || c.y < 0 || c.x >= size || c.y >= size) return -1;
|
||||
return at(size, c);
|
||||
}
|
||||
|
||||
export function sideOf(p: Piece | undefined): Side | null {
|
||||
return p === 'a' ? 'attackers' : p === 'd' || p === 'k' ? 'defenders' : null;
|
||||
}
|
||||
|
||||
/** The square's name on the coordinates: a file letter and a rank counted from the bottom. */
|
||||
export function squareName(size: number, i: number): string {
|
||||
const c = cellOf(size, i);
|
||||
return `${String.fromCharCode(97 + c.x)}${size - c.y}`;
|
||||
}
|
||||
|
||||
/** A piece in words, for the record and the screen reader. */
|
||||
export function pieceWord(p: Piece): string {
|
||||
return p === 'k' ? 'the king' : p === 'a' ? 'an attacker' : 'a defender';
|
||||
}
|
||||
|
||||
/** Whether the king standing on `i` has escaped under this set. */
|
||||
export function escapeAt(set: Ruleset, i: number): boolean {
|
||||
return set.escape === 'corner' ? cornersOf(set.size).includes(i) : isEdge(set.size, i);
|
||||
}
|
||||
|
||||
/** Where the piece on `from` may go: along its row and column, through empty squares. */
|
||||
export function movesOn(set: Ruleset, cells: Piece[], from: number): number[] {
|
||||
const size = set.size;
|
||||
const piece = cells[from];
|
||||
if (piece === '.' || piece === undefined) return [];
|
||||
const king = piece === 'k';
|
||||
const throne = throneOf(size);
|
||||
const corners = set.markedCorners ? cornersOf(size) : [];
|
||||
const { x, y } = cellOf(size, from);
|
||||
const out: number[] = [];
|
||||
for (const [dx, dy] of DIRECTIONS) {
|
||||
for (let step = 1; ; step++) {
|
||||
const nx = x + dx * step;
|
||||
const ny = y + dy * step;
|
||||
if (nx < 0 || ny < 0 || nx >= size || ny >= size) break;
|
||||
const i = at(size, { x: nx, y: ny });
|
||||
if (cells[i] !== '.') break;
|
||||
if (corners.includes(i)) {
|
||||
if (king) out.push(i);
|
||||
break;
|
||||
}
|
||||
if (i === throne) {
|
||||
if (king) out.push(i);
|
||||
else if (!set.passThrone) break;
|
||||
continue;
|
||||
}
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every move `side` may make on `cells`. */
|
||||
export function movesOf(set: Ruleset, cells: Piece[], side: Side): Input[] {
|
||||
const out: Input[] = [];
|
||||
cells.forEach((p, i) => {
|
||||
if (sideOf(p) !== side) return;
|
||||
for (const to of movesOn(set, cells, i)) out.push({ from: i, to });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether `i` closes a capture on a piece of `side`: an enemy (an armed king counts), a marked corner, or the empty throne. */
|
||||
function hostile(set: Ruleset, cells: Piece[], i: number, side: Side): boolean {
|
||||
const p = cells[i];
|
||||
if (p !== '.') {
|
||||
if (sideOf(p) === side) return false;
|
||||
return p !== 'k' || set.kingArmed;
|
||||
}
|
||||
if (set.markedCorners && cornersOf(set.size).includes(i)) return true;
|
||||
return i === throneOf(set.size);
|
||||
}
|
||||
|
||||
/** Whether the attackers have the king where he stands. */
|
||||
function kingTaken(set: Ruleset, cells: Piece[], kingAt: number): boolean {
|
||||
const size = set.size;
|
||||
const throne = throneOf(size);
|
||||
const around = neighbors(size, kingAt);
|
||||
const attackersAround = around.filter((n) => cells[n] === 'a').length;
|
||||
if (kingAt === throne) return attackersAround === 4;
|
||||
if (around.includes(throne) && cells[throne] === '.') return attackersAround === 3;
|
||||
if (set.kingCapturedByFour) return around.length === 4 && attackersAround === 4;
|
||||
// Like any piece: two attackers on opposite sides.
|
||||
const { x, y } = cellOf(size, kingAt);
|
||||
if (x > 0 && x < size - 1 && cells[kingAt - 1] === 'a' && cells[kingAt + 1] === 'a') return true;
|
||||
return y > 0 && y < size - 1 && cells[kingAt - size] === 'a' && cells[kingAt + size] === 'a';
|
||||
}
|
||||
|
||||
/** The squares a piece just landed on `to` takes: custodial captures, the king by his own rule, a shieldwall along the edge. */
|
||||
function capturesOn(set: Ruleset, cells: Piece[], to: number): number[] {
|
||||
const size = set.size;
|
||||
const piece = cells[to];
|
||||
const side = sideOf(piece)!;
|
||||
const captured: number[] = [];
|
||||
for (const n of neighbors(size, to)) {
|
||||
const target = cells[n];
|
||||
if (target === '.' || target === 'k' || sideOf(target) === side) continue;
|
||||
const far = beyond(size, to, n);
|
||||
if (far !== -1 && hostile(set, cells, far, sideOf(target)!)) captured.push(n);
|
||||
}
|
||||
if (side === 'attackers') {
|
||||
const kingAt = cells.indexOf('k');
|
||||
if (kingAt !== -1 && neighbors(size, kingAt).includes(to) && kingTaken(set, cells, kingAt)) captured.push(kingAt);
|
||||
}
|
||||
if (set.shieldwall && isEdge(size, to)) {
|
||||
for (const run of shieldwallRuns(set, cells, to, side)) captured.push(...run);
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
/** Slide a piece and take what it takes, on a copy of the board. */
|
||||
export function playOn(set: Ruleset, cells: Piece[], from: number, to: number): { cells: Piece[]; captured: number[] } {
|
||||
const next = cells.slice();
|
||||
next[to] = next[from];
|
||||
next[from] = '.';
|
||||
const captured = capturesOn(set, next, to);
|
||||
for (const i of captured) next[i] = '.';
|
||||
return { cells: next, captured };
|
||||
}
|
||||
|
||||
/** Runs of enemy pieces along the edge beside `to`, bracketed at both ends and each faced from inland. */
|
||||
function shieldwallRuns(set: Ruleset, cells: Piece[], to: number, side: Side): number[][] {
|
||||
const size = set.size;
|
||||
const { x, y } = cellOf(size, to);
|
||||
const along: [number, number][] = x === 0 || x === size - 1 ? [[0, 1], [0, -1]] : [[1, 0], [-1, 0]];
|
||||
const inland = x === 0 ? { x: 1, y: 0 } : x === size - 1 ? { x: -1, y: 0 } : y === 0 ? { x: 0, y: 1 } : { x: 0, y: -1 };
|
||||
const runs: number[][] = [];
|
||||
for (const [dx, dy] of along) {
|
||||
const run: number[] = [];
|
||||
let cx = x + dx;
|
||||
let cy = y + dy;
|
||||
let closed = false;
|
||||
while (cx >= 0 && cy >= 0 && cx < size && cy < size) {
|
||||
const i = at(size, { x: cx, y: cy });
|
||||
const p = cells[i];
|
||||
if (p === '.') {
|
||||
closed = cornersOf(size).includes(i);
|
||||
break;
|
||||
}
|
||||
if (sideOf(p) === side) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
const facing = cells[at(size, { x: cx + inland.x, y: cy + inland.y })];
|
||||
if (sideOf(facing) !== side) break;
|
||||
run.push(i);
|
||||
cx += dx;
|
||||
cy += dy;
|
||||
}
|
||||
if (closed && run.length >= 2) runs.push(run.filter((i) => cells[i] !== 'k'));
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
+18
-51
@@ -3,9 +3,13 @@
|
||||
// and the attackers pressing around the king. A function of the state
|
||||
// alone, so a ledger replays to the same choice.
|
||||
|
||||
import { cornersOf, isEdge, cellOf, movesOn, playOn, rulesetOf, sideOf, type Input, type Piece, type Side, type State } from './index';
|
||||
import type { Ruleset } from './rules';
|
||||
import { cellOf, cornersOf, DIRECTIONS, escapeAt, isEdge, movesOf, neighbors, playOn, type Input, type Piece, type Side } from './board';
|
||||
import { RULESETS, type Ruleset } from './rules';
|
||||
import type { SeatId } from './spec';
|
||||
import type { State } from './index';
|
||||
|
||||
/** Plies of lookahead: three is well under a second a move on either board. */
|
||||
const DEPTH = 3;
|
||||
|
||||
/** The board as the search sees it: pieces, who moves, and a verdict when one has fallen. */
|
||||
interface Node {
|
||||
@@ -15,31 +19,16 @@ interface Node {
|
||||
won: Side | null;
|
||||
}
|
||||
|
||||
function movesOf(set: Ruleset, cells: Piece[], side: Side): { from: number; to: number }[] {
|
||||
const out: { from: number; to: number }[] = [];
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (sideOf(cells[i]) !== side) continue;
|
||||
for (const to of movesOn(set, cells, i)) out.push({ from: i, to });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stepNode(set: Ruleset, node: Node, from: number, to: number): Node {
|
||||
const piece = node.cells[from];
|
||||
const { cells } = playOn(set, node.cells, from, to);
|
||||
const kingAt = cells.indexOf('k');
|
||||
let won: Side | null = null;
|
||||
if (kingAt === -1) won = 'attackers';
|
||||
else if (piece === 'k' && (set.escape === 'corner' ? cornersOf(set.size).includes(kingAt) : isEdge(set.size, kingAt))) won = 'defenders';
|
||||
else if (piece === 'k' && escapeAt(set, kingAt)) won = 'defenders';
|
||||
return { cells, toMove: node.toMove === 'attackers' ? 'defenders' : 'attackers', won };
|
||||
}
|
||||
|
||||
/** Search depth in plies: three looks ahead on either board, well under a second a move. */
|
||||
function depthFor(size: number): number {
|
||||
void size;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/** Positive is good for the attackers. */
|
||||
function evaluate(set: Ruleset, node: Node): number {
|
||||
if (node.won) return node.won === 'attackers' ? 10_000 : -10_000;
|
||||
@@ -58,18 +47,13 @@ function evaluate(set: Ruleset, node: Node): number {
|
||||
if (kingAt === -1) return 10_000;
|
||||
const k = cellOf(size, kingAt);
|
||||
// The king's distance to safety, and how open his roads are.
|
||||
const goals = set.escape === 'corner' ? cornersOf(size).map((i) => cellOf(size, i)) : [];
|
||||
let distance: number;
|
||||
if (set.escape === 'corner') distance = Math.min(...goals.map((g) => Math.abs(g.x - k.x) + Math.abs(g.y - k.y)));
|
||||
else distance = Math.min(k.x, k.y, size - 1 - k.x, size - 1 - k.y);
|
||||
const distance =
|
||||
set.escape === 'corner'
|
||||
? Math.min(...cornersOf(size).map((i) => cellOf(size, i)).map((g) => Math.abs(g.x - k.x) + Math.abs(g.y - k.y)))
|
||||
: Math.min(k.x, k.y, size - 1 - k.x, size - 1 - k.y);
|
||||
score += distance * 6;
|
||||
// Open lines from the king straight to an escape square are worth a great deal.
|
||||
for (const [dx, dy] of [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1]
|
||||
] as const) {
|
||||
for (const [dx, dy] of DIRECTIONS) {
|
||||
let x = k.x + dx;
|
||||
let y = k.y + dy;
|
||||
let open = true;
|
||||
@@ -81,30 +65,14 @@ function evaluate(set: Ruleset, node: Node): number {
|
||||
x += dx;
|
||||
y += dy;
|
||||
}
|
||||
if (open) {
|
||||
const last = { x: x - dx, y: y - dy };
|
||||
const reaches = set.escape === 'edge' ? isEdge(size, last.y * size + last.x) : cornersOf(size).includes(last.y * size + last.x);
|
||||
if (reaches) score -= 400;
|
||||
}
|
||||
if (open && escapeAt(set, (y - dy) * size + (x - dx))) score -= 400;
|
||||
}
|
||||
// Attackers pressing the king.
|
||||
let press = 0;
|
||||
for (const [dx, dy] of [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1]
|
||||
] as const) {
|
||||
const x = k.x + dx;
|
||||
const y = k.y + dy;
|
||||
if (x < 0 || y < 0 || x >= size || y >= size) continue;
|
||||
if (cells[y * size + x] === 'a') press += 1;
|
||||
}
|
||||
score += press * 12;
|
||||
score += neighbors(size, kingAt).filter((n) => cells[n] === 'a').length * 12;
|
||||
return score;
|
||||
}
|
||||
|
||||
function orderMoves(set: Ruleset, cells: Piece[], moves: { from: number; to: number }[]): { from: number; to: number }[] {
|
||||
function orderMoves(set: Ruleset, cells: Piece[], moves: Input[]): Input[] {
|
||||
// King moves and edge moves first: they change the position most.
|
||||
const king = cells.indexOf('k');
|
||||
return moves
|
||||
@@ -133,17 +101,16 @@ function search(set: Ruleset, node: Node, depth: number, alpha: number, beta: nu
|
||||
}
|
||||
|
||||
export function chooseMove(state: State, seat: SeatId): Input {
|
||||
const set = rulesetOf(state);
|
||||
const set = RULESETS[state.set];
|
||||
const side = state.players[seat].side;
|
||||
const root: Node = { cells: state.cells, toMove: side, won: null };
|
||||
// A side with no move has already lost in applyMove, so there is always one.
|
||||
const moves = orderMoves(set, state.cells, movesOf(set, state.cells, side));
|
||||
if (moves.length === 0) return { from: -1, to: -1 };
|
||||
let best = moves[0];
|
||||
let bestScore = -Infinity;
|
||||
const depth = depthFor(state.size);
|
||||
for (const m of moves) {
|
||||
const next = stepNode(set, root, m.from, m.to);
|
||||
const v = search(set, next, depth - 1, -Infinity, Infinity, side);
|
||||
const v = search(set, next, DEPTH - 1, -Infinity, Infinity, side);
|
||||
if (v > bestScore) {
|
||||
bestScore = v;
|
||||
best = m;
|
||||
|
||||
+22
-16
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyMove, at, cornersOf, createGame, game, legalMoves, movesFrom, throneOf, validateMove, type Piece, type State } from './index';
|
||||
import { applyMove, at, createGame, game, movesFrom, rulesetOf, throneOf, validateMove, type Piece, type State } from './index';
|
||||
import { movesOf } from './board';
|
||||
import { chooseMove } from './bot';
|
||||
|
||||
const names = { A: 'Ann', B: 'Bo' };
|
||||
@@ -80,7 +81,7 @@ describe('moving', () => {
|
||||
describe('capturing', () => {
|
||||
it('takes a piece sandwiched between two enemies, only by the moving piece', () => {
|
||||
const s = board([E11, '.a.d.a.....', ...Array(9).fill(E11)]);
|
||||
// The attacker at x=1 slides to x=2: the defender at x=3 is between it and the attacker at x=5? No: x=4 is empty.
|
||||
// The attacker at x=1 slides to x=2; x=4 is empty, so the defender at x=3 is not bracketed.
|
||||
let next = applyMove(s, at(11, { x: 1, y: 1 }), at(11, { x: 2, y: 1 }));
|
||||
expect(next.cells[at(11, { x: 3, y: 1 })]).toBe('d');
|
||||
// The attacker at x=5 slides to x=4: now the defender is sandwiched.
|
||||
@@ -115,7 +116,7 @@ describe('capturing', () => {
|
||||
|
||||
describe('the king', () => {
|
||||
it('wins Copenhagen on a corner and not on a plain edge square', () => {
|
||||
const s = board(['..k........', ...Array(9).fill(E11), '..........a'], 'defenders');
|
||||
const s = board(['..k........', ...Array(9).fill(E11), '.........a.'], 'defenders');
|
||||
expect(applyMove(s, at(11, { x: 2, y: 0 }), at(11, { x: 1, y: 0 })).over).toBeNull();
|
||||
const won = applyMove(s, at(11, { x: 2, y: 0 }), at(11, { x: 0, y: 0 }));
|
||||
expect(won.over).toEqual({ winner: 'A', reason: 'The king reaches a corner.' });
|
||||
@@ -139,16 +140,22 @@ describe('the king', () => {
|
||||
|
||||
it('needs four attackers on the throne, and three beside it', () => {
|
||||
const s = createGame(names);
|
||||
// Clear the defenders and ring the throne with three attackers; the fourth arrives.
|
||||
const cells = s.cells.map((p) => (p === 'd' ? '.' : p)) as Piece[];
|
||||
const t = throneOf(11);
|
||||
cells[t - 1] = 'a';
|
||||
cells[t + 1] = 'a';
|
||||
cells[t - 11] = 'a';
|
||||
cells[t + 22] = 'a';
|
||||
const state = { ...s, cells, seen: {} };
|
||||
const done = applyMove(state, t + 22, t + 11);
|
||||
expect(done.over?.reason).toBe('The king is taken.');
|
||||
// Clear the defenders and ring the throne with three attackers; the fourth arrives.
|
||||
const onThrone = s.cells.map((p) => (p === 'd' ? '.' : p)) as Piece[];
|
||||
onThrone[t - 1] = 'a';
|
||||
onThrone[t + 1] = 'a';
|
||||
onThrone[t - 11] = 'a';
|
||||
onThrone[t + 22] = 'a';
|
||||
expect(applyMove({ ...s, cells: onThrone, seen: {} }, t + 22, t + 11).over?.reason).toBe('The king is taken.');
|
||||
// Beside the empty throne, the throne stands in for the fourth attacker.
|
||||
const beside = s.cells.map((p) => (p === 'd' ? '.' : p)) as Piece[];
|
||||
beside[t] = '.';
|
||||
beside[t + 1] = 'k';
|
||||
beside[t + 2] = 'a';
|
||||
beside[t + 1 - 11] = 'a';
|
||||
beside[t + 1 + 22] = 'a';
|
||||
expect(applyMove({ ...s, cells: beside, seen: {} }, t + 1 + 22, t + 1 + 11).over?.reason).toBe('The king is taken.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,10 +215,9 @@ describe('the ends of the game', () => {
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it('chooses the same move from the same position', () => {
|
||||
it('opens with the same move every time, so a ledger replays to the same choice', () => {
|
||||
const s = createGame(names, 3);
|
||||
expect(chooseMove(s, 'B')).toEqual(chooseMove(s, 'B'));
|
||||
expect(legalMoves(s).length).toBeGreaterThan(50);
|
||||
expect(cornersOf(11)).toEqual([0, 10, 110, 120]);
|
||||
expect(movesOf(rulesetOf(s), s.cells, 'attackers').length).toBe(116);
|
||||
expect(chooseMove(s, 'B')).toEqual({ from: 3, to: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
+39
-217
@@ -2,17 +2,17 @@
|
||||
// attackers, who move first and must capture the king; the defenders, whose
|
||||
// king must reach a corner (Copenhagen) or the edge (Tablut). Every piece
|
||||
// moves like a rook, and a piece is taken by being sandwiched between two
|
||||
// enemies. Nothing is hidden, so a view is the whole state.
|
||||
// enemies. Nothing is hidden, so a view is the whole state. The board's own
|
||||
// mechanics are in board.ts; this file is the game played on it.
|
||||
|
||||
import type { GameSpec, Outcome, SeatId, TableOptions } from './spec';
|
||||
import { RULESETS, type Cell, type Ruleset } from './rules';
|
||||
import { RULESETS, type Ruleset } from './rules';
|
||||
import { at, escapeAt, isEdge, movesOf, movesOn, neighbors, playOn, sideOf, throneOf, type Input, type Piece, type Side } from './board';
|
||||
import { chooseMove } from './bot';
|
||||
|
||||
export const CURRENT_RULES = 1;
|
||||
export { at, beyond, cellOf, cornersOf, isEdge, pieceWord, sideOf, squareName, throneOf, type Input, type Piece, type Side } from './board';
|
||||
|
||||
export type Side = 'attackers' | 'defenders';
|
||||
/** One square: empty, an attacker, a defender, or the king. */
|
||||
export type Piece = '.' | 'a' | 'd' | 'k';
|
||||
const CURRENT_RULES = 1;
|
||||
|
||||
export interface Player {
|
||||
id: SeatId;
|
||||
@@ -42,66 +42,20 @@ export interface State {
|
||||
over: Outcome | null;
|
||||
}
|
||||
|
||||
export type Input = { from: number; to: number };
|
||||
|
||||
export const SIDES: { value: Side; label: string; note: string }[] = [
|
||||
const SIDES: { value: Side; label: string; note: string }[] = [
|
||||
{ value: 'defenders', label: 'the defenders, with the king', note: 'Fewer pieces, and the king must reach safety before the ring closes.' },
|
||||
{ value: 'attackers', label: 'the attackers', note: 'Twice the pieces, and the first move; close every road and take the king.' }
|
||||
];
|
||||
|
||||
// --- The board ---------------------------------------------------------------
|
||||
|
||||
export function at(size: number, c: Cell): number {
|
||||
return c.y * size + c.x;
|
||||
}
|
||||
|
||||
export function cellOf(size: number, i: number): Cell {
|
||||
return { x: i % size, y: Math.floor(i / size) };
|
||||
}
|
||||
|
||||
export function throneOf(size: number): number {
|
||||
const mid = (size - 1) / 2;
|
||||
return mid * size + mid;
|
||||
}
|
||||
|
||||
export function cornersOf(size: number): number[] {
|
||||
return [0, size - 1, size * (size - 1), size * size - 1];
|
||||
}
|
||||
|
||||
export function isEdge(size: number, i: number): boolean {
|
||||
const { x, y } = cellOf(size, i);
|
||||
return x === 0 || y === 0 || x === size - 1 || y === size - 1;
|
||||
}
|
||||
|
||||
const DIRECTIONS = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1]
|
||||
] as const;
|
||||
|
||||
function neighbors(size: number, i: number): number[] {
|
||||
const { x, y } = cellOf(size, i);
|
||||
const out: number[] = [];
|
||||
if (y > 0) out.push(i - size);
|
||||
if (y < size - 1) out.push(i + size);
|
||||
if (x > 0) out.push(i - 1);
|
||||
if (x < size - 1) out.push(i + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The square on the far side of `mid` from `from`, or -1 off the board. */
|
||||
export function beyond(size: number, from: number, mid: number): number {
|
||||
const a = cellOf(size, from);
|
||||
const b = cellOf(size, mid);
|
||||
const c = { x: b.x + (b.x - a.x), y: b.y + (b.y - a.y) };
|
||||
if (c.x < 0 || c.y < 0 || c.x >= size || c.y >= size) return -1;
|
||||
return at(size, c);
|
||||
}
|
||||
|
||||
export function sideOf(p: Piece | undefined): Side | null {
|
||||
return p === 'a' ? 'attackers' : p === 'd' || p === 'k' ? 'defenders' : null;
|
||||
}
|
||||
/** The sentences a game ends on. The tally counts finished games by them, so they are written here and nowhere else. */
|
||||
const ENDS = {
|
||||
corner: 'The king reaches a corner.',
|
||||
edge: 'The king reaches the edge.',
|
||||
taken: 'The king is taken.',
|
||||
fort: 'The king holds an edge fort the attackers cannot break.',
|
||||
sealed: 'The defenders are sealed away from every edge.',
|
||||
repetition: 'The position stands a third time: perpetual repetition loses for the defenders.'
|
||||
} as const;
|
||||
|
||||
export function rulesetOf(state: State): Ruleset {
|
||||
return RULESETS[state.set];
|
||||
@@ -131,7 +85,7 @@ function positionKey(state: State): string {
|
||||
return state.cells.join('') + (state.toMove === 'attackers' ? 'a' : 'd');
|
||||
}
|
||||
|
||||
export function seatOfSide(state: State, side: Side): SeatId | null {
|
||||
function seatOfSide(state: State, side: Side): SeatId | null {
|
||||
return state.seats.find((id) => state.players[id].side === side) ?? null;
|
||||
}
|
||||
|
||||
@@ -142,106 +96,6 @@ export function movesFrom(state: State, from: number): number[] {
|
||||
return movesOn(rulesetOf(state), state.cells, from);
|
||||
}
|
||||
|
||||
export function movesOn(set: Ruleset, cells: Piece[], from: number): number[] {
|
||||
const size = set.size;
|
||||
const piece = cells[from];
|
||||
if (piece === '.' || piece === undefined) return [];
|
||||
const king = piece === 'k';
|
||||
const throne = throneOf(size);
|
||||
const corners = set.markedCorners ? cornersOf(size) : [];
|
||||
const { x, y } = cellOf(size, from);
|
||||
const out: number[] = [];
|
||||
for (const [dx, dy] of DIRECTIONS) {
|
||||
for (let step = 1; ; step++) {
|
||||
const nx = x + dx * step;
|
||||
const ny = y + dy * step;
|
||||
if (nx < 0 || ny < 0 || nx >= size || ny >= size) break;
|
||||
const i = at(size, { x: nx, y: ny });
|
||||
if (cells[i] !== '.') break;
|
||||
if (corners.includes(i)) {
|
||||
if (king) out.push(i);
|
||||
break;
|
||||
}
|
||||
if (i === throne) {
|
||||
if (king) out.push(i);
|
||||
else if (!set.passThrone) break;
|
||||
continue;
|
||||
}
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function legalMoves(state: State, side: Side = state.toMove): Move[] {
|
||||
const out: Move[] = [];
|
||||
if (state.over) return out;
|
||||
state.cells.forEach((p, i) => {
|
||||
if (sideOf(p) !== side) return;
|
||||
for (const to of movesFrom(state, i)) out.push({ from: i, to, captured: [] });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether `i` closes a capture on a piece of `side`: an enemy (an armed king counts), a marked corner, or the empty throne. */
|
||||
function hostile(set: Ruleset, cells: Piece[], i: number, side: Side): boolean {
|
||||
const p = cells[i];
|
||||
if (p !== '.') {
|
||||
const s = sideOf(p);
|
||||
if (s === null || s === side) return false;
|
||||
return p !== 'k' || set.kingArmed;
|
||||
}
|
||||
if (set.markedCorners && cornersOf(set.size).includes(i)) return true;
|
||||
return i === throneOf(set.size);
|
||||
}
|
||||
|
||||
/** Whether the attackers have the king where he stands. */
|
||||
function kingTaken(set: Ruleset, cells: Piece[], kingAt: number): boolean {
|
||||
const size = set.size;
|
||||
const throne = throneOf(size);
|
||||
const around = neighbors(size, kingAt);
|
||||
const attackersAround = around.filter((n) => cells[n] === 'a').length;
|
||||
if (kingAt === throne) return attackersAround === 4;
|
||||
if (around.includes(throne) && cells[throne] === '.') return attackersAround === 3;
|
||||
if (set.kingCapturedByFour) return around.length === 4 && attackersAround === 4;
|
||||
// Like any piece: two attackers on opposite sides.
|
||||
const { x, y } = cellOf(size, kingAt);
|
||||
if (x > 0 && x < size - 1 && cells[kingAt - 1] === 'a' && cells[kingAt + 1] === 'a') return true;
|
||||
return y > 0 && y < size - 1 && cells[kingAt - size] === 'a' && cells[kingAt + size] === 'a';
|
||||
}
|
||||
|
||||
/** The squares a piece just landed on `to` takes: custodial captures, the king by his own rule, a shieldwall along the edge. */
|
||||
export function capturesOn(set: Ruleset, cells: Piece[], to: number): number[] {
|
||||
const size = set.size;
|
||||
const piece = cells[to];
|
||||
const side = sideOf(piece)!;
|
||||
const captured: number[] = [];
|
||||
for (const n of neighbors(size, to)) {
|
||||
const target = cells[n];
|
||||
if (target === '.' || target === 'k' || sideOf(target) === side) continue;
|
||||
const far = beyond(size, to, n);
|
||||
if (far !== -1 && hostile(set, cells, far, sideOf(target)!)) captured.push(n);
|
||||
}
|
||||
if (side === 'attackers') {
|
||||
const kingAt = cells.indexOf('k');
|
||||
if (kingAt !== -1 && neighbors(size, kingAt).includes(to) && kingTaken(set, cells, kingAt)) captured.push(kingAt);
|
||||
}
|
||||
if (set.shieldwall && isEdge(size, to)) {
|
||||
for (const run of shieldwallRuns(set, cells, to, side)) captured.push(...run);
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
/** Slide a piece and take what it takes, on a copy of the board. */
|
||||
export function playOn(set: Ruleset, cells: Piece[], from: number, to: number): { cells: Piece[]; captured: number[] } {
|
||||
const next = cells.slice();
|
||||
next[to] = next[from];
|
||||
next[from] = '.';
|
||||
const captured = capturesOn(set, next, to);
|
||||
for (const i of captured) next[i] = '.';
|
||||
return { cells: next, captured };
|
||||
}
|
||||
|
||||
/** Apply a move to a copy of the state: the piece slides, captures fall, and the game may end. */
|
||||
export function applyMove(previous: State, from: number, to: number): State {
|
||||
const set = rulesetOf(previous);
|
||||
@@ -249,67 +103,28 @@ export function applyMove(previous: State, from: number, to: number): State {
|
||||
const side = sideOf(piece)!;
|
||||
const played = playOn(set, previous.cells, from, to);
|
||||
const state: State = { ...previous, cells: played.cells, moves: [...previous.moves, { from, to, captured: played.captured }], seen: { ...previous.seen } };
|
||||
const { size, cells } = state;
|
||||
const { cells } = state;
|
||||
|
||||
const kingAt = cells.indexOf('k');
|
||||
const win = (winner: Side, reason: string) => {
|
||||
state.over = { winner: seatOfSide(state, winner), reason };
|
||||
};
|
||||
if (kingAt === -1) win('attackers', 'The king is taken.');
|
||||
else if (side === 'defenders' && piece === 'k' && escaped(state, kingAt)) win('defenders', set.escape === 'corner' ? 'The king reaches a corner.' : 'The king reaches the edge.');
|
||||
else if (set.edgeFort && side === 'defenders' && edgeFort(state, kingAt)) win('defenders', 'The king holds an edge fort the attackers cannot break.');
|
||||
else if (set.encirclement && side === 'attackers' && encircled(state)) win('attackers', 'The defenders are sealed away from every edge.');
|
||||
if (kingAt === -1) win('attackers', ENDS.taken);
|
||||
else if (side === 'defenders' && piece === 'k' && escapeAt(set, kingAt)) win('defenders', set.escape === 'corner' ? ENDS.corner : ENDS.edge);
|
||||
else if (set.edgeFort && side === 'defenders' && edgeFort(state, kingAt)) win('defenders', ENDS.fort);
|
||||
else if (set.encirclement && side === 'attackers' && encircled(state)) win('attackers', ENDS.sealed);
|
||||
|
||||
state.toMove = side === 'attackers' ? 'defenders' : 'attackers';
|
||||
if (!state.over) {
|
||||
const key = positionKey(state);
|
||||
state.seen[key] = (state.seen[key] ?? 0) + 1;
|
||||
const next = state.players[seatOfSide(state, state.toMove)!]?.name ?? state.toMove;
|
||||
// Perpetual repetition is forbidden, and the Copenhagen rule lays the loss on the defenders.
|
||||
if (state.seen[key] >= 3) win('attackers', 'The position stands a third time: perpetual repetition loses for the defenders.');
|
||||
else if (legalMoves(state, state.toMove).length === 0) win(side, `${next} has no move.`);
|
||||
if (state.seen[key] >= 3) win('attackers', ENDS.repetition);
|
||||
else if (movesOf(set, cells, state.toMove).length === 0) win(side, `${state.players[seatOfSide(state, state.toMove)!].name} has no move.`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function escaped(state: State, kingAt: number): boolean {
|
||||
return rulesetOf(state).escape === 'corner' ? cornersOf(state.size).includes(kingAt) : isEdge(state.size, kingAt);
|
||||
}
|
||||
|
||||
/** Runs of enemy pieces along the edge beside `to`, bracketed at both ends and each faced from inland. */
|
||||
function shieldwallRuns(set: Ruleset, cells: Piece[], to: number, side: Side): number[][] {
|
||||
const size = set.size;
|
||||
const { x, y } = cellOf(size, to);
|
||||
const along: [number, number][] = x === 0 || x === size - 1 ? [[0, 1], [0, -1]] : [[1, 0], [-1, 0]];
|
||||
const inland = x === 0 ? { x: 1, y: 0 } : x === size - 1 ? { x: -1, y: 0 } : y === 0 ? { x: 0, y: 1 } : { x: 0, y: -1 };
|
||||
const runs: number[][] = [];
|
||||
for (const [dx, dy] of along) {
|
||||
const run: number[] = [];
|
||||
let cx = x + dx;
|
||||
let cy = y + dy;
|
||||
let closed = false;
|
||||
while (cx >= 0 && cy >= 0 && cx < size && cy < size) {
|
||||
const i = at(size, { x: cx, y: cy });
|
||||
const p = cells[i];
|
||||
if (p === '.') {
|
||||
closed = cornersOf(size).includes(i);
|
||||
break;
|
||||
}
|
||||
if (sideOf(p) === side) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
const facing = cells[at(size, { x: cx + inland.x, y: cy + inland.y })];
|
||||
if (sideOf(facing) !== side) break;
|
||||
run.push(i);
|
||||
cx += dx;
|
||||
cy += dy;
|
||||
}
|
||||
if (closed && run.length >= 2) runs.push(run.filter((i) => cells[i] !== 'k'));
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** The pieces that bound the empty squares reachable from `start`. */
|
||||
function bounds(state: State, start: number): Set<number> {
|
||||
const squares = new Set<number>([start]);
|
||||
@@ -362,7 +177,7 @@ function encircled(state: State): boolean {
|
||||
|
||||
// --- The contract ---------------------------------------------------------------
|
||||
|
||||
export function resolveRound(previous: State, inputs: Record<SeatId, Input>): State {
|
||||
function resolveRound(previous: State, inputs: Record<SeatId, Input>): State {
|
||||
if (previous.over) return previous;
|
||||
const seat = seatOfSide(previous, previous.toMove);
|
||||
const input = seat ? inputs[seat] : undefined;
|
||||
@@ -412,14 +227,21 @@ export const game: GameSpec<State, Input> = {
|
||||
validate: validateMove,
|
||||
nameOf: (state, seat) => state.players[seat]?.name ?? seat,
|
||||
tally: (state) => {
|
||||
const lines: Record<string, number> = {};
|
||||
lines[state.set === 'tablut' ? 'played as Tablut' : 'played by Copenhagen rules'] = 1;
|
||||
const lines: Record<string, number> = { [state.set === 'tablut' ? 'played as Tablut' : 'played by Copenhagen rules']: 1 };
|
||||
const reason = state.over?.reason ?? '';
|
||||
if (/reaches/.test(reason)) lines["won by the king's escape"] = 1;
|
||||
else if (/taken/.test(reason)) lines["won by taking the king"] = 1;
|
||||
else if (/edge fort/.test(reason)) lines['won by an edge fort'] = 1;
|
||||
else if (/sealed/.test(reason)) lines['won by encirclement'] = 1;
|
||||
else if (/repetition|no move/.test(reason)) lines['decided by repetition or a stalemate'] = 1;
|
||||
const how =
|
||||
reason === ENDS.corner || reason === ENDS.edge
|
||||
? "won by the king's escape"
|
||||
: reason === ENDS.taken
|
||||
? 'won by taking the king'
|
||||
: reason === ENDS.fort
|
||||
? 'won by an edge fort'
|
||||
: reason === ENDS.sealed
|
||||
? 'won by encirclement'
|
||||
: reason === ENDS.repetition || reason.endsWith(' has no move.')
|
||||
? 'decided by repetition or a stalemate'
|
||||
: null;
|
||||
if (how) lines[how] = 1;
|
||||
return lines;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ function cells(spec: string): Cell[] {
|
||||
});
|
||||
}
|
||||
|
||||
export const COPENHAGEN: Ruleset = {
|
||||
const COPENHAGEN: Ruleset = {
|
||||
id: 'copenhagen',
|
||||
name: 'Copenhagen',
|
||||
size: 11,
|
||||
@@ -58,7 +58,7 @@ export const COPENHAGEN: Ruleset = {
|
||||
encirclement: true
|
||||
};
|
||||
|
||||
export const TABLUT: Ruleset = {
|
||||
const TABLUT: Ruleset = {
|
||||
id: 'tablut',
|
||||
name: 'Tablut',
|
||||
size: 9,
|
||||
|
||||
+13
-2
@@ -34,13 +34,24 @@ export interface TableOption {
|
||||
/** The host's choices for a table, by option key; only keys the game declares get through. */
|
||||
export type TableOptions = Record<string, string>;
|
||||
|
||||
/** A setting the game adds to the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */
|
||||
export interface Preference {
|
||||
key: string;
|
||||
label: string;
|
||||
/** A toggle holds a boolean; a choice holds one of its choices' values. */
|
||||
kind: 'toggle' | 'choice';
|
||||
choices?: { value: string; label: string }[];
|
||||
default: boolean | string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface GameSpec<State, Input> {
|
||||
/** Seats in table order; the table takes at most this many. */
|
||||
seatIds: SeatId[];
|
||||
/** What the host may choose when opening a table; empty for a game with one way to play. */
|
||||
options?: TableOption[];
|
||||
/** The game's own settings for the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */
|
||||
preferences?: { key: string; label: string; kind: 'toggle' | 'choice'; choices?: { value: string; label: string }[]; default: boolean | string; note?: string }[];
|
||||
/** The game's own settings for the preferences panel. */
|
||||
preferences?: Preference[];
|
||||
minSeats: number;
|
||||
/** Names the server draws for bots, in preference order. */
|
||||
botNames: string[];
|
||||
|
||||
+13
-39
@@ -2,7 +2,7 @@
|
||||
// browser holds, remembered so a shared link never has to carry a token.
|
||||
|
||||
import type { SeatId, TableOptions } from '$lib/game/spec';
|
||||
import type { RoomView } from './view';
|
||||
import type { Report, RoomView, Tally } from './view';
|
||||
|
||||
export interface HeldSeat {
|
||||
seat: SeatId;
|
||||
@@ -41,7 +41,7 @@ export function forgetSeat(roomId: string): void {
|
||||
/**
|
||||
* A seat the server refused is doubted, not dropped: a restarting server, a
|
||||
* stale answer or the wrong backend all answer with ignorance, and ignorance
|
||||
* is not deletion. Only the SECOND refusal in a row costs the seat.
|
||||
* is not deletion. Only the second refusal in a row costs the seat.
|
||||
*/
|
||||
const DOUBT_KEY = 'hnefatafl:doubted';
|
||||
|
||||
@@ -103,44 +103,20 @@ export class ServerError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'content-type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
/** The server's answer as JSON, or its refusal as a ServerError with the reason it gave. */
|
||||
async function fetchJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, init);
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** A report to the keeper, with the exchange under it. */
|
||||
export interface Report {
|
||||
id: string;
|
||||
at: string;
|
||||
roomId: string;
|
||||
player: string;
|
||||
seat: SeatId | null;
|
||||
turn: number | null;
|
||||
happened: string;
|
||||
expected: string;
|
||||
thread: { at: string; text: string; from: 'desk' | 'player'; status?: string }[];
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/** What the ledgers add up to. */
|
||||
export interface Tally {
|
||||
tablesOpened: number;
|
||||
gamesBegun: number;
|
||||
gamesFinished: number;
|
||||
namesSeated: number;
|
||||
turnsPlayed: number;
|
||||
minutesAtTable: number;
|
||||
longestGameTurns: number;
|
||||
gamesWithBot: number;
|
||||
talkLines: number;
|
||||
firstGameAt: string | null;
|
||||
byGame: Record<string, number>;
|
||||
function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
return fetchJson<T>(path, {
|
||||
method,
|
||||
headers: body ? { 'content-type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function makeApi<State, Input>() {
|
||||
@@ -167,10 +143,8 @@ export function makeApi<State, Input>() {
|
||||
claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
|
||||
report: (roomId: string, token: string, happened: string, expected: string) =>
|
||||
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
|
||||
reportImage: async (id: string, file: File) => {
|
||||
const res = await fetch(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file });
|
||||
if (!res.ok) throw new ServerError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? `The server answered ${res.status}.`, res.status);
|
||||
},
|
||||
reportImage: (id: string, file: File) =>
|
||||
fetchJson<void>(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file }),
|
||||
tally: () => call<Tally>('GET', '/api/tally'),
|
||||
myReports: (seats: { roomId: string; token: string }[]) => call<{ reports: Report[] }>('POST', '/api/reports/mine', { seats }),
|
||||
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The reactive room a page shows: the seat this browser holds (or the
|
||||
// gallery), the table, the talk, and the game's view. It knows the game only
|
||||
// through GameSpec; a game's own store wraps or extends this one.
|
||||
// through GameSpec.
|
||||
|
||||
import { game } from '$lib/game';
|
||||
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
|
||||
@@ -31,12 +31,6 @@ export function rememberName(raw: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */
|
||||
export function listText(names: string[]): string {
|
||||
if (names.length <= 1) return names[0] ?? '';
|
||||
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
||||
}
|
||||
|
||||
export class Room {
|
||||
view = $state<RoomView<State>>(null!);
|
||||
token: string;
|
||||
@@ -57,13 +51,11 @@ export class Room {
|
||||
started = $derived(this.view.state !== null);
|
||||
state = $derived(this.view.state);
|
||||
isHost = $derived(!this.spectating && this.view.host === this.view.me);
|
||||
/** Your move is in and the round waits on someone else. */
|
||||
moved = $derived(this.started && !this.view.over && !this.spectating && !this.view.waitingOn.includes(this.me));
|
||||
yourMove = $derived(this.started && !this.view.over && this.view.waitingOn.includes(this.me));
|
||||
/** Names of the seats the round is waiting on, yours aside. */
|
||||
awaiting = $derived(this.view.waitingOn.filter((id) => id !== this.me).map((id) => this.nameOf(id)));
|
||||
awaitingText = $derived(listText(this.awaiting));
|
||||
others = $derived(this.view.seats.filter((s) => s.id !== this.me));
|
||||
/** A chair at the table is empty, held for someone or not. */
|
||||
seatEmpty = $derived(!this.started && this.view.seats.length < this.view.size);
|
||||
/** An empty chair not held for anyone: a stranger may sit. */
|
||||
seatUnheld = $derived(this.seatEmpty && this.view.seats.length + this.view.expected.length < this.view.size);
|
||||
|
||||
nameOf(id: SeatId): string {
|
||||
return this.view.seats.find((s) => s.id === id)?.name ?? (this.view.state ? game.nameOf(this.view.state, id) : id);
|
||||
|
||||
@@ -46,3 +46,48 @@ export interface RoomView<State> {
|
||||
keeper: string;
|
||||
keeperZone: string;
|
||||
}
|
||||
|
||||
/** One line under a report: the keeper's reply, with its status, or the player's answer. */
|
||||
export interface ReportLine {
|
||||
at: string;
|
||||
text: string;
|
||||
from: 'desk' | 'player';
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** A report to the keeper, with the exchange under it. */
|
||||
export interface Report {
|
||||
id: string;
|
||||
at: string;
|
||||
roomId: string;
|
||||
/** The reporter's name, or "(gallery)" for a watcher. */
|
||||
player: string;
|
||||
seat: SeatId | null;
|
||||
/** The round being written when the report was filed, and the ledger's length. */
|
||||
turn: number | null;
|
||||
seq: number;
|
||||
happened: string;
|
||||
expected: string;
|
||||
/** The whole exchange in order: the keeper's replies and the player's answers. */
|
||||
thread: ReportLine[];
|
||||
/** A screenshot's file name under images/, when one was sent. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/** What the ledgers add up to, for the hall's about panel. */
|
||||
export interface Tally {
|
||||
tablesOpened: number;
|
||||
gamesBegun: number;
|
||||
gamesFinished: number;
|
||||
/** Distinct names of people who have taken a seat; a bot's name is not counted. */
|
||||
namesSeated: number;
|
||||
turnsPlayed: number;
|
||||
/** Minutes at the table: gaps under ten minutes between one move and the next, summed. */
|
||||
minutesAtTable: number;
|
||||
longestGameTurns: number;
|
||||
gamesWithBot: number;
|
||||
talkLines: number;
|
||||
firstGameAt: string | null;
|
||||
/** The game's own lines, summed over finished games. */
|
||||
byGame: Record<string, number>;
|
||||
}
|
||||
|
||||
+3
-12
@@ -4,19 +4,10 @@
|
||||
// panel renders every declaration. Nothing here leaves the browser.
|
||||
|
||||
import { game } from '$lib/game';
|
||||
|
||||
export interface Preference {
|
||||
key: string;
|
||||
label: string;
|
||||
/** A toggle holds a boolean; a choice holds one of its choices' values. */
|
||||
kind: 'toggle' | 'choice';
|
||||
choices?: { value: string; label: string }[];
|
||||
default: boolean | string;
|
||||
note?: string;
|
||||
}
|
||||
import type { Preference } from '$lib/game/spec';
|
||||
|
||||
/** The settings every game in the kit shares. */
|
||||
export const KIT_PREFERENCES: Preference[] = [
|
||||
const KIT_PREFERENCES: Preference[] = [
|
||||
{ key: 'motion', label: 'Motion', kind: 'choice', choices: [{ value: 'full', label: 'pieces move and fade' }, { value: 'reduced', label: 'changes appear at once' }], default: 'full' },
|
||||
{ key: 'confirmMoves', label: 'Confirm a move before it is sent', kind: 'toggle', default: false }
|
||||
];
|
||||
@@ -54,7 +45,7 @@ function load(): Record<string, boolean | string> {
|
||||
}
|
||||
|
||||
/** The live preferences; read them where they matter, set them through setPref. */
|
||||
export const prefs = $state<Record<string, boolean | string>>(typeof localStorage === 'undefined' ? defaults() : load());
|
||||
export const prefs = $state<Record<string, boolean | string>>(load());
|
||||
|
||||
export function setPref(key: string, value: boolean | string): void {
|
||||
prefs[key] = value;
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
|
||||
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
|
||||
const withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path));
|
||||
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Hnefatafl: how to play</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="guide">
|
||||
<main class="reading">
|
||||
<header>
|
||||
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
|
||||
<h1>How to play</h1>
|
||||
@@ -85,40 +85,11 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.guide {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem var(--gutter) 4rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 300;
|
||||
margin: 0.6rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
nav {
|
||||
margin-top: 0.8rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.capture {
|
||||
margin: 0.6rem 0 0;
|
||||
}
|
||||
@@ -135,9 +106,4 @@
|
||||
color: var(--bone-dim);
|
||||
max-width: 38em;
|
||||
}
|
||||
|
||||
.word {
|
||||
margin-top: 2rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,10 +22,8 @@
|
||||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||||
let rematching = $state(false);
|
||||
|
||||
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
|
||||
const link = $derived(`${location.origin}/join/${roomId}`);
|
||||
const watching = $derived(room?.spectating ?? false);
|
||||
/** A watcher may still sit while the table is laid and a seat is free. */
|
||||
const seatFree = $derived(!!room && !room.started && room.view.seats.length < room.view.size);
|
||||
|
||||
// Return to a held seat, or take a place in the gallery and offer one.
|
||||
$effect(() => {
|
||||
@@ -157,12 +155,12 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if joining && seatFree}
|
||||
{#if joining && room?.seatEmpty}
|
||||
<section class="seat">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>Take a seat</h2>
|
||||
<p>A player has opened this table and is gathering company. Choose a name and sit; the game begins when the host says.</p>
|
||||
<form class="rename" onsubmit={takeSeat}>
|
||||
<form class="sit" onsubmit={takeSeat}>
|
||||
<label class="field">
|
||||
<span>Your name</span>
|
||||
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
|
||||
@@ -174,7 +172,7 @@
|
||||
{/if}
|
||||
|
||||
{#if room && !room.started}
|
||||
<Lobby {room} {link} />
|
||||
<Lobby {room} {link} {copied} {copyLink} />
|
||||
{:else if room}
|
||||
<Board {room} />
|
||||
{:else if !error}
|
||||
@@ -259,7 +257,7 @@
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.rename {
|
||||
.sit {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -278,7 +276,7 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rename input {
|
||||
.sit input {
|
||||
width: 14em;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
// gaps in Linnaeus that every reconstruction fills for itself.
|
||||
import { page } from '$app/state';
|
||||
|
||||
/** Opened from a table, the page offers the way back to it. */
|
||||
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
|
||||
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
|
||||
const withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path));
|
||||
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Hnefatafl: the rules</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="rules">
|
||||
<main class="reading">
|
||||
<header>
|
||||
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
|
||||
<h1>The rules</h1>
|
||||
@@ -121,36 +121,10 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.rules {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem var(--gutter) 4rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 300;
|
||||
margin: 0.6rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--bone-dim);
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.05rem;
|
||||
font-style: italic;
|
||||
@@ -168,12 +142,4 @@
|
||||
li + li {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.word {
|
||||
margin-top: 2rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user