AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
1/**
2 * AT Mot — global configuration & frozen constants.
3 *
4 * This file is the single source of truth for every value that, once shipped,
5 * can never change without breaking historical data:
6 * - the puzzle epoch (renumbering all history),
7 * - the canonical domain (baked into every leaderboard target forever),
8 * - the lexicon namespace,
9 * - the `puzzleTarget` URL format (Constellation compares it literally).
10 *
11 * Treat everything in here as immutable post-launch. See DECISIONS.md.
12 */
13
14/** Supported languages in v1. Adding a language is a drop-in: add lists + extend this tuple. */
15export const LANGS = ['en', 'fr'] as const;
16export type Lang = (typeof LANGS)[number];
17
18export function isLang(value: string): value is Lang {
19 return (LANGS as readonly string[]).includes(value);
20}
21
22/** Canonical, permanent, final domain. Baked immutably into every puzzleTarget. */
23export const DOMAIN = 'atmot.herve.bzh';
24export const ORIGIN = `https://${DOMAIN}`;
25
26/** Lexicon authority / namespace. */
27export const NSID_AUTHORITY = 'bzh.herve.atmot';
28export const COLLECTION = {
29 result: `${NSID_AUTHORITY}.result`,
30 stats: `${NSID_AUTHORITY}.stats`,
31} as const;
32
33/** The single mutable stats/declaration record is keyed `self`. */
34export const STATS_RKEY = 'self';
35
36/**
37 * Frozen epoch: 2026-06-23 (UTC). Launch day = puzzle #1 for each language.
38 * Shared by EN and FR; the lists differ, so the same number yields different words.
39 *
40 * Stored as the UTC millisecond timestamp of midnight on the epoch date.
41 */
42export const EPOCH_UTC_MS = Date.UTC(2026, 5, 23); // month is 0-indexed: 5 = June
43
44const MS_PER_DAY = 86_400_000;
45
46/**
47 * Whole UTC days elapsed since the epoch for a given instant (default: now).
48 * Day of the epoch returns 0.
49 */
50export function daysSinceEpoch(at: number = Date.now()): number {
51 // Normalize `at` to the start of its UTC day so the result only changes at
52 // UTC midnight, never mid-day, regardless of the caller's local timezone.
53 const utcMidnight = Math.floor(at / MS_PER_DAY) * MS_PER_DAY;
54 return Math.floor((utcMidnight - EPOCH_UTC_MS) / MS_PER_DAY);
55}
56
57/**
58 * The puzzle number for a UTC day. Launch day (epoch) = 1.
59 * Days before the epoch return values < 1 (callers should treat those as "no puzzle yet").
60 */
61export function puzzleNumberFor(at: number = Date.now()): number {
62 return daysSinceEpoch(at) + 1;
63}
64
65/** The UTC calendar date (YYYY-MM-DD) for a given puzzle number. */
66export function puzzleDateString(puzzleNumber: number): string {
67 const ms = EPOCH_UTC_MS + (puzzleNumber - 1) * MS_PER_DAY;
68 return new Date(ms).toISOString().slice(0, 10);
69}
70
71/** Full UTC datetime (midnight) for a puzzle number — used for the `puzzleDate` field. */
72export function puzzleDateTime(puzzleNumber: number): string {
73 const ms = EPOCH_UTC_MS + (puzzleNumber - 1) * MS_PER_DAY;
74 return new Date(ms).toISOString();
75}
76
77/**
78 * THE shared helper for the leaderboard target. Both the write path (stored in
79 * each result record) and the Constellation read path MUST go through this so
80 * the format can never diverge.
81 *
82 * Frozen conventions (Constellation compares the string literally):
83 * scheme https, host exactly atmot.herve.bzh, path /p/<lang>/<puzzleNumber>,
84 * lowercase BCP-47 lang, bare integer, NO trailing slash.
85 */
86export function puzzleTarget(lang: Lang, puzzleNumber: number): string {
87 return `${ORIGIN}/p/${lang}/${puzzleNumber}`;
88}
89
90/** Parse a puzzleTarget / permalink path back into (lang, puzzleNumber), or null. */
91export function parsePuzzleTarget(input: string): { lang: Lang; puzzleNumber: number } | null {
92 let path = input;
93 try {
94 if (input.includes('://')) path = new URL(input).pathname;
95 } catch {
96 return null;
97 }
98 const m = /^\/p\/([a-z-]+)\/(\d+)$/.exec(path);
99 if (!m) return null;
100 const lang = m[1]!;
101 const puzzleNumber = Number(m[2]);
102 if (!isLang(lang) || !Number.isInteger(puzzleNumber) || puzzleNumber < 1) return null;
103 return { lang, puzzleNumber };
104}
105
106/**
107 * Where the language toggle should take you. Puzzle pages encode the language
108 * in the URL (`/p/<lang>/<n>`), so the router re-derives the language from the
109 * path on every render — switching `ctx.lang` alone leaves a puzzle page on its
110 * original language. Returns the equivalent puzzle path in `lang`, or null when
111 * the current path is language-independent (or already in `lang`) and a plain
112 * re-render suffices.
113 */
114export function pathForLangSwitch(currentPath: string, lang: Lang): string | null {
115 const puzzle = parsePuzzleTarget(currentPath);
116 if (puzzle && puzzle.lang !== lang) return `/p/${lang}/${puzzle.puzzleNumber}`;
117 return null;
118}
119
120/** Deterministic rkey for a result record: `<lang>-<puzzleNumber>` (documented in the lexicon). */
121export function resultRkey(lang: Lang, puzzleNumber: number): string {
122 return `${lang}-${puzzleNumber}`;
123}
124
125/** Game rules. */
126export const WORD_LENGTH = 5;
127export const MAX_GUESSES = 6;
128
129/** Constellation (microcosm) public backlink index. */
130export const CONSTELLATION_BASE = 'https://constellation.microcosm.blue';
131
132/** Sent on every Constellation request (microcosm asks for a descriptive UA + contact). */
133export const USER_AGENT = `atmot/1.0 (+${ORIGIN}; word game; https://tangled.org/jeremy.herve.bzh/atmot)`;
134
135/** Handle/identity resolution endpoint (Bluesky AppView; web apps lack DNS access). */
136export const APPVIEW_URL = 'https://public.api.bsky.app';