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
|
||||
Wiz-War's "Steal treasure. Blast through walls. Outwit your friends."
|
||||
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
|
||||
meant to sound wise ("This is the whole art", "Nothing is unlocked by 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
|
||||
carries its own small `package.json` and installs only that.
|
||||
- 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
|
||||
`127.0.0.1` against `localhost` (Vite needs `--host`).
|
||||
- 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)"
|
||||
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
|
||||
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/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
|
||||
|
||||
+9
-1
@@ -31,8 +31,16 @@
|
||||
"name": "Dip",
|
||||
"url": "https://dip.kestrelsnest.social",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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
|
||||
# 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.
|
||||
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")
|
||||
|
||||
|
||||
|
||||
+28
-35
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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/">
|
||||
<meta property="og:type" content="website">
|
||||
<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); }
|
||||
|
||||
.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 {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
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 img.fill { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.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 h2 { margin: 0; font-size: 1.45rem; font-weight: 500; }
|
||||
.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 .length:empty { display: none; }
|
||||
.game .go { margin-top: auto; padding-top: 10px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.game .play { color: var(--gold); font-size: 1rem; }
|
||||
.game .site { color: var(--bone-dim); font-size: 0.85rem; }
|
||||
.game:hover .play { text-decoration: underline; }
|
||||
@media (max-width: 860px) {
|
||||
.games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; }
|
||||
.game.first { flex-direction: column; }
|
||||
.game.first .art { flex: none; aspect-ratio: 5 / 3; }
|
||||
.game.first .body { padding: 16px 18px 18px; }
|
||||
.game.first h2 { font-size: 1.45rem; }
|
||||
}
|
||||
.nowrap { white-space: nowrap; }
|
||||
|
||||
/* 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">
|
||||
<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>
|
||||
<!-- 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>
|
||||
</header>
|
||||
|
||||
<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" href="https://wizwar.kestrelsnest.social">
|
||||
<a class="game first" href="https://wizwar.kestrelsnest.social">
|
||||
<div class="art">
|
||||
<div class="maze">
|
||||
<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="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="length"></p>
|
||||
<div class="go">
|
||||
<span class="play">Play Wiz-War →</span>
|
||||
<span class="site">wizwar.kestrelsnest.social</span>
|
||||
</div>
|
||||
</div>
|
||||
</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="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="length"></p>
|
||||
<div class="go">
|
||||
<span class="play">Play Waving Hands →</span>
|
||||
<span class="site">hands.kestrelsnest.social</span>
|
||||
</div>
|
||||
</div>
|
||||
</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="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="length"></p>
|
||||
<div class="go">
|
||||
<span class="play">Play Hnefatafl →</span>
|
||||
<span class="site">tafl.kestrelsnest.social</span>
|
||||
</div>
|
||||
</div>
|
||||
</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="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="length"></p>
|
||||
<div class="go">
|
||||
<span class="play">Play Dip →</span>
|
||||
<span class="site">dip.kestrelsnest.social</span>
|
||||
</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>
|
||||
</a>
|
||||
|
||||
@@ -235,7 +228,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</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.
|
||||
# ./new-game.sh <slug> "<Name>" [port] [domain]
|
||||
# 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.
|
||||
set -euo pipefail
|
||||
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
|
||||
|
||||
@@ -43,7 +43,10 @@ __HOST__ {
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
@mutable not path /_app/immutable/*
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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,
|
||||
dip 8790.
|
||||
dip 8790, wyrm 8791.
|
||||
|
||||
## 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.
|
||||
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='/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='*' \
|
||||
./ "root@$HOST:/opt/__SLUG__/app/"
|
||||
|
||||
@@ -60,6 +60,12 @@ ssh "root@$HOST" '
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now __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
|
||||
# "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.
|
||||
|
||||
@@ -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)
|
||||
# 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.
|
||||
# Meta's crawlers (meta-externalagent, meta-webindexer) name facebook.com too but share nothing.
|
||||
UNFURL = [
|
||||
("Slack", re.compile(r"slack", 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)),
|
||||
("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)),
|
||||
("Telegram", re.compile(r"telegram", re.I)),
|
||||
("Reddit", re.compile(r"redditbot", re.I)),
|
||||
@@ -144,8 +146,10 @@ def scan(day, logs=LOGS):
|
||||
t["bot"] += 1
|
||||
t["botAddresses"].add(ip)
|
||||
service = unfurled_by(ua)
|
||||
# One share per app and page: a preview is often fetched twice.
|
||||
if service and (service, path) not in seen_share:
|
||||
# One share per app and page: a preview is often fetched twice. Only a
|
||||
# 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))
|
||||
t["shared"][service] += 1
|
||||
continue
|
||||
|
||||
@@ -16,6 +16,9 @@ afterEach(() => {
|
||||
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. */
|
||||
function table(spec: GameSpec<State, Input> = game, withCy = false) {
|
||||
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/);
|
||||
});
|
||||
|
||||
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 talking: GameSpec<State, Input> = {
|
||||
...game,
|
||||
@@ -179,6 +182,7 @@ describe('rooms', () => {
|
||||
const cy = rooms.join(host.room, 'Cy');
|
||||
const room = host.room;
|
||||
rooms.begin(room, host.token);
|
||||
await answered();
|
||||
rooms.say(room, host.token, 'Psst.', ['B']);
|
||||
rooms.say(room, cy.token, 'Hello, all.');
|
||||
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.']);
|
||||
rooms.submit(room, host.token, { pick: 1 }, 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(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
|
||||
expect(rooms.view(room, 'A').parting).toEqual([]);
|
||||
@@ -229,6 +234,7 @@ describe('rooms', () => {
|
||||
rooms.addBot(host.room, host.token);
|
||||
rooms.begin(host.room, host.token);
|
||||
const room = host.room;
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(room.state!.n).toBe(4);
|
||||
expect(rooms.view(room, 'A').playingOn).toBe(true);
|
||||
vi.advanceTimersByTime(2000);
|
||||
@@ -243,16 +249,18 @@ describe('rooms', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
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(rooms.waitingOn(room)).toEqual(['A']);
|
||||
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 });
|
||||
rooms.submit(room, ann, { pick: 3 }, 1);
|
||||
await answered();
|
||||
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/);
|
||||
});
|
||||
|
||||
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 { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||
rooms.standIn(room, ann, 'B');
|
||||
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);
|
||||
expect(next.expected).toEqual(['Bo']);
|
||||
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);
|
||||
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.
|
||||
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.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 next = setTimeout(() => {
|
||||
this.pacing.delete(room.id);
|
||||
// A room forgotten and loaded again drives itself; this one is no longer the table.
|
||||
if (this.rooms.get(room.id) !== room) return;
|
||||
if (!this.isTable(room)) return;
|
||||
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.
|
||||
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.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
this.botsAfterAnswering(room);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="description" content="__NAME__, a game between friends, free in the browser." />
|
||||
<link rel="canonical" href="https://__DOMAIN__/" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="__NAME__" />
|
||||
|
||||
@@ -45,14 +45,13 @@
|
||||
{ n: t.turnsPlayed, label: 'turns recorded' },
|
||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||
];
|
||||
const onCards = new Set<string>([]);
|
||||
const details = [
|
||||
{ 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.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' },
|
||||
...(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' })}. ` : '';
|
||||
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 aboutTally()}
|
||||
{#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}
|
||||
<p class="muted">Counting…</p>
|
||||
{/if}
|
||||
@@ -341,7 +340,7 @@
|
||||
{/if}
|
||||
<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
|
||||
<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 class="made">__NAME__ is its designer's.</p>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
let beginAsked = $state(false);
|
||||
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()));
|
||||
/** 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.
|
||||
const choices = $derived(game.rankable?.(v.options) ?? null);
|
||||
@@ -134,11 +136,11 @@
|
||||
{/if}
|
||||
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
|
||||
<div class="keeper">
|
||||
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
|
||||
<p class="muted small 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>
|
||||
<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">{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>
|
||||
{: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 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
|
||||
// server. Each route is prerendered as an empty shell that the client fills in.
|
||||
// Everything happens in the browser against the game server, so by default a route is
|
||||
// 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 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)
|
||||
},
|
||||
// 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: {
|
||||
|
||||
Reference in new issue
Block a user