A credibility pass over ee5690e..HEAD: traffic.py parses each request once and counts visits one way, the deploy notes and skills agree with the scripts, and a few edit leftovers in the hall are tidied
traffic.py gives identical counts before and after over 41 days of the host's access logs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
57ae338cdb
commit
7f66be8016
@@ -72,7 +72,8 @@ to hear his players' voices; never compress their words into a table.
|
|||||||
passing `rules` to `createGame`). Old ledgers then replay as their
|
passing `rules` to `createGame`). Old ledgers then replay as their
|
||||||
players saw them; new games get the fix.
|
players saw them; new games get the fix.
|
||||||
Either way add a test in `src/lib/game/*.test.ts` pinning the
|
Either way add a test in `src/lib/game/*.test.ts` pinning the
|
||||||
corrected behaviour. Deploy with `deploy/deploy.sh 159.203.115.252`.
|
corrected behaviour. Deploy with
|
||||||
|
`SITE=https://tafl.159.203.115.252.sslip.io deploy/deploy.sh 159.203.115.252`.
|
||||||
5. **Reply.** `bash deploy/report-reply.sh 159.203.115.252 <reportId> <status> "text"`.
|
5. **Reply.** `bash deploy/report-reply.sh 159.203.115.252 <reportId> <status> "text"`.
|
||||||
Pass the id as ONE clean argument. Write to the PLAYER: name what you
|
Pass the id as ONE clean argument. Write to the PLAYER: name what you
|
||||||
replayed, cite the rule, and say plainly what was wrong or why nothing
|
replayed, cite the rule, and say plainly what was wrong or why nothing
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ spills into the next UTC day. Run yesterday too when the hour is early.
|
|||||||
|
|
||||||
- **as of / live sockets / rooms touched in the last hour**: the "right
|
- **as of / live sockets / rooms touched in the last hour**: the "right
|
||||||
now" line. One socket at a quiet hour is Eric.
|
now" line. One socket at a quiet hour is Eric.
|
||||||
- **traffic**: Caddy's access log for the day, bots split out by user
|
- **traffic**: Caddy's access log for the day, people and bots apart. A
|
||||||
agent, page requests by page (hall, rules, guide) and room links opened,
|
bot is known by its user agent, or by its address: scanners ask for
|
||||||
referrers and campaign tags.
|
paths no game serves, monitors come back all day and never play. Then
|
||||||
|
page requests by page (hall, rules, guide), rooms opened from the site
|
||||||
|
("rooms opened here"), room links opened, the apps a link was previewed
|
||||||
|
in ("shared on"), referrers and campaign tags.
|
||||||
- **players today / NEW today**: names seated in rooms created today;
|
- **players today / NEW today**: names seated in rooms created today;
|
||||||
NEW means the name's first room ever is today.
|
NEW means the name's first room ever is today.
|
||||||
- **rooms today**: one line each: time, code, humans vs bots, empty
|
- **rooms today**: one line each: time, code, humans vs bots, empty
|
||||||
|
|||||||
+2
-2
@@ -26,8 +26,8 @@ dip 8790.
|
|||||||
- URLs: https://tafl.kestrelsnest.social (A record at Hover, where kestrelsnest.social's
|
- URLs: https://tafl.kestrelsnest.social (A record at Hover, where kestrelsnest.social's
|
||||||
DNS lives) and https://tafl.159.203.115.252.sslip.io (always works, zero DNS).
|
DNS lives) and https://tafl.159.203.115.252.sslip.io (always works, zero DNS).
|
||||||
- Everyday deploy: `SITE=https://tafl.159.203.115.252.sslip.io deploy/deploy.sh 159.203.115.252`
|
- Everyday deploy: `SITE=https://tafl.159.203.115.252.sslip.io deploy/deploy.sh 159.203.115.252`
|
||||||
(this Mac's resolver caches the old *.kestrelsnest.social wildcard, so the gate reads the sslip name).
|
(the determinism gate reads the ledgers from `SITE`; the sslip name answers wherever
|
||||||
- Moved here from its own droplet (209.97.148.244) on 2026-09-25.
|
the real name does not resolve to this host yet).
|
||||||
- Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/hnefatafl/feedback.jsonl
|
- Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/hnefatafl/feedback.jsonl
|
||||||
and the screenshots to ~/Desktop/hnefatafl-reports with a digest;
|
and the screenshots to ~/Desktop/hnefatafl-reports with a digest;
|
||||||
`deploy/report-reply.sh 159.203.115.252 <id> <status> "text"` answers one.
|
`deploy/report-reply.sh 159.203.115.252 <id> <status> "text"` answers one.
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ LOG="/var/log/hnefatafl/rollup.log"
|
|||||||
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
|
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
|
||||||
SCHEDULE="12 0 * * *"
|
SCHEDULE="12 0 * * *"
|
||||||
CRON_URL=$(cat /root/.hnefatafl-sentry-cron-rollup 2>/dev/null || true)
|
CRON_URL=$(cat /root/.hnefatafl-sentry-cron-rollup 2>/dev/null || true)
|
||||||
# The check-in carries the schedule: Sentry keeps a monitor only once told its shape.
|
# One check-in, when the run ends, carrying its duration and the schedule
|
||||||
# One check-in, when the run ends, carrying its duration: a start and a
|
# (Sentry keeps a monitor only once told its shape). A start and a finish
|
||||||
# finish sent seconds apart can reach Sentry in either order, and a start
|
# sent seconds apart can reach Sentry in either order, and a start that lands
|
||||||
# that lands second is never closed. A run that never ends sends nothing,
|
# second is never closed. A run that never ends sends nothing, and Sentry
|
||||||
# and Sentry marks the night missed.
|
# marks the night missed.
|
||||||
START=$SECONDS
|
START=$SECONDS
|
||||||
checkin() {
|
checkin() {
|
||||||
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
|
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# own port, service user, data directory and access log, and this script
|
# own port, service user, data directory and access log, and this script
|
||||||
# touches nothing that belongs to another game. Copy it and Caddyfile.tmpl
|
# touches nothing that belongs to another game. Copy it and Caddyfile.tmpl
|
||||||
# to the host and run ON it as root; safe to run again.
|
# to the host and run ON it as root; safe to run again.
|
||||||
# bash setup-droplet.sh 'tafl.kestrelsnest.social, hnefatafl.<host-ip>.sslip.io'
|
# bash setup-droplet.sh 'tafl.kestrelsnest.social, tafl.<host-ip>.sslip.io'
|
||||||
# The argument is this game's Caddy site address line: one name, or several
|
# The argument is this game's Caddy site address line: one name, or several
|
||||||
# separated by commas. Every name must already resolve to this host.
|
# separated by commas. Every name must already resolve to this host.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
+37
-38
@@ -46,6 +46,28 @@ def unfurled_by(ua):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse(r):
|
||||||
|
"""The parts of one log line the counts read."""
|
||||||
|
q = r.get("request", {})
|
||||||
|
h = q.get("headers", {})
|
||||||
|
uri = q.get("uri", "")
|
||||||
|
return dict(
|
||||||
|
ts=r.get("ts", 0), status=r.get("status", 0), method=q.get("method"), uri=uri, path=uri.split("?")[0],
|
||||||
|
ip=q.get("client_ip") or q.get("remote_ip") or "?",
|
||||||
|
ua=" ".join(h.get("User-Agent", [""])), referer=" ".join(h.get("Referer", [""])),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def visit_starts(times):
|
||||||
|
"""When each visit began: requests more than VISIT_GAP apart are separate visits."""
|
||||||
|
starts, last = [], None
|
||||||
|
for ts in sorted(times):
|
||||||
|
if last is None or ts - last > VISIT_GAP:
|
||||||
|
starts.append(ts)
|
||||||
|
last = ts
|
||||||
|
return starts
|
||||||
|
|
||||||
|
|
||||||
def scan(day, logs=LOGS):
|
def scan(day, logs=LOGS):
|
||||||
"""Counts for one UTC day (YYYY-MM-DD): every request, page requests by
|
"""Counts for one UTC day (YYYY-MM-DD): every request, page requests by
|
||||||
people, the addresses of people and of bots, requests by page, room links
|
people, the addresses of people and of bots, requests by page, room links
|
||||||
@@ -53,8 +75,8 @@ def scan(day, logs=LOGS):
|
|||||||
referrers, and campaign tags (each counted once per address).
|
referrers, and campaign tags (each counted once per address).
|
||||||
|
|
||||||
A request is a bot's when its browser says so. An address is a bot's for
|
A request is a bot's when its browser says so. An address is a bot's for
|
||||||
the whole day when it asks for a scanner's paths, or comes back for pages
|
the whole day when it asks for a scanner's paths, or comes back three
|
||||||
three visits or more without ever opening or joining a game: a monitor,
|
visits or more without ever opening or joining a game: a monitor,
|
||||||
not a person. A monitor that takes a new address every visit gives itself
|
not a person. A monitor that takes a new address every visit gives itself
|
||||||
away by keeping time: one browser, page loads at a steady interval, four
|
away by keeping time: one browser, page loads at a steady interval, four
|
||||||
visits or more, never a game."""
|
visits or more, never a game."""
|
||||||
@@ -73,55 +95,37 @@ def scan(day, logs=LOGS):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
if t0 <= r.get("ts", 0) < t1:
|
if t0 <= r.get("ts", 0) < t1:
|
||||||
requests.append(r)
|
requests.append(parse(r))
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# First pass: what each address did all day under a browser's name, to find scanners and monitors.
|
# First pass: what each address did all day under a browser's name, to find scanners and monitors.
|
||||||
seen = defaultdict(lambda: dict(times=[], played=False, scanner=False))
|
seen = defaultdict(lambda: dict(times=[], played=False, scanner=False))
|
||||||
for r in requests:
|
for r in requests:
|
||||||
q = r.get("request", {})
|
if BOT.search(r["ua"]) or not r["ua"]:
|
||||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
|
||||||
ua = " ".join(q.get("headers", {}).get("User-Agent", [""]))
|
|
||||||
if BOT.search(ua) or not ua:
|
|
||||||
continue
|
continue
|
||||||
path = q.get("uri", "").split("?")[0]
|
path = r["path"]
|
||||||
a = seen[ip]
|
a = seen[r["ip"]]
|
||||||
a["times"].append(r.get("ts", 0))
|
a["times"].append(r["ts"])
|
||||||
if not path.startswith("/_app/") and SCANNER.search(path):
|
if not path.startswith("/_app/") and SCANNER.search(path):
|
||||||
a["scanner"] = True
|
a["scanner"] = True
|
||||||
if path.startswith(("/join/", "/api/rooms")) or path == "/ws":
|
if path.startswith(("/join/", "/api/rooms")) or path == "/ws":
|
||||||
a["played"] = True
|
a["played"] = True
|
||||||
|
|
||||||
def visits(times):
|
|
||||||
n, last = 0, None
|
|
||||||
for ts in sorted(times):
|
|
||||||
if last is None or ts - last > VISIT_GAP:
|
|
||||||
n += 1
|
|
||||||
last = ts
|
|
||||||
return n
|
|
||||||
|
|
||||||
def keeps_time(times):
|
def keeps_time(times):
|
||||||
starts, last = [], None
|
starts = visit_starts(times)
|
||||||
for ts in sorted(times):
|
|
||||||
if last is None or ts - last > VISIT_GAP:
|
|
||||||
starts.append(ts)
|
|
||||||
last = ts
|
|
||||||
gaps = [b - a for a, b in zip(starts, starts[1:])]
|
|
||||||
if len(starts) < 4:
|
if len(starts) < 4:
|
||||||
return False
|
return False
|
||||||
|
gaps = [b - a for a, b in zip(starts, starts[1:])]
|
||||||
typical = sorted(gaps)[len(gaps) // 2]
|
typical = sorted(gaps)[len(gaps) // 2]
|
||||||
return all(0.75 * typical <= g <= 1.25 * typical for g in gaps)
|
return all(0.75 * typical <= g <= 1.25 * typical for g in gaps)
|
||||||
|
|
||||||
scanners = {ip for ip, a in seen.items() if a["scanner"]}
|
scanners = {ip for ip, a in seen.items() if a["scanner"]}
|
||||||
monitors = {ip for ip, a in seen.items() if not (a["scanner"] or a["played"]) and visits(a["times"]) >= 3}
|
monitors = {ip for ip, a in seen.items() if not (a["scanner"] or a["played"]) and len(visit_starts(a["times"])) >= 3}
|
||||||
by_browser = defaultdict(list)
|
by_browser = defaultdict(list)
|
||||||
for r in requests:
|
for r in requests:
|
||||||
q = r.get("request", {})
|
if r["ua"] and not BOT.search(r["ua"]) and r["path"] in PAGES and not seen[r["ip"]]["played"]:
|
||||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
by_browser[r["ua"]].append((r["ts"], r["ip"]))
|
||||||
ua = " ".join(q.get("headers", {}).get("User-Agent", [""]))
|
|
||||||
if ua and not BOT.search(ua) and q.get("uri", "").split("?")[0] in PAGES and not seen[ip]["played"]:
|
|
||||||
by_browser[ua].append((r.get("ts", 0), ip))
|
|
||||||
for hits in by_browser.values():
|
for hits in by_browser.values():
|
||||||
if keeps_time([ts for ts, _ in hits]):
|
if keeps_time([ts for ts, _ in hits]):
|
||||||
monitors |= {ip for _, ip in hits}
|
monitors |= {ip for _, ip in hits}
|
||||||
@@ -134,12 +138,7 @@ def scan(day, logs=LOGS):
|
|||||||
seen_campaign = set()
|
seen_campaign = set()
|
||||||
seen_share = set()
|
seen_share = set()
|
||||||
for r in requests:
|
for r in requests:
|
||||||
q = r.get("request", {})
|
ip, ua, uri, path = r["ip"], r["ua"], r["uri"], r["path"]
|
||||||
h = q.get("headers", {})
|
|
||||||
ua = " ".join(h.get("User-Agent", [""]))
|
|
||||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
|
||||||
uri = q.get("uri", "")
|
|
||||||
path = uri.split("?")[0]
|
|
||||||
t["requests"] += 1
|
t["requests"] += 1
|
||||||
if ip in posing or BOT.search(ua) or not ua:
|
if ip in posing or BOT.search(ua) or not ua:
|
||||||
t["bot"] += 1
|
t["bot"] += 1
|
||||||
@@ -151,7 +150,7 @@ def scan(day, logs=LOGS):
|
|||||||
t["shared"][service] += 1
|
t["shared"][service] += 1
|
||||||
continue
|
continue
|
||||||
# A room opened from the site itself never passes through /join: count the API's answer.
|
# A room opened from the site itself never passes through /join: count the API's answer.
|
||||||
if q.get("method") == "POST" and path == "/api/rooms" and 200 <= r.get("status", 0) < 300:
|
if r["method"] == "POST" and path == "/api/rooms" and 200 <= r["status"] < 300:
|
||||||
t["opened"] += 1
|
t["opened"] += 1
|
||||||
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")):
|
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")):
|
||||||
continue
|
continue
|
||||||
@@ -161,7 +160,7 @@ def scan(day, logs=LOGS):
|
|||||||
t["pages"][PAGES[path]] += 1
|
t["pages"][PAGES[path]] += 1
|
||||||
elif path.startswith("/join/"):
|
elif path.startswith("/join/"):
|
||||||
t["joins"][path.split("/")[2].upper()] += 1
|
t["joins"][path.split("/")[2].upper()] += 1
|
||||||
ref = " ".join(h.get("Referer", [""]))
|
ref = r["referer"]
|
||||||
if ref and not OWN.search(ref):
|
if ref and not OWN.search(ref):
|
||||||
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
||||||
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
||||||
|
|||||||
+1
-1
@@ -366,7 +366,7 @@ export class Rooms<State, Input> {
|
|||||||
* arrives after its round was played (a slow connection, a second click, a stale tab) is refused
|
* arrives after its round was played (a slow connection, a second click, a stale tab) is refused
|
||||||
* rather than taken as the move for the next round.
|
* rather than taken as the move for the next round.
|
||||||
*/
|
*/
|
||||||
submit(room: Room<State>, token: string | undefined, raw: unknown, resolved?: unknown): void {
|
submit(room: Room<State>, token: string | undefined, raw: unknown, resolved: unknown): void {
|
||||||
const seat = this.seatOf(room, token);
|
const seat = this.seatOf(room, token);
|
||||||
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
|
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
|
||||||
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
|
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
until?: string;
|
until?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An empty file, `{}`, is how the keeper takes an announcement down. */
|
||||||
|
function isAnnouncement(a: unknown): a is Announcement {
|
||||||
|
if (!a || typeof a !== 'object') return false;
|
||||||
|
const { id, text } = a as Partial<Announcement>;
|
||||||
|
return typeof id === 'string' && typeof text === 'string' && text !== '';
|
||||||
|
}
|
||||||
|
|
||||||
const SOURCE = '/hall/announcement.json';
|
const SOURCE = '/hall/announcement.json';
|
||||||
const CHECK_MS = 5 * 60_000;
|
const CHECK_MS = 5 * 60_000;
|
||||||
const DISMISSED = 'hall:dismissed';
|
const DISMISSED = 'hall:dismissed';
|
||||||
@@ -28,7 +35,7 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(SOURCE, { cache: 'no-store' });
|
const res = await fetch(SOURCE, { cache: 'no-store' });
|
||||||
const a: unknown = res.ok ? await res.json() : null;
|
const a: unknown = res.ok ? await res.json() : null;
|
||||||
current = a && typeof a === 'object' && typeof (a as Announcement).text === 'string' && (a as Announcement).text && typeof (a as Announcement).id === 'string' ? (a as Announcement) : null;
|
current = isAnnouncement(a) ? a : null;
|
||||||
} catch {
|
} catch {
|
||||||
// No announcement reachable: none shown.
|
// No announcement reachable: none shown.
|
||||||
}
|
}
|
||||||
@@ -68,7 +75,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
// Taken first: once dismissed, nothing is shown.
|
// Read before dismissing: once dismissed, nothing is shown.
|
||||||
const id = shown?.id;
|
const id = shown?.id;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
dismissed = id;
|
dismissed = id;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// The hall: the landing page every game in the kit shares. Name, play the
|
// The hall: the landing page every game in the kit shares. Name, play the
|
||||||
// bot, open a table, join by code, the ledger of your tables, and the
|
// bot, open a table, join by code, the ledger of your tables, and the
|
||||||
// about panel with the rules, the guide and a way to reach the keeper.
|
// about panel with the rules, the guide and a way to reach the keeper.
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { loadFamily, type FamilyGame } from '$lib/family';
|
import { loadFamily, type FamilyGame } from '$lib/family';
|
||||||
import About from './About.svelte';
|
import About from './About.svelte';
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
let seats = $state(allSeats());
|
let seats = $state(allSeats());
|
||||||
let games = $state<Record<string, RoomView<State> | null>>({});
|
let games = $state<Record<string, RoomView<State> | null>>({});
|
||||||
let aboutOpen = $state(false);
|
let aboutOpen = $state(false);
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
if (location.hash === '#about') aboutOpen = true;
|
if (location.hash === '#about') aboutOpen = true;
|
||||||
});
|
});
|
||||||
/** The ledgers' tally, fetched when the about panel opens. */
|
/** The ledgers' tally, fetched when the about panel opens. */
|
||||||
@@ -43,21 +44,20 @@
|
|||||||
const told = $derived.by(() => {
|
const told = $derived.by(() => {
|
||||||
const t = tally;
|
const t = tally;
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
|
|
||||||
const headline = [
|
const headline = [
|
||||||
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'game played to a finish' : 'games played to a finish' },
|
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'game played to a finish' : 'games played to a finish' },
|
||||||
{ n: t.byGame["won by the king's escape"] ?? 0, label: "won by the king's escape" },
|
{ n: t.byGame["won by the king's escape"] ?? 0, label: "won by the king's escape" },
|
||||||
{ n: t.byGame['won by taking the king'] ?? 0, label: 'won by taking the king' },
|
{ n: t.byGame['won by taking the king'] ?? 0, label: 'won by taking the king' },
|
||||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||||
];
|
];
|
||||||
const told = new Set<string>(["won by the king's escape", 'won by taking the king']);
|
const onCards = new Set<string>(["won by the king's escape", 'won by taking the king']);
|
||||||
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: `games with a housecarl seated` },
|
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a housecarl seated' : 'games with a housecarl 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 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.` };
|
||||||
@@ -627,5 +627,4 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// What the ledgers add up to: a few counts that are the game's own story,
|
// What the ledgers add up to: a few counts that are the game's own story,
|
||||||
// large, on cards; then the rest in a column of figures; then the fine
|
// large, on cards; then the rest in a column of figures; then the fine
|
||||||
// print. Each game chooses its counts and words.
|
// print. Each game chooses its counts and words.
|
||||||
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 }: { 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);
|
||||||
|
|||||||
+7
-5
@@ -1,7 +1,7 @@
|
|||||||
// The other games of Kestrel's Hall, from the family list the kit keeps
|
// The other games of Kestrel's Hall, from the family list the kit keeps
|
||||||
// (family.json, one copy shipped with every game), so a player who liked one
|
// (family.json, one copy shipped with every game): the hall's front door, and
|
||||||
// table can find the next, and the hall's front door. This game is left out
|
// the next table for a player who liked this one. This game is left out of its
|
||||||
// of its own list.
|
// own list.
|
||||||
|
|
||||||
export interface FamilyGame {
|
export interface FamilyGame {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -18,13 +18,15 @@ export interface Family {
|
|||||||
games: FamilyGame[];
|
games: FamilyGame[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NONE: Family = { hall: '', games: [] };
|
||||||
|
|
||||||
export async function loadFamily(self: string): Promise<Family> {
|
export async function loadFamily(self: string): Promise<Family> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/family.json', { cache: 'no-cache' });
|
const res = await fetch('/family.json', { cache: 'no-cache' });
|
||||||
if (!res.ok) return { hall: '', games: [] };
|
if (!res.ok) return NONE;
|
||||||
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
|
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
|
||||||
return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) };
|
return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) };
|
||||||
} catch {
|
} catch {
|
||||||
return { hall: '', games: [] };
|
return NONE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user