Compare commits
9
Commits
e91d209cbe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee7b1d68ec | ||
|
|
c6366853cd | ||
|
|
647e8cb2bf | ||
|
|
40a52dfe37 | ||
|
|
75428b5a9b | ||
|
|
62cb00be33 | ||
|
|
dcd2293b42 | ||
|
|
cbe85d2d31 | ||
|
|
5e7ac999db |
No files matched your search
@@ -294,6 +294,10 @@ and read each new page against this list before it ships.
|
|||||||
deliberate triplet as its tagline when the energy is the point, as
|
deliberate triplet as its tagline when the energy is the point, as
|
||||||
Wiz-War's "Steal treasure. Blast through walls. Outwit your friends."
|
Wiz-War's "Steal treasure. Blast through walls. Outwit your friends."
|
||||||
does. Once is a choice, and a pattern is a tell.
|
does. Once is a choice, and a pattern is a tell.
|
||||||
|
- **No one sentence shape for a whole list.** Thirty chronicle lines that
|
||||||
|
each run image, colon, consequence read as a template however good the
|
||||||
|
images are. Let the fact come first in some, in its own sentence in others,
|
||||||
|
and joined by "and" or "which" in the rest.
|
||||||
- **No closing aphorisms.** A paragraph ends on its last fact, not on a line
|
- **No closing aphorisms.** A paragraph ends on its last fact, not on a line
|
||||||
meant to sound wise ("This is the whole art", "Nothing is unlocked by it
|
meant to sound wise ("This is the whole art", "Nothing is unlocked by it
|
||||||
and nothing is withheld without it").
|
and nothing is withheld without it").
|
||||||
@@ -351,6 +355,11 @@ opened its mouth.") as long as the fact survives.
|
|||||||
- `npm install --omit=optional` on the droplet breaks Vite; the server
|
- `npm install --omit=optional` on the droplet breaks Vite; the server
|
||||||
carries its own small `package.json` and installs only that.
|
carries its own small `package.json` and installs only that.
|
||||||
- A dynamic `/join/[code]` route must set `prerender = false`.
|
- A dynamic `/join/[code]` route must set `prerender = false`.
|
||||||
|
- The hall, the rules and the guide set `ssr = true` in their own `+page.ts`,
|
||||||
|
so view-source, a crawler and a reader without scripts get the text; the
|
||||||
|
rest stays an empty shell. The adapter's fallback is `shell.html`, not
|
||||||
|
`index.html`, because a fallback named `index.html` overwrites the
|
||||||
|
prerendered hall, and Caddy's `try_files` ends in `/shell.html`.
|
||||||
- Two tabs on one origin share a seat; test a second player from
|
- Two tabs on one origin share a seat; test a second player from
|
||||||
`127.0.0.1` against `localhost` (Vite needs `--host`).
|
`127.0.0.1` against `localhost` (Vite needs `--host`).
|
||||||
- Svelte proxies do not `structuredClone`: hand the engine `$state.snapshot`.
|
- Svelte proxies do not `structuredClone`: hand the engine `$state.snapshot`.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ SLUG="${2:-$(basename "$(cd "$GAME" && pwd)")}"
|
|||||||
KIT="$(cd "$(dirname "$0")" && pwd)"
|
KIT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
MAP="$KIT/drift-map/$SLUG.txt"
|
MAP="$KIT/drift-map/$SLUG.txt"
|
||||||
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts server/src/tally.ts
|
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts server/src/tally.ts
|
||||||
|
src/routes/+layout.ts src/routes/+page.ts src/routes/rules/+page.ts src/routes/guide/+page.ts vite.config.ts
|
||||||
src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.ts src/lib/net/room.svelte.ts
|
src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.ts src/lib/net/room.svelte.ts
|
||||||
src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.svelte src/lib/components/Announcement.svelte
|
src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.svelte src/lib/components/Announcement.svelte
|
||||||
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
|
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
|
||||||
|
|||||||
+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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ LOGS = "/var/lib/caddy/gamehall-access*.log*"
|
|||||||
# The games that share this host, by the name the hall links to. A visitor
|
# The games that share this host, by the name the hall links to. A visitor
|
||||||
# who follows a card arrives with the hall as referrer (the hall sends its
|
# who follows a card arrives with the hall as referrer (the hall sends its
|
||||||
# origin and nothing more). Wiz-War keeps its own host and its own count.
|
# origin and nothing more). Wiz-War keeps its own host and its own count.
|
||||||
GAMES = {"dip": "dip.kestrelsnest.social", "waving-hands": "hands.kestrelsnest.social", "hnefatafl": "tafl.kestrelsnest.social"}
|
GAMES = {"dip": "dip.kestrelsnest.social", "waving-hands": "hands.kestrelsnest.social", "hnefatafl": "tafl.kestrelsnest.social", "wyrm": "wyrm.kestrelsnest.social"}
|
||||||
ASSET = (".png", ".jpg", ".svg", ".ico", ".txt", ".webmanifest")
|
ASSET = (".png", ".jpg", ".svg", ".ico", ".txt", ".webmanifest")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+28
-35
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Kestrel's Hall</title>
|
<title>Kestrel's Hall</title>
|
||||||
<meta name="description" content="Free board games in the browser: Wiz-War, Waving Hands, Hnefatafl, and Dip. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
|
<meta name="description" content="Free board games in the browser: Wiz-War, Waving Hands, Hnefatafl, Dip and Wyrm. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
|
||||||
<link rel="canonical" href="https://gamehall.kestrelsnest.social/">
|
<link rel="canonical" href="https://gamehall.kestrelsnest.social/">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:title" content="Kestrel's Hall">
|
<meta property="og:title" content="Kestrel's Hall">
|
||||||
@@ -58,18 +58,22 @@
|
|||||||
header.mast p.newcomer { margin: 10px auto 0; max-width: 38em; font-size: 0.95rem; color: var(--bone-dim); }
|
header.mast p.newcomer { margin: 10px auto 0; max-width: 38em; font-size: 0.95rem; color: var(--bone-dim); }
|
||||||
|
|
||||||
.games { display: grid; gap: 22px; grid-template-columns: repeat(2, 1fr); margin-top: 22px; }
|
.games { display: grid; gap: 22px; grid-template-columns: repeat(2, 1fr); margin-top: 22px; }
|
||||||
@media (max-width: 860px) { .games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; } }
|
|
||||||
.game {
|
.game {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 12px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: transform .15s ease, border-color .15s ease;
|
|
||||||
}
|
}
|
||||||
.game:hover { transform: translateY(-3px); border-color: var(--gold); text-decoration: none; }
|
.game:hover { border-color: var(--gold); text-decoration: none; }
|
||||||
|
.game:hover h2 { color: var(--gold); }
|
||||||
|
/* The first game is the one newcomers are pointed at, so it has the whole width: art beside the words. */
|
||||||
|
.game.first { grid-column: 1 / -1; flex-direction: row; }
|
||||||
|
.game.first .art { flex: 0 0 55%; aspect-ratio: 2 / 1; }
|
||||||
|
.game.first .body { justify-content: center; padding: 22px 26px; }
|
||||||
|
.game.first h2 { font-size: 1.9rem; }
|
||||||
.game .art { position: relative; aspect-ratio: 5 / 3; background: #222; overflow: hidden; }
|
.game .art { position: relative; aspect-ratio: 5 / 3; background: #222; overflow: hidden; }
|
||||||
.game .art img.fill { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.game .art img.fill { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.game .art img.tafl { object-position: 50% 50%; }
|
.game .art img.tafl { object-position: 50% 50%; }
|
||||||
@@ -77,13 +81,15 @@
|
|||||||
.game .body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
.game .body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||||||
.game h2 { margin: 0; font-size: 1.45rem; font-weight: 500; }
|
.game h2 { margin: 0; font-size: 1.45rem; font-weight: 500; }
|
||||||
.game .pitch { margin: 0; color: var(--bone); }
|
.game .pitch { margin: 0; color: var(--bone); }
|
||||||
.game .players, .game .length { margin: 0; color: var(--bone-dim); font-size: 0.92rem; }
|
.game .players { margin: 0; color: var(--bone-dim); font-size: 0.92rem; }
|
||||||
.game .origin { margin: 0; color: var(--bone-dim); font-size: 0.85rem; }
|
.game .origin { margin: 0; color: var(--bone-dim); font-size: 0.85rem; }
|
||||||
.game .length:empty { display: none; }
|
@media (max-width: 860px) {
|
||||||
.game .go { margin-top: auto; padding-top: 10px; display: flex; flex-direction: column; gap: 2px; }
|
.games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; }
|
||||||
.game .play { color: var(--gold); font-size: 1rem; }
|
.game.first { flex-direction: column; }
|
||||||
.game .site { color: var(--bone-dim); font-size: 0.85rem; }
|
.game.first .art { flex: none; aspect-ratio: 5 / 3; }
|
||||||
.game:hover .play { text-decoration: underline; }
|
.game.first .body { padding: 16px 18px 18px; }
|
||||||
|
.game.first h2 { font-size: 1.45rem; }
|
||||||
|
}
|
||||||
.nowrap { white-space: nowrap; }
|
.nowrap { white-space: nowrap; }
|
||||||
|
|
||||||
/* The maze card: a stone floor in CSS, mortar-coloured walls, and the hall's own token drawings. */
|
/* The maze card: a stone floor in CSS, mortar-coloured walls, and the hall's own token drawings. */
|
||||||
@@ -128,14 +134,11 @@
|
|||||||
<header class="mast">
|
<header class="mast">
|
||||||
<h1>Kestrel's Hall</h1>
|
<h1>Kestrel's Hall</h1>
|
||||||
<p class="lede"><b>Free board games in the browser.</b> Play alone against the house right away, or start a table and send friends the link. No accounts, no adverts, no timers, and nothing sold.</p>
|
<p class="lede"><b>Free board games in the browser.</b> Play alone against the house right away, or start a table and send friends the link. No accounts, no adverts, no timers, and nothing sold.</p>
|
||||||
<!-- A word for newcomers, one line to tune or remove. -->
|
|
||||||
<p class="newcomer">New here? Wiz-War is the friendliest place to start, and a game can be over in minutes. Dip is the long game.</p>
|
<p class="newcomer">New here? Wiz-War is the friendliest place to start, and a game can be over in minutes. Dip is the long game.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="games">
|
<div class="games">
|
||||||
<!-- Each card's empty "length" line is for a typical game's length, once real play has shown it; it is hidden until filled. -->
|
<a class="game first" href="https://wizwar.kestrelsnest.social">
|
||||||
|
|
||||||
<a class="game" href="https://wizwar.kestrelsnest.social">
|
|
||||||
<div class="art">
|
<div class="art">
|
||||||
<div class="maze">
|
<div class="maze">
|
||||||
<div class="w h" style="left:12.5%;top:19%;width:25%"></div>
|
<div class="w h" style="left:12.5%;top:19%;width:25%"></div>
|
||||||
@@ -159,11 +162,6 @@
|
|||||||
<p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p>
|
<p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p>
|
||||||
<p class="players">2 to 6 players, or solo against the automaton</p>
|
<p class="players">2 to 6 players, or solo against the automaton</p>
|
||||||
<p class="origin">An unofficial rebuild of Tom Jolly's 1993 board game</p>
|
<p class="origin">An unofficial rebuild of Tom Jolly's 1993 board game</p>
|
||||||
<p class="length"></p>
|
|
||||||
<div class="go">
|
|
||||||
<span class="play">Play Wiz-War →</span>
|
|
||||||
<span class="site">wizwar.kestrelsnest.social</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -174,11 +172,6 @@
|
|||||||
<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="players">2 to 12 players, or solo against a rival wizard</p>
|
<p class="players">2 to 12 players, or solo against a rival wizard</p>
|
||||||
<p class="origin">Richard Bartle's pencil-and-paper duel, 1977</p>
|
<p class="origin">Richard Bartle's pencil-and-paper duel, 1977</p>
|
||||||
<p class="length"></p>
|
|
||||||
<div class="go">
|
|
||||||
<span class="play">Play Waving Hands →</span>
|
|
||||||
<span class="site">hands.kestrelsnest.social</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -189,11 +182,6 @@
|
|||||||
<p class="pitch">The Viking board game of a king breaking for safety and a ring of attackers closing in.</p>
|
<p class="pitch">The Viking board game of a king breaking for safety and a ring of attackers closing in.</p>
|
||||||
<p class="players">2 players, or solo against the housecarl</p>
|
<p class="players">2 players, or solo against the housecarl</p>
|
||||||
<p class="origin">The Norse tafl game, by the Copenhagen rules or Linnaeus's 1732 Tablut</p>
|
<p class="origin">The Norse tafl game, by the Copenhagen rules or Linnaeus's 1732 Tablut</p>
|
||||||
<p class="length"></p>
|
|
||||||
<div class="go">
|
|
||||||
<span class="play">Play Hnefatafl →</span>
|
|
||||||
<span class="site">tafl.kestrelsnest.social</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -204,11 +192,16 @@
|
|||||||
<p class="pitch">Seven powers share one map, and none of them has to keep its word. Other maps seat five or ten.</p>
|
<p class="pitch">Seven powers share one map, and none of them has to keep its word. Other maps seat five or ten.</p>
|
||||||
<p class="players">2 to 10 players, depending on the map, or solo against the house's envoys</p>
|
<p class="players">2 to 10 players, depending on the map, or solo against the house's envoys</p>
|
||||||
<p class="origin">An unofficial rebuild of Allan B. Calhamer's 1959 Diplomacy</p>
|
<p class="origin">An unofficial rebuild of Allan B. Calhamer's 1959 Diplomacy</p>
|
||||||
<p class="length"></p>
|
|
||||||
<div class="go">
|
|
||||||
<span class="play">Play Dip →</span>
|
|
||||||
<span class="site">dip.kestrelsnest.social</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="game" href="https://wyrm.kestrelsnest.social">
|
||||||
|
<div class="art"><img class="fill" src="wyrm.jpg" alt="Wyrm's glass board at the opening, seen from a corner: three tinted sheets, the air, the land and the underworld, with Gold's figures on the near ranks and Scarlet's on the far ones."></div>
|
||||||
|
<div class="body">
|
||||||
|
<h2>Wyrm</h2>
|
||||||
|
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
|
||||||
|
<p class="players">2 players, or solo against the drake</p>
|
||||||
|
<p class="origin">An unofficial rebuild of Gary Gygax's 1985 Dragonchess, from Dragon Magazine No. 100</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -235,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 style="margin-top:28px">The hosting</h2>
|
<h2 style="margin-top:28px">The hosting</h2>
|
||||||
<p>The games 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 class="nowrap" href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it.</p>
|
<p>The games 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 class="nowrap" href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
# Stamp out a new game from the template.
|
# Stamp out a new game from the template.
|
||||||
# ./new-game.sh <slug> "<Name>" [port] [domain]
|
# ./new-game.sh <slug> "<Name>" [port] [domain]
|
||||||
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social
|
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social
|
||||||
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790. The
|
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790, wyrm 8791. The
|
||||||
# games share one host (deploy/README.md), so a new game takes the next free port.
|
# games share one host (deploy/README.md), so a new game takes the next free port.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
|
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ service user, /opt/<slug> and /var/lib/<slug> trees, access log
|
|||||||
small Node process capped at 300 MB by its unit file, and a room is a few
|
small Node process capped at 300 MB by its unit file, and a room is a few
|
||||||
kilobytes of ledger, so the host carries several. Each game needs a port
|
kilobytes of ledger, so the host carries several. Each game needs a port
|
||||||
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
|
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
|
||||||
dip 8790.
|
dip 8790, wyrm 8791.
|
||||||
|
|
||||||
## Current production
|
## Current production
|
||||||
|
|
||||||
|
|||||||
@@ -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='/src/lib/net/' --include='/src/lib/net/view.ts' \
|
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' --include='/src/lib/net/' --include='/src/lib/net/view.ts' \
|
||||||
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/traffic.py' \
|
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/Caddyfile.tmpl' --include='/deploy/traffic.py' \
|
||||||
--exclude='*' \
|
--exclude='*' \
|
||||||
./ "root@$HOST:/opt/__SLUG__/app/"
|
./ "root@$HOST:/opt/__SLUG__/app/"
|
||||||
|
|
||||||
@@ -60,6 +60,12 @@ ssh "root@$HOST" '
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now __SLUG__
|
systemctl enable --now __SLUG__
|
||||||
systemctl restart __SLUG__
|
systemctl restart __SLUG__
|
||||||
|
# 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/__SLUG__.caddy | sed "s/ {\$//")
|
||||||
|
sed "s|__HOST__|$ADDR|" /opt/__SLUG__/app/deploy/Caddyfile.tmpl > /etc/caddy/sites/__SLUG__.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
|
||||||
# "active" means only that the process started: wait for it to answer HTTP,
|
# "active" means only that the process started: wait for it to answer HTTP,
|
||||||
# so a server that dies loading its code fails the deploy instead of looping.
|
# so a server that dies loading its code fails the deploy instead of looping.
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ from collections import Counter, defaultdict
|
|||||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||||
# Link previews: the app that fetched the page to show it in a conversation. The people
|
# Link previews: the app that fetched the page to show it in a conversation. The people
|
||||||
# who click such a link arrive with no referrer, so this is the best sign of where it went.
|
# who click such a link arrive with no referrer, so this is the best sign of where it went.
|
||||||
|
# Meta's crawlers (meta-externalagent, meta-webindexer) name facebook.com too but share nothing.
|
||||||
UNFURL = [
|
UNFURL = [
|
||||||
("Slack", re.compile(r"slack", re.I)),
|
("Slack", re.compile(r"slack", re.I)),
|
||||||
("Discord", re.compile(r"discord", re.I)),
|
("Discord", re.compile(r"discord", re.I)),
|
||||||
|
("Google Messages", re.compile(r"googlemessages", re.I)),
|
||||||
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
|
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
|
||||||
("Facebook", re.compile(r"facebookexternalhit|facebot|meta-externalagent", re.I)),
|
("Facebook", re.compile(r"facebookexternalhit|facebot", re.I)),
|
||||||
("WhatsApp", re.compile(r"whatsapp", re.I)),
|
("WhatsApp", re.compile(r"whatsapp", re.I)),
|
||||||
("Telegram", re.compile(r"telegram", re.I)),
|
("Telegram", re.compile(r"telegram", re.I)),
|
||||||
("Reddit", re.compile(r"redditbot", re.I)),
|
("Reddit", re.compile(r"redditbot", re.I)),
|
||||||
@@ -144,8 +146,10 @@ def scan(day, logs=LOGS):
|
|||||||
t["bot"] += 1
|
t["bot"] += 1
|
||||||
t["botAddresses"].add(ip)
|
t["botAddresses"].add(ip)
|
||||||
service = unfurled_by(ua)
|
service = unfurled_by(ua)
|
||||||
# One share per app and page: a preview is often fetched twice.
|
# One share per app and page: a preview is often fetched twice. Only a
|
||||||
if service and (service, path) not in seen_share:
|
# page counts: an app that already holds a preview fetches its card
|
||||||
|
# image again to show it, and robots.txt and scripts are not shared.
|
||||||
|
if service and (path in PAGES or path.startswith("/join/")) and (service, path) not in seen_share:
|
||||||
seen_share.add((service, path))
|
seen_share.add((service, path))
|
||||||
t["shared"][service] += 1
|
t["shared"][service] += 1
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ afterEach(() => {
|
|||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The bots move once the request that made their move due has been answered: after this tick. */
|
||||||
|
const answered = () => new Promise<void>((r) => setImmediate(r));
|
||||||
|
|
||||||
/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */
|
/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */
|
||||||
function table(spec: GameSpec<State, Input> = game, withCy = false) {
|
function table(spec: GameSpec<State, Input> = game, withCy = false) {
|
||||||
const rooms = new Rooms(spec, new Store(dir));
|
const rooms = new Rooms(spec, new Store(dir));
|
||||||
@@ -161,7 +164,7 @@ describe('rooms', () => {
|
|||||||
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
|
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => {
|
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', async () => {
|
||||||
const heard: string[][] = [];
|
const heard: string[][] = [];
|
||||||
const talking: GameSpec<State, Input> = {
|
const talking: GameSpec<State, Input> = {
|
||||||
...game,
|
...game,
|
||||||
@@ -179,6 +182,7 @@ describe('rooms', () => {
|
|||||||
const cy = rooms.join(host.room, 'Cy');
|
const cy = rooms.join(host.room, 'Cy');
|
||||||
const room = host.room;
|
const room = host.room;
|
||||||
rooms.begin(room, host.token);
|
rooms.begin(room, host.token);
|
||||||
|
await answered();
|
||||||
rooms.say(room, host.token, 'Psst.', ['B']);
|
rooms.say(room, host.token, 'Psst.', ['B']);
|
||||||
rooms.say(room, cy.token, 'Hello, all.');
|
rooms.say(room, cy.token, 'Hello, all.');
|
||||||
const annSees = rooms.view(room, 'A').chat;
|
const annSees = rooms.view(room, 'A').chat;
|
||||||
@@ -188,6 +192,7 @@ describe('rooms', () => {
|
|||||||
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
|
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
|
||||||
rooms.submit(room, host.token, { pick: 1 }, 0);
|
rooms.submit(room, host.token, { pick: 1 }, 0);
|
||||||
rooms.submit(room, cy.token, { pick: 2 }, 0);
|
rooms.submit(room, cy.token, { pick: 2 }, 0);
|
||||||
|
await answered();
|
||||||
expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']);
|
expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']);
|
||||||
expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
|
expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
|
||||||
expect(rooms.view(room, 'A').parting).toEqual([]);
|
expect(rooms.view(room, 'A').parting).toEqual([]);
|
||||||
@@ -229,6 +234,7 @@ describe('rooms', () => {
|
|||||||
rooms.addBot(host.room, host.token);
|
rooms.addBot(host.room, host.token);
|
||||||
rooms.begin(host.room, host.token);
|
rooms.begin(host.room, host.token);
|
||||||
const room = host.room;
|
const room = host.room;
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
expect(room.state!.n).toBe(4);
|
expect(room.state!.n).toBe(4);
|
||||||
expect(rooms.view(room, 'A').playingOn).toBe(true);
|
expect(rooms.view(room, 'A').playingOn).toBe(true);
|
||||||
vi.advanceTimersByTime(2000);
|
vi.advanceTimersByTime(2000);
|
||||||
@@ -243,16 +249,18 @@ describe('rooms', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('the host and the other seats', () => {
|
describe('the host and the other seats', () => {
|
||||||
it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", () => {
|
it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", async () => {
|
||||||
const { rooms, room, ann, bo } = table();
|
const { rooms, room, ann, bo } = table();
|
||||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||||
rooms.standIn(room, ann, 'B');
|
rooms.standIn(room, ann, 'B');
|
||||||
// The round waited on Bo alone; the bot played it.
|
// The round waited on Bo alone; the bot plays it as soon as the host has been answered.
|
||||||
|
await answered();
|
||||||
expect(room.resolved).toBe(1);
|
expect(room.resolved).toBe(1);
|
||||||
expect(rooms.waitingOn(room)).toEqual(['A']);
|
expect(rooms.waitingOn(room)).toEqual(['A']);
|
||||||
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
|
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
|
||||||
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
||||||
rooms.submit(room, ann, { pick: 3 }, 1);
|
rooms.submit(room, ann, { pick: 3 }, 1);
|
||||||
|
await answered();
|
||||||
expect(room.resolved).toBe(2);
|
expect(room.resolved).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,12 +313,15 @@ describe('the host and the other seats', () => {
|
|||||||
expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/);
|
expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', () => {
|
it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', async () => {
|
||||||
const { rooms, room, ann } = table();
|
const { rooms, room, ann } = table();
|
||||||
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||||
rooms.standIn(room, ann, 'B');
|
rooms.standIn(room, ann, 'B');
|
||||||
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||||
while (!game.over(room.state!)) rooms.submit(room, ann, { pick: 5 }, room.resolved);
|
while (!game.over(room.state!)) {
|
||||||
|
rooms.submit(room, ann, { pick: 5 }, room.resolved);
|
||||||
|
await answered();
|
||||||
|
}
|
||||||
const next = rooms.get(rooms.rematch(room, ann).roomId);
|
const next = rooms.get(rooms.rematch(room, ann).roomId);
|
||||||
expect(next.expected).toEqual(['Bo']);
|
expect(next.expected).toEqual(['Bo']);
|
||||||
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
|
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
|
||||||
|
|||||||
@@ -351,8 +351,32 @@ export class Rooms<State, Input> {
|
|||||||
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409);
|
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409);
|
||||||
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) });
|
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) });
|
||||||
// A game whose first move belongs to a bot must not wait for a human who is not owed a move.
|
// A game whose first move belongs to a bot must not wait for a human who is not owed a move.
|
||||||
|
this.botsAfterAnswering(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bots' moves, once the request that made them due has been answered.
|
||||||
|
* A bot that thinks for a second or two would otherwise hold up the reply
|
||||||
|
* to the person whose move it is answering, so their own move would seem
|
||||||
|
* to hang and the bot's to come at once. The table is told as each move
|
||||||
|
* lands, the same as any other.
|
||||||
|
*/
|
||||||
|
/** A room forgotten and loaded again drives itself; a timer or tick that wakes with the old one must do nothing. */
|
||||||
|
private isTable(room: Room<State>): boolean {
|
||||||
|
return this.rooms.get(room.id) === room;
|
||||||
|
}
|
||||||
|
|
||||||
|
private botsAfterAnswering(room: Room<State>): void {
|
||||||
|
setImmediate(() => {
|
||||||
|
if (!this.isTable(room)) return;
|
||||||
|
try {
|
||||||
this.driveBots(room);
|
this.driveBots(room);
|
||||||
this.botsSpeak(room, 'phase');
|
this.botsSpeak(room, 'phase');
|
||||||
|
} catch (e) {
|
||||||
|
// Nothing is waiting on this callback: an error here would take the process down, so it is logged and the table left as it stands.
|
||||||
|
console.error(`${room.id}: the bots could not move: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -383,8 +407,7 @@ export class Rooms<State, Input> {
|
|||||||
const hurried = this.hurried.has(room.id);
|
const hurried = this.hurried.has(room.id);
|
||||||
const next = setTimeout(() => {
|
const next = setTimeout(() => {
|
||||||
this.pacing.delete(room.id);
|
this.pacing.delete(room.id);
|
||||||
// A room forgotten and loaded again drives itself; this one is no longer the table.
|
if (!this.isTable(room)) return;
|
||||||
if (this.rooms.get(room.id) !== room) return;
|
|
||||||
this.driveBots(room, hurried ? HURRIED_STEP : 1);
|
this.driveBots(room, hurried ? HURRIED_STEP : 1);
|
||||||
// Played out: the bots have their say once, as after any round, which ends a game with parting words.
|
// Played out: the bots have their say once, as after any round, which ends a game with parting words.
|
||||||
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
|
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
|
||||||
@@ -538,8 +561,7 @@ export class Rooms<State, Input> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||||
this.driveBots(room);
|
this.botsAfterAnswering(room);
|
||||||
this.botsSpeak(room, 'phase');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<meta name="description" content="__NAME__, a game between friends, free in the browser." />
|
<meta name="description" content="__NAME__, a game between friends, free in the browser." />
|
||||||
<link rel="canonical" href="https://__DOMAIN__/" />
|
<link rel="canonical" href="https://__DOMAIN__/" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:site_name" content="__NAME__" />
|
<meta property="og:site_name" content="__NAME__" />
|
||||||
|
|||||||
@@ -45,14 +45,13 @@
|
|||||||
{ n: t.turnsPlayed, label: 'turns recorded' },
|
{ n: t.turnsPlayed, label: 'turns recorded' },
|
||||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||||
];
|
];
|
||||||
const onCards = new Set<string>([]);
|
|
||||||
const details = [
|
const details = [
|
||||||
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
{ n: t.gamesBegun, label: `games 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 recorded; ${t.longestGameTurns} in the longest game` },
|
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
|
||||||
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
|
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a 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]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
|
...Object.entries(t.byGame).map(([label, n]) => ({ n, label }))
|
||||||
];
|
];
|
||||||
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
const first = t.firstGameAt ? `The first game 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 moves, so a game left open overnight adds nothing for the night.` };
|
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 moves, so a game left open overnight adds nothing for the night.` };
|
||||||
@@ -325,7 +324,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet aboutTally()}
|
{#snippet aboutTally()}
|
||||||
{#if told}
|
{#if told}
|
||||||
<TallyBoard title="How much love the table 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}
|
||||||
@@ -341,7 +340,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">__NAME__ is its designer's.</p>
|
<p class="made">__NAME__ is its designer's.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
let beginAsked = $state(false);
|
let beginAsked = $state(false);
|
||||||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||||||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||||||
|
/** A server with no KEEPER set calls its keeper "the keeper", which reads badly beside the title. */
|
||||||
|
const named = $derived(v.keeper.toLowerCase() !== 'the keeper');
|
||||||
|
|
||||||
// A table that asks for rankings: this seat's, best first, kept on the server as it changes.
|
// A table that asks for rankings: this seat's, best first, kept on the server as it changes.
|
||||||
const choices = $derived(game.rankable?.(v.options) ?? null);
|
const choices = $derived(game.rankable?.(v.options) ?? null);
|
||||||
@@ -134,11 +136,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
|
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
|
||||||
<div class="keeper">
|
<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>
|
<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 {named ? `${v.keeper}, the keeper` : 'the keeper'}</button>
|
||||||
<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>
|
<p class="muted small note">{named ? v.keeper : 'The keeper'} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {named ? v.keeper : 'the keeper'} lives.{/if} The seat is held either way.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if v.challenge}
|
{:else if v.challenge}
|
||||||
<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 for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
|
<p class="muted small note">{named ? v.keeper : 'The keeper'} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Everything happens in the browser against the game server, so nothing is rendered on the
|
// Everything happens in the browser against the game server, 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;
|
||||||
@@ -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;
|
||||||
@@ -10,7 +10,9 @@ export default defineConfig({
|
|||||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||||
},
|
},
|
||||||
// A static build: Caddy serves files and proxies /api and /ws to the game server.
|
// A static build: Caddy serves files and proxies /api and /ws to the game server.
|
||||||
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