···11+/**
22+ * Frozen constants and puzzle math, duplicated from the AT Mot app.
33+ * Every value here is immutable post-launch (it indexes historical data),
44+ * so duplicating it into the bot carries no divergence risk.
55+ */
66+77+export const LANGS = ['en', 'fr'] as const;
88+export type Lang = (typeof LANGS)[number];
99+1010+export const DOMAIN = 'atmot.herve.bzh';
1111+export const ORIGIN = `https://${DOMAIN}`;
1212+1313+export const NSID_AUTHORITY = 'bzh.herve.atmot';
1414+export const COLLECTION = { result: `${NSID_AUTHORITY}.result` } as const;
1515+1616+/** Epoch: 2026-06-23 (UTC) midnight. Launch day = puzzle #1. (Month is 0-indexed: 5 = June.) */
1717+export const EPOCH_UTC_MS = Date.UTC(2026, 5, 23);
1818+1919+const MS_PER_DAY = 86_400_000;
2020+2121+/** Whole UTC days from the epoch to `at` (epoch day = 0). */
2222+export function daysSinceEpoch(at: number = Date.now()): number {
2323+ const utcMidnight = Math.floor(at / MS_PER_DAY) * MS_PER_DAY;
2424+ return Math.floor((utcMidnight - EPOCH_UTC_MS) / MS_PER_DAY);
2525+}
2626+2727+/** Puzzle number for a UTC day. Epoch = 1; days before the epoch are < 1. */
2828+export function puzzleNumberFor(at: number = Date.now()): number {
2929+ return daysSinceEpoch(at) + 1;
3030+}
3131+3232+/** Canonical, frozen leaderboard target. Constellation compares this literally. */
3333+export function puzzleTarget(lang: Lang, puzzleNumber: number): string {
3434+ return `${ORIGIN}/p/${lang}/${puzzleNumber}`;
3535+}
3636+3737+export const CONSTELLATION_BASE = 'https://constellation.microcosm.blue';
3838+export const APPVIEW_URL = 'https://public.api.bsky.app';
3939+export const USER_AGENT = `atmot-bot/1.0 (+${ORIGIN}; daily word game bot)`;
4040+4141+/** Free Workers plan: keep ~2 subrequests/record under the 50/invocation cap. */
4242+export const SOLVER_SAMPLE_CAP = 20;
4343+4444+/** The result record shape we read from each player's PDS (colours only; subset we use). */
4545+export interface ResultRecord {
4646+ $type: 'bzh.herve.atmot.result';
4747+ lang: string;
4848+ puzzleNumber: number;
4949+ solved: boolean;
5050+ guessCount?: number;
5151+ puzzleTarget: string;
5252+ createdAt: string;
5353+}
5454+5555+/** Yesterday's aggregate, produced by counts.ts and consumed by compose.ts. */
5656+export interface YesterdayCounts {
5757+ /** Distinct players who recorded a result (wins + losses); null if Constellation was unreachable. */
5858+ players: number | null;
5959+ /** Players who solved, counted from the sampled records (≤ SOLVER_SAMPLE_CAP). */
6060+ solvers: number;
6161+ /** True if the sample hit the cap (more results may exist than were counted). */
6262+ sampled: boolean;
6363+}
+29
test/config.test.ts
···11+import { describe, it, expect } from 'vitest';
22+import { EPOCH_UTC_MS, puzzleNumberFor, daysSinceEpoch, puzzleTarget } from '../src/config.js';
33+44+describe('puzzle numbering', () => {
55+ it('epoch day is puzzle #1', () => {
66+ expect(puzzleNumberFor(EPOCH_UTC_MS)).toBe(1);
77+ expect(daysSinceEpoch(EPOCH_UTC_MS)).toBe(0);
88+ });
99+1010+ it('four days after epoch is puzzle #5', () => {
1111+ expect(puzzleNumberFor(Date.UTC(2026, 5, 27))).toBe(5);
1212+ });
1313+1414+ it('only changes at UTC midnight, not mid-day', () => {
1515+ const lateInEpochDay = EPOCH_UTC_MS + 23 * 3_600_000;
1616+ expect(puzzleNumberFor(lateInEpochDay)).toBe(1);
1717+ });
1818+1919+ it('days before the epoch are < 1 (no puzzle yet)', () => {
2020+ expect(puzzleNumberFor(Date.UTC(2026, 5, 22))).toBe(0);
2121+ });
2222+});
2323+2424+describe('puzzleTarget format (frozen)', () => {
2525+ it('builds the canonical permalink', () => {
2626+ expect(puzzleTarget('en', 5)).toBe('https://atmot.herve.bzh/p/en/5');
2727+ expect(puzzleTarget('fr', 12)).toBe('https://atmot.herve.bzh/p/fr/12');
2828+ });
2929+});