AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 5.4 kB View raw
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';