···11+# atmot-bot
22+33+Daily Bluesky bot for [AT Mot](https://atmot.herve.bzh). Posts once per language
44+each day just after the UTC puzzle rollover: invites players to today's puzzle and
55+congratulates yesterday's solvers (by count only).
66+77+Runs as a stateless Cloudflare Worker with two Cron Triggers (EN 00:10 UTC, FR
88+00:11 UTC). Counts are read from the public Constellation backlink index; there is
99+no database. The bot duplicates a small set of the app's frozen constants
1010+(`src/config.ts`) rather than importing the app.
1111+1212+## Develop
1313+1414+```sh
1515+npm install
1616+npm test # vitest — composer + facets + puzzle math
1717+npm run typecheck
1818+npm run dev # wrangler dev --test-scheduled (see Dry run below)
1919+```
2020+2121+## Dry run (no real post)
2222+2323+Create a gitignored `.dev.vars`:
2424+2525+```
2626+DRY_RUN = "1"
2727+ATMOT_BOT_IDENTIFIER = "atmot.herve.bzh"
2828+ATMOT_BOT_APP_PASSWORD = "dry-run-unused"
2929+```
3030+3131+Then `npm run dev` and, in another shell:
3232+3333+```sh
3434+curl "http://localhost:8787/__scheduled?cron=10+0+*+*+*" # EN
3535+curl "http://localhost:8787/__scheduled?cron=11+0+*+*+*" # FR
3636+```
3737+3838+The composed post is logged instead of published.
3939+4040+## Deploy
4141+4242+The bot posts as **@atmot.herve.bzh** using a Bluesky **app password** (Settings →
4343+Privacy and security → App passwords — not the account password).
4444+4545+```sh
4646+npx wrangler login
4747+npx wrangler secret put ATMOT_BOT_IDENTIFIER # e.g. atmot.herve.bzh
4848+npx wrangler secret put ATMOT_BOT_APP_PASSWORD # the app password
4949+npm run deploy
5050+```
5151+5252+The free Workers plan is sufficient: each language runs in its own scheduled
5353+invocation, so each gets the full 50-subrequest budget. Solver counting samples up
5454+to `SOLVER_SAMPLE_CAP` (20) records per language; beyond that the count is hedged
5555+(e.g. "20+"). On the paid plan, raise `SOLVER_SAMPLE_CAP` in `src/config.ts` to ~200.
···11+import { ORIGIN, type Lang, type YesterdayCounts } from './config.js';
22+import { linkFacet, type FacetLink } from './facets.js';
33+44+export interface ComposeInput {
55+ lang: Lang;
66+ todayN: number;
77+ /** Yesterday's counts, or null on day 1 when there is no previous puzzle. */
88+ yesterday: YesterdayCounts | null;
99+}
1010+1111+export interface ComposedPost {
1212+ text: string;
1313+ facets: FacetLink[];
1414+ langs: [Lang];
1515+}
1616+1717+/** Verbatim in the post text; also the idempotency marker. */
1818+export function postMarker(lang: Lang, todayN: number): string {
1919+ return lang === 'fr' ? `AT Mot n°${todayN}` : `AT Mot #${todayN}`;
2020+}
2121+2222+function inviteLine(lang: Lang, todayN: number): string {
2323+ const marker = postMarker(lang, todayN);
2424+ return lang === 'fr'
2525+ ? `🟩 ${marker} est en ligne ! Six essais pour deviner le mot de cinq lettres du jour.`
2626+ : `🟩 ${marker} is live! Six tries to guess today's five-letter word.`;
2727+}
2828+2929+/** The congrats sentence, or null when there's nothing meaningful to say. */
3030+function congratsLine(lang: Lang, c: YesterdayCounts): string | null {
3131+ const { players, solvers, sampled } = c;
3232+ if (players == null || players === 0) return null;
3333+3434+ if (solvers === 0) {
3535+ return lang === 'fr'
3636+ ? `Personne n'a trouvé le mot d'hier. Nouveau départ aujourd'hui !`
3737+ : `Nobody cracked yesterday's word. Fresh start today!`;
3838+ }
3939+4040+ if (sampled) {
4141+ return lang === 'fr'
4242+ ? `Bravo aux ${solvers}+ qui ont trouvé le mot d'hier. Saurez-vous les rejoindre aujourd'hui ?`
4343+ : `Congrats to the ${solvers}+ of you who cracked yesterday's word. Can you join them today?`;
4444+ }
4545+4646+ const nonSolvers = Math.max(0, players - solvers);
4747+4848+ if (lang === 'fr') {
4949+ const who = solvers === 1 ? 'au seul joueur' : `aux ${solvers}`;
5050+ const verb = solvers === 1 ? 'a' : 'ont';
5151+ let line = `Bravo ${who} qui ${verb} trouvé le mot d'hier.`;
5252+ if (nonSolvers > 0) {
5353+ const ns = nonSolvers === 1 ? 'Et au seul qui a séché' : `Et aux ${nonSolvers} qui ont séché`;
5454+ line += ` ${ns}, meilleure chance aujourd'hui !`;
5555+ }
5656+ return line;
5757+ }
5858+5959+ const who = solvers === 1 ? 'the one player' : `the ${solvers} of you`;
6060+ let line = `Congrats to ${who} who cracked yesterday's word.`;
6161+ if (nonSolvers > 0) {
6262+ const ns = nonSolvers === 1 ? "And to the one who didn't" : `And to the ${nonSolvers} who didn't`;
6363+ line += ` ${ns}, better luck today!`;
6464+ }
6565+ return line;
6666+}
6767+6868+export function composePost(input: ComposeInput): ComposedPost {
6969+ const { lang, todayN, yesterday } = input;
7070+ const congrats = yesterday ? congratsLine(lang, yesterday) : null;
7171+ const playLine = lang === 'fr' ? `Jouez : ${ORIGIN}` : `Play: ${ORIGIN}`;
7272+ const tags = lang === 'fr' ? '#JeuDeMots #atproto' : '#WordGame #atproto';
7373+7474+ const text = [inviteLine(lang, todayN), congrats, playLine, tags].filter(Boolean).join('\n');
7575+ return { text, facets: linkFacet(text, ORIGIN), langs: [lang] };
7676+}
+63
src/config.ts
···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+}
+80
src/constellation.ts
···11+/**
22+ * Constellation (microcosm) backlink-index client. Returns null/[] on any
33+ * failure so the bot degrades to no-congrats copy rather than crashing.
44+ */
55+import { CONSTELLATION_BASE, USER_AGENT, COLLECTION, puzzleTarget, type Lang } from './config.js';
66+77+export interface BacklinkRecord {
88+ did: string;
99+ collection: string;
1010+ rkey: string;
1111+}
1212+1313+/** Source path Constellation indexes for the result record's leaderboard link. */
1414+const RESULT_TARGET_PATH = 'puzzleTarget';
1515+1616+async function get(path: string, params: Record<string, string>): Promise<any | null> {
1717+ const url = new URL(path, CONSTELLATION_BASE);
1818+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
1919+ try {
2020+ const res = await fetch(url, { headers: { accept: 'application/json', 'user-agent': USER_AGENT } });
2121+ if (!res.ok) return null;
2222+ return await res.json();
2323+ } catch {
2424+ return null;
2525+ }
2626+}
2727+2828+/** Distinct DIDs linking to `target`. Null on failure. */
2929+async function countDistinctDids(target: string, collection: string, dottedPath: string): Promise<number | null> {
3030+ const data = await get('/links/count/distinct-dids', {
3131+ target,
3232+ collection,
3333+ path: dottedPath.startsWith('.') ? dottedPath : `.${dottedPath}`,
3434+ });
3535+ return typeof data?.total === 'number' ? data.total : null;
3636+}
3737+3838+async function getBacklinksPage(
3939+ target: string,
4040+ collection: string,
4141+ path: string,
4242+ opts: { limit: number; cursor?: string },
4343+): Promise<{ records: BacklinkRecord[]; cursor: string | null } | null> {
4444+ const params: Record<string, string> = {
4545+ subject: target,
4646+ source: `${collection}:${path}`,
4747+ limit: String(opts.limit),
4848+ };
4949+ if (opts.cursor) params.cursor = opts.cursor;
5050+ const data = await get('/xrpc/blue.microcosm.links.getBacklinks', params);
5151+ if (!data || !Array.isArray(data.records)) return null;
5252+ return { records: data.records as BacklinkRecord[], cursor: data.cursor ?? null };
5353+}
5454+5555+async function collectBacklinks(target: string, collection: string, path: string, cap: number): Promise<BacklinkRecord[]> {
5656+ const out: BacklinkRecord[] = [];
5757+ let cursor: string | undefined;
5858+ for (let i = 0; i < 10 && out.length < cap; i++) {
5959+ const page = await getBacklinksPage(target, collection, path, { limit: Math.min(100, cap), cursor });
6060+ if (!page) break;
6161+ out.push(...page.records);
6262+ if (!page.cursor) break;
6363+ cursor = page.cursor;
6464+ }
6565+ return out.slice(0, cap);
6666+}
6767+6868+/** Distinct players who recorded a result for this puzzle (wins + losses). Null on failure. */
6969+export function dailyPlayerCount(lang: Lang, puzzleNumber: number): Promise<number | null> {
7070+ return countDistinctDids(puzzleTarget(lang, puzzleNumber), COLLECTION.result, RESULT_TARGET_PATH);
7171+}
7272+7373+/** Up to `cap` result-record backlinks for this puzzle. */
7474+export function dailyResultBacklinks(lang: Lang, puzzleNumber: number, cap: number): Promise<BacklinkRecord[]> {
7575+ return collectBacklinks(puzzleTarget(lang, puzzleNumber), COLLECTION.result, RESULT_TARGET_PATH, cap);
7676+}
7777+7878+export function backlinkUri(r: BacklinkRecord): string {
7979+ return `at://${r.did}/${r.collection}/${r.rkey}`;
8080+}
+25
src/counts.ts
···11+import { SOLVER_SAMPLE_CAP, type Lang, type ResultRecord, type YesterdayCounts } from './config.js';
22+import { dailyPlayerCount, dailyResultBacklinks, backlinkUri } from './constellation.js';
33+import { getRecordByUri } from './identity.js';
44+55+/**
66+ * Yesterday's { players, solvers, sampled } for one language. `players` is the
77+ * cheap distinct-DID count; `solvers` is counted from the sampled records
88+ * (≤ SOLVER_SAMPLE_CAP) to stay under the free-plan subrequest budget.
99+ */
1010+export async function yesterdayCounts(lang: Lang, yesterdayN: number): Promise<YesterdayCounts> {
1111+ const [players, backlinks] = await Promise.all([
1212+ dailyPlayerCount(lang, yesterdayN),
1313+ dailyResultBacklinks(lang, yesterdayN, SOLVER_SAMPLE_CAP),
1414+ ]);
1515+1616+ let solvers = 0;
1717+ await Promise.all(
1818+ backlinks.map(async (bl) => {
1919+ const rec = await getRecordByUri<ResultRecord>(backlinkUri(bl));
2020+ if (rec && rec.lang === lang && rec.puzzleNumber === yesterdayN && rec.solved) solvers++;
2121+ }),
2222+ );
2323+2424+ return { players, solvers, sampled: backlinks.length >= SOLVER_SAMPLE_CAP };
2525+}
+16
src/facets.ts
···11+/** A Bluesky richtext link facet (UTF-8 byte offsets, per the lexicon). */
22+export interface FacetLink {
33+ index: { byteStart: number; byteEnd: number };
44+ features: Array<{ $type: 'app.bsky.richtext.facet#link'; uri: string }>;
55+}
66+77+const encoder = new TextEncoder();
88+99+/** Facet over the first occurrence of `url` in `text`; [] if it's not there. */
1010+export function linkFacet(text: string, url: string): FacetLink[] {
1111+ const charIndex = text.indexOf(url);
1212+ if (charIndex === -1) return [];
1313+ const byteStart = encoder.encode(text.slice(0, charIndex)).length;
1414+ const byteEnd = byteStart + encoder.encode(url).length;
1515+ return [{ index: { byteStart, byteEnd }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }] }];
1616+}
+52
src/identity.ts
···11+/**
22+ * Identity resolution (handle/DID -> PDS) and per-PDS record reads.
33+ * Mirrors the app's read path; failures resolve to null and drop the entry.
44+ */
55+import {
66+ CompositeDidDocumentResolver,
77+ LocalActorResolver,
88+ PlcDidDocumentResolver,
99+ WebDidDocumentResolver,
1010+ XrpcHandleResolver,
1111+ type ResolvedActor,
1212+} from '@atcute/identity-resolver';
1313+import { Client, simpleFetchHandler } from '@atcute/client';
1414+import { APPVIEW_URL } from './config.js';
1515+1616+const actorResolver = new LocalActorResolver({
1717+ handleResolver: new XrpcHandleResolver({ serviceUrl: APPVIEW_URL }),
1818+ didDocumentResolver: new CompositeDidDocumentResolver({
1919+ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() },
2020+ }),
2121+});
2222+2323+/** Resolve a handle or DID to { did, handle, pds }. May reject. */
2424+export function resolveActor(identifier: string): Promise<ResolvedActor> {
2525+ return actorResolver.resolve(identifier as Parameters<typeof actorResolver.resolve>[0]);
2626+}
2727+2828+/** Unauthenticated read client pointed at a specific PDS. */
2929+export function pdsClient(pds: string): Client {
3030+ return new Client({ handler: simpleFetchHandler({ service: pds }) });
3131+}
3232+3333+/** Public read of a record by at:// URI; null on any failure. */
3434+export async function getRecordByUri<T = unknown>(uri: string): Promise<T | null> {
3535+ const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(uri);
3636+ if (!m) return null;
3737+ try {
3838+ const actor = await resolveActor(m[1]!);
3939+ const rpc = pdsClient(actor.pds);
4040+ const res = await rpc.get('com.atproto.repo.getRecord', {
4141+ params: {
4242+ repo: m[1]! as `did:${string}:${string}`,
4343+ collection: m[2]! as `${string}.${string}.${string}`,
4444+ rkey: m[3]!,
4545+ },
4646+ });
4747+ if (!res.ok) return null;
4848+ return res.data.value as unknown as T;
4949+ } catch {
5050+ return null;
5151+ }
5252+}
+45
src/index.ts
···11+import { puzzleNumberFor, type Lang } from './config.js';
22+import { yesterdayCounts } from './counts.js';
33+import { composePost, postMarker } from './compose.js';
44+import { createBotSession, alreadyPosted, publishPost } from './post.js';
55+66+export interface Env {
77+ ATMOT_BOT_IDENTIFIER: string;
88+ ATMOT_BOT_APP_PASSWORD: string;
99+ /** When "1", compose and log the post but do not authenticate or publish. */
1010+ DRY_RUN?: string;
1111+}
1212+1313+/** Map the firing cron expression to a language (FR runs one minute after EN). */
1414+function langForCron(cron: string): Lang {
1515+ return cron.startsWith('11 ') ? 'fr' : 'en';
1616+}
1717+1818+export default {
1919+ async scheduled(controller: ScheduledController, env: Env, _ctx: ExecutionContext): Promise<void> {
2020+ const lang = langForCron(controller.cron);
2121+ const todayN = puzzleNumberFor();
2222+ const yesterdayN = todayN - 1;
2323+2424+ try {
2525+ const yesterday = yesterdayN >= 1 ? await yesterdayCounts(lang, yesterdayN) : null;
2626+ const post = composePost({ lang, todayN, yesterday });
2727+2828+ if (env.DRY_RUN === '1') {
2929+ console.log(`[atmot-bot] DRY_RUN ${lang} #${todayN}:\n${post.text}`);
3030+ return;
3131+ }
3232+3333+ const session = await createBotSession(env.ATMOT_BOT_IDENTIFIER, env.ATMOT_BOT_APP_PASSWORD);
3434+ if (await alreadyPosted(session, postMarker(lang, todayN))) {
3535+ console.log(`[atmot-bot] ${lang} #${todayN} already posted; skipping`);
3636+ return;
3737+ }
3838+ const uri = await publishPost(session, post);
3939+ console.log(`[atmot-bot] posted ${lang} #${todayN}: ${uri}`);
4040+ } catch (err) {
4141+ console.error(`[atmot-bot] ${lang} #${todayN} failed:`, err);
4242+ throw err; // surface the failure to Cloudflare's cron logs
4343+ }
4444+ },
4545+};
+68
src/post.ts
···11+/**
22+ * Posting side: create an app-password session against the bot's PDS, check we
33+ * haven't already posted today (by scanning our own recent posts — stateless),
44+ * and publish the post.
55+ */
66+import { Client, simpleFetchHandler, type FetchHandler } from '@atcute/client';
77+import { resolveActor } from './identity.js';
88+import type { ComposedPost } from './compose.js';
99+1010+export interface BotSession {
1111+ client: Client;
1212+ did: string;
1313+}
1414+1515+/** Log in with an app password; returns a client whose requests carry the bearer token. */
1616+export async function createBotSession(identifier: string, appPassword: string): Promise<BotSession> {
1717+ const actor = await resolveActor(identifier);
1818+ const pds = actor.pds;
1919+2020+ const loginClient = new Client({ handler: simpleFetchHandler({ service: pds }) });
2121+ const res = await loginClient.post('com.atproto.server.createSession', {
2222+ input: { identifier, password: appPassword },
2323+ });
2424+ if (!res.ok) throw new Error(`createSession failed: ${JSON.stringify(res.data)}`);
2525+2626+ const accessJwt = res.data.accessJwt;
2727+ const base = simpleFetchHandler({ service: pds });
2828+ const authed: FetchHandler = (pathname, init) => {
2929+ const headers = new Headers(init.headers ?? {});
3030+ headers.set('authorization', `Bearer ${accessJwt}`);
3131+ return base(pathname, { ...init, headers });
3232+ };
3333+3434+ return { client: new Client({ handler: authed }), did: res.data.did };
3535+}
3636+3737+/** True if any of the bot's recent posts already contains `marker` (today's puzzle token). */
3838+export async function alreadyPosted(session: BotSession, marker: string): Promise<boolean> {
3939+ const res = await session.client.get('com.atproto.repo.listRecords', {
4040+ params: {
4141+ repo: session.did as `did:${string}:${string}`,
4242+ collection: 'app.bsky.feed.post' as `${string}.${string}.${string}`,
4343+ limit: 20,
4444+ },
4545+ });
4646+ if (!res.ok) return false; // can't confirm — allow the post rather than skip forever
4747+ const records = res.data.records as Array<{ value?: { text?: string } }>;
4848+ return records.some((r) => typeof r.value?.text === 'string' && r.value.text.includes(marker));
4949+}
5050+5151+/** Create the post; returns its at:// uri. */
5252+export async function publishPost(session: BotSession, post: ComposedPost): Promise<string> {
5353+ const res = await session.client.post('com.atproto.repo.createRecord', {
5454+ input: {
5555+ repo: session.did as `did:${string}:${string}`,
5656+ collection: 'app.bsky.feed.post' as `${string}.${string}.${string}`,
5757+ record: {
5858+ $type: 'app.bsky.feed.post',
5959+ text: post.text,
6060+ langs: post.langs,
6161+ facets: post.facets,
6262+ createdAt: new Date().toISOString(),
6363+ } as unknown as Record<string, unknown>,
6464+ },
6565+ });
6666+ if (!res.ok) throw new Error(`createRecord failed: ${JSON.stringify(res.data)}`);
6767+ return res.data.uri;
6868+}
+112
test/compose.test.ts
···11+import { describe, it, expect } from 'vitest';
22+import { composePost, postMarker } from '../src/compose.js';
33+import { ORIGIN, type YesterdayCounts } from '../src/config.js';
44+55+const y = (players: number | null, solvers: number, sampled = false): YesterdayCounts => ({
66+ players,
77+ solvers,
88+ sampled,
99+});
1010+1111+describe('postMarker', () => {
1212+ it('is language-specific and carries the puzzle number', () => {
1313+ expect(postMarker('en', 5)).toBe('AT Mot #5');
1414+ expect(postMarker('fr', 5)).toBe('AT Mot n°5');
1515+ });
1616+});
1717+1818+describe('composePost — EN', () => {
1919+ it('normal: solvers + non-solvers', () => {
2020+ const { text, langs, facets } = composePost({ lang: 'en', todayN: 5, yesterday: y(33, 25) });
2121+ expect(text).toBe(
2222+ `🟩 AT Mot #5 is live! Six tries to guess today's five-letter word.\n` +
2323+ `Congrats to the 25 of you who cracked yesterday's word. And to the 8 who didn't, better luck today!\n` +
2424+ `Play: ${ORIGIN}\n` +
2525+ `#WordGame #atproto`,
2626+ );
2727+ expect(langs).toEqual(['en']);
2828+ expect(facets).toHaveLength(1);
2929+ expect(facets[0]!.features[0]!.uri).toBe(ORIGIN);
3030+ });
3131+3232+ it('singular solver, no non-solvers', () => {
3333+ const { text } = composePost({ lang: 'en', todayN: 5, yesterday: y(1, 1) });
3434+ expect(text).toContain('Congrats to the one player who cracked yesterday\'s word.');
3535+ expect(text).not.toContain('who didn\'t');
3636+ });
3737+3838+ it('singular non-solver', () => {
3939+ const { text } = composePost({ lang: 'en', todayN: 5, yesterday: y(5, 4) });
4040+ expect(text).toContain('And to the one who didn\'t, better luck today!');
4141+ });
4242+4343+ it('zero solvers', () => {
4444+ const { text } = composePost({ lang: 'en', todayN: 5, yesterday: y(5, 0) });
4545+ expect(text).toContain('Nobody cracked yesterday\'s word. Fresh start today!');
4646+ });
4747+4848+ it('everyone solved (no non-solver clause)', () => {
4949+ const { text } = composePost({ lang: 'en', todayN: 5, yesterday: y(10, 10) });
5050+ expect(text).toContain('Congrats to the 10 of you who cracked yesterday\'s word.');
5151+ expect(text).not.toContain('who didn\'t');
5252+ });
5353+5454+ it('sampled: hedged floor, no exact non-solver count', () => {
5555+ const { text } = composePost({ lang: 'en', todayN: 5, yesterday: y(50, 20, true) });
5656+ expect(text).toContain('Congrats to the 20+ of you who cracked yesterday\'s word. Can you join them today?');
5757+ });
5858+5959+ it('day 1 (no yesterday): invite only', () => {
6060+ const { text } = composePost({ lang: 'en', todayN: 1, yesterday: null });
6161+ expect(text).toBe(
6262+ `🟩 AT Mot #1 is live! Six tries to guess today's five-letter word.\n` +
6363+ `Play: ${ORIGIN}\n` +
6464+ `#WordGame #atproto`,
6565+ );
6666+ });
6767+6868+ it('Constellation unreachable (players null): no congrats', () => {
6969+ const { text } = composePost({ lang: 'en', todayN: 5, yesterday: y(null, 0) });
7070+ expect(text).not.toContain('Congrats');
7171+ expect(text).not.toContain('Nobody');
7272+ });
7373+7474+ it('nobody played yesterday (players 0): no congrats', () => {
7575+ const { text } = composePost({ lang: 'en', todayN: 5, yesterday: y(0, 0) });
7676+ expect(text).not.toContain('Congrats');
7777+ expect(text).not.toContain('Nobody');
7878+ });
7979+});
8080+8181+describe('composePost — FR', () => {
8282+ it('normal: solvers + non-solvers', () => {
8383+ const { text, langs } = composePost({ lang: 'fr', todayN: 5, yesterday: y(23, 18) });
8484+ expect(text).toBe(
8585+ `🟩 AT Mot n°5 est en ligne ! Six essais pour deviner le mot de cinq lettres du jour.\n` +
8686+ `Bravo aux 18 qui ont trouvé le mot d'hier. Et aux 5 qui ont séché, meilleure chance aujourd'hui !\n` +
8787+ `Jouez : ${ORIGIN}\n` +
8888+ `#JeuDeMots #atproto`,
8989+ );
9090+ expect(langs).toEqual(['fr']);
9191+ });
9292+9393+ it('singular solver uses "au seul joueur" + "a trouvé"', () => {
9494+ const { text } = composePost({ lang: 'fr', todayN: 5, yesterday: y(1, 1) });
9595+ expect(text).toContain('Bravo au seul joueur qui a trouvé le mot d\'hier.');
9696+ });
9797+9898+ it('singular non-solver', () => {
9999+ const { text } = composePost({ lang: 'fr', todayN: 5, yesterday: y(5, 4) });
100100+ expect(text).toContain('Et au seul qui a séché, meilleure chance aujourd\'hui !');
101101+ });
102102+103103+ it('zero solvers', () => {
104104+ const { text } = composePost({ lang: 'fr', todayN: 5, yesterday: y(5, 0) });
105105+ expect(text).toContain('Personne n\'a trouvé le mot d\'hier. Nouveau départ aujourd\'hui !');
106106+ });
107107+108108+ it('sampled hedge', () => {
109109+ const { text } = composePost({ lang: 'fr', todayN: 5, yesterday: y(50, 20, true) });
110110+ expect(text).toContain('Bravo aux 20+ qui ont trouvé le mot d\'hier. Saurez-vous les rejoindre aujourd\'hui ?');
111111+ });
112112+});
+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+});