Compare commits
6
Commits
f21b2c3b16
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9de0a55d54 | ||
|
|
42144c1cc9 | ||
|
|
22979afd35 | ||
|
|
4c224a8e22 | ||
|
|
26d9b7f5d6 | ||
|
|
fa5e9cfc08 |
No files matched your search
@@ -43,7 +43,10 @@ __HOST__ {
|
|||||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
@mutable not path /_app/immutable/*
|
@mutable not path /_app/immutable/*
|
||||||
header @mutable Cache-Control "no-cache"
|
header @mutable Cache-Control "no-cache"
|
||||||
try_files {path} {path}.html /index.html
|
# try_files passes the root over as a directory and would fall to the shell; the hall's
|
||||||
|
# own page is index.html.
|
||||||
|
rewrite / /index.html
|
||||||
|
try_files {path} {path}.html /shell.html
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-1
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
|
|||||||
# it needs only its code and the engine. Ledgers live outside this tree.
|
# it needs only its code and the engine. Ledgers live outside this tree.
|
||||||
rsync -az --delete --exclude='/server/node_modules' \
|
rsync -az --delete --exclude='/server/node_modules' \
|
||||||
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' \
|
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' \
|
||||||
--include='/deploy/' --include='/deploy/waving-hands.service' --include='/deploy/waving-hands.cron' --include='/deploy/*.sh' --include='/deploy/hash-tokens.ts' --include='/deploy/traffic.py' \
|
--include='/deploy/' --include='/deploy/waving-hands.service' --include='/deploy/waving-hands.cron' --include='/deploy/*.sh' --include='/deploy/Caddyfile.tmpl' --include='/deploy/hash-tokens.ts' --include='/deploy/traffic.py' \
|
||||||
--exclude='*' \
|
--exclude='*' \
|
||||||
./ "root@$HOST:/opt/waving-hands/app/"
|
./ "root@$HOST:/opt/waving-hands/app/"
|
||||||
|
|
||||||
@@ -60,6 +60,12 @@ ssh "root@$HOST" '
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now waving-hands
|
systemctl enable --now waving-hands
|
||||||
systemctl restart waving-hands
|
systemctl restart waving-hands
|
||||||
|
# The site file is rendered from the template on every deploy, with the address line the box was set up with.
|
||||||
|
ADDR=$(head -1 /etc/caddy/sites/waving-hands.caddy | sed "s/ {\$//")
|
||||||
|
sed "s|__HOST__|$ADDR|" /opt/waving-hands/app/deploy/Caddyfile.tmpl > /etc/caddy/sites/waving-hands.caddy
|
||||||
|
caddy validate --config /etc/caddy/Caddyfile >/dev/null
|
||||||
|
# Validation as root touches the access logs; Caddy runs as caddy and must own them.
|
||||||
|
chown -R caddy:caddy /var/lib/caddy
|
||||||
systemctl reload caddy
|
systemctl reload caddy
|
||||||
sleep 1
|
sleep 1
|
||||||
systemctl --no-pager -l status waving-hands | head -3
|
systemctl --no-pager -l status waving-hands | head -3
|
||||||
|
|||||||
+94
@@ -108,6 +108,100 @@ button:disabled {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The smaller reveal used in forms and slips. Qualified with the element so it
|
||||||
|
outranks a component's scoped `.small` text rule on the same button. */
|
||||||
|
button.reveal.small {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A secondary action: an outlined button or link. */
|
||||||
|
.quiet {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--bone);
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiet:hover:not(:disabled) {
|
||||||
|
border-color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The page head on the play and join pages: title and standfirst on the left, actions on the right. */
|
||||||
|
.masthead {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem var(--gutter) 0.6rem;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead h1 {
|
||||||
|
font-size: clamp(2rem, 4vw, 2.7rem);
|
||||||
|
font-weight: 300;
|
||||||
|
font-variation-settings: 'opsz' 144;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standfirst {
|
||||||
|
max-width: 38em;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standfirst em {
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead .actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem var(--gutter) 3rem;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon a {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Must match COMPACT_QUERY in duel.svelte.ts. The extra foot padding clears MobileBar. */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.masthead {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon {
|
||||||
|
padding-bottom: 6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A modal backdrop that centres its dialog. */
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* An italic prompt beside a control, e.g. "at" before a target list. */
|
/* An italic prompt beside a control, e.g. "at" before a target list. */
|
||||||
.field {
|
.field {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The about panel: the game's story, its keeping and its company, a tab
|
// A tabbed modal for the about content. Opened from the landing page's
|
||||||
// at a time rather than one long page. Opened from the hall's "about this
|
// "about this game" link or the #about hash; closes on its button, a click
|
||||||
// game" link (and #about); closes with its button, a click outside it, or
|
// on the backdrop, or Escape. Keeps the last-read tab while mounted, so
|
||||||
// Escape. The tab last read is the one it reopens on.
|
// reopening shows it.
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
|
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<svelte:window onkeydown={onKey} />
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="about-scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
|
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
|
||||||
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title">
|
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title">
|
||||||
<header>
|
<header>
|
||||||
<h2 id="about-title">{title}</h2>
|
<h2 id="about-title">{title}</h2>
|
||||||
@@ -71,16 +71,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Its own backdrop: not every game has another dialog to share one with. */
|
|
||||||
.about-scrim {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.panel {
|
.panel {
|
||||||
width: min(100%, 44rem);
|
width: min(100%, 44rem);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The host's announcement, shown by every game of Kestrel's Hall: the keeper
|
// The site-family announcement, shown by every game: the maintainer writes
|
||||||
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each
|
// /var/lib/hall/announcement.json on the host (hall-announce), and each game's
|
||||||
// game's site serves it at /hall/announcement.json. It is checked when the page
|
// site serves it at /hall/announcement.json. It is fetched on load, every few
|
||||||
// loads, every few minutes, and whenever the page is looked at again. A reader
|
// minutes, and whenever the tab becomes visible again. Dismissal is per
|
||||||
// may dismiss one announcement; the next one still shows.
|
// announcement id, so the next one still shows.
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Announcement {
|
interface Announcement {
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */
|
/* Fixed colours rather than the game's tokens, so the notice looks the same on every site that shows it. */
|
||||||
.hall-announcement {
|
.hall-announcement {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
+170
-170
@@ -18,9 +18,9 @@
|
|||||||
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
|
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
|
||||||
const outcome = $derived(duel.state.over);
|
const outcome = $derived(duel.state.over);
|
||||||
const won = $derived(outcome?.winner === duel.me);
|
const won = $derived(outcome?.winner === duel.me);
|
||||||
/** Past three wizards the rail lists the others on a line each. */
|
/** With three or more opponents the rail shows a one-line roster instead of a status card each. */
|
||||||
const crowded = $derived(duel.others.length >= 3);
|
const crowded = $derived(duel.others.length >= 3);
|
||||||
/** The button's word for what the player is about to do. */
|
/** The seal button's label for the current state. */
|
||||||
const sealLabel = $derived(
|
const sealLabel = $derived(
|
||||||
duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Seal the turn'
|
duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Seal the turn'
|
||||||
);
|
);
|
||||||
@@ -275,260 +275,260 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
details.next summary {
|
details.next summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
details.next summary::before {
|
details.next summary::before {
|
||||||
content: '\25B8 ';
|
content: '\25B8 ';
|
||||||
color: var(--bone-faint);
|
color: var(--bone-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
details.next[open] summary::before {
|
details.next[open] summary::before {
|
||||||
content: '\25BE ';
|
content: '\25BE ';
|
||||||
}
|
}
|
||||||
|
|
||||||
.board {
|
.board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
|
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
|
||||||
padding: 0 var(--gutter);
|
padding: 0 var(--gutter);
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play {
|
.play {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail {
|
.rail {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.5rem;
|
width: 0.5rem;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intent {
|
.intent {
|
||||||
margin: 0.6rem 0 0.2rem;
|
margin: 0.6rem 0 0.2rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
.move {
|
.move {
|
||||||
padding: 0.6rem 0 1.5rem;
|
padding: 0.6rem 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.move h2 {
|
.move h2 {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.threats {
|
.threats {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-bottom: 0.7rem;
|
margin-bottom: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.threats ul {
|
.threats ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0.15rem 0 0.4rem;
|
margin: 0.15rem 0 0.4rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem 0.5rem;
|
gap: 0.3rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.threat {
|
.threat {
|
||||||
background: var(--slate);
|
background: var(--slate);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.threat.soon {
|
.threat.soon {
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
}
|
}
|
||||||
|
|
||||||
.threat:hover,
|
.threat:hover,
|
||||||
.threat.active {
|
.threat.active {
|
||||||
border-color: var(--frost);
|
border-color: var(--frost);
|
||||||
}
|
}
|
||||||
|
|
||||||
.threat.active {
|
.threat.active {
|
||||||
background: rgba(140, 200, 224, 0.12);
|
background: rgba(140, 200, 224, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thand {
|
.thand {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tseq {
|
.tseq {
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tseq strong {
|
.tseq strong {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prefs {
|
.prefs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem 1.5rem;
|
gap: 0.3rem 1.5rem;
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pref {
|
.pref {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hands {
|
.hands {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1.25rem 2rem;
|
gap: 1.25rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders {
|
.orders {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem 1.5rem;
|
gap: 0.5rem 1.5rem;
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.haste-note {
|
.haste-note {
|
||||||
color: var(--frost);
|
color: var(--frost);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
margin: 1rem 0 0.6rem;
|
margin: 1rem 0 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Your hands, framed apart from the ledger's columns above them. */
|
/* Box the hand pickers so they read apart from the ledger table above. */
|
||||||
.hands {
|
.hands {
|
||||||
background: var(--slate);
|
background: var(--slate);
|
||||||
border: 1px solid var(--rule-strong);
|
border: 1px solid var(--rule-strong);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.7rem 0.9rem 0.9rem;
|
padding: 0.7rem 0.9rem 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The turn about to be sealed, kept in view while the hands and orders scroll. */
|
/* Sticky at the foot so the chosen pair and the seal button stay in view while the pickers scroll. */
|
||||||
.commit {
|
.commit {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem 1rem;
|
gap: 0.4rem 1rem;
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
padding: 0.6rem 0.2rem;
|
padding: 0.6rem 0.2rem;
|
||||||
background: var(--slate-deep);
|
background: var(--slate-deep);
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pair {
|
.pair {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hand-pair {
|
.hand-pair {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.which {
|
.which {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--bone-faint);
|
color: var(--bone-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pair .letter {
|
.pair .letter {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
min-width: 1ch;
|
min-width: 1ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pair .letter.empty {
|
.pair .letter.empty {
|
||||||
color: var(--bone-faint);
|
color: var(--bone-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes p {
|
.notes p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes p + p {
|
.notes p + p {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keys {
|
.keys {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verdict {
|
.verdict {
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verdict h2 {
|
.verdict h2 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
}
|
}
|
||||||
|
|
||||||
.verdict.won h2 {
|
.verdict.won h2 {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
.verdict p {
|
.verdict p {
|
||||||
margin: 0.3rem 0 0.8rem;
|
margin: 0.3rem 0 0.8rem;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.board {
|
.board {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On phones the bar at the foot of the screen carries the pair and the button. */
|
/* Narrow screens: MobileBar carries the pair and the button instead. */
|
||||||
.commit {
|
.commit {
|
||||||
position: static;
|
position: static;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commit .pair,
|
.commit .pair,
|
||||||
.commit .reveal,
|
.commit .reveal,
|
||||||
.keys {
|
.keys {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail {
|
.rail {
|
||||||
order: 2;
|
order: 2;
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hands {
|
.hands {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.waiting {
|
.waiting {
|
||||||
|
|||||||
@@ -18,14 +18,14 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (location.hash === '#about') aboutOpen = true;
|
if (location.hash === '#about') aboutOpen = true;
|
||||||
});
|
});
|
||||||
/** The ledgers' tally, fetched when the about panel opens. */
|
/** Site-wide statistics, fetched the first time the about panel opens. */
|
||||||
let tally = $state<Tally | null>(null);
|
let tally = $state<Tally | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
|
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
|
||||||
});
|
});
|
||||||
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
|
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
|
||||||
|
|
||||||
/** The tally as the about panel tells it: this game's own story on cards, the rest in detail. */
|
/** The tally split for the about panel: headline figures for the cards, every other count as a detail line. */
|
||||||
const told = $derived.by(() => {
|
const told = $derived.by(() => {
|
||||||
const t = tally;
|
const t = tally;
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
@@ -36,19 +36,19 @@
|
|||||||
{ n: t.byGame["duels won by a rival's surrender"] ?? 0, label: "won by a rival's surrender" },
|
{ n: t.byGame["duels won by a rival's surrender"] ?? 0, label: "won by a rival's surrender" },
|
||||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||||
];
|
];
|
||||||
const told = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]);
|
const onCards = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]);
|
||||||
const details = [
|
const details = [
|
||||||
{ n: t.gamesBegun, label: `duels begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
{ n: t.gamesBegun, label: `duels begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
||||||
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
|
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
|
||||||
{ n: t.turnsPlayed, label: `turns of gestures written; ${t.longestGameTurns} in the longest duel` },
|
{ n: t.turnsPlayed, label: `turns of gestures written; ${t.longestGameTurns} in the longest duel` },
|
||||||
{ n: t.gamesWithBot, label: `duels with the bot seated` },
|
{ n: t.gamesWithBot, label: `duels with the bot seated` },
|
||||||
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
||||||
...Object.entries(t.byGame).filter(([label]) => !told.has(label)).map(([label, n]) => ({ n, label }))
|
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
|
||||||
];
|
];
|
||||||
const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
||||||
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.` };
|
return { headline, details, note: `${first}The count is of names, so one player under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.` };
|
||||||
});
|
});
|
||||||
/** The other games of Kestrel's Hall, for the about panel. */
|
/** The sibling games listed in the about panel, and the family's landing page. */
|
||||||
let family = $state<FamilyGame[]>([]);
|
let family = $state<FamilyGame[]>([]);
|
||||||
let hall = $state('');
|
let hall = $state('');
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -57,12 +57,12 @@
|
|||||||
hall = f.hall;
|
hall = f.hall;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
/** Reports this browser's seats have filed, with the keeper's replies. */
|
/** Bug reports filed from this browser's seats, with the maintainer's replies. */
|
||||||
let reports = $state<Report[]>([]);
|
let reports = $state<Report[]>([]);
|
||||||
let answers = $state<Record<string, string>>({});
|
let answers = $state<Record<string, string>>({});
|
||||||
let answerError = $state('');
|
let answerError = $state('');
|
||||||
|
|
||||||
// The desk is asked once per visit for every seat this browser holds.
|
// Fetch the reports for every held seat; re-runs when the held seats change.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const held = seats;
|
const held = seats;
|
||||||
if (!held.length) return;
|
if (!held.length) return;
|
||||||
@@ -85,13 +85,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The single-player duel saved in this browser, if one is under way. */
|
/** The offline game saved in this browser, if one is unfinished. */
|
||||||
const local = $derived(duel.inProgress ? duel : null);
|
const local = $derived(duel.inProgress ? duel : null);
|
||||||
const ready = $derived(name.trim().length > 0);
|
const ready = $derived(name.trim().length > 0);
|
||||||
|
|
||||||
// Each held seat is looked up once, so the ledger can say whose move it
|
// Each held seat is fetched once so the list can say whose move it is. A 403 or 404 marks
|
||||||
// is. A seat the server refuses outright is doubted; a second refusal in
|
// the seat doubtful, and a second refusal in a row drops it; a network failure changes nothing.
|
||||||
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const held of seats) {
|
for (const held of seats) {
|
||||||
if (held.roomId in games) continue;
|
if (held.roomId in games) continue;
|
||||||
@@ -111,7 +110,7 @@
|
|||||||
let phrase = $state('');
|
let phrase = $state('');
|
||||||
let claiming = $state(false);
|
let claiming = $state(false);
|
||||||
|
|
||||||
/** Bring a seat from another device by the phrase it minted. */
|
/** Claim a seat transferred from another device, by its transfer phrase. */
|
||||||
async function claim(e: SubmitEvent) {
|
async function claim(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
claiming = true;
|
claiming = true;
|
||||||
@@ -167,7 +166,7 @@
|
|||||||
if (c) void goto(`/join/${c}`);
|
if (c) void goto(`/join/${c}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A duel in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
|
/** Forgetting an unfinished game asks first: this browser's token may be the only copy of the seat. */
|
||||||
let forgetting = $state<string | null>(null);
|
let forgetting = $state<string | null>(null);
|
||||||
function forget(roomId: string) {
|
function forget(roomId: string) {
|
||||||
if (!games[roomId]?.state?.over && forgetting !== roomId) {
|
if (!games[roomId]?.state?.over && forgetting !== roomId) {
|
||||||
@@ -195,7 +194,7 @@
|
|||||||
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
|
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lines of table talk said since this browser last looked in. */
|
/** Chat lines posted since this browser last opened the room. */
|
||||||
function unread(held: { roomId: string }): number {
|
function unread(held: { roomId: string }): number {
|
||||||
const g = games[held.roomId];
|
const g = games[held.roomId];
|
||||||
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
|
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
|
||||||
@@ -206,7 +205,7 @@
|
|||||||
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A few turns of a duel, as the ledger writes them. */
|
/** Fixed sample rows for the illustration table. */
|
||||||
const sample = [
|
const sample = [
|
||||||
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
|
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
|
||||||
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
|
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
|
||||||
@@ -220,7 +219,7 @@
|
|||||||
<h1>Waving Hands</h1>
|
<h1>Waving Hands</h1>
|
||||||
<p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p>
|
<p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p>
|
||||||
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture for each hand, and all are shown together. A Fireball is F-S-S-D-D on one hand, and a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p>
|
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture for each hand, and all are shown together. A Fireball is F-S-S-D-D on one hand, and a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</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></p>
|
<p class="links" id="about"><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></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lid-play">
|
<div class="lid-play">
|
||||||
@@ -332,7 +331,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
|
<!-- The about panel's tabs. Their copy is this game's own, in its in-world voice by design. -->
|
||||||
{#snippet aboutGame()}
|
{#snippet aboutGame()}
|
||||||
<div class="about-body">
|
<div class="about-body">
|
||||||
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
|
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
|
||||||
@@ -342,7 +341,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet aboutTally()}
|
{#snippet aboutTally()}
|
||||||
{#if told}
|
{#if told}
|
||||||
<TallyBoard title="How much love the duel is getting" headline={told.headline} details={told.details} note={told.note} />
|
<TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted">Counting…</p>
|
<p class="muted">Counting…</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -358,7 +357,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<p class="hosting">
|
<p class="hosting">
|
||||||
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
|
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
|
||||||
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it.
|
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.
|
||||||
</p>
|
</p>
|
||||||
<p class="made">Waving Hands is Richard Bartle's, 1977.</p>
|
<p class="made">Waving Hands is Richard Bartle's, 1977.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,18 +499,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiet {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--rule-strong);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.45rem 0.9rem;
|
|
||||||
color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet:hover:not(:disabled) {
|
|
||||||
border-color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
}
|
}
|
||||||
@@ -576,7 +563,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the games ledger */
|
/* The list of held seats, and the reports under it. */
|
||||||
.ledger {
|
.ledger {
|
||||||
grid-area: ledger;
|
grid-area: ledger;
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
@@ -686,7 +673,7 @@
|
|||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The question takes a line of its own under the duel on a narrow screen. */
|
/* The confirmation wraps onto its own line when the row is narrow. */
|
||||||
.ledger-row.asking {
|
.ledger-row.asking {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
cells: Record<WizardId, Placed[]>;
|
cells: Record<WizardId, Placed[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** You first, then the others in table order; the gallery sees the table as laid. */
|
/** Column order: the viewer first, then the others in seat order; a spectator gets no column of their own. */
|
||||||
const columns = $derived<WizardId[]>(duel.spectating ? duel.others : [duel.me, ...duel.others]);
|
const columns = $derived<WizardId[]>(duel.spectating ? duel.others : [duel.me, ...duel.others]);
|
||||||
|
|
||||||
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
|
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
|
||||||
@@ -68,9 +68,42 @@
|
|||||||
return !!h && h.who === who && h.hand === hand && index >= h.from && index <= h.to;
|
return !!h && h.who === who && h.hand === hand && index >= h.from && index <= h.to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The stylesheet caps the box at a share of the viewport, which is rarely a whole number of
|
||||||
|
// rows, so after each turn the cap is trimmed to the header plus as many of the newest rows
|
||||||
|
// as fit under it, and the box is scrolled to the newest. Older rows then sit wholly under
|
||||||
|
// the sticky header rather than showing a cut-off sliver. The second header row sticks at
|
||||||
|
// the measured height of the first, so the two meet exactly whatever the font or wrapping.
|
||||||
|
// Row heights change with the viewport and the web font, so this is redone on resize and
|
||||||
|
// once fonts are in.
|
||||||
|
function fitRows() {
|
||||||
|
if (!scroller) return;
|
||||||
|
scroller.style.maxHeight = '';
|
||||||
|
const names = scroller.querySelector('thead tr');
|
||||||
|
if (names) scroller.style.setProperty('--names-row', `${names.getBoundingClientRect().height}px`);
|
||||||
|
const cap = parseFloat(getComputedStyle(scroller).maxHeight);
|
||||||
|
if (Number.isFinite(cap)) {
|
||||||
|
const head = scroller.querySelector('thead')?.getBoundingClientRect().height ?? 0;
|
||||||
|
const chrome = scroller.offsetHeight - scroller.clientHeight;
|
||||||
|
let inner = head;
|
||||||
|
for (const row of [...scroller.querySelectorAll('tbody tr')].reverse()) {
|
||||||
|
const h = row.getBoundingClientRect().height;
|
||||||
|
if (inner > head && inner + h + chrome > cap) break;
|
||||||
|
inner += h;
|
||||||
|
}
|
||||||
|
scroller.style.maxHeight = `${Math.ceil(inner + chrome)}px`;
|
||||||
|
}
|
||||||
|
scroller.scrollTop = scroller.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void turns;
|
void turns;
|
||||||
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
fitRows();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
window.addEventListener('resize', fitRows);
|
||||||
|
void document.fonts?.ready.then(fitRows);
|
||||||
|
return () => window.removeEventListener('resize', fitRows);
|
||||||
});
|
});
|
||||||
|
|
||||||
const crowded = $derived(duel.others.length >= 3);
|
const crowded = $derived(duel.others.length >= 3);
|
||||||
@@ -221,7 +254,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scroller {
|
.scroller {
|
||||||
max-height: clamp(170px, 30vh, 400px);
|
max-height: clamp(170px, 38vh, 420px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-top: 1px solid var(--rule-strong);
|
border-top: 1px solid var(--rule-strong);
|
||||||
border-bottom: 1px solid var(--rule-strong);
|
border-bottom: 1px solid var(--rule-strong);
|
||||||
@@ -259,16 +292,13 @@
|
|||||||
left: 7.4rem;
|
left: 7.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead th.pin-left,
|
|
||||||
thead th.pin-right {
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead th.gone {
|
thead th.gone {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The header must stack above every body cell, including the pinned ones (z-index 1 and 2 below),
|
||||||
|
or a row scrolled under it shows its turn number through the header while its letters are hidden. */
|
||||||
thead th {
|
thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -277,7 +307,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.5rem 0 0.15rem;
|
padding: 0.5rem 0 0.15rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
z-index: 1;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead th.you,
|
thead th.you,
|
||||||
@@ -287,7 +317,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
thead tr.hands th {
|
thead tr.hands th {
|
||||||
top: 2.2rem;
|
/* The measured height of the row above (set by fitRows), so the two sticky rows meet with no gap. */
|
||||||
|
top: var(--names-row, 2.2rem);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--bone-dim);
|
color: var(--bone-dim);
|
||||||
@@ -312,6 +343,13 @@
|
|||||||
padding-top: 0.6rem;
|
padding-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The header's own pinned cells sit above the rest of the header, which slides under them sideways. */
|
||||||
|
thead th.turn,
|
||||||
|
thead th.pin-left,
|
||||||
|
thead th.pin-right {
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
.rownote {
|
.rownote {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
|
|||||||
@@ -88,16 +88,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.scrim {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slip {
|
.slip {
|
||||||
width: min(100%, 30rem);
|
width: min(100%, 30rem);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
@@ -168,11 +158,6 @@
|
|||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal.small {
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
background: none;
|
background: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// A crowded table's rail: every other wizard on one line, with the one
|
// The rail for three or more opponents: each on one line, with the focused
|
||||||
// you are looking at opened out. Your own panel stays in full above.
|
// one expanded. The player's own status card stays above in full.
|
||||||
import WizardStatus from './WizardStatus.svelte';
|
import WizardStatus from './WizardStatus.svelte';
|
||||||
import type { Duel } from '$lib/game/duel.svelte';
|
import type { Duel } from '$lib/game/duel.svelte';
|
||||||
import { seatColor } from '$lib/game/colors';
|
import { seatColor } from '$lib/game/colors';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The host's hand on the other seats during a duel: a transfer phrase for a
|
// Host-only controls over the other seats during a game: a transfer phrase
|
||||||
// wizard whose browser lost their seat, and a bot to duel in the seat of one
|
// for a player whose browser lost its seat, and handing a seat to a bot until
|
||||||
// who cannot go on, until a phrase gives it back.
|
// a phrase claims it back.
|
||||||
import type { Duel } from '$lib/game/duel.svelte';
|
import type { Duel } from '$lib/game/duel.svelte';
|
||||||
import type { WizardId } from '$lib/game/state';
|
import type { WizardId } from '$lib/game/state';
|
||||||
|
|
||||||
@@ -80,15 +80,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* A definite row, so the slip's max-height resolves and it scrolls instead of overflowing the viewport. */
|
||||||
.scrim {
|
.scrim {
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: minmax(0, 1fr);
|
grid-template-rows: minmax(0, 1fr);
|
||||||
place-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slip {
|
.slip {
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
const remote = $derived(duel.remote);
|
const remote = $derived(duel.remote);
|
||||||
const lines = $derived(remote?.chat ?? []);
|
const lines = $derived(remote?.chat ?? []);
|
||||||
let draft = $state('');
|
let draft = $state('');
|
||||||
/** The gallery signs its lines; the name is the one the hall remembers. */
|
/** Spectators sign their lines; the default is the name saved in local storage. */
|
||||||
let galleryName = $state(playerName());
|
let galleryName = $state(playerName());
|
||||||
let sending = $state(false);
|
let sending = $state(false);
|
||||||
let list = $state<HTMLElement | null>(null);
|
let list = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
// New talk scrolls into view, as at any table.
|
// Keep the list scrolled to the newest line.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void lines.length;
|
void lines.length;
|
||||||
void tick().then(() => {
|
void tick().then(() => {
|
||||||
@@ -128,14 +128,6 @@
|
|||||||
flex: 0 1 8rem;
|
flex: 0 1 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiet {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--rule-strong);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet:disabled {
|
.quiet:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
/** One count told: the number, and what it counts. */
|
/** One statistic: the figure and its label. */
|
||||||
export interface TallyLine {
|
export interface TallyLine {
|
||||||
n: number | string;
|
n: number | string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// What the ledgers add up to: a few counts that are the game's own story,
|
// A statistics panel: a few headline counts on large cards, the rest in a
|
||||||
// large, on cards; then the rest in a column of figures; then the fine
|
// two-column list, then an optional note. The caller supplies the counts
|
||||||
// print. Each game chooses its counts and words.
|
// and their labels.
|
||||||
let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
|
let { headline, details, note, title = 'What the ledgers add up to' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
|
||||||
$props();
|
$props();
|
||||||
|
|
||||||
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
|
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
|
||||||
|
|||||||
+4
-5
@@ -1,7 +1,6 @@
|
|||||||
// The other games of Kestrel's Hall, from the family list the kit keeps
|
// The sibling games, from the family list the kit ships (family.json, one copy
|
||||||
// (family.json, one copy shipped with every game), so a player who liked one
|
// per game), so a player can find the next game and the family's landing page.
|
||||||
// table can find the next, and the hall's front door. This game is left out
|
// This game is left out of its own list.
|
||||||
// of its own list.
|
|
||||||
|
|
||||||
export interface FamilyGame {
|
export interface FamilyGame {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -12,7 +11,7 @@ export interface FamilyGame {
|
|||||||
origin: string;
|
origin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The family list: the hall's front door and every game but this one. */
|
/** The family list: the landing page's URL and every game but this one. */
|
||||||
export interface Family {
|
export interface Family {
|
||||||
hall: string;
|
hall: string;
|
||||||
games: FamilyGame[];
|
games: FamilyGame[];
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function heldSeats(): Record<string, HeldSeat> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */
|
/** A seat carried in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of server logs. */
|
||||||
export function seatInLink(hash: string): HeldSeat | null {
|
export function seatInLink(hash: string): HeldSeat | null {
|
||||||
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
|
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
|
||||||
return m ? { seat: m[1], token: m[2] } : null;
|
return m ? { seat: m[1], token: m[2] } : null;
|
||||||
@@ -122,8 +122,7 @@ async function call<T>(method: string, path: string, body?: unknown): Promise<T>
|
|||||||
|
|
||||||
type Seated = { seat: WizardId; token: string; view: RoomView };
|
type Seated = { seat: WizardId; token: string; view: RoomView };
|
||||||
|
|
||||||
/** A report to the keeper, with the exchange under it. */
|
/** Site-wide statistics. */
|
||||||
/** What the ledgers add up to. */
|
|
||||||
export interface Tally {
|
export interface Tally {
|
||||||
tablesOpened: number;
|
tablesOpened: number;
|
||||||
gamesBegun: number;
|
gamesBegun: number;
|
||||||
@@ -138,6 +137,7 @@ export interface Tally {
|
|||||||
byGame: Record<string, number>;
|
byGame: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A bug report, with the exchange of replies under it. */
|
||||||
export interface Report {
|
export interface Report {
|
||||||
id: string;
|
id: string;
|
||||||
at: string;
|
at: string;
|
||||||
@@ -184,7 +184,7 @@ export const api = {
|
|||||||
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
|
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */
|
/** A socket that only ever signals "fetch the view again"; reconnects on its own. An empty token spectates. */
|
||||||
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
|
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
|
||||||
let socket: WebSocket | null = null;
|
let socket: WebSocket | null = null;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export interface Remote extends HeldSeat {
|
|||||||
started: boolean;
|
started: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */
|
/** Set when the server stopped accepting this seat's token while the page was open, with the reason; the page then spectates. */
|
||||||
lost: string;
|
lost: string;
|
||||||
chat: ChatLine[];
|
chat: ChatLine[];
|
||||||
/** How many watch from the Peanut Gallery. */
|
/** How many watch from the Peanut Gallery. */
|
||||||
@@ -340,7 +340,7 @@ export class Duel {
|
|||||||
|
|
||||||
/** Watching from the gallery, with no seat at the table. */
|
/** Watching from the gallery, with no seat at the table. */
|
||||||
spectating = $derived(this.remote?.spectating ?? false);
|
spectating = $derived(this.remote?.spectating ?? false);
|
||||||
/** The gallery has no wizard of its own; it borrows the first seat for the fields that need one, and shows none of them. */
|
/** A spectator has no wizard of their own; borrow the first seat for the fields that need one (none of them are shown). */
|
||||||
you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]);
|
you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]);
|
||||||
/** The opponent spells go at by default: the first still standing. */
|
/** The opponent spells go at by default: the first still standing. */
|
||||||
foeId = $derived(enemyOf(this.state, this.me));
|
foeId = $derived(enemyOf(this.state, this.me));
|
||||||
@@ -850,12 +850,12 @@ export class Duel {
|
|||||||
return this.remoteAct((r) => api.setGalleryTalk(r.roomId, r.token, on), 'The gallery could not be told.');
|
return this.remoteAct((r) => api.setGalleryTalk(r.roomId, r.token, on), 'The gallery could not be told.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Call the keeper of the site to a held seat; their phone rings. */
|
/** Invite the site maintainer to the seat held for them; the server notifies them. */
|
||||||
callKeeper(): Promise<boolean> {
|
callKeeper(): Promise<boolean> {
|
||||||
return this.remoteAct((r) => api.challenge(r.roomId, r.token), 'The keeper could not be called.');
|
return this.remoteAct((r) => api.challenge(r.roomId, r.token), 'The keeper could not be called.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A finished table opens another with the same company; the caller is seated as its host. */
|
/** Open a rematch room for the same players; the caller becomes its host. */
|
||||||
async rematch(): Promise<string> {
|
async rematch(): Promise<string> {
|
||||||
const r = this.remote!;
|
const r = this.remote!;
|
||||||
const next = await api.rematch(r.roomId, r.token);
|
const next = await api.rematch(r.roomId, r.token);
|
||||||
@@ -943,7 +943,7 @@ export class Duel {
|
|||||||
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
|
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** You opened this table, so the duel begins when you say. */
|
/** The viewer holds the host seat, so may begin the game. */
|
||||||
isHost = $derived(!!this.remote && this.remote.host === this.remote.seat);
|
isHost = $derived(!!this.remote && this.remote.host === this.remote.seat);
|
||||||
|
|
||||||
/** Begin the duel with whoever is seated. */
|
/** Begin the duel with whoever is seated. */
|
||||||
@@ -1026,7 +1026,7 @@ export class Duel {
|
|||||||
this.resetDrafts();
|
this.resetDrafts();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a table on the server, seated as its host; it takes up to a dozen and begins when the host says. */
|
/** Create a room on the server with the caller as host; it takes up to `size` seats and starts when the host says. */
|
||||||
static async createRoom(name: string, size = 12): Promise<Remote> {
|
static async createRoom(name: string, size = 12): Promise<Remote> {
|
||||||
const seated = await api.create(name, size);
|
const seated = await api.create(name, size);
|
||||||
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
||||||
@@ -1052,7 +1052,7 @@ export class Duel {
|
|||||||
return remoteFrom(await api.view(roomId, held.token), held.token);
|
return remoteFrom(await api.view(roomId, held.token), held.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */
|
/** Open a room as a spectator: no seat and no token. */
|
||||||
static async watchRoom(roomId: string): Promise<Remote> {
|
static async watchRoom(roomId: string): Promise<Remote> {
|
||||||
return remoteFrom(await api.watch(roomId), '');
|
return remoteFrom(await api.watch(roomId), '');
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-22
@@ -312,7 +312,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
record(w, l2, r2, 'haste');
|
record(w, l2, r2, 'haste');
|
||||||
entries[id].push(w.history.length - 1);
|
entries[id].push(w.history.length - 1);
|
||||||
}
|
}
|
||||||
if (w.surrendered) log(`${w.name} lowers both hands and shows empty palms: a surrender.`, 'death');
|
if (w.surrendered) log(`${w.name} lowers both hands and shows empty palms in surrender.`, 'death');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 2. Which completed spells are actually loosed ----
|
// ---- 2. Which completed spells are actually loosed ----
|
||||||
@@ -336,7 +336,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
options.find((c) => choice.seqIndex === undefined || c.seqIndex === choice.seqIndex) ?? options[0];
|
options.find((c) => choice.seqIndex === undefined || c.seqIndex === choice.seqIndex) ?? options[0];
|
||||||
if (!completion) continue;
|
if (!completion) continue;
|
||||||
if (completion.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) {
|
if (completion.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) {
|
||||||
log(`${w.name}'s quick lightning bolt sputters: it has already been used this duel.`, 'fizzle');
|
log(`${w.name}'s quick lightning bolt sputters and dies, spent earlier in this duel.`, 'fizzle');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cells = usedCells(completion, choice.hand, index + 1);
|
const cells = usedCells(completion, choice.hand, index + 1);
|
||||||
@@ -404,7 +404,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const countered = new Set<TargetId>();
|
const countered = new Set<TargetId>();
|
||||||
const dispels = live().filter((c) => c.spell.id === 'dispel_magic');
|
const dispels = live().filter((c) => c.spell.id === 'dispel_magic');
|
||||||
if (dispels.length > 0) {
|
if (dispels.length > 0) {
|
||||||
log('A wave of stillness rolls across the field. Dispel magic: every other spell fails, every enchantment ends, and every creature will fade after this turn.', 'spell');
|
log('A wave of stillness rolls across the field as the magic is dispelled. Every other spell fails, every enchantment ends, and every creature will fade after this turn.', 'spell');
|
||||||
for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled';
|
for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled';
|
||||||
for (const d of dispels) shielded.set(d.target, 'dispel magic');
|
for (const d of dispels) shielded.set(d.target, 'dispel magic');
|
||||||
for (const id of seats) clearEnchantments(W[id]);
|
for (const id of seats) clearEnchantments(W[id]);
|
||||||
@@ -476,7 +476,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
if (chosen) {
|
if (chosen) {
|
||||||
chosen.permanent = true;
|
chosen.permanent = true;
|
||||||
extendedNow.add(id);
|
extendedNow.add(id);
|
||||||
log(`${w.name}'s ${chosen.spell.name} sets hard, like wax cooling: it will not fade.`, 'spell');
|
log(`${w.name}'s ${chosen.spell.name} sets hard, like wax cooling, and will not fade.`, 'spell');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
};
|
};
|
||||||
if (fireStorm && iceStorm) {
|
if (fireStorm && iceStorm) {
|
||||||
fireStorm = iceStorm = false;
|
fireStorm = iceStorm = false;
|
||||||
log('Fire storm meets ice storm: a great hiss of steam, and the two storms cancel. Nothing is harmed.', 'fizzle');
|
log('Fire storm meets ice storm in a great hiss of steam, and the two cancel. Nothing is harmed.', 'fizzle');
|
||||||
}
|
}
|
||||||
if (fireStorm && elementals('ice_elemental').length > 0) {
|
if (fireStorm && elementals('ice_elemental').length > 0) {
|
||||||
fireStorm = false;
|
fireStorm = false;
|
||||||
@@ -621,14 +621,14 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.pending.amnesia = true;
|
wiz.pending.amnesia = true;
|
||||||
if (c.permanent) wiz.permanent.amnesia = true;
|
if (c.permanent) wiz.permanent.amnesia = true;
|
||||||
log(`A grey haze drifts behind ${wiz.name}'s eyes: next turn the hands will repeat what they just did${c.permanent ? ', and every turn after' : ''}.`);
|
log(`A grey haze drifts behind ${wiz.name}'s eyes. Next turn the hands will repeat what they just did${c.permanent ? ', and every turn after' : ''}.`);
|
||||||
} else if (mon) mon.pending.forcedTarget = orders.get(mon.id);
|
} else if (mon) mon.pending.forcedTarget = orders.get(mon.id);
|
||||||
break;
|
break;
|
||||||
case 'confusion':
|
case 'confusion':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.pending.confusion = true;
|
wiz.pending.confusion = true;
|
||||||
if (c.permanent) wiz.permanent.confusion = true;
|
if (c.permanent) wiz.permanent.confusion = true;
|
||||||
log(`The field tilts and swims around ${wiz.name}: next turn one hand will go astray${c.permanent ? ', and every turn after' : ''}.`);
|
log(`The field tilts and swims around ${wiz.name}. One hand will go astray next turn${c.permanent ? ', and every turn after' : ''}.`);
|
||||||
} else if (mon) {
|
} else if (mon) {
|
||||||
const candidates = [...seats.filter((id) => W[id].alive), ...state.monsters.filter((o) => o.id !== mon.id).map((o) => o.id)];
|
const candidates = [...seats.filter((id) => W[id].alive), ...state.monsters.filter((o) => o.id !== mon.id).map((o) => o.id)];
|
||||||
const t = pick(candidates as TargetId[]);
|
const t = pick(candidates as TargetId[]);
|
||||||
@@ -641,7 +641,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const hand = c.choice.chosenHand ?? pick(HANDS);
|
const hand = c.choice.chosenHand ?? pick(HANDS);
|
||||||
wiz.pending.charmed = { hand, by: c.caster };
|
wiz.pending.charmed = { hand, by: c.caster };
|
||||||
if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster };
|
if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster };
|
||||||
log(`A warm, easy trust settles on ${wiz.name}: the ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
|
log(`A warm, easy trust comes over ${wiz.name}, whose ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
|
||||||
} else {
|
} else {
|
||||||
c.nullified = 'monsters cannot be charmed as people';
|
c.nullified = 'monsters cannot be charmed as people';
|
||||||
log(`The charm slides off ${target}, finding no mind to hold.`, 'fizzle');
|
log(`The charm slides off ${target}, finding no mind to hold.`, 'fizzle');
|
||||||
@@ -651,7 +651,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
if (mon && !isElemental(mon.kind)) {
|
if (mon && !isElemental(mon.kind)) {
|
||||||
mon.owner = c.caster;
|
mon.owner = c.caster;
|
||||||
orders.set(mon.id, input(c.caster).monsterOrders?.[mon.id] ?? enemyOf(state, c.caster));
|
orders.set(mon.id, input(c.caster).monsterOrders?.[mon.id] ?? enemyOf(state, c.caster));
|
||||||
log(`${mon.name} turns, blinks, and pads over to ${W[c.caster].name}'s side: it serves a new master.`);
|
log(`${mon.name} turns, blinks, and pads over to ${W[c.caster].name}'s side to serve a new master.`);
|
||||||
} else {
|
} else {
|
||||||
c.nullified = 'not a charmable monster';
|
c.nullified = 'not a charmable monster';
|
||||||
log(`The charm slides off ${target} and takes no hold.`, 'fizzle');
|
log(`The charm slides off ${target} and takes no hold.`, 'fizzle');
|
||||||
@@ -662,10 +662,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left';
|
const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left';
|
||||||
wiz.pending.paralysedHand = hand;
|
wiz.pending.paralysedHand = hand;
|
||||||
if (c.permanent) wiz.permanent.paralysedHand = hand;
|
if (c.permanent) wiz.permanent.paralysedHand = hand;
|
||||||
log(`A cold stiffness creeps into ${wiz.name}'s ${hand} hand: it will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`);
|
log(`A cold stiffness creeps into ${wiz.name}'s ${hand} hand, which will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`);
|
||||||
} else if (mon && !isElemental(mon.kind)) {
|
} else if (mon && !isElemental(mon.kind)) {
|
||||||
mon.pending.skipAttack = true;
|
mon.pending.skipAttack = true;
|
||||||
log(`${mon.name}'s limbs stiffen: it will stand paralysed next turn.`);
|
log(`${mon.name}'s limbs stiffen, and it will stand paralysed next turn.`);
|
||||||
} else {
|
} else {
|
||||||
c.nullified = 'elementals cannot be paralysed';
|
c.nullified = 'elementals cannot be paralysed';
|
||||||
}
|
}
|
||||||
@@ -686,7 +686,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
case 'protection_from_evil':
|
case 'protection_from_evil':
|
||||||
if (wiz) wiz.protectionTurns = duration(4);
|
if (wiz) wiz.protectionTurns = duration(4);
|
||||||
else if (mon) mon.protectionTurns = duration(4);
|
else if (mon) mon.protectionTurns = duration(4);
|
||||||
log(`A pale circle of light rises around ${target}: protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`);
|
log(`A pale circle of light rises around ${target}, protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`);
|
||||||
break;
|
break;
|
||||||
case 'resist_heat':
|
case 'resist_heat':
|
||||||
if (wiz) wiz.resistHeat = true;
|
if (wiz) wiz.resistHeat = true;
|
||||||
@@ -696,7 +696,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
case 'resist_cold':
|
case 'resist_cold':
|
||||||
if (wiz) wiz.resistCold = true;
|
if (wiz) wiz.resistCold = true;
|
||||||
else if (mon) mon.resistCold = true;
|
else if (mon) mon.resistCold = true;
|
||||||
log(`A warm rosy aura settles over ${target}, who now resists cold.`);
|
log(`A warm rosy aura wraps ${target}, who now resists cold.`);
|
||||||
break;
|
break;
|
||||||
case 'disease':
|
case 'disease':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
@@ -707,13 +707,13 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
case 'poison':
|
case 'poison':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.poisonTurns = 6;
|
wiz.poisonTurns = 6;
|
||||||
log(`${wiz.name} goes grey about the lips: poison is in the blood. Unless cured, ${wiz.name} will die at the end of turn ${state.turn + 6}.`);
|
log(`${wiz.name} goes grey about the lips as poison takes the blood. Unless cured, ${wiz.name} will die at the end of turn ${state.turn + 6}.`);
|
||||||
} else c.nullified = 'monsters do not sicken';
|
} else c.nullified = 'monsters do not sicken';
|
||||||
break;
|
break;
|
||||||
case 'blindness':
|
case 'blindness':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.blindTurns = duration(4);
|
wiz.blindTurns = duration(4);
|
||||||
log(`A milky film clouds ${wiz.name}'s eyes: blind${c.permanent ? forever : ' for three turns'}.`);
|
log(`A milky film clouds ${wiz.name}'s eyes and leaves them blind${c.permanent ? forever : ' for three turns'}.`);
|
||||||
} else if (mon) destroyBefore(mon, 'blinded and unmade');
|
} else if (mon) destroyBefore(mon, 'blinded and unmade');
|
||||||
break;
|
break;
|
||||||
case 'invisibility':
|
case 'invisibility':
|
||||||
@@ -725,25 +725,25 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
case 'haste':
|
case 'haste':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.hasteTurns = duration(4);
|
wiz.hasteTurns = duration(4);
|
||||||
log(`The air about ${wiz.name} quickens; everyone else seems to move through honey. Hastened${c.permanent ? forever : ' for the next three turns'}: two pairs of gestures a turn.`);
|
log(`The air about ${wiz.name} quickens; everyone else seems to move through honey. Hastened${c.permanent ? forever : ' for the next three turns'}, ${wiz.name} makes two pairs of gestures a turn.`);
|
||||||
} else c.nullified = 'monsters cannot be hastened';
|
} else c.nullified = 'monsters cannot be hastened';
|
||||||
break;
|
break;
|
||||||
case 'time_stop':
|
case 'time_stop':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
state.timeStops.push(wiz.id);
|
state.timeStops.push(wiz.id);
|
||||||
log(`The moment catches on ${wiz.name} like a snagged thread: when this turn ends, time will stop, and ${wiz.name} alone will move.`);
|
log(`The moment catches on ${wiz.name} like a snagged thread. When this turn ends, time will stop, and ${wiz.name} alone will move.`);
|
||||||
} else c.nullified = 'only wizards can stop time';
|
} else c.nullified = 'only wizards can stop time';
|
||||||
break;
|
break;
|
||||||
case 'delayed_effect':
|
case 'delayed_effect':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.delayedTurns = bankedNow.has(wiz.id) ? 0 : duration(4);
|
wiz.delayedTurns = bankedNow.has(wiz.id) ? 0 : duration(4);
|
||||||
if (!bankedNow.has(wiz.id)) log(`A hollow of waiting opens around ${wiz.name}'s hands: the next spell will be caught and held.`);
|
if (!bankedNow.has(wiz.id)) log(`A hollow of waiting opens around ${wiz.name}'s hands, ready to catch and hold the next spell.`);
|
||||||
} else c.nullified = 'only wizards can delay a spell';
|
} else c.nullified = 'only wizards can delay a spell';
|
||||||
break;
|
break;
|
||||||
case 'permanency':
|
case 'permanency':
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
wiz.permanencyTurns = extendedNow.has(wiz.id) ? 0 : 4;
|
wiz.permanencyTurns = extendedNow.has(wiz.id) ? 0 : 4;
|
||||||
if (!extendedNow.has(wiz.id)) log(`Something steady and unhurried settles on ${wiz.name}: the next enchantment will be permanent.`);
|
if (!extendedNow.has(wiz.id)) log(`Something steady and unhurried takes hold of ${wiz.name}, and the next enchantment will be permanent.`);
|
||||||
} else c.nullified = 'only a wizard can hold a permanency';
|
} else c.nullified = 'only a wizard can hold a permanency';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -756,10 +756,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
const mon = monsterOf(c.target);
|
const mon = monsterOf(c.target);
|
||||||
if (wiz) {
|
if (wiz) {
|
||||||
clearEnchantments(wiz);
|
clearEnchantments(wiz);
|
||||||
log(`Threads of old magic lift from ${wiz.name} and drift away: every enchantment is gone.`);
|
log(`Threads of old magic lift from ${wiz.name} and drift away. Every enchantment is gone.`);
|
||||||
} else if (mon && c.spell.id === 'remove_enchantment') {
|
} else if (mon && c.spell.id === 'remove_enchantment') {
|
||||||
destroyedAfterAttack.add(mon.id);
|
destroyedAfterAttack.add(mon.id);
|
||||||
log(`${mon.name} begins to fray at the edges: it will be unmade once it has attacked.`);
|
log(`${mon.name} begins to fray at the edges, and will be unmade once it has attacked.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +820,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
|||||||
heal(t, 2, 'Cure heavy wounds');
|
heal(t, 2, 'Cure heavy wounds');
|
||||||
if (isWizard(t) && W[t].diseaseTurns !== null) {
|
if (isWizard(t) && W[t].diseaseTurns !== null) {
|
||||||
W[t].diseaseTurns = null;
|
W[t].diseaseTurns = null;
|
||||||
log(`${W[t].name}'s fever breaks, and the flush fades from the skin: the disease is cured.`);
|
log(`${W[t].name}'s fever breaks, and the flush fades from the skin. The disease is cured.`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'raise_dead':
|
case 'raise_dead':
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ export function visibleHistory(state: GameState, viewer: WizardId, subject: Wiza
|
|||||||
return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h));
|
return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The gallery holds no seat: it is shown only what every wizard at the table could see. */
|
/** A spectator holds no seat and is shown only what every seated player could see. */
|
||||||
export const SPECTATOR: WizardId = '';
|
export const SPECTATOR: WizardId = '';
|
||||||
|
|
||||||
export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean {
|
export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// How much of each table's talk this browser has read, so the hall can say what is new.
|
// How many chat lines of each room this browser has read, so the landing page can count what is new.
|
||||||
|
|
||||||
const SEEN_KEY = 'wh:talk-seen';
|
const SEEN_KEY = 'wh:talk-seen';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// The game's own lines for the hall's tally, read off a finished duel. Only
|
// This game's lines for the site-wide tally, read off a finished game: only
|
||||||
// what the final state records for certain: who is dead, who yielded, and
|
// what the final state records for certain (who is dead, who yielded, and
|
||||||
// how the duel was decided.
|
// how the game was decided).
|
||||||
|
|
||||||
import type { GameState } from './state';
|
import type { GameState } from './state';
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`), or from the keeper of the site. */
|
/** One chat line: from a seat, from a spectator when the host allows it (id SPECTATOR, signed with `name`), or from the site maintainer (`keeper`). */
|
||||||
export interface ChatLine {
|
export interface ChatLine {
|
||||||
id: WizardId;
|
id: WizardId;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -46,15 +46,15 @@ export interface RoomView {
|
|||||||
audience: number;
|
audience: number;
|
||||||
/** The host lets the gallery talk. */
|
/** The host lets the gallery talk. */
|
||||||
galleryTalk: boolean;
|
galleryTalk: boolean;
|
||||||
/** The keeper of the site was called to a seat, and by whom. */
|
/** The site maintainer was invited to a held seat, and by whom. */
|
||||||
challenge: { by: WizardId; at: number } | null;
|
challenge: { by: WizardId; at: number } | null;
|
||||||
/** Names with a seat held who have not yet sat: the last table's wizards, or the keeper. */
|
/** Names with a seat held who have not yet sat: the previous room's players, or the site maintainer. */
|
||||||
expected: string[];
|
expected: string[];
|
||||||
/** A finished table's rematch: where it went, and who called. */
|
/** A finished table's rematch: where it went, and who called. */
|
||||||
rematch: { to: string; by: WizardId } | null;
|
rematch: { to: string; by: WizardId } | null;
|
||||||
/** The table this one is the rematch of. */
|
/** The table this one is the rematch of. */
|
||||||
rematchOf: string | null;
|
rematchOf: string | null;
|
||||||
/** The keeper of the site, and where they live, for a table that calls them. */
|
/** The site maintainer's name and time zone, for rooms that invite them. */
|
||||||
keeper: string;
|
keeper: string;
|
||||||
keeperZone: string;
|
keeperZone: string;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// The duel lives in the browser and is saved to local storage, so nothing is rendered on the
|
// The duel lives in the browser and is saved to local storage, so by default a route is
|
||||||
// server. Each route is prerendered as an empty shell that the client fills in.
|
// prerendered as an empty shell that the client fills in. The hall, the rules and the guide
|
||||||
|
// turn rendering on in their own +page.ts, since their text is the same for everyone.
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
|
||||||
|
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
|
||||||
|
export const ssr = true;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
|
||||||
|
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
|
||||||
|
export const ssr = true;
|
||||||
@@ -13,22 +13,22 @@
|
|||||||
let room = $state<Duel | null>(null);
|
let room = $state<Duel | null>(null);
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let joining = $state(false);
|
let joining = $state(false);
|
||||||
/** The host asked to begin while a seat is still held for someone on their way. */
|
/** The host pressed Begin while a seat is still held for someone expected; confirm before going on. */
|
||||||
let beginAsked = $state(false);
|
let beginAsked = $state(false);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let reporting = $state(false);
|
let reporting = $state(false);
|
||||||
/** A phrase minted to carry this seat to another device, while it lasts. */
|
/** This seat's transfer phrase, until it expires. */
|
||||||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||||||
let rematching = $state(false);
|
let rematching = $state(false);
|
||||||
let seatsOpen = $state(false);
|
let seatsOpen = $state(false);
|
||||||
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
|
/** The server's reason for refusing this browser's seat; shown while spectating. */
|
||||||
let seatLost = $state('');
|
let seatLost = $state('');
|
||||||
let claimPhrase = $state('');
|
let claimPhrase = $state('');
|
||||||
let claiming = $state(false);
|
let claiming = $state(false);
|
||||||
|
|
||||||
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
/** The current hour in the site maintainer's time zone, in words, shown beside the challenge button. */
|
||||||
let minute = $state(0);
|
let minute = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = setInterval(() => (minute += 1), 60_000);
|
const id = setInterval(() => (minute += 1), 60_000);
|
||||||
@@ -76,18 +76,18 @@
|
|||||||
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
|
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
|
||||||
const lobby = $derived(room !== null && !room.remote?.started);
|
const lobby = $derived(room !== null && !room.remote?.started);
|
||||||
const watching = $derived(room?.spectating ?? false);
|
const watching = $derived(room?.spectating ?? false);
|
||||||
/** A watcher may still sit while the table is laid and a seat is free. */
|
/** A spectator may still take a seat before the game starts, while one is free. */
|
||||||
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
|
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
|
||||||
|
|
||||||
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
|
// Resume a held seat, else spectate. A link may carry a seat (#seat=...): it is stripped from the address
|
||||||
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
|
// bar at once, saved only once the server accepts it, and never replaces a held seat the server still
|
||||||
// still opens, so a link someone else sends cannot take a working seat away.
|
// accepts, so a forwarded link cannot take a working seat.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = roomId;
|
const id = roomId;
|
||||||
room = null;
|
room = null;
|
||||||
error = '';
|
error = '';
|
||||||
const sent = seatInLink(location.hash);
|
const sent = seatInLink(location.hash);
|
||||||
// The browser's own call: this runs as the page first loads, before the router could take one.
|
// The History API directly, not SvelteKit's replaceState: this runs on first load, before the router is ready.
|
||||||
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
|
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
|
||||||
const held = seatFor(id);
|
const held = seatFor(id);
|
||||||
const take = (s: HeldSeat) =>
|
const take = (s: HeldSeat) =>
|
||||||
@@ -96,9 +96,9 @@
|
|||||||
trustSeat(id);
|
trustSeat(id);
|
||||||
return remote;
|
return remote;
|
||||||
});
|
});
|
||||||
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat.
|
// Only a 403 on the held token lets the link's seat in; a dropped connection is not a lost seat.
|
||||||
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
|
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
|
||||||
// A held seat the server refuses, with no link to replace it, is watched from the gallery, with the reason.
|
// A refused seat with no link to replace it falls back to spectating, keeping the server's reason.
|
||||||
const watchRefused = (e: Error) => {
|
const watchRefused = (e: Error) => {
|
||||||
seatLost = e.message;
|
seatLost = e.message;
|
||||||
return Duel.watchRoom(id);
|
return Duel.watchRoom(id);
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
return stop;
|
return stop;
|
||||||
});
|
});
|
||||||
|
|
||||||
// The seat stopped opening while the page was open (the host gave it to a bot): watch on from the gallery.
|
// The seat was revoked while the page was open (the host gave it to a bot): switch to spectating.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const r = room;
|
const r = room;
|
||||||
const lost = r?.remote?.lost;
|
const lost = r?.remote?.lost;
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
.catch((e: Error) => (error = e.message));
|
.catch((e: Error) => (error = e.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Sit back down by a phrase: the host's, or one this seat minted on another device. */
|
/** Claim a seat by transfer phrase: the host's, or one this seat made on another device. */
|
||||||
async function claim(e: SubmitEvent) {
|
async function claim(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
claiming = true;
|
claiming = true;
|
||||||
@@ -380,57 +380,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.masthead {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem var(--gutter) 0.6rem;
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2rem, 4vw, 2.7rem);
|
|
||||||
font-weight: 300;
|
|
||||||
font-variation-settings: 'opsz' 144;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst {
|
|
||||||
max-width: 38em;
|
|
||||||
color: var(--bone-dim);
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst em {
|
|
||||||
color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--rule-strong);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--bone);
|
|
||||||
text-decoration: none;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet:hover {
|
|
||||||
border-color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet.room b {
|
.quiet.room b {
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -517,11 +466,6 @@
|
|||||||
width: 14em;
|
width: 14em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal.small {
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roster {
|
.roster {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0.9rem 0 0.6rem;
|
margin: 0.9rem 0 0.6rem;
|
||||||
@@ -583,27 +527,4 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colophon {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem var(--gutter) 3rem;
|
|
||||||
color: var(--bone-faint);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colophon a {
|
|
||||||
color: var(--frost);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.masthead {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colophon {
|
|
||||||
padding-bottom: 6rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -90,116 +90,37 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.masthead {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem var(--gutter) 0.6rem;
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2rem, 4vw, 2.7rem);
|
|
||||||
font-weight: 300;
|
|
||||||
font-variation-settings: 'opsz' 144;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst {
|
|
||||||
max-width: 38em;
|
|
||||||
color: var(--bone-dim);
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst em {
|
|
||||||
color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.standfirst .link {
|
.standfirst .link {
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rename {
|
.rename {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem 0.75rem;
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rename input {
|
|
||||||
width: 14em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
gap: 0.5rem 0.75rem;
|
||||||
min-width: 0;
|
margin-top: 0.4rem;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiet {
|
.rename input {
|
||||||
background: none;
|
width: 14em;
|
||||||
border: 1px solid var(--rule-strong);
|
}
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--bone);
|
|
||||||
text-decoration: none;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiet:hover {
|
|
||||||
border-color: var(--bone);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm {
|
.confirm {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto 0.75rem;
|
margin: 0 auto 0.75rem;
|
||||||
padding: 0.75rem var(--gutter);
|
padding: 0.75rem var(--gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm p {
|
.confirm p {
|
||||||
color: var(--blood);
|
color: var(--blood);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choices {
|
.choices {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
|
||||||
|
|
||||||
.reveal.small {
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colophon {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem var(--gutter) 3rem;
|
|
||||||
color: var(--bone-faint);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colophon a {
|
|
||||||
color: var(--frost);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.colophon {
|
|
||||||
padding-bottom: 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masthead {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
|
||||||
|
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
|
||||||
|
export const ssr = true;
|
||||||
+9
-1
@@ -31,8 +31,16 @@
|
|||||||
"name": "Dip",
|
"name": "Dip",
|
||||||
"url": "https://dip.kestrelsnest.social",
|
"url": "https://dip.kestrelsnest.social",
|
||||||
"pitch": "Seven powers share one map, and none of them has to keep its word.",
|
"pitch": "Seven powers share one map, and none of them has to keep its word.",
|
||||||
"players": "2 to 7",
|
"players": "2 to 10, depending on the map",
|
||||||
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "wyrm",
|
||||||
|
"name": "Wyrm",
|
||||||
|
"url": "https://wyrm.kestrelsnest.social",
|
||||||
|
"pitch": "Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.",
|
||||||
|
"players": "2",
|
||||||
|
"origin": "Gary Gygax's Dragonchess, Dragon Magazine No. 100, 1985; an unofficial rebuild"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 106 KiB |
+3
-1
@@ -11,7 +11,9 @@ export default defineConfig({
|
|||||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||||
},
|
},
|
||||||
// A static build: the duel runs entirely in the browser, so Caddy serves files and nothing else.
|
// A static build: the duel runs entirely in the browser, so Caddy serves files and nothing else.
|
||||||
adapter: adapter({ fallback: 'index.html' })
|
// The fallback is the empty shell Caddy serves for a room address; it is not index.html, so the
|
||||||
|
// hall's own prerendered page is not overwritten by it.
|
||||||
|
adapter: adapter({ fallback: 'shell.html' })
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in new issue
Block a user