clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / worker / src / service.ts
service.ts 31.3K
1/// <reference types="@cloudflare/workers-types" />2/**3 * Business logic: single TribesService implementation used by both the REST4 * router and the MCP transport (`createService` is the integration surface).5 *6 * Documented decisions:7 * - REJECTED SUBMISSIONS WRITE NOTHING. A failed validation performs zero DB8 * writes (no blob, no version, no status flip on an existing script). The9 * response carries a synthetic ScriptRecord with status 'rejected' — its id10 * is the existing script's id when (owner,name) already exists, else '' —11 * plus the failing ValidationReport. The stored script (if any) keeps its12 * last validated state.13 * - EXPIRED READS LOOK LIKE MISSING ROWS. Liveness rule: an entity is live iff14 * (expires_at IS NULL OR expires_at > now) OR starred by anyone. Reads of15 * expired-but-unswept entities throw ServiceError('not_found') — never16 * 'expired' — so clients see one uniform failure and existence isn't leaked.17 * - IDS ARE NAMES, NOT SECRETS. Friendly `adjective-noun` ids are enumerable,18 * so authz gates access: script SOURCE reads (getLogic) are owner-only, and19 * createSim locks only (a) your own scripts, (b) LISTED scripts you have20 * hearted, or (c) frozen examples (`ex_<name>`); sims/bundles are public by21 * design (the fishbowl). Listing remains owner/creator-scoped; delete is22 * owner-only.23 * - RESUBMITTING A NAME REVIVES IT. If (owner,name) exists but has expired or24 * sits in the trash and has not yet been swept, a successful submit bumps25 * the version, refreshes expires_at, and clears deleted_at — the stable id26 * is retained.27 * - DEDUP SOFT-LINKS. Every successful submit hashes the normalized source28 * (blobs.norm_hash). When a LISTED script owned by SOMEONE ELSE has the same29 * norm_hash, the submitted script records linked_script_id to that canonical30 * script and is itself never LISTED; a later version whose norm_hash no31 * longer matches clears the link. Own duplicates never link.32 * - `starred` ON RECORDS means "starred by the requesting user". The public33 * getSimBundle has no viewer, so its record always reports starred: false.34 * - TTL refresh: every successful submit refreshes the script's expires_at to35 * now + TTL_DAYS unless the script is starred by anyone (then it stays NULL).36 * - TRASH. deleteLogic is a soft delete (deleted_at = now); the script drops37 * out of listLogic/LISTED/createSim but keeps its stars, and restoreLogic38 * undoes it until the sweep purges at deleted_at + 14 d.39 */40import { BROWSE_WINDOW_SECONDS, EXAMPLE_ID_PREFIX, LIMITS, mapSizeForTribes } from '@tribes/protocol';41import type {42 AuthedUser,43 BrowseInputT,44 CreateSimInputT,45 HeartInputT,46 LeaderboardInputT,47 LeaderboardRow,48 ListModelsInputT,49 ModelRecord,50 ProofSim,51 PublicScriptDetail,52 PublicScriptListing,53 ScriptRecord,54 ScriptWithSource,55 ServiceErrorShape,56 SetSharingInputT,57 SimBundle,58 SimRecord,59 SimScriptRef,60 StarInputT,61 StarKind,62 SubmitLogicInputT,63 TribesService,64 UpdateLogicMetaInputT,65 UserInfo,66 ValidationReport,67} from '@tribes/protocol';68import type { Env } from './env';69import * as db from './db';70import { isoOf, nanoid, nowSec, randomDecimalSeed, sha256Hex, TRASH_SECONDS } from './util';71import { friendlyId } from './friendly-id';72import { computeSimMetrics } from './simrun';73import { normalizeSource } from './normalize';74import { getSourceAnywhere, putSource, resolveSources } from './r2blobs';75import { linkByCode } from './keys';76import { sanitizeMeta } from './sanitize';77import { exampleHashes, getExample } from './examples';7879const TTL_SECONDS = LIMITS.TTL_DAYS * 24 * 3600;80/** Sims scanned for a public script's aggregate stats (proof shows the top 20). */81const PROOF_SCAN_SIMS = 200;82const PROOF_SIMS = 20;8384export class ServiceError extends Error implements ServiceErrorShape {85 constructor(86 public readonly code: ServiceErrorShape['code'],87 message: string,88 ) {89 super(message);90 this.name = 'ServiceError';91 }92}9394export interface ServiceDeps {95 /** Validation pipeline (swappable; integration wires QuickJS + smoke test). */96 validate(source: string): Promise<ValidationReport>;97 /** Absolute request origin (e.g. https://tribes.example.com) for share URLs. */98 origin: string;99 /**100 * Schedule background work past the response (ctx.waitUntil). Used to101 * precompute sim metrics at creation so share links render instantly.102 */103 background?: (work: Promise<unknown>) => void;104}105106// ---------------------------------------------------------------------------107// Row -> DTO mapping108// ---------------------------------------------------------------------------109110function isLive(row: { expires_at: number | null; star_count: number }, now: number): boolean {111 return row.expires_at === null || row.expires_at > now || row.star_count > 0;112}113114function scriptRecord(row: db.ScriptRowX): ScriptRecord {115 return {116 id: row.id,117 name: row.name,118 lang: 'js',119 version: row.version,120 hash: row.hash,121 status: row.status,122 createdAt: isoOf(row.created_at),123 expiresAt: row.expires_at === null ? null : isoOf(row.expires_at),124 starred: row.starred_by_me === 1,125 title: row.title ?? null,126 description: row.description ?? null,127 sourceShared: row.source_shared === 1,128 modelId: row.model_id ?? null,129 linkedScriptId: row.linked_script_id ?? null,130 trashedUntil: row.deleted_at === null ? null : isoOf(row.deleted_at + TRASH_SECONDS),131 listed: row.listed === 1,132 };133}134135function simRecord(row: db.SimRowX, refs: SimScriptRef[], origin: string): SimRecord {136 return {137 id: row.id,138 seed: row.seed,139 config: JSON.parse(row.config_json) as SimRecord['config'],140 scripts: refs,141 createdAt: isoOf(row.created_at),142 expiresAt: row.expires_at === null ? null : isoOf(row.expires_at),143 starred: row.starred_by_me === 1,144 url: `${origin}/s/${row.id}`,145 visibility: row.visibility,146 kind: row.kind,147 };148}149150function toRef(r: db.SimScriptRow): SimScriptRef {151 return {152 slot: r.slot,153 scriptId: r.script_id,154 scriptName: r.script_name,155 version: r.version,156 hash: r.hash,157 };158}159160const NAME_COOLDOWN_SECONDS = 7 * 24 * 3600;161162function nameLockedUntilOf(row: db.UserRow, now: number): string | null {163 if (row.name_changed_at === null) return null;164 const until = row.name_changed_at + NAME_COOLDOWN_SECONDS;165 return until > now ? isoOf(until) : null;166}167168function userInfoOf(row: db.UserRow, now = nowSec()): UserInfo {169 return {170 id: row.id,171 externalId: row.external_id,172 name: row.name,173 publicSharing: row.public_sharing === 1,174 nameLockedUntil: nameLockedUntilOf(row, now),175 };176}177178function modelRecordOf(row: db.ModelRow): ModelRecord {179 return {180 id: row.id,181 name: row.name,182 lab: row.lab,183 openWeights: row.open_weights === 1,184 releaseDate: row.release_date,185 reasoning: row.reasoning === null ? null : row.reasoning === 1,186 };187}188189function listingOf(row: db.BrowseRow): PublicScriptListing {190 return {191 id: row.id,192 title: row.title,193 description: row.description,194 owner: { id: row.owner_id, name: row.owner_name },195 model:196 row.model_id === null197 ? null198 : {199 id: row.model_id,200 name: row.model_name ?? row.model_id,201 lab: row.model_lab ?? '',202 openWeights: row.model_open_weights === 1,203 releaseDate: row.model_release_date,204 reasoning: row.model_reasoning === null ? null : row.model_reasoning === 1,205 },206 rating: row.rating,207 windowDelta: row.window_delta,208 games: row.games,209 wins: row.wins,210 battles: row.battles,211 hearts: row.hearts,212 heartedByMe: row.hearted_by_me === 1,213 sourceShared: row.source_shared === 1,214 version: row.version,215 createdAt: isoOf(row.created_at),216 updatedAt: isoOf(row.updated_at),217 };218}219220// ---------------------------------------------------------------------------221// Service222// ---------------------------------------------------------------------------223224export function createService(env: Env, deps: ServiceDeps): TribesService {225 const D = env.DB;226227 async function requireLiveScript(id: string, viewerId: string | null, now: number): Promise<db.ScriptRowX> {228 const row = await db.getScriptById(D, id, viewerId);229 if (!row || !isLive(row, now)) throw new ServiceError('not_found', `script ${id} not found`);230 return row;231 }232233 async function requireOwnScript(user: AuthedUser, id: string): Promise<db.ScriptRowX> {234 const row = await db.getScriptById(D, id, user.userId);235 if (!row) throw new ServiceError('not_found', `script ${id} not found`);236 if (row.owner_id !== user.userId) {237 throw new ServiceError('forbidden', 'only the owner can modify a script');238 }239 return row;240 }241242 async function rereadScript(id: string, viewerId: string): Promise<ScriptRecord> {243 const row = await db.getScriptById(D, id, viewerId);244 if (!row) throw new ServiceError('not_found', `script ${id} not found`);245 return scriptRecord(row);246 }247248 return {249 async submitLogic(user: AuthedUser, input: SubmitLogicInputT) {250 const now = nowSec();251 const report = await deps.validate(input.source);252 const hash = await sha256Hex(input.source);253 const existing = await db.getScriptByOwnerName(D, user.userId, input.name);254255 if (!report.ok) {256 // Decision: no DB writes for rejected submissions (see module doc).257 const script: ScriptRecord = {258 ...(existing259 ? scriptRecord(existing)260 : {261 id: '',262 name: input.name,263 lang: 'js' as const,264 version: 0,265 title: null,266 description: null,267 sourceShared: false,268 modelId: null,269 linkedScriptId: null,270 trashedUntil: null,271 listed: false,272 starred: false,273 }),274 hash,275 status: 'rejected',276 createdAt: isoOf(now),277 expiresAt: null,278 };279 return { script, report };280 }281282 // Dedup identity: hash of the comment/whitespace-normalized source.283 const normHash = await sha256Hex(normalizeSource(input.source));284 const linkedScriptId = await db.findCanonicalListed(D, normHash, user.userId);285286 if (existing) {287 const version = existing.version + 1;288 // Starred-by-anyone scripts stay pinned (expires_at NULL); otherwise refresh TTL.289 const expiresAt = existing.star_count > 0 ? null : now + TTL_SECONDS;290 await putSource(env, hash, input.source); // bytes to R2, D1 keeps the index291 await D.batch([292 db.stmtUpsertBlob(D, hash, '', now, normHash, 1),293 db.stmtBumpScript(D, existing.id, version, hash, expiresAt, linkedScriptId),294 db.stmtInsertScriptVersion(D, existing.id, version, hash, now),295 ]);296 return { script: await rereadScript(existing.id, user.userId), report };297 }298299 // Friendly id seeded by content hash + owner; retry through collisions.300 let id = '';301 const expiresAt = now + TTL_SECONDS;302 for (let attempt = 0; ; attempt++) {303 id = 'lg_' + friendlyId('script', `${hash}:${user.userId}`, attempt);304 const taken = await db.getScriptById(D, id, user.userId);305 if (taken === null) break;306 if (attempt >= 8) {307 id = 'lg_' + nanoid();308 break;309 }310 }311 await putSource(env, hash, input.source); // bytes to R2, D1 keeps the index312 await D.batch([313 db.stmtUpsertBlob(D, hash, '', now, normHash, 1),314 db.stmtInsertScript(D, {315 id,316 ownerId: user.userId,317 name: input.name,318 version: 1,319 hash,320 createdAt: now,321 expiresAt,322 linkedScriptId,323 }),324 db.stmtInsertScriptVersion(D, id, 1, hash, now),325 ]);326 return { script: await rereadScript(id, user.userId), report };327 },328329 async listLogic(user: AuthedUser, opts?: { trashed?: boolean }): Promise<ScriptRecord[]> {330 const rows = await db.listScriptsByOwner(D, user.userId, nowSec(), opts?.trashed === true);331 return rows.map(scriptRecord);332 },333334 async getLogic(user: AuthedUser, id: string, version?: number): Promise<ScriptWithSource> {335 const now = nowSec();336 const row = await requireLiveScript(id, user.userId, now);337 // Friendly ids are enumerable — script SOURCE is owner-only. (not_found,338 // not forbidden: don't confirm existence to non-owners.)339 if (row.owner_id !== user.userId) {340 throw new ServiceError('not_found', `script ${id} not found`);341 }342 let hash = row.hash;343 let effectiveVersion = row.version;344 if (version !== undefined && version !== row.version) {345 const versionHash = await db.getVersionHash(D, id, version);346 if (!versionHash) throw new ServiceError('not_found', `script ${id} has no version ${version}`);347 hash = versionHash;348 effectiveVersion = version;349 }350 const source = await getSourceAnywhere(env, hash);351 if (source === null) throw new ServiceError('not_found', `source blob missing for ${id}`);352 return { ...scriptRecord(row), version: effectiveVersion, hash, source };353 },354355 async deleteLogic(user: AuthedUser, id: string): Promise<{ trashedUntil: string }> {356 const row = await requireOwnScript(user, id);357 if (row.deleted_at !== null) {358 // Idempotent: already in the trash, same deadline.359 return { trashedUntil: isoOf(row.deleted_at + TRASH_SECONDS) };360 }361 const now = nowSec();362 // Soft delete: stars stay intact so a restore recovers pinned state; the363 // sweep hard-purges at deleted_at + 14 d (sims keep working via blob pins).364 await db.stmtSetDeletedAt(D, id, now).run();365 return { trashedUntil: isoOf(now + TRASH_SECONDS) };366 },367368 async restoreLogic(user: AuthedUser, id: string): Promise<ScriptRecord> {369 const row = await requireOwnScript(user, id);370 if (row.deleted_at === null) {371 throw new ServiceError('not_found', `script ${id} is not in the trash`);372 }373 await db.stmtSetDeletedAt(D, id, null).run();374 return rereadScript(id, user.userId);375 },376377 async updateLogicMeta(user: AuthedUser, input: UpdateLogicMetaInputT): Promise<ScriptRecord> {378 const row = await requireOwnScript(user, input.id);379 if (row.deleted_at !== null) {380 throw new ServiceError('bad_input', `script ${input.id} is in the trash — restore it first`);381 }382 const fields: Parameters<typeof db.updateScriptMeta>[2] = {};383 if (input.title !== undefined) {384 fields.title = input.title === null ? null : sanitizeMeta(input.title, 60);385 }386 if (input.description !== undefined) {387 fields.description = input.description === null ? null : sanitizeMeta(input.description, 280);388 }389 if (input.modelId !== undefined) {390 if (input.modelId !== null && !(await db.modelExists(D, input.modelId))) {391 throw new ServiceError('bad_input', `unknown model id '${input.modelId}' — see list_models`);392 }393 fields.modelId = input.modelId;394 }395 if (input.sourceShared !== undefined) fields.sourceShared = input.sourceShared;396 await db.updateScriptMeta(D, input.id, fields);397 return rereadScript(input.id, user.userId);398 },399400 async star(user: AuthedUser, input: StarInputT): Promise<void> {401 const now = nowSec();402 const row =403 input.kind === 'script'404 ? await db.getScriptById(D, input.id, user.userId)405 : await db.getSimById(D, input.id, user.userId);406 if (!row || !isLive(row, now)) {407 throw new ServiceError('not_found', `${input.kind} ${input.id} not found`);408 }409 if (row.starred_by_me === 1) return; // idempotent410 // The insert enforces the cap atomically (see stmtInsertStar); only pin411 // the target's expiry once the star row actually landed. changes === 0412 // means either at-cap or a concurrent duplicate star — re-read to tell.413 const inserted = await db.stmtInsertStar(D, user.userId, input.kind, input.id, now).run();414 if ((inserted.meta.changes ?? 0) === 0) {415 const already = await db.hasStar(D, user.userId, input.kind, input.id);416 if (!already) {417 throw new ServiceError('star_limit', `star limit reached (${LIMITS.MAX_STARS})`);418 }419 }420 await db.stmtSetExpiry(D, input.kind, input.id, null).run(); // starred => pinned421 },422423 async unstar(user: AuthedUser, input: StarInputT): Promise<void> {424 const removed = await db.deleteStar(D, user.userId, input.kind, input.id);425 if (removed === 0) return; // idempotent: nothing to do, don't touch expiry426 const remaining = await db.countStarsOnTarget(D, input.kind, input.id);427 if (remaining === 0) {428 // Last star removed: restart the TTL clock (14d grace from now).429 await db.stmtSetExpiry(D, input.kind, input.id, nowSec() + TTL_SECONDS).run();430 }431 },432433 async listStars(user: AuthedUser): Promise<Array<{ kind: StarKind; id: string }>> {434 const rows = await db.listStarsByUser(D, user.userId);435 return rows.map((r) => ({ kind: r.kind, id: r.target_id }));436 },437438 async setSharing(user: AuthedUser, input: SetSharingInputT): Promise<UserInfo> {439 if (input.publicSharing !== undefined) {440 await db.setUserSharing(D, user.userId, input.publicSharing);441 }442 if (input.name !== undefined) {443 const now = nowSec();444 const current = await db.getUserById(D, user.userId);445 if (!current) throw new ServiceError('not_found', 'user no longer exists');446 const name = input.name === null ? null : sanitizeMeta(input.name, 40);447 const next = name === '' ? null : name;448 // Same value = no-op; an effective change consumes the 7-day cooldown.449 if (next !== current.name) {450 const lockedUntil = nameLockedUntilOf(current, now);451 if (lockedUntil !== null) {452 throw new ServiceError(453 'bad_input',454 `display name changes are limited to once per 7 days (next change ${lockedUntil})`,455 );456 }457 await db.updateUserName(D, user.userId, next, now);458 }459 }460 const row = await db.getUserById(D, user.userId);461 if (!row) throw new ServiceError('not_found', 'user no longer exists');462 return userInfoOf(row);463 },464465 async deleteAccount(user: AuthedUser, confirm: string): Promise<void> {466 if (confirm !== user.userId) {467 throw new ServiceError('bad_input', 'confirm must be your own user id');468 }469 // Scripts someone else depends on (soft-links, hearts, rating history)470 // are anonymized to the system ghost and keep their listing eligibility471 // via a transferred star; everything else cascades with the user row.472 const canonical = await db.listCanonicalScriptIds(D, user.userId);473 const stmts: D1PreparedStatement[] = [];474 if (canonical.length > 0) {475 const ghost = await db.getOrCreateSystemUser(D, 'system:ghost', 'deleted user', true);476 for (const id of canonical) {477 stmts.push(db.stmtTransferStarToGhost(D, user.userId, ghost.id, id));478 stmts.push(db.stmtReassignScript(D, id, ghost.id));479 }480 }481 stmts.push(db.stmtDeleteUser(D, user.userId));482 await D.batch(stmts);483 },484485 async linkAccount(486 user: AuthedUser,487 input: { code: string },488 ): Promise<{ linked: boolean; alreadyLinked: boolean }> {489 const outcome = await linkByCode(env, user, input.code);490 if (outcome === 'invalid') {491 throw new ServiceError('bad_input', 'invalid or expired link code');492 }493 return { linked: true, alreadyLinked: outcome === 'already' };494 },495496 async heart(user: AuthedUser, input: HeartInputT): Promise<void> {497 const row = await db.getScriptById(D, input.id, user.userId);498 if (!row) throw new ServiceError('not_found', `script ${input.id} not found`);499 if (row.owner_id === user.userId) {500 throw new ServiceError('bad_input', 'you cannot heart your own script');501 }502 if (row.listed !== 1) {503 throw new ServiceError('bad_input', `script ${input.id} is not publicly listed`);504 }505 await db.insertHeart(D, user.userId, input.id, nowSec()); // idempotent506 },507508 async unheart(user: AuthedUser, input: HeartInputT): Promise<void> {509 await db.deleteHeart(D, user.userId, input.id); // idempotent510 },511512 async listHearts(user: AuthedUser): Promise<PublicScriptListing[]> {513 // Hearts whose target vanished or got delisted silently drop out via the514 // LISTED join — the heart row itself is kept (the target may relist).515 const rows = await db.browseListings(D, {516 viewerId: user.userId,517 sort: 'updated',518 windowSeconds: BROWSE_WINDOW_SECONDS['7d'],519 now: nowSec(),520 limit: 100,521 offset: 0,522 heartedBy: user.userId,523 });524 return rows.map(listingOf);525 },526527 async browseScripts(input: BrowseInputT, viewerId?: string | null): Promise<PublicScriptListing[]> {528 const rows = await db.browseListings(D, {529 viewerId: viewerId ?? null,530 sort: input.sort,531 windowSeconds: BROWSE_WINDOW_SECONDS[input.window],532 now: nowSec(),533 openWeights: input.openWeights,534 lab: input.lab,535 releasedAfter: input.releasedAfter,536 releasedBefore: input.releasedBefore,537 q: input.q,538 limit: input.limit,539 offset: input.offset,540 });541 return rows.map(listingOf);542 },543544 async getPublicScript(id: string, viewerId?: string | null): Promise<PublicScriptDetail> {545 const now = nowSec();546 const head = (547 await db.browseListings(D, {548 viewerId: viewerId ?? null,549 sort: 'top',550 windowSeconds: BROWSE_WINDOW_SECONDS['7d'],551 now,552 limit: 1,553 offset: 0,554 id,555 })556 )[0];557 if (head === undefined) throw new ServiceError('not_found', `script ${id} not found`);558 const listing = listingOf(head);559560 const [versions, resultRows] = await Promise.all([561 db.getScriptVersions(D, id),562 db.getPublicSimResultsForScript(D, id, PROOF_SCAN_SIMS),563 ]);564565 // Group all-slot results by sim, newest sim first (rows are pre-ordered).566 const simOrder: string[] = [];567 const bySim = new Map<string, { createdAt: number; kind: 'user' | 'ladder'; rows: db.PublicResultRow[] }>();568 for (const r of resultRows) {569 let g = bySim.get(r.sim_id);570 if (g === undefined) {571 g = { createdAt: r.created_at, kind: r.kind, rows: [] };572 bySim.set(r.sim_id, g);573 simOrder.push(r.sim_id);574 }575 g.rows.push(r);576 }577 const titles = await db.getScriptTitles(D, [...new Set(resultRows.map((r) => r.script_id))]);578 const titleOf = (scriptId: string): string => titles.get(scriptId) ?? 'unknown';579580 const proof: ProofSim[] = simOrder.slice(0, PROOF_SIMS).map((simId) => {581 const g = bySim.get(simId)!;582 return {583 simId,584 url: `${deps.origin}/s/${simId}`,585 createdAt: isoOf(g.createdAt),586 kind: g.kind,587 results: g.rows.map((r) => ({588 scriptId: r.script_id,589 title: titleOf(r.script_id),590 score: r.score,591 place: r.place,592 })),593 };594 });595596 // Aggregates: winRate from the all-time ratings row; place/score/opponent597 // records from the scanned recent public sims (cap PROOF_SCAN_SIMS).598 const mine = resultRows.filter((r) => r.script_id === id);599 const winRate = listing.games > 0 ? Math.round((listing.wins * 1000) / listing.games) : 0;600 const avgPlace =601 mine.length > 0 ? Math.round((mine.reduce((a, r) => a + r.place, 0) / mine.length) * 100) : 0;602 const avgScore =603 mine.length > 0 ? Math.round(mine.reduce((a, r) => a + r.score, 0) / mine.length) : 0;604 const oppMap = new Map<string, { wins: number; losses: number }>();605 for (const simId of simOrder) {606 const g = bySim.get(simId)!;607 const my = g.rows.find((r) => r.script_id === id);608 if (my === undefined) continue;609 for (const r of g.rows) {610 if (r.script_id === id) continue;611 const rec = oppMap.get(r.script_id) ?? { wins: 0, losses: 0 };612 if (my.place < r.place) rec.wins++;613 else if (my.place > r.place) rec.losses++;614 oppMap.set(r.script_id, rec);615 }616 }617 const opponents = [...oppMap.entries()]618 .map(([scriptId, rec]) => ({ scriptId, title: titleOf(scriptId), wins: rec.wins, losses: rec.losses }))619 .sort((a, b) => b.wins + b.losses - (a.wins + a.losses) || a.scriptId.localeCompare(b.scriptId));620621 const detail: PublicScriptDetail = {622 ...listing,623 versions: versions.map((v) => ({ version: v.version, hash: v.hash, createdAt: isoOf(v.created_at) })),624 proof,625 aggregate: { winRate, avgPlace, avgScore, opponents },626 };627 if (head.source_shared === 1) {628 const latestHash = versions[0]?.hash;629 const source = latestHash === undefined ? null : await getSourceAnywhere(env, latestHash);630 if (source !== null) detail.source = source;631 }632 return detail;633 },634635 async leaderboard(input: LeaderboardInputT): Promise<LeaderboardRow[]> {636 const rows = await db.browseListings(D, {637 viewerId: null,638 sort: input.window === 'all' ? 'top' : 'rising',639 windowSeconds: BROWSE_WINDOW_SECONDS[input.window],640 now: nowSec(),641 limit: input.limit,642 offset: 0,643 });644 return rows.map((row, i) => {645 const l = listingOf(row);646 return {647 rank: i + 1,648 id: l.id,649 title: l.title,650 owner: l.owner,651 model: l.model,652 rating: l.rating,653 windowDelta: l.windowDelta,654 games: l.games,655 battles: l.battles,656 hearts: l.hearts,657 };658 });659 },660661 async listModels(input: ListModelsInputT): Promise<ModelRecord[]> {662 const rows = await db.listModelRows(D, {663 q: input.q,664 lab: input.lab,665 openWeights: input.openWeights,666 limit: input.limit,667 });668 return rows.map(modelRecordOf);669 },670671 async createSim(user: AuthedUser, input: CreateSimInputT): Promise<SimRecord> {672 const now = nowSec();673 const refs: SimScriptRef[] = [];674 const blobStmts: D1PreparedStatement[] = [];675 let allListed = true;676 for (let slot = 0; slot < input.scriptIds.length; slot++) {677 const scriptId = input.scriptIds[slot]!;678 if (scriptId.startsWith(EXAMPLE_ID_PREFIX)) {679 // Frozen system examples: runnable bots only; source pinned as a blob680 // under a stable synthetic ref (version 0).681 const name = scriptId.slice(EXAMPLE_ID_PREFIX.length);682 const example = getExample(name);683 if (example === null || example.kind !== 'bot') {684 throw new ServiceError('bad_input', `no runnable example '${name}' — see list_examples`);685 }686 const exHash = (await exampleHashes())[name]!;687 await putSource(env, exHash, example.source);688 blobStmts.push(db.stmtUpsertBlob(D, exHash, '', now, null, 1));689 refs.push({ slot, scriptId, scriptName: name, version: 0, hash: exHash });690 allListed = false; // examples are never LISTED -> sim stays private691 continue;692 }693 const row = await requireLiveScript(scriptId, user.userId, now);694 if (row.owner_id === user.userId) {695 if (row.deleted_at !== null) {696 throw new ServiceError('not_found', `script ${scriptId} not found`);697 }698 if (row.status !== 'validated') {699 throw new ServiceError('bad_input', `script ${scriptId} is not validated`);700 }701 } else if (row.listed !== 1 || !(await db.hasHeart(D, user.userId, scriptId))) {702 // Others' scripts resolve only when LISTED and hearted by the caller703 // (not_found, not forbidden: don't confirm existence).704 throw new ServiceError('not_found', `script ${scriptId} not found`);705 }706 if (row.listed !== 1) allListed = false;707 // Lock the latest validated version at creation time.708 refs.push({ slot, scriptId: row.id, scriptName: row.name, version: row.version, hash: row.hash });709 }710 // 'public' (rated by the server replay) iff EVERY slot is a LISTED script.711 const visibility: 'private' | 'public' = allListed ? 'public' : 'private';712713 const seed = input.seed ?? randomDecimalSeed();714 // Friendly id seeded by game seed + creator; retry through collisions.715 let id = '';716 for (let attempt = 0; ; attempt++) {717 id = 'sim_' + friendlyId('sim', `${seed}:${user.userId}`, attempt);718 const taken = await db.getSimById(D, id, user.userId);719 if (taken === null) break;720 if (attempt >= 8) {721 id = 'sim_' + nanoid();722 break;723 }724 }725 const config = {726 tribeCount: input.scriptIds.length,727 // World scales with the roster (2-3 -> 32, 4-5 -> 48, 6-8 -> 64).728 mapSize: mapSizeForTribes(input.scriptIds.length),729 maxTicks: LIMITS.MAX_TICKS,730 };731 const expiresAt = now + TTL_SECONDS;732 await D.batch([733 ...blobStmts,734 db.stmtInsertSim(D, {735 id,736 creatorId: user.userId,737 seed,738 configJson: JSON.stringify(config),739 createdAt: now,740 expiresAt,741 visibility,742 kind: 'user',743 }),744 ...refs.map((r) =>745 db.stmtInsertSimScript(D, {746 simId: id,747 slot: r.slot,748 scriptId: r.scriptId,749 scriptName: r.scriptName,750 version: r.version,751 hash: r.hash,752 }),753 ),754 ]);755 // Precompute the full metrics series in the background so the share756 // link renders finished stats instantly (client fallback covers CPU757 // limits — see simrun.ts). Public sims are also rated there. The replay758 // only starts when a background scheduler is wired (tests omit it).759 if (deps.background !== undefined) deps.background(computeSimMetrics(env, id));760 return {761 id,762 seed,763 config,764 scripts: refs,765 createdAt: isoOf(now),766 expiresAt: isoOf(expiresAt),767 starred: false,768 url: `${deps.origin}/s/${id}`,769 visibility,770 kind: 'user',771 };772 },773774 async listSims(user: AuthedUser): Promise<SimRecord[]> {775 const now = nowSec();776 const [rows, allRefs] = await Promise.all([777 db.listSimsByCreator(D, user.userId, now),778 db.getSimScriptsByCreator(D, user.userId),779 ]);780 const bySim = new Map<string, SimScriptRef[]>();781 for (const r of allRefs) {782 const list = bySim.get(r.sim_id) ?? [];783 list.push(toRef(r));784 bySim.set(r.sim_id, list);785 }786 return rows.map((row) => simRecord(row, bySim.get(row.id) ?? [], deps.origin));787 },788789 async getSimBundle(id: string): Promise<SimBundle> {790 const now = nowSec();791 const row = await db.getSimById(D, id, null); // public: no viewer => starred:false792 if (!row || !isLive(row, now)) throw new ServiceError('not_found', `sim ${id} not found`);793 const [refs, sources] = await Promise.all([794 db.getSimScripts(D, id),795 db.getSimSources(D, id),796 ]);797 const sourceMap = await resolveSources(env, sources);798 return { sim: simRecord(row, refs.map(toRef), deps.origin), sources: sourceMap };799 },800 };801}