1/**2 * Elo for rated (public) sims. Pure math here; the D1 wiring lives in the3 * service/simrun layer. A sim rates once (rating_events PK enforces it),4 * only from the server-side deterministic replay — client-posted metrics5 * are display-only and never reach this module.6 */78export const ELO_K = 24;9export const ELO_START = 1000;10/** Ratings never fall below this floor (keeps boards readable). */11export const ELO_FLOOR = 100;1213export interface RatedSlot {14 scriptId: string;15 score: number;16 /** Current rating (ELO_START for unrated). */17 rating: number;18}1920export interface SlotOutcome {21 scriptId: string;22 /** Integer rating delta from this sim (sum of pairwise updates). */23 delta: number;24 ratingAfter: number;25 /** 1-based; ties share the better place. */26 place: number;27 /** Pairwise wins/losses inside this sim (ties count to neither). */28 wins: number;29 losses: number;30}3132const expected = (ra: number, rb: number): number => 1 / (1 + Math.pow(10, (rb - ra) / 400));3334/**35 * Compute placements and pairwise-Elo deltas for one sim. Every ordered pair36 * plays one virtual game decided by final score (tie = 0.5). Deltas are37 * rounded half-away-from-zero and floored so ratings stay >= ELO_FLOOR.38 */39export function rateSim(slots: readonly RatedSlot[]): SlotOutcome[] {40 // Places: 1-based by descending score; equal scores share the better place.41 const sorted = [...slots].sort((a, b) => b.score - a.score);42 const placeOf = new Map<string, number>();43 for (let i = 0; i < sorted.length; i++) {44 const s = sorted[i]!;45 const prev = i > 0 ? sorted[i - 1]! : null;46 const place = prev !== null && prev.score === s.score ? placeOf.get(prev.scriptId)! : i + 1;47 placeOf.set(s.scriptId, place);48 }4950 return slots.map((me) => {51 let sum = 0;52 let wins = 0;53 let losses = 0;54 for (const other of slots) {55 if (other.scriptId === me.scriptId) continue;56 const actual = me.score > other.score ? 1 : me.score < other.score ? 0 : 0.5;57 if (actual === 1) wins++;58 else if (actual === 0) losses++;59 sum += ELO_K * (actual - expected(me.rating, other.rating));60 }61 const delta = sum >= 0 ? Math.round(sum) : -Math.round(-sum);62 const ratingAfter = Math.max(ELO_FLOOR, me.rating + delta);63 return {64 scriptId: me.scriptId,65 delta: ratingAfter - me.rating,66 ratingAfter,67 place: placeOf.get(me.scriptId)!,68 wins,69 losses,70 };71 });72}