1/// <reference types="@cloudflare/workers-types" />2/**3 * Nightly ladder: after the sweep, pair rating-adjacent LISTED canonical4 * scripts into public 'ladder' sims created by the system 'the ladder' user,5 * then compute (and rate) each one inline.6 *7 * Roster picking is a pure function (pickLadderRosters) so it unit-tests8 * without a database:9 * - candidates are sorted by rating (desc) and the list is rotated by10 * day-of-epoch % 3, so adjacent pairings vary night to night;11 * - a sliding window then cuts rosters of 2-4 (size cycles with the day and12 * roster index), never leaving a 1-script remainder behind;13 * - a roster whose exact participant set already shares a rated sim within14 * the last 7 days is skipped (those scripts sit the night out);15 * - at most LADDER_MAX_SIMS rosters per run (cron CPU budget).16 */17import { LIMITS, mapSizeForTribes } from '@tribes/protocol';18import type { Env } from './env';19import * as db from './db';20import { nanoid, nowSec, randomDecimalSeed } from './util';21import { friendlyId } from './friendly-id';22import { dispatchCompute } from './sweep';2324/** Hard cap on ladder sims per nightly run (each replays inline on the cron). */25export const LADDER_MAX_SIMS = 16;26const RECENT_ROSTER_WINDOW_SECONDS = 7 * 24 * 3600;27const TTL_SECONDS = LIMITS.TTL_DAYS * 24 * 3600;2829export interface LadderCandidate {30 id: string;31 rating: number;32}3334/** Canonical key for an exact participant set. */35export function rosterKey(ids: readonly string[]): string {36 return [...ids].sort().join('+');37}3839/**40 * Deterministically pick tonight's rosters. `candidates` in any order;41 * `day` is the day-of-epoch; `recentKeys` holds rosterKey()s that already42 * played (rating_events) within the last 7 days.43 */44export function pickLadderRosters(45 candidates: readonly LadderCandidate[],46 day: number,47 recentKeys: ReadonlySet<string>,48 maxSims: number = LADDER_MAX_SIMS,49): string[][] {50 const sorted = [...candidates].sort((a, b) => b.rating - a.rating || a.id.localeCompare(b.id));51 if (sorted.length < 2) return [];52 const offset = (day % 3) % sorted.length;53 const rotated = sorted.slice(offset).concat(sorted.slice(0, offset));5455 const rosters: string[][] = [];56 // Each picked lineup plays BOTH slot orders (same seed pair-wise fairness:57 // spawn/slot variance is the deepest balance axis the tournaments found —58 // rotated slots cancel it at the rating level).59 let i = 0;60 let g = 0;61 while (rosters.length < maxSims) {62 const remaining = rotated.length - i;63 if (remaining < 2) break; // never a 1-script roster; leftover sits out64 let size = 2 + ((day + g) % 3); // 2..4, cycling for variety65 if (remaining <= 4) size = remaining;66 else if (remaining - size === 1) size -= 1; // avoid stranding a singleton67 const roster = rotated.slice(i, i + size).map((c) => c.id);68 i += size;69 g++;70 if (recentKeys.has(rosterKey(roster))) continue; // played recently — skip71 rosters.push(roster);72 if (rosters.length < maxSims) rosters.push([...roster].reverse());73 }74 return rosters;75}7677/** Creates and inline-computes tonight's ladder sims. Returns sims created. */78export async function runLadder(env: Env, maxSims: number = LADDER_MAX_SIMS): Promise<number> {79 const now = nowSec();80 const listed = await db.listListedForLadder(env.DB);81 if (listed.length < 2) return 0;8283 const recent = await db.listRecentRatingEvents(env.DB, now - RECENT_ROSTER_WINDOW_SECONDS);84 const bySim = new Map<string, string[]>();85 for (const e of recent) {86 const ids = bySim.get(e.sim_id) ?? [];87 ids.push(e.script_id);88 bySim.set(e.sim_id, ids);89 }90 const recentKeys = new Set<string>();91 for (const ids of bySim.values()) recentKeys.add(rosterKey(ids));9293 const day = Math.floor(now / 86_400);94 const rosters = pickLadderRosters(listed, day, recentKeys, maxSims);95 if (rosters.length === 0) return 0;9697 const byId = new Map(listed.map((row) => [row.id, row]));98 const ladderUser = await db.getOrCreateSystemUser(env.DB, 'system:ladder', 'the ladder', false);99100 let created = 0;101 for (const roster of rosters) {102 const seed = randomDecimalSeed();103 let simId = '';104 for (let attempt = 0; ; attempt++) {105 simId = 'sim_' + friendlyId('sim', `${seed}:${ladderUser.id}`, attempt);106 if ((await db.getSimById(env.DB, simId, null)) === null) break;107 if (attempt >= 8) {108 simId = 'sim_' + nanoid();109 break;110 }111 }112 const config = {113 tribeCount: roster.length,114 mapSize: mapSizeForTribes(roster.length),115 maxTicks: LIMITS.MAX_TICKS,116 };117 // Same locking semantics as createSim: latest validated version + hash of118 // each script frozen into sim_scripts at creation time.119 await env.DB.batch([120 db.stmtInsertSim(env.DB, {121 id: simId,122 creatorId: ladderUser.id,123 seed,124 configJson: JSON.stringify(config),125 createdAt: now,126 expiresAt: now + TTL_SECONDS,127 visibility: 'public',128 kind: 'ladder',129 }),130 ...roster.map((scriptId, slot) => {131 const s = byId.get(scriptId)!;132 return db.stmtInsertSimScript(env.DB, {133 simId,134 slot,135 scriptId: s.id,136 scriptName: s.name,137 version: s.version,138 hash: s.hash,139 });140 }),141 ]);142 created++;143 // Inline (cron CPU budget is generous but finite — LADDER_MAX_SIMS caps us);144 // this also applies the Elo updates for the night.145 await dispatchCompute(env, simId);146 }147 console.log(JSON.stringify({ event: 'ladder', created, candidates: listed.length }));148 return created;149}