clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

site: / shows generating and missing states with the last finished day's replay, and switches to live by polling /status

dami committed 32eea51
commit
32eea5198e4a7c93c833605394e9518cc552d129
parent
e187ece13a2746ac59e369f60c9951768e2e3bf7
author
dami <gazey55@gmail.com>
5 files changed, +170 -27
Asite/islands/Today.tsx72++++++++++++++++++++++++++++++
Msite/lib/daily.ts50++++++++++++++++++++-
Msite/routes/index.tsx42+++++++----------
Asite/routes/status.ts24++++++++++
Msite/tests/daily_test.ts9++++
A site/islands/Today.tsx +72 -0
@@ -0,0 +1,72 @@
1+// The front page's three states: today's field live; today's field being2+// produced (a status line and the latest finished day's replay underneath,3+// polling until live, no reload); no field for the day (same, without the4+// timer). Switches to live the moment the status says so.5+import { useEffect, useState } from "preact/hooks";6+import Viewer from "./Viewer.tsx";7+import type { DailyStatus } from "../lib/daily.ts";8+9+export default function Today(10+ props: { initial: DailyStatus; theme: "dark" | "light" | null },11+) {12+ const [st, setSt] = useState<DailyStatus>(props.initial);13+ useEffect(() => {14+ if (st.phase === "live") return;15+ const t = setInterval(async () => {16+ try {17+ const r = await fetch("/status", { cache: "no-store" });18+ if (r.ok) setSt(await r.json());19+ } catch {20+ // next tick21+ }22+ }, 5000);23+ return () => clearInterval(t);24+ }, [st.phase]);25+ const line = st.phase === "live"26+ ? (st.emergency27+ ? `today's field (${st.date}) is a small emergency field: nothing was prepared or pushed by rollover, so it was generated on the spot`28+ : null)29+ : st.phase === "generating"30+ ? `generating today's field (${st.date}), ${fmt(st.since_s ?? 0)} so far` +31+ (st.latest_archived32+ ? `; showing ${st.latest_archived.date}'s replay meanwhile`33+ : "")34+ : `no field for ${st.date} yet` +35+ (st.latest_archived36+ ? `; showing ${st.latest_archived.date}'s replay meanwhile`37+ : "");38+ const replay = st.phase === "live"39+ ? null40+ : st.latest_archived?.game_id ?? null;41+ return (42+ <div style="display:flex;flex-direction:column;height:100%">43+ {line && (44+ <div style="padding:8px 12px;color:var(--muted);font-size:12px;border-bottom:1px solid var(--rule)">45+ {line}46+ </div>47+ )}48+ <div style="flex:1;min-height:0">49+ {st.phase === "live" || replay50+ ? (51+ <Viewer52+ key={st.phase === "live" ? `live:${st.live}` : `replay:${replay}`}53+ replay={replay}54+ theme={props.theme}55+ live={st.phase === "live" ? st.live : null}56+ />57+ )58+ : (59+ <p style="padding:24px;color:var(--muted)">60+ Nothing to show yet. <a href="/calendar">calendar</a>61+ </p>62+ )}63+ </div>64+ </div>65+ );66+}67+68+function fmt(s: number): string {69+ if (s < 90) return `${s} s`;70+ const m = Math.round(s / 60);71+ return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} h`;72+}
M site/lib/daily.ts +48 -2
@@ -279,6 +279,16 @@ function parseRow(r: Record<string, string>): DayRow {
279279 };280280 }281281 282+/** The state `/` renders and polls. */283+export type DailyStatus = {284+ date: string;285+ phase: "live" | "generating" | "missing";286+ live: string | null; // today's game id when playing287+ since_s: number | null; // seconds spent generating so far288+ emergency: boolean;289+ latest_archived: { date: string; game_id: string } | null;290+};291+282292 export type DailyState = {283293 date: string;284294 game_id: string;
@@ -312,6 +322,9 @@ export class Daily {
312322 missing: string | null = null;313323 /** the date whose field is a small emergency one, generated at rollover */314324 emergency: string | null = null;325+ /** today's field being produced right now (emergency generation or a326+ * prepared/pushed one loading), with when that started */327+ generating: { date: string; since: number } | null = null;315328 private preparing: string | null = null;316329 private prepared = new Set<string>();317330 private preparedSeeds = new Set<string>();
@@ -607,7 +620,13 @@ export class Daily {
607620 const seed = await seedFor(date, this.cfg);608621 const p = paramsFor(date, seed, this.cfg);609622 // the field first: honours need a seat on the actual field610- const field = await this.fieldFor(date, seed);623+ this.generating = { date, since: this.now() };624+ let field: Awaited<ReturnType<typeof this.fieldFor>>;625+ try {626+ field = await this.fieldFor(date, seed);627+ } finally {628+ this.generating = null;629+ }611630 if (!field) {612631 if (this.missing !== date) {613632 this.log(`no field for ${date}: nothing pushed and generation is off`);
@@ -628,7 +647,12 @@ export class Daily {
628647 this.now,629648 this.judgeFn,630649 );631- await whenReady(pending.game_id);650+ this.generating = { date, since: this.now() };651+ try {652+ await whenReady(pending.game_id);653+ } finally {654+ this.generating = null;655+ }632656 this.current = { date, game_id: pending.game_id, seed, source };633657 await this.saveState();634658 this.log(
@@ -710,6 +734,28 @@ export class Daily {
710734 return row;711735 }712736 737+ /** What `/` should show right now. */738+ status(): DailyStatus {739+ const cur = this.current;740+ const live = cur && getGame(cur.game_id) ? cur.game_id : null;741+ const latest = this.index.list().at(-1) ?? null;742+ const today = this.today();743+ let phase: DailyStatus["phase"] = "live";744+ if (!live) phase = this.generating ? "generating" : "missing";745+ return {746+ date: cur?.date ?? this.generating?.date ?? today,747+ phase,748+ live,749+ since_s: this.generating750+ ? Math.round((this.now() - this.generating.since) / 1000)751+ : null,752+ emergency: live !== null && this.emergency === cur?.date,753+ latest_archived: latest754+ ? { date: latest.date, game_id: latest.game_id }755+ : null,756+ };757+ }758+713759 /** Cumulative boards across every archived day. */714760 cumulative(): ReturnType<typeof mergeBoards> {715761 return mergeBoards(this.index.list().map((r) => r.boards));
M site/routes/index.tsx +17 -25
@@ -1,10 +1,11 @@
1-// Today: the daily's live field when one is running, else its finished2-// replay; a plain checkout without the daily follows the newest game.1+// Today: the daily's field live; while it is being produced (or missing),2+// a status line and the latest finished day's replay, polling until live.3+// A plain checkout without the daily follows the newest game.34 import { define } from "../utils.ts";45 import Viewer from "../islands/Viewer.tsx";6+import Today from "../islands/Today.tsx";57 import CopyPrompt from "../islands/CopyPrompt.tsx";68 import { daily } from "../lib/daily.ts";7-import { getGame } from "../lib/games.ts";89 import { bootstrapText } from "../lib/prompt.ts";910 1011 export default define.page(function Home(ctx) {
@@ -12,43 +13,34 @@ export default define.page(function Home(ctx) {
1213 const t = ctx.url.searchParams.get("theme");1314 const theme = t === "dark" || t === "light" ? t : null;1415 const d = daily();15- const cur = d?.current ?? null;16- const liveId = !replay && cur && getGame(cur.game_id) ? cur.game_id : null;17- const missing = !replay && d?.missing && !liveId ? d.missing : null;18- const emergency = !replay && liveId && d?.emergency === cur?.date;16+ const status = d?.status() ?? null;1917 const origin = ctx.url.origin;20- const prompt = bootstrapText(origin, cur?.date ?? null);18+ const prompt = bootstrapText(origin, status?.date ?? null);2119 return (2220 <div style="display:flex;flex-direction:column;height:100vh">2321 <nav style="display:flex;gap:14px;align-items:center;padding:6px 12px;border-bottom:1px solid var(--rule);background:var(--chrome);font-size:12px;color:var(--text-dim)">2422 <a href="/" style="color:var(--text-strong);text-decoration:none">2523 mosaic2624 </a>27- <span>{cur ? `today ${cur.date}` : replay ? "replay" : "live"}</span>25+ <span>26+ {replay ? "replay" : status ? `today ${status.date}` : "live"}27+ </span>2828 <a href="/calendar" style="color:var(--text-dim)">calendar</a>29- {cur && (30- <a href={`/leaderboard/${cur.date}`} style="color:var(--text-dim)">29+ {status && (30+ <a31+ href={`/leaderboard/${status.date}`}32+ style="color:var(--text-dim)"33+ >3134 leaderboard3235 </a>3336 )}3437 <span style="flex:1" />3538 <CopyPrompt text={prompt} />3639 </nav>37- {missing && (38- <div style="padding:8px 12px;color:var(--muted);font-size:12px;border-bottom:1px solid var(--rule)">39- no field for {missing}{" "}40- yet: today's field has not been pushed to this server. Past days:{" "}41- <a href="/calendar">calendar</a>.42- </div>43- )}44- {emergency && (45- <div style="padding:8px 12px;color:var(--muted);font-size:12px;border-bottom:1px solid var(--rule)">46- today's field is a small emergency field: nothing was prepared or47- pushed for {cur!.date} by rollover, so it was generated on the spot.48- </div>49- )}5040 <div style="flex:1;min-height:0">51- <Viewer replay={replay} theme={theme} live={liveId} />41+ {replay || !status42+ ? <Viewer replay={replay} theme={theme} live={null} />43+ : <Today initial={status} theme={theme} />}5244 </div>5345 </div>5446 );
A site/routes/status.ts +24 -0
M site/tests/daily_test.ts +9 -0
@@ -200,6 +200,8 @@ Deno.test("a day with nothing pushed and generation off is missing; a push start
200200 await d.boot();201201 assertEquals(d.current, null);202202 assertEquals(d.missing, "2026-10-01");203+ assertEquals(d.status().phase, "missing");204+ assertEquals(d.status().latest_archived, null);203205 assert(logs.some((m) => m.startsWith("no field for 2026-10-01")));204206 // a push lands the day's field; the next tick starts it205207 const out = await generate({ seed: "pushed", G: cfg.G, kind: "field" });
@@ -222,6 +224,11 @@ Deno.test("a day with nothing pushed and generation off is missing; a push start
222224 assertEquals(d.index.list().map((r) => r.date), ["2026-10-01"]);223225 assertEquals(d.current, null);224226 assertEquals(d.missing, "2026-10-02");227+ // the front page shows the last finished day's replay meanwhile228+ const st = d.status();229+ assertEquals(st.phase, "missing");230+ assertEquals(st.latest_archived?.date, "2026-10-01");231+ assertEquals(st.live, null);225232 d.stop();226233 resetGames();227234 });
@@ -251,6 +258,8 @@ Deno.test("nightly prepare waits for its hour, and rollover with nothing ready m
251258 assert(d.current && d.current.date === "2026-11-01");252259 assertEquals(d.current!.source, "emergency");253260 assertEquals(d.emergency, "2026-11-01");261+ assertEquals(d.status().phase, "live");262+ assertEquals(d.status().emergency, true);254263 assertEquals(calls.at(-1)!.rings, cfg.emergency.rings);255264 assert(logs.some((m) => m.includes("emergency field")));256265 // before 20:00 UTC the nightly prepare only announces itself