···5252The free Workers plan is sufficient: each language runs in its own scheduled
5353invocation, so each gets the full 50-subrequest budget. Solver counting samples up
5454to `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.
5555+(e.g. "20+").
5656+5757+Each sampled record costs ~2 subrequests (DID-document resolution + the record
5858+read), so the default cap of 20 already uses most of the per-invocation budget on
5959+the free plan — keep headroom for the count, session, idempotency check, and
6060+publish (~6 more). On the paid plan you can raise `SOLVER_SAMPLE_CAP` in
6161+`src/config.ts`, but size it against the subrequest cost (~2 per record), not just
6262+the desired sample size.
+2
src/compose.ts
···3232 if (players == null || players === 0) return null;
33333434 if (solvers === 0) {
3535+ // An incomplete sample can't prove nobody won — only claim it when we're sure.
3636+ if (sampled) return null;
3537 return lang === 'fr'
3638 ? `Personne n'a trouvé le mot d'hier. Nouveau départ aujourd'hui !`
3739 : `Nobody cracked yesterday's word. Fresh start today!`;
+8-3
src/config.ts
···3838export const APPVIEW_URL = 'https://public.api.bsky.app';
3939export const USER_AGENT = `atmot-bot/1.0 (+${ORIGIN}; daily word game bot)`;
40404141-/** Free Workers plan: keep ~2 subrequests/record under the 50/invocation cap. */
4141+/**
4242+ * Solver sample size. Each record costs ~2 subrequests (DID-doc resolution +
4343+ * record read), so 20 records ≈ 40, leaving ~10 of the free plan's 50/invocation
4444+ * for the count, session, idempotency check, and publish. Raise with care, and
4545+ * size it against the ~2-subrequests-per-record cost — not just the sample size.
4646+ */
4247export const SOLVER_SAMPLE_CAP = 20;
43484449/** The result record shape we read from each player's PDS (colours only; subset we use). */
···5661export interface YesterdayCounts {
5762 /** Distinct players who recorded a result (wins + losses); null if Constellation was unreachable. */
5863 players: number | null;
5959- /** Players who solved, counted from the sampled records (≤ SOLVER_SAMPLE_CAP). */
6464+ /** Distinct DIDs that solved, from the sample (≤ SOLVER_SAMPLE_CAP); clamped to `players`. */
6065 solvers: number;
6161- /** True if the sample hit the cap (more results may exist than were counted). */
6666+ /** True when the sample is incomplete (truncated page or unresolved players); compose then hedges. */
6267 sampled: boolean;
6368}
+40-13
src/constellation.ts
···1313/** Source path Constellation indexes for the result record's leaderboard link. */
1414const RESULT_TARGET_PATH = 'puzzleTarget';
15151616-async function get(path: string, params: Record<string, string>): Promise<any | null> {
1616+async function get(path: string, params: Record<string, string>): Promise<unknown> {
1717 const url = new URL(path, CONSTELLATION_BASE);
1818 for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
1919 try {
···27272828/** Distinct DIDs linking to `target`. Null on failure. */
2929async function countDistinctDids(target: string, collection: string, dottedPath: string): Promise<number | null> {
3030- const data = await get('/links/count/distinct-dids', {
3030+ const data = (await get('/links/count/distinct-dids', {
3131 target,
3232 collection,
3333 path: dottedPath.startsWith('.') ? dottedPath : `.${dottedPath}`,
3434- });
3434+ })) as { total?: unknown } | null;
3535 return typeof data?.total === 'number' ? data.total : null;
3636}
3737···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);
5050+ const data = (await get('/xrpc/blue.microcosm.links.getBacklinks', params)) as
5151+ | { records?: unknown; cursor?: unknown }
5252+ | null;
5153 if (!data || !Array.isArray(data.records)) return null;
5252- return { records: data.records as BacklinkRecord[], cursor: data.cursor ?? null };
5454+ const cursor = typeof data.cursor === 'string' ? data.cursor : null;
5555+ return { records: data.records as BacklinkRecord[], cursor };
5356}
54575555-async function collectBacklinks(target: string, collection: string, path: string, cap: number): Promise<BacklinkRecord[]> {
5858+/**
5959+ * Up to `cap` backlink records. `truncated` is true when the sample is known to
6060+ * be incomplete — more than `cap` records exist, a page fetch failed mid-walk,
6161+ * or pages remained when the page budget ran out — so callers can hedge.
6262+ */
6363+async function collectBacklinks(
6464+ target: string,
6565+ collection: string,
6666+ path: string,
6767+ cap: number,
6868+): Promise<{ records: BacklinkRecord[]; truncated: boolean }> {
5669 const out: BacklinkRecord[] = [];
5770 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;
7171+ let incomplete = false;
7272+ for (let i = 0; i < 10; i++) {
7373+ if (out.length > cap) break; // already have more than we need; the rest is truncated
7474+ // Fetch one extra so we can distinguish "exactly cap" from "more than cap".
7575+ const page = await getBacklinksPage(target, collection, path, { limit: Math.min(100, cap + 1), cursor });
7676+ if (!page) {
7777+ incomplete = true; // a page fetch failed; we can't be sure we have everything
7878+ break;
7979+ }
6180 out.push(...page.records);
6262- if (!page.cursor) break;
8181+ if (!page.cursor) {
8282+ cursor = undefined;
8383+ break;
8484+ }
6385 cursor = page.cursor;
6486 }
6565- return out.slice(0, cap);
8787+ const truncated = incomplete || out.length > cap || cursor !== undefined;
8888+ return { records: out.slice(0, cap), truncated };
6689}
67906891/** Distinct players who recorded a result for this puzzle (wins + losses). Null on failure. */
···7093 return countDistinctDids(puzzleTarget(lang, puzzleNumber), COLLECTION.result, RESULT_TARGET_PATH);
7194}
72957373-/** Up to `cap` result-record backlinks for this puzzle. */
7474-export function dailyResultBacklinks(lang: Lang, puzzleNumber: number, cap: number): Promise<BacklinkRecord[]> {
9696+/** Up to `cap` result-record backlinks for this puzzle, plus a `truncated` flag. */
9797+export function dailyResultBacklinks(
9898+ lang: Lang,
9999+ puzzleNumber: number,
100100+ cap: number,
101101+): Promise<{ records: BacklinkRecord[]; truncated: boolean }> {
75102 return collectBacklinks(puzzleTarget(lang, puzzleNumber), COLLECTION.result, RESULT_TARGET_PATH, cap);
76103}
77104
+22-7
src/counts.ts
···4455/**
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.
77+ * cheap distinct-DID count; `solvers` is the number of *distinct* DIDs whose
88+ * sampled record (≤ SOLVER_SAMPLE_CAP) shows a win.
99+ *
1010+ * Both numbers are deduped by DID so they are commensurable, and `solvers` is
1111+ * clamped to `players` so the published copy can never say more people solved
1212+ * than played. `sampled` is true whenever the sample is incomplete — the page
1313+ * was truncated, or we resolved fewer distinct players than Constellation
1414+ * counted (a dropped read or index skew) — so compose hedges instead of
1515+ * publishing an untrustworthy exact "X solved, Y didn't".
916 */
1017export async function yesterdayCounts(lang: Lang, yesterdayN: number): Promise<YesterdayCounts> {
1111- const [players, backlinks] = await Promise.all([
1818+ const [players, { records, truncated }] = await Promise.all([
1219 dailyPlayerCount(lang, yesterdayN),
1320 dailyResultBacklinks(lang, yesterdayN, SOLVER_SAMPLE_CAP),
1421 ]);
15221616- let solvers = 0;
2323+ const seenDids = new Set<string>();
2424+ const solverDids = new Set<string>();
1725 await Promise.all(
1818- backlinks.map(async (bl) => {
2626+ records.map(async (bl) => {
1927 const rec = await getRecordByUri<ResultRecord>(backlinkUri(bl));
2020- if (rec && rec.lang === lang && rec.puzzleNumber === yesterdayN && rec.solved) solvers++;
2828+ if (!rec || rec.lang !== lang || rec.puzzleNumber !== yesterdayN) return;
2929+ seenDids.add(bl.did);
3030+ if (rec.solved) solverDids.add(bl.did);
2131 }),
2232 );
23332424- return { players, solvers, sampled: backlinks.length >= SOLVER_SAMPLE_CAP };
3434+ let solvers = solverDids.size;
3535+ if (players != null) solvers = Math.min(solvers, players);
3636+3737+ const sampled = truncated || players == null || (players > 0 && seenDids.size < players);
3838+3939+ return { players, solvers, sampled };
2540}
+14-3
src/index.ts
···1010 DRY_RUN?: string;
1111}
12121313-/** 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';
1313+/**
1414+ * Map the firing cron expression to a language. Keyed on the exact expressions
1515+ * in wrangler.toml (EN 00:10 UTC, FR 00:11 UTC); an unrecognized cron throws so
1616+ * a trigger misconfiguration fails loudly instead of silently posting English.
1717+ */
1818+const CRON_LANG: Record<string, Lang> = {
1919+ '10 0 * * *': 'en',
2020+ '11 0 * * *': 'fr',
2121+};
2222+2323+export function langForCron(cron: string): Lang {
2424+ const lang = CRON_LANG[cron];
2525+ if (!lang) throw new Error(`[atmot-bot] unmapped cron expression: ${JSON.stringify(cron)}`);
2626+ return lang;
1627}
17281829export default {