Public: anyone can read this; the crew can change it.
forked from trevato/cs
What the planner cost is visible: the asks, the cache hits, the refusals and the fallbacks, in the terminal #402
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/246-what-the-planner-cost-is-visible-the-ask → main 1 commit fork
13 files
changed +818
−30
README.md +27
| @@ -74,6 +74,33 @@ Planning is rate-limited per caller and under a spend ceiling for the whole | ||
| 74 | 74 | process; over either, or on any failure, the round gets the local plan, which is |
| 75 | 75 | a plan and not an error. |
| 76 | 76 | |
| 77 | ### What the planner cost | |
| 78 | ||
| 79 | The planner counts what it does, where it decides it: rounds put to it, answers | |
| 80 | from its memory, requests joined in flight, rounds a model answered, and each | |
| 81 | failure by kind — timeout, connection, HTTP status, an answer that was not a | |
| 82 | plan. The HTTP requests it sends upstream are counted too, the SDK's one retry | |
| 83 | included, because that is what is billed. The per-caller limit and the spend | |
| 84 | ceiling each count their own refusals (`src/limit.ts`), and a round asked of a | |
| 85 | server with no key is counted as that. TypeSafe reports **tokens, not a | |
| 86 | price**, so the readout counts input and output tokens and never shows money. | |
| 87 | ||
| 88 | Where to read it: | |
| 89 | ||
| 90 | - **The terminal** — the `policy` tab, under the card: planner on or off, the | |
| 91 | model, the ceilings (per caller, whole process, timeout, retries, how many | |
| 92 | answers are remembered), then every count. The key is never shown, not even | |
| 93 | in part. | |
| 94 | - **`/`** — one sentence under the paragraph about planning, the same sentence | |
| 95 | the terminal prints; with no key it says no round has been asked of a model. | |
| 96 | ||
| 97 | These are integers in the server process, reset when it restarts and labelled | |
| 98 | with when it started: a readout, not a metrics system. Nothing is logged per | |
| 99 | round. The terminal is a separate process, so the server leaves a copy in | |
| 100 | `FORGE_APP_DATA/planner.json` (zeros at start-up, then at most once a second | |
| 101 | after a round is planned), and the terminal reads and validates that file. | |
| 102 | `src/plan/readout.ts` is the shape and the sentence. | |
| 103 | ||
| 77 | 104 | ### The radio: three calls, and your side goes |
| 78 | 105 | |
| 79 | 106 | <kbd>Z</kbd> says **go A**, <kbd>Q</kbd> **go B** and <kbd>H</kbd> **hold** |
src/limit.test.ts +14
| @@ -43,3 +43,17 @@ test("the table is bounded, whatever a caller invents for a key", () => { | ||
| 43 | 43 | // Forgetting a key gives back a burst; it never gives away more than that. |
| 44 | 44 | expect([limit.take("key-0", 0), limit.take("key-0", 0), limit.take("key-0", 0)]).toEqual([true, true, false]); |
| 45 | 45 | }); |
| 46 | ||
| 47 | test("a limiter counts what it refused, and only that", () => { | |
| 48 | const limit = createLimiter({ burst: 2, per: 1000 }); | |
| 49 | expect(limit.refused()).toBe(0); | |
| 50 | expect([limit.take("a", 0), limit.take("a", 0), limit.take("a", 0), limit.take("b", 0)]) | |
| 51 | .toEqual([true, true, false, true]); | |
| 52 | expect(limit.refused()).toBe(1); | |
| 53 | limit.take("a", 100); | |
| 54 | limit.take("a", 200); | |
| 55 | expect(limit.refused()).toBe(3); | |
| 56 | // A token earned back is a yes, and a yes is not counted. | |
| 57 | expect(limit.take("a", 1000)).toBe(true); | |
| 58 | expect(limit.refused()).toBe(3); | |
| 59 | }); |
src/limit.ts +8 −1
| @@ -24,10 +24,13 @@ export type Limiter = { | ||
| 24 | 24 | take(key: string, now?: number): boolean; |
| 25 | 25 | /** How many keys are remembered. For the tests and nothing else. */ |
| 26 | 26 | size(): number; |
| 27 | /** How many times `take` has said no since this limiter was made. */ | |
| 28 | refused(): number; | |
| 27 | 29 | }; |
| 28 | 30 | |
| 29 | 31 | export function createLimiter({ burst, per, ceiling = 10_000 }: LimiterOptions): Limiter { |
| 30 | 32 | const tokens = new Map<string, { left: number; at: number }>(); |
| 33 | let refusals = 0; | |
| 31 | 34 | return { |
| 32 | 35 | take(key, now = Date.now()) { |
| 33 | 36 | const bucket = tokens.get(key) ?? { left: burst, at: now }; |
| @@ -36,12 +39,16 @@ export function createLimiter({ burst, per, ceiling = 10_000 }: LimiterOptions): | ||
| 36 | 39 | // A refusal writes nothing: the bucket keeps the one anchor it was |
| 37 | 40 | // filled from, so a caller that knocks a hundred times a second earns |
| 38 | 41 | // exactly what one that waits does, instead of a hundred roundings of it. |
| 39 | if (left < 1) return false; | |
| 42 | if (left < 1) { | |
| 43 | refusals += 1; | |
| 44 | return false; | |
| 45 | } | |
| 40 | 46 | if (tokens.size >= ceiling && !tokens.has(key)) sweep(tokens, { burst, per, ceiling }, now); |
| 41 | 47 | tokens.set(key, { left: left - 1, at: now }); |
| 42 | 48 | return true; |
| 43 | 49 | }, |
| 44 | 50 | size: () => tokens.size, |
| 51 | refused: () => refusals, | |
| 45 | 52 | }; |
| 46 | 53 | } |
| 47 | 54 |
src/pages/game.ts +12 −4
| @@ -29,6 +29,7 @@ import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; | ||
| 29 | 29 | import { LONGEST_PASTE } from "../matchlink.ts"; |
| 30 | 30 | import { roomLink } from "../roomlink.ts"; |
| 31 | 31 | import type { Who } from "../who.ts"; |
| 32 | import { plannedSaid, type Readout } from "../plan/readout.ts"; | |
| 32 | 33 | import { figure } from "./figure.ts"; |
| 33 | 34 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; |
| 34 | 35 | import { WEAPONS_HREF, WEAPONS_SUMMARY } from "./weapons.ts"; |
| @@ -163,6 +164,12 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script | ||
| 163 | 164 | * every round in the page, which is what the paragraph then says. |
| 164 | 165 | */ |
| 165 | 166 | planning?: boolean; |
| 167 | /** | |
| 168 | * What the planner has done since the server started (`src/plan/readout.ts`), | |
| 169 | * said under the paragraph in the same words the terminal uses. Absent says | |
| 170 | * nothing, which is what a page rendered without a server behind it knows. | |
| 171 | */ | |
| 172 | plans?: Readout; | |
| 166 | 173 | /** |
| 167 | 174 | * The moment this page is being rendered at: which day the match of the day |
| 168 | 175 | * is, and which matches in the feed still read as live. Absent is now, and a |
| @@ -469,7 +476,7 @@ export const policyHtml = (card: Card): string => | ||
| 469 | 476 | * given a key — the page never claims a model is planning anything when the |
| 470 | 477 | * only planner running is the arithmetic in the page. |
| 471 | 478 | */ |
| 472 | export const planHtml = (planning: boolean): string => | |
| 479 | export const planHtml = (planning: boolean, plans?: Readout): string => | |
| 473 | 480 | `<p>Above the fight the bots now plan a round rather than five separate ones. Each side gets a |
| 474 | 481 | site, a shape — everyone through one site, a split across both, or a fake with a lurker on the |
| 475 | 482 | other — a tempo and a decision about whether to save, and every bot gets a job cut from it: |
| @@ -485,7 +492,8 @@ export const planHtml = (planning: boolean): string => | ||
| 485 | 492 | : `This deployment has no TypeSafe key, so every round is planned in your browser by the same |
| 486 | 493 | arithmetic the server would use as its fallback: nothing is sent anywhere, and the plan is |
| 487 | 494 | still a plan.` |
| 488 | }</p>`; | |
| 495 | }</p>${plans === undefined ? "" : ` | |
| 496 | <p class="hint" id="planned">${escape(plannedSaid(plans))}</p>`}`; | |
| 489 | 497 | |
| 490 | 498 | /** |
| 491 | 499 | * The pause panel. Its line about Escape is rendered with the sentence that is |
| @@ -559,7 +567,7 @@ const SAVED_LINE = `<p class="hint" id="saved" role="status" aria-live="polite"> | ||
| 559 | 567 | |
| 560 | 568 | export function gamePage( |
| 561 | 569 | db: Database, |
| 562 | { who: visitor, asked = NOTHING_ASKED, card = CARD, planning = false, now = Date.now(), ...options }: PageOptions, | |
| 570 | { who: visitor, asked = NOTHING_ASKED, card = CARD, planning = false, plans, now = Date.now(), ...options }: PageOptions, | |
| 563 | 571 | ): string { |
| 564 | 572 | const daily = dailyOn(now); |
| 565 | 573 | return page({ |
| @@ -625,7 +633,7 @@ export function gamePage( | ||
| 625 | 633 | Everything you see is generated — the map, the textures, the models and the sounds — so the |
| 626 | 634 | whole game is one small download and no asset files.</p> |
| 627 | 635 | ${policyHtml(card)} |
| 628 | ${planHtml(planning)} | |
| 636 | ${planHtml(planning, plans)} | |
| 629 | 637 | <p>The match runs entirely in your browser; the leaderboard here is what your browser |
| 630 | 638 | reported about it, checked against what a match can produce. The name on a row is not |
| 631 | 639 | the browser's word, though: signed in to the forge, your results are recorded under |
src/plan/readout.test.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * The readout: the shape the planner's counts are shown in, the one sentence | |
| 3 | * the page and the terminal share, and the file the terminal reads — parsed | |
| 4 | * like any other input, because it is one. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { | |
| 12 | emptyTally, localPlans, parseReadout, plannedSaid, readoutFile, readReadout, type Readout, writeReadout, | |
| 13 | } from "./readout.ts"; | |
| 14 | ||
| 15 | const readout = (over: Partial<Readout> = {}): Readout => ({ | |
| 16 | on: true, | |
| 17 | model: "jev-1", | |
| 18 | since: "2026-09-23T10:04:11.201Z", | |
| 19 | at: "2026-09-23T11:00:00.000Z", | |
| 20 | limits: { cache: 500, timeout: 2500, retries: 1, caller: { burst: 12, per: 3000 }, spend: { burst: 30, per: 2000 } }, | |
| 21 | tally: { | |
| 22 | ...emptyTally(), | |
| 23 | asks: 10, hits: 3, joined: 1, answered: 4, sent: 7, | |
| 24 | failed: { timeout: 1, connection: 0, status: { "500": 1 }, invalid: 0, other: 0 }, | |
| 25 | tokens: { input: 1200, output: 80 }, | |
| 26 | }, | |
| 27 | refused: { caller: 2, spend: 1 }, | |
| 28 | unkeyed: 0, | |
| 29 | ...over, | |
| 30 | }); | |
| 31 | ||
| 32 | test("the sentence is the counts, added up by hand", () => { | |
| 33 | const r = readout(); | |
| 34 | // 10 to the planner + 3 refused = 13 asked; 2 failed + 3 refused = 5 local. | |
| 35 | expect(localPlans(r)).toBe(5); | |
| 36 | expect(plannedSaid(r)).toBe( | |
| 37 | "Since this server started at 2026-09-23 10:04 UTC, 13 rounds asked to be planned: 4 answered by jev-1, " + | |
| 38 | "4 shared an answer already asked for, and 5 planned locally — 3 over a rate limit, 2 because the model failed. " + | |
| 39 | "That was 7 requests upstream and 1,200 tokens in, 80 out; TypeSafe reports tokens, not a price. " + | |
| 40 | "These counts are this process's and start again at zero when it restarts.", | |
| 41 | ); | |
| 42 | }); | |
| 43 | ||
| 44 | test("a deployment with no key says so plainly", () => { | |
| 45 | expect(plannedSaid(readout({ on: false, tally: emptyTally() }))) | |
| 46 | .toBe("No TypeSafe key: no round has been asked of a model since this server started at 2026-09-23 10:04 UTC, and none will be."); | |
| 47 | }); | |
| 48 | ||
| 49 | test("what is written is what is read back, and nothing else is", () => { | |
| 50 | const dir = mkdtempSync(join(tmpdir(), "readout-")); | |
| 51 | expect(readReadout(dir)).toBeNull(); | |
| 52 | writeReadout(dir, readout()); | |
| 53 | expect(readReadout(dir)).toEqual(readout()); | |
| 54 | // A field the shape does not have does not survive the parse. | |
| 55 | writeFileSync(readoutFile(dir), JSON.stringify({ ...readout(), apiKey: "sk-live" })); | |
| 56 | expect(JSON.stringify(readReadout(dir))).not.toContain("sk-live"); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a file that is not a readout is none", () => { | |
| 60 | const dir = mkdtempSync(join(tmpdir(), "readout-")); | |
| 61 | writeFileSync(readoutFile(dir), "{not json"); | |
| 62 | expect(readReadout(dir)).toBeNull(); | |
| 63 | const bad: unknown[] = [ | |
| 64 | null, [], "x", | |
| 65 | { ...readout(), on: "yes" }, | |
| 66 | { ...readout(), model: "" }, | |
| 67 | { ...readout(), model: "m".repeat(65) }, | |
| 68 | { ...readout(), since: "yesterday" }, | |
| 69 | { ...readout(), unkeyed: -1 }, | |
| 70 | { ...readout(), unkeyed: 1.5 }, | |
| 71 | { ...readout(), refused: { caller: 1 } }, | |
| 72 | { ...readout(), tally: { ...readout().tally, failed: { ...readout().tally.failed, status: { "600": 1 } } } }, | |
| 73 | { ...readout(), tally: { ...readout().tally, failed: { ...readout().tally.failed, status: { __proto__x: 1 } } } }, | |
| 74 | { ...readout(), limits: { ...readout().limits, spend: { burst: 1 } } }, | |
| 75 | ]; | |
| 76 | for (const v of bad) expect(parseReadout(v)).toBeNull(); | |
| 77 | }); |
src/plan/readout.ts added +241
| @@ -0,0 +1,241 @@ | ||
| 1 | /** | |
| 2 | * What the planner did, as a readout. The planner and the two limiters in front | |
| 3 | * of it count in this process, where each decision is made — a cache hit is | |
| 4 | * only visible inside `src/plan/typesafe.ts`, a refusal only inside | |
| 5 | * `src/limit.ts` — and this file is the shape those counts are shown in, on the | |
| 6 | * page and in the terminal. | |
| 7 | * | |
| 8 | * This is a readout, not a metrics system. Every figure is an integer since the | |
| 9 | * server process started (`since`), none is kept anywhere after it stops, and | |
| 10 | * each is labelled as such wherever it is shown. Nothing here logs. | |
| 11 | * | |
| 12 | * The terminal is a different process from the server, so the server leaves a | |
| 13 | * copy of the readout in `FORGE_APP_DATA/planner.json` — overwritten whole, a | |
| 14 | * second after the counts move and at start-up — and the terminal reads it. | |
| 15 | * The file is a window onto the running server, not a history: a restart | |
| 16 | * writes zeros over it. What the terminal reads is from disk, so it is parsed | |
| 17 | * here like any other input, and a file that is not a readout is none. | |
| 18 | * | |
| 19 | * Money: TypeSafe answers with the tokens a request used and no price, so the | |
| 20 | * readout counts tokens and requests and never invents a currency. | |
| 21 | */ | |
| 22 | ||
| 23 | import { renameSync, readFileSync, writeFileSync } from "node:fs"; | |
| 24 | import { join } from "node:path"; | |
| 25 | ||
| 26 | /** The planner's own counts. Every round put to it ends as exactly one of | |
| 27 | * `hits`, `joined`, `answered` or one of the `failed` kinds. */ | |
| 28 | export type Tally = { | |
| 29 | /** Rounds put to the planner. */ | |
| 30 | asks: number; | |
| 31 | /** Answered from the planner's memory: no request made. */ | |
| 32 | hits: number; | |
| 33 | /** Joined a request already in flight for the same round: no request made. */ | |
| 34 | joined: number; | |
| 35 | /** HTTP requests sent upstream, the SDK's one retry included. What is billed. */ | |
| 36 | sent: number; | |
| 37 | /** Rounds a model's answer planned. */ | |
| 38 | answered: number; | |
| 39 | /** Rounds that went upstream and came back as the local plan, by why. */ | |
| 40 | failed: { | |
| 41 | /** An attempt ran past its timeout, retry included. */ | |
| 42 | timeout: number; | |
| 43 | /** DNS, TLS, a dropped connection: no status came back. */ | |
| 44 | connection: number; | |
| 45 | /** An HTTP status that was not success, by status: `{ "500": 2, "401": 1 }`. */ | |
| 46 | status: Record<string, number>; | |
| 47 | /** An answer arrived and was not a plan this game can play. */ | |
| 48 | invalid: number; | |
| 49 | /** Anything else the SDK threw. */ | |
| 50 | other: number; | |
| 51 | }; | |
| 52 | /** Tokens the answers reported using. Tokens, not money: no price comes back. */ | |
| 53 | tokens: { input: number; output: number }; | |
| 54 | }; | |
| 55 | ||
| 56 | export const emptyTally = (): Tally => ({ | |
| 57 | asks: 0, hits: 0, joined: 0, sent: 0, answered: 0, | |
| 58 | failed: { timeout: 0, connection: 0, status: {}, invalid: 0, other: 0 }, | |
| 59 | tokens: { input: 0, output: 0 }, | |
| 60 | }); | |
| 61 | ||
| 62 | /** How many rounds went upstream and came back as the local plan. */ | |
| 63 | export const failures = (tally: Tally): number => | |
| 64 | tally.failed.timeout + tally.failed.connection + tally.failed.invalid + tally.failed.other + | |
| 65 | Object.values(tally.failed.status).reduce((sum, n) => sum + n, 0); | |
| 66 | ||
| 67 | export type Rate = { burst: number; per: number }; | |
| 68 | ||
| 69 | /** Everything the page and the terminal say about the planner. Never the key. */ | |
| 70 | export type Readout = { | |
| 71 | /** Whether this deployment was given a key. */ | |
| 72 | on: boolean; | |
| 73 | /** The model asked for: `TYPESAFE_MODEL`, or the SDK's default. */ | |
| 74 | model: string; | |
| 75 | /** When the server process that counted these started, ISO 8601 UTC. */ | |
| 76 | since: string; | |
| 77 | /** When these counts were read, ISO 8601 UTC. */ | |
| 78 | at: string; | |
| 79 | /** What the deployment is configured with. */ | |
| 80 | limits: { | |
| 81 | /** Answers remembered before the memory is dropped. */ | |
| 82 | cache: number; | |
| 83 | /** Milliseconds one attempt gets. */ | |
| 84 | timeout: number; | |
| 85 | /** Retries behind a failed attempt. */ | |
| 86 | retries: number; | |
| 87 | /** Per caller: `burst` rounds back to back, then one every `per` ms. */ | |
| 88 | caller: Rate; | |
| 89 | /** The whole process, the same way: the spend ceiling. */ | |
| 90 | spend: Rate; | |
| 91 | }; | |
| 92 | tally: Tally; | |
| 93 | /** Rounds a limiter turned away before the planner saw them. */ | |
| 94 | refused: { caller: number; spend: number }; | |
| 95 | /** Rounds asked of this server with no key to ask with. */ | |
| 96 | unkeyed: number; | |
| 97 | }; | |
| 98 | ||
| 99 | /** Every round this server answered with the local plan, for whatever reason. */ | |
| 100 | export const localPlans = (r: Readout): number => failures(r.tally) + r.refused.caller + r.refused.spend + r.unkeyed; | |
| 101 | ||
| 102 | /** The file the server leaves for the terminal. */ | |
| 103 | export const readoutFile = (dir: string): string => join(dir, "planner.json"); | |
| 104 | ||
| 105 | /** | |
| 106 | * Overwrite the copy the terminal reads. Written beside and renamed over, so a | |
| 107 | * terminal reading at the same moment sees the old readout or the new one, | |
| 108 | * never half of either. | |
| 109 | */ | |
| 110 | export function writeReadout(dir: string, readout: Readout): void { | |
| 111 | const file = readoutFile(dir); | |
| 112 | writeFileSync(`${file}.tmp`, JSON.stringify(readout)); | |
| 113 | renameSync(`${file}.tmp`, file); | |
| 114 | } | |
| 115 | ||
| 116 | /** The copy on disk, or null when there is none or it is not a readout. */ | |
| 117 | export function readReadout(dir: string): Readout | null { | |
| 118 | let text: string; | |
| 119 | try { | |
| 120 | text = readFileSync(readoutFile(dir), "utf8"); | |
| 121 | } catch { | |
| 122 | return null; | |
| 123 | } | |
| 124 | if (text.length > 16_384) return null; | |
| 125 | try { | |
| 126 | return parseReadout(JSON.parse(text)); | |
| 127 | } catch { | |
| 128 | return null; | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | // --- the boundary: a file on disk is input like any other ------------------- | |
| 133 | ||
| 134 | type Obj = Record<string, unknown>; | |
| 135 | ||
| 136 | const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v); | |
| 137 | ||
| 138 | /** A count: a non-negative whole number a double holds exactly. */ | |
| 139 | const count = (v: unknown): number | null => (Number.isSafeInteger(v) && (v as number) >= 0 ? (v as number) : null); | |
| 140 | ||
| 141 | const text = (v: unknown, max: number): string | null => | |
| 142 | typeof v === "string" && v.length > 0 && v.length <= max ? v : null; | |
| 143 | ||
| 144 | const when = (v: unknown): string | null => { | |
| 145 | const s = text(v, 32); | |
| 146 | return s !== null && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z$/.test(s) ? s : null; | |
| 147 | }; | |
| 148 | ||
| 149 | const rate = (v: unknown): Rate | null => { | |
| 150 | if (!isObj(v)) return null; | |
| 151 | const burst = count(v.burst); | |
| 152 | const per = count(v.per); | |
| 153 | return burst === null || per === null ? null : { burst, per }; | |
| 154 | }; | |
| 155 | ||
| 156 | /** Parse throws on the first thing that is wrong; `parseReadout` turns that into null. */ | |
| 157 | class NotAReadout extends Error {} | |
| 158 | ||
| 159 | const need = <T>(v: T | null): T => { | |
| 160 | if (v === null) throw new NotAReadout(); | |
| 161 | return v; | |
| 162 | }; | |
| 163 | ||
| 164 | function tallyFrom(v: unknown): Tally { | |
| 165 | if (!isObj(v) || !isObj(v.failed) || !isObj(v.tokens) || !isObj(v.failed.status)) throw new NotAReadout(); | |
| 166 | const status: Record<string, number> = {}; | |
| 167 | const codes = Object.entries(v.failed.status); | |
| 168 | if (codes.length > 500) throw new NotAReadout(); | |
| 169 | for (const [code, n] of codes) { | |
| 170 | if (!/^[1-5]\d\d$/.test(code)) throw new NotAReadout(); | |
| 171 | status[code] = need(count(n)); | |
| 172 | } | |
| 173 | return { | |
| 174 | asks: need(count(v.asks)), | |
| 175 | hits: need(count(v.hits)), | |
| 176 | joined: need(count(v.joined)), | |
| 177 | sent: need(count(v.sent)), | |
| 178 | answered: need(count(v.answered)), | |
| 179 | failed: { | |
| 180 | timeout: need(count(v.failed.timeout)), | |
| 181 | connection: need(count(v.failed.connection)), | |
| 182 | status, | |
| 183 | invalid: need(count(v.failed.invalid)), | |
| 184 | other: need(count(v.failed.other)), | |
| 185 | }, | |
| 186 | tokens: { input: need(count(v.tokens.input)), output: need(count(v.tokens.output)) }, | |
| 187 | }; | |
| 188 | } | |
| 189 | ||
| 190 | /** A readout, or null. Only the fields above survive; nothing else is kept. */ | |
| 191 | export function parseReadout(v: unknown): Readout | null { | |
| 192 | try { | |
| 193 | if (!isObj(v) || typeof v.on !== "boolean" || !isObj(v.limits) || !isObj(v.refused)) return null; | |
| 194 | const l = v.limits; | |
| 195 | return { | |
| 196 | on: v.on, | |
| 197 | model: need(text(v.model, 64)), | |
| 198 | since: need(when(v.since)), | |
| 199 | at: need(when(v.at)), | |
| 200 | limits: { | |
| 201 | cache: need(count(l.cache)), | |
| 202 | timeout: need(count(l.timeout)), | |
| 203 | retries: need(count(l.retries)), | |
| 204 | caller: need(rate(l.caller)), | |
| 205 | spend: need(rate(l.spend)), | |
| 206 | }, | |
| 207 | tally: tallyFrom(v.tally), | |
| 208 | refused: { caller: need(count(v.refused.caller)), spend: need(count(v.refused.spend)) }, | |
| 209 | unkeyed: need(count(v.unkeyed)), | |
| 210 | }; | |
| 211 | } catch (error) { | |
| 212 | if (error instanceof NotAReadout) return null; | |
| 213 | throw error; | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | // --- said: one sentence the page and the terminal both print ---------------- | |
| 218 | ||
| 219 | const n = (x: number): string => x.toLocaleString("en"); | |
| 220 | const rounds = (x: number): string => `${n(x)} round${x === 1 ? "" : "s"}`; | |
| 221 | ||
| 222 | /** `2026-09-23T10:04:11.201Z` as `2026-09-23 10:04 UTC`. */ | |
| 223 | export const minuteOf = (iso: string): string => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`; | |
| 224 | ||
| 225 | /** | |
| 226 | * What the readout says in words, as plain text for the caller to escape. The | |
| 227 | * page and the terminal print this same sentence, so they cannot disagree | |
| 228 | * with each other or with the counts. | |
| 229 | */ | |
| 230 | export function plannedSaid(r: Readout): string { | |
| 231 | const since = `since this server started at ${minuteOf(r.since)}`; | |
| 232 | if (!r.on) return `No TypeSafe key: no round has been asked of a model ${since}, and none will be.`; | |
| 233 | const t = r.tally; | |
| 234 | const asked = t.asks + r.refused.caller + r.refused.spend + r.unkeyed; | |
| 235 | const refused = r.refused.caller + r.refused.spend; | |
| 236 | return `${since[0]?.toUpperCase()}${since.slice(1)}, ${rounds(asked)} asked to be planned: ` + | |
| 237 | `${n(t.answered)} answered by ${r.model}, ${n(t.hits + t.joined)} shared an answer already asked for, ` + | |
| 238 | `and ${n(localPlans(r))} planned locally — ${n(refused)} over a rate limit, ${n(failures(t))} because the model failed. ` + | |
| 239 | `That was ${n(t.sent)} request${t.sent === 1 ? "" : "s"} upstream and ${n(t.tokens.input)} tokens in, ` + | |
| 240 | `${n(t.tokens.output)} out; TypeSafe reports tokens, not a price. These counts are this process's and start again at zero when it restarts.`; | |
| 241 | } |
src/plan/typesafe.test.ts +100
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import { localPlan } from "./plan.ts"; |
| 10 | import { createLimiter } from "../limit.ts"; | |
| 11 | import { emptyTally, failures } from "./readout.ts"; | |
| 10 | 12 | import { createPlanner } from "./typesafe.ts"; |
| 11 | 13 | import type { Situation } from "./situation.ts"; |
| 12 | 14 | |
| @@ -164,3 +166,101 @@ test("the cache is bounded: past the ceiling it is dropped rather than grown", a | ||
| 164 | 166 | expect(planner?.size()).toBe(1); |
| 165 | 167 | }); |
| 166 | 168 | |
| 169 | ||
| 170 | // --- what it did: the counts ------------------------------------------------ | |
| 171 | ||
| 172 | test("an answer, then an error, then a refusal: three rounds, three different counts", async () => { | |
| 173 | // The route's order, in miniature: the limiter decides, then the planner. | |
| 174 | const limit = createLimiter({ burst: 2, per: 60_000 }); | |
| 175 | let calls = 0; | |
| 176 | const planner = createPlanner({ | |
| 177 | apiKey: "sk-test", | |
| 178 | fetch: async () => { | |
| 179 | calls += 1; | |
| 180 | return calls === 1 ? reply() : new Response("nope", { status: 500 }); | |
| 181 | }, | |
| 182 | }); | |
| 183 | const round = async (n: number): Promise<string> => | |
| 184 | limit.take("caller", 0) ? ((await planner?.plan(situation({ round: n })))?.source ?? "none") : "refused"; | |
| 185 | ||
| 186 | expect(await round(1)).toBe("typesafe"); | |
| 187 | expect(await round(2)).toBe("local"); | |
| 188 | expect(await round(3)).toBe("refused"); | |
| 189 | ||
| 190 | const tally = planner?.tally(); | |
| 191 | expect(tally?.asks).toBe(2); | |
| 192 | expect(tally?.answered).toBe(1); | |
| 193 | expect(tally?.failed.status).toEqual({ "500": 1 }); | |
| 194 | expect(tally === undefined ? -1 : failures(tally)).toBe(1); | |
| 195 | expect(limit.refused()).toBe(1); | |
| 196 | // Three requests upstream for two rounds: the 500 was retried once. | |
| 197 | expect(tally?.sent).toBe(3); | |
| 198 | // The reply said one token in and one out, and that is all that is claimed. | |
| 199 | expect(tally?.tokens).toEqual({ input: 1, output: 1 }); | |
| 200 | }); | |
| 201 | ||
| 202 | test("memory and a joined request are counted as the answers they reused, and cost no request", async () => { | |
| 203 | let release = (): void => {}; | |
| 204 | const held = new Promise<void>((resolve) => { | |
| 205 | release = resolve; | |
| 206 | }); | |
| 207 | const planner = createPlanner({ apiKey: "sk-test", fetch: async () => (await held, reply()) }); | |
| 208 | const both = Promise.all([planner?.plan(situation()), planner?.plan(situation())]); | |
| 209 | release(); | |
| 210 | await both; | |
| 211 | await planner?.plan(situation()); | |
| 212 | expect(planner?.tally()).toEqual({ ...emptyTally(), asks: 3, answered: 1, joined: 1, hits: 1, sent: 1, | |
| 213 | tokens: { input: 1, output: 1 } }); | |
| 214 | }); | |
| 215 | ||
| 216 | test("each failure is counted by its kind, and every ask ends as exactly one thing", async () => { | |
| 217 | const answering = (body: () => Response | Promise<Response>) => | |
| 218 | createPlanner({ apiKey: "sk-test", timeout: 20, fetch: async () => body() }); | |
| 219 | ||
| 220 | const invalid = answering(() => reply({ tSite: { type: "choice", choice: "Middle" } })); | |
| 221 | await invalid?.plan(situation()); | |
| 222 | expect(invalid?.tally().failed.invalid).toBe(1); | |
| 223 | ||
| 224 | // A body that is not JSON is an answer that is not a plan, not a crash. | |
| 225 | const junk = answering(() => new Response("<html>a proxy</html>", { headers: { "content-type": "text/html" } })); | |
| 226 | await junk?.plan(situation()); | |
| 227 | expect(junk?.tally().failed.invalid).toBe(1); | |
| 228 | ||
| 229 | const refused = answering(() => new Response("{}", { status: 401 })); | |
| 230 | await refused?.plan(situation()); | |
| 231 | expect(refused?.tally().failed.status).toEqual({ "401": 1 }); | |
| 232 | ||
| 233 | const slow = createPlanner({ | |
| 234 | apiKey: "sk-test", | |
| 235 | timeout: 20, | |
| 236 | fetch: (_url, init) => new Promise((_resolve, reject) => { | |
| 237 | init?.signal?.addEventListener("abort", () => reject(init.signal?.reason)); | |
| 238 | }), | |
| 239 | }); | |
| 240 | await slow?.plan(situation()); | |
| 241 | expect(slow?.tally().failed.timeout).toBe(1); | |
| 242 | ||
| 243 | const gone = answering(() => { | |
| 244 | throw new TypeError("fetch failed"); | |
| 245 | }); | |
| 246 | await gone?.plan(situation()); | |
| 247 | expect(gone?.tally().failed.connection).toBe(1); | |
| 248 | ||
| 249 | for (const p of [invalid, junk, refused, slow, gone]) { | |
| 250 | const t = p?.tally() ?? emptyTally(); | |
| 251 | expect(t.asks).toBe(t.hits + t.joined + t.answered + failures(t)); | |
| 252 | expect(t.answered).toBe(0); | |
| 253 | } | |
| 254 | }); | |
| 255 | ||
| 256 | test("the tally handed out is a copy: a caller cannot move the planner's counts", async () => { | |
| 257 | const planner = createPlanner({ apiKey: "sk-test", fetch: async () => reply() }); | |
| 258 | const tally = planner?.tally(); | |
| 259 | if (tally !== undefined) tally.asks = 99; | |
| 260 | expect(planner?.tally().asks).toBe(0); | |
| 261 | }); | |
| 262 | ||
| 263 | test("the model it reports is the one configured, or the SDK's default", () => { | |
| 264 | expect(createPlanner({ apiKey: "sk-test", model: "jev-2" })?.model).toBe("jev-2"); | |
| 265 | expect(createPlanner({ apiKey: "sk-test" })?.model).toBe(process.env.TYPESAFE_DEFAULT_MODEL ?? "jev-latest"); | |
| 266 | }); |
src/plan/typesafe.ts +87 −18
| @@ -36,10 +36,22 @@ | ||
| 36 | 36 | * `localPlan`: the deterministic plan in `src/plan/plan.ts`, which is what a |
| 37 | 37 | * deployment with no key runs all the time. The game never waits on this and |
| 38 | 38 | * never fails because of it. |
| 39 | * | |
| 40 | * ## What it did | |
| 41 | * | |
| 42 | * Nothing here logs a line per round: a round a minute per player is noise in | |
| 43 | * the run log. Instead the planner counts, as integers, at the place each thing | |
| 44 | * is decided — a memory hit, a joined request, every HTTP attempt the SDK makes | |
| 45 | * (counted in the `fetch` it is handed, so a retry is a request too), an answer, | |
| 46 | * and each failure by its kind — and `tally()` hands them back as plain data. | |
| 47 | * `src/plan/readout.ts` is how they are shown; they reset with the process. | |
| 39 | 48 | */ |
| 40 | 49 | |
| 41 | import { choice, noul, score, TypeSafeClient, type Fetch } from "@typesafe-ai/sdk"; | |
| 50 | import { | |
| 51 | APIConnectionError, APIError, APITimeoutError, choice, noul, score, TypeSafeClient, type Fetch, | |
| 52 | } from "@typesafe-ai/sdk"; | |
| 42 | 53 | import { localPlan, type RoundPlan, type Shape, type SiteName, type TeamPlan } from "./plan.ts"; |
| 54 | import { emptyTally, type Tally } from "./readout.ts"; | |
| 43 | 55 | import { keyOf, type Situation } from "./situation.ts"; |
| 44 | 56 | |
| 45 | 57 | export type Planner = { |
| @@ -47,6 +59,10 @@ export type Planner = { | ||
| 47 | 59 | plan(situation: Situation): Promise<RoundPlan>; |
| 48 | 60 | /** How many answers are being remembered. For the tests. */ |
| 49 | 61 | size(): number; |
| 62 | /** What it has done since it was made: a copy, so the caller cannot move it. */ | |
| 63 | tally(): Tally; | |
| 64 | /** The model it asks for: the option, or the SDK's default. */ | |
| 65 | model: string; | |
| 50 | 66 | }; |
| 51 | 67 | |
| 52 | 68 | export type PlannerOptions = { |
| @@ -63,9 +79,10 @@ export type PlannerOptions = { | ||
| 63 | 79 | }; |
| 64 | 80 | |
| 65 | 81 | /** One attempt gets this long, and there is one retry behind it. */ |
| 66 | const TIMEOUT = 2500; | |
| 82 | export const TIMEOUT = 2500; | |
| 83 | export const RETRIES = 1; | |
| 67 | 84 | |
| 68 | const CEILING = 500; | |
| 85 | export const CEILING = 500; | |
| 69 | 86 | |
| 70 | 87 | /** |
| 71 | 88 | * The planner, or null when this deployment has no key — which is the state |
| @@ -75,30 +92,52 @@ const CEILING = 500; | ||
| 75 | 92 | export function createPlanner(options: PlannerOptions): Planner | null { |
| 76 | 93 | if (typeof options.apiKey !== "string" || options.apiKey.trim() === "") return null; |
| 77 | 94 | const { model, timeout = TIMEOUT, ceiling = CEILING, fetch } = options; |
| 95 | const counts = emptyTally(); | |
| 96 | const send = fetch ?? ((url, init) => globalThis.fetch(url, init)); | |
| 78 | 97 | const client = new TypeSafeClient({ |
| 79 | 98 | apiKey: options.apiKey, |
| 80 | 99 | ...(model === undefined ? {} : { defaultModel: model }), |
| 81 | 100 | timeout, |
| 82 | retry: { maxRetries: 1 }, | |
| 83 | ...(fetch === undefined ? {} : { fetch }), | |
| 101 | retry: { maxRetries: RETRIES }, | |
| 102 | // Every attempt passes through here, the retry included: this is the | |
| 103 | // count of requests the deployment is billed for, not of rounds. | |
| 104 | fetch: (url, init) => { | |
| 105 | counts.sent += 1; | |
| 106 | return send(url, init); | |
| 107 | }, | |
| 84 | 108 | }); |
| 85 | 109 | const answers = new Map<string, RoundPlan>(); |
| 86 | 110 | const flight = new Map<string, Promise<RoundPlan>>(); |
| 87 | 111 | |
| 88 | 112 | return { |
| 89 | 113 | size: () => answers.size, |
| 114 | tally: () => structuredClone(counts), | |
| 115 | model: client.defaultModel, | |
| 90 | 116 | async plan(situation) { |
| 117 | counts.asks += 1; | |
| 91 | 118 | const key = keyOf(situation); |
| 92 | 119 | // The key is the situation, round included, so two browsers playing the |
| 93 | 120 | // same round of the same match ask upstream once between them. |
| 94 | 121 | const held = answers.get(key); |
| 95 | if (held !== undefined) return held; | |
| 122 | if (held !== undefined) { | |
| 123 | counts.hits += 1; | |
| 124 | return held; | |
| 125 | } | |
| 96 | 126 | const running = flight.get(key); |
| 97 | if (running !== undefined) return running; | |
| 98 | const asking = ask(client, situation).catch((error: unknown) => { | |
| 99 | console.warn(`plan: falling back to the local plan — ${describeError(error)}`); | |
| 100 | return localPlan(situation); | |
| 101 | }); | |
| 127 | if (running !== undefined) { | |
| 128 | counts.joined += 1; | |
| 129 | return running; | |
| 130 | } | |
| 131 | const asking = ask(client, situation, counts).then( | |
| 132 | (plan) => { | |
| 133 | counts.answered += 1; | |
| 134 | return plan; | |
| 135 | }, | |
| 136 | (error: unknown) => { | |
| 137 | fail(counts, error); | |
| 138 | return localPlan(situation); | |
| 139 | }, | |
| 140 | ); | |
| 102 | 141 | flight.set(key, asking); |
| 103 | 142 | const plan = await asking; |
| 104 | 143 | flight.delete(key); |
| @@ -113,8 +152,24 @@ export function createPlanner(options: PlannerOptions): Planner | null { | ||
| 113 | 152 | }; |
| 114 | 153 | } |
| 115 | 154 | |
| 116 | const describeError = (error: unknown): string => | |
| 117 | error instanceof Error ? `${error.name}: ${error.message}` : "the planner threw something that is not an error"; | |
| 155 | /** An answer arrived and was not a plan: counted apart from the wire failing. */ | |
| 156 | class NotAPlan extends Error {} | |
| 157 | ||
| 158 | /** | |
| 159 | * One failure, counted by what it was. The timeout is asked before the | |
| 160 | * connection because the SDK's timeout is a kind of connection error; a status | |
| 161 | * is kept by its number, which is bounded by being one. | |
| 162 | */ | |
| 163 | function fail(counts: Tally, error: unknown): void { | |
| 164 | const failed = counts.failed; | |
| 165 | if (error instanceof NotAPlan) failed.invalid += 1; | |
| 166 | else if (error instanceof APITimeoutError) failed.timeout += 1; | |
| 167 | else if (error instanceof APIConnectionError) failed.connection += 1; | |
| 168 | else if (error instanceof APIError && Number.isInteger(error.status) && error.status >= 100 && error.status <= 599) { | |
| 169 | const code = String(error.status); | |
| 170 | failed.status[code] = (failed.status[code] ?? 0) + 1; | |
| 171 | } else failed.other += 1; | |
| 172 | } | |
| 118 | 173 | |
| 119 | 174 | /** |
| 120 | 175 | * The questions, and the answers turned back into a plan. Both sides are asked |
| @@ -122,8 +177,8 @@ const describeError = (error: unknown): string => | ||
| 122 | 177 | * answer about the attack it expects, and two requests could not know that |
| 123 | 178 | * about each other. |
| 124 | 179 | */ |
| 125 | async function ask(client: TypeSafeClient, situation: Situation): Promise<RoundPlan> { | |
| 126 | const { answers, model } = await client.systemOne({ | |
| 180 | async function ask(client: TypeSafeClient, situation: Situation, counts: Tally): Promise<RoundPlan> { | |
| 181 | const { answers: typed, model, usage } = await client.systemOne({ | |
| 127 | 182 | state: { |
| 128 | 183 | game: "a five-a-side bomb-defusal round, Counter-Strike rules, on a map with two bombsites called A and B", |
| 129 | 184 | map: situation.map, |
| @@ -150,11 +205,19 @@ async function ask(client: TypeSafeClient, situation: Situation): Promise<RoundP | ||
| 150 | 205 | ctSave: noul("Should the defenders save their money this round instead of buying?", SAVE_CRITERIA), |
| 151 | 206 | }, |
| 152 | 207 | }); |
| 153 | const t = teamFrom(answers.tSite.choice, answers.tShape.choice, answers.tTempo.score, answers.tSave.noul); | |
| 154 | const ct = teamFrom(answers.ctSite.choice, answers.ctShape.choice, answers.ctTempo.score, answers.ctSave.noul); | |
| 208 | // Tokens are what the vendor bills by, and the one figure it reports: they | |
| 209 | // are counted whether or not the answer turns out to be a plan. | |
| 210 | counts.tokens.input += tokensOf(usage, "input_tokens"); | |
| 211 | counts.tokens.output += tokensOf(usage, "output_tokens"); | |
| 212 | // Typed by the questions, and read as what arrived: a body that was not JSON | |
| 213 | // comes back from the SDK as a string, not as eight answers. | |
| 214 | const answers = typed as unknown as Record<string, Record<string, unknown> | undefined> | null; | |
| 215 | if (typeof answers !== "object" || answers === null) throw new NotAPlan("the answers were not answers"); | |
| 216 | const t = teamFrom(answers.tSite?.choice, answers.tShape?.choice, answers.tTempo?.score, answers.tSave?.noul); | |
| 217 | const ct = teamFrom(answers.ctSite?.choice, answers.ctShape?.choice, answers.ctTempo?.score, answers.ctSave?.noul); | |
| 155 | 218 | // An answer that is not a plan is not half-used: the round gets the local one |
| 156 | 219 | // whole, so both sides are always playing the same kind of round. |
| 157 | if (t === null || ct === null) throw new Error("the answers were not a plan this game can play"); | |
| 220 | if (t === null || ct === null) throw new NotAPlan("the answers were not a plan this game can play"); | |
| 158 | 221 | return { round: situation.round, source: "typesafe", model: modelName(model), t, ct }; |
| 159 | 222 | } |
| 160 | 223 | |
| @@ -183,6 +246,12 @@ const SAVE_CRITERIA = { | ||
| 183 | 246 | false: "the side should buy what it can afford and play the round", |
| 184 | 247 | } as const; |
| 185 | 248 | |
| 249 | /** A token count from the wire, or nothing: it is added to an integer. */ | |
| 250 | const tokensOf = (usage: unknown, field: string): number => { | |
| 251 | const n = typeof usage === "object" && usage !== null ? (usage as Record<string, unknown>)[field] : undefined; | |
| 252 | return Number.isSafeInteger(n) && (n as number) >= 0 && (n as number) < 10_000_000 ? (n as number) : 0; | |
| 253 | }; | |
| 254 | ||
| 186 | 255 | /** A model name is a string from somewhere else: it is shown, so it is bounded. */ |
| 187 | 256 | const modelName = (model: unknown): string => |
| 188 | 257 | typeof model === "string" && model.length > 0 && model.length <= 64 ? model : "typesafe"; |
src/server.test.ts +28 −1
| @@ -21,6 +21,7 @@ import { WEAPONS } from "./client/weapons.ts"; | ||
| 21 | 21 | import { endpoints, escape } from "./pages/shell.ts"; |
| 22 | 22 | import { localPlan, parsePlan } from "./plan/plan.ts"; |
| 23 | 23 | import { parseSituation, type Situation } from "./plan/situation.ts"; |
| 24 | import { plannedSaid, readReadout } from "./plan/readout.ts"; | |
| 24 | 25 | import { sign } from "./who.ts"; |
| 25 | 26 | import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts"; |
| 26 | 27 | import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts"; |
| @@ -43,7 +44,7 @@ const BUNDLES = bundlesDir(); | ||
| 43 | 44 | const KEY = "the-key-the-forge-signs-with"; |
| 44 | 45 | process.env.FORGE_APP_IDENTITY_KEY = KEY; |
| 45 | 46 | const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, PLAN_ID, ROOM_LIMIT, SOCKET_PATH, hub, onError, findScripts, |
| 46 | holdStatic } = await import("./server.ts"); | |
| 47 | holdStatic, plannerReadout } = await import("./server.ts"); | |
| 47 | 48 | const base = `http://${server.hostname}:${server.port}`; |
| 48 | 49 | |
| 49 | 50 | /** The two headers the forge puts on a request it proxies for a signed-in person. */ |
| @@ -1358,6 +1359,32 @@ test("a page with no planner behind it never tells a browser to ask for one", as | ||
| 1358 | 1359 | expect(body).toContain(escape("Above the fight the bots now plan a round")); |
| 1359 | 1360 | }); |
| 1360 | 1361 | |
| 1362 | test("a round asked of a server with no key is counted as that, and the terminal's copy follows", async () => { | |
| 1363 | // Written at start-up, before any round: the terminal has a readout to read. | |
| 1364 | const first = readReadout(process.env.FORGE_APP_DATA ?? ""); | |
| 1365 | expect(first?.on).toBe(false); | |
| 1366 | expect(first?.since).toBe(plannerReadout().since); | |
| 1367 | ||
| 1368 | const before = plannerReadout().unkeyed; | |
| 1369 | expect((await askPlan(JSON.stringify(situation()))).status).toBe(200); | |
| 1370 | const after = plannerReadout(); | |
| 1371 | expect(after.unkeyed).toBe(before + 1); | |
| 1372 | // Nothing went upstream and nothing was refused: there was nothing to ask with. | |
| 1373 | expect(after.tally.asks).toBe(0); | |
| 1374 | expect(after.refused).toEqual({ caller: 0, spend: 0 }); | |
| 1375 | expect(JSON.stringify(after)).not.toContain("TYPESAFE_API_KEY"); | |
| 1376 | ||
| 1377 | // A second after the round, the file the terminal reads says the same. | |
| 1378 | await Bun.sleep(1200); | |
| 1379 | expect(readReadout(process.env.FORGE_APP_DATA ?? "")?.unkeyed).toBe(after.unkeyed); | |
| 1380 | }); | |
| 1381 | ||
| 1382 | test("the page says in words what the counts say, and with no key it says so plainly", async () => { | |
| 1383 | const body = await (await fetch(base + "/")).text(); | |
| 1384 | expect(body).toContain(`<p class="hint" id="planned">${escape(plannedSaid(plannerReadout()))}</p>`); | |
| 1385 | expect(body).toContain("No TypeSafe key: no round has been asked of a model since this server started at"); | |
| 1386 | }); | |
| 1387 | ||
| 1361 | 1388 | test("the endpoint meta is how a page names where a plan comes from", () => { |
| 1362 | 1389 | expect(endpoints([{ id: PLAN_ID, href: "/api/plan" }])) |
| 1363 | 1390 | .toBe(`<meta name="endpoint" id="${PLAN_ID}" content="/api/plan">\n`); |
src/server.ts +55 −2
| @@ -67,7 +67,8 @@ import { mapFrom } from "./mapname.ts"; | ||
| 67 | 67 | import { seedFrom } from "./client/generate.ts"; |
| 68 | 68 | import { localPlan } from "./plan/plan.ts"; |
| 69 | 69 | import { parseSituation } from "./plan/situation.ts"; |
| 70 | import { createPlanner, type Planner } from "./plan/typesafe.ts"; | |
| 70 | import { CEILING, createPlanner, type Planner, RETRIES, TIMEOUT } from "./plan/typesafe.ts"; | |
| 71 | import { emptyTally, type Readout, writeReadout } from "./plan/readout.ts"; | |
| 71 | 72 | import { matchFrom } from "./matchid.ts"; |
| 72 | 73 | import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts"; |
| 73 | 74 | import { boardsHtml, rangePage } from "./pages/range.ts"; |
| @@ -255,6 +256,54 @@ const planLimit = createLimiter(PLAN_LIMIT); | ||
| 255 | 256 | export const PLAN_SPEND = { burst: 30, per: 2000 }; |
| 256 | 257 | const planSpend = createLimiter(PLAN_SPEND); |
| 257 | 258 | |
| 259 | /** | |
| 260 | * What the planner has done since this process started, and what it is set up | |
| 261 | * with: the planner and the two limiters count where they decide, and the one | |
| 262 | * decision made here — there is no key to ask with — is counted here. Plain | |
| 263 | * data, read in microseconds, never the key. `src/plan/readout.ts` says what | |
| 264 | * each figure means. | |
| 265 | */ | |
| 266 | const startedAt = new Date().toISOString(); | |
| 267 | let unkeyed = 0; | |
| 268 | export const plannerReadout = (): Readout => ({ | |
| 269 | on: planner !== null, | |
| 270 | model: planner?.model ?? process.env.TYPESAFE_MODEL ?? "jev-latest", | |
| 271 | since: startedAt, | |
| 272 | at: new Date().toISOString(), | |
| 273 | limits: { cache: CEILING, timeout: TIMEOUT, retries: RETRIES, caller: PLAN_LIMIT, spend: PLAN_SPEND }, | |
| 274 | tally: planner?.tally() ?? emptyTally(), | |
| 275 | refused: { caller: planLimit.refused(), spend: planSpend.refused() }, | |
| 276 | unkeyed, | |
| 277 | }); | |
| 278 | ||
| 279 | /** | |
| 280 | * The terminal is another process, so it reads the readout from a file the | |
| 281 | * server leaves in FORGE_APP_DATA: written at start-up (zeros — a restart is a | |
| 282 | * reset, and says so) and then at most once a second, a second after a round | |
| 283 | * was planned. Work that can wait, done after the response and off its path; | |
| 284 | * a disk that will not take it is said once in the run log and not again. | |
| 285 | */ | |
| 286 | const readoutDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 287 | let readoutDue: ReturnType<typeof setTimeout> | null = null; | |
| 288 | let readoutFailed = false; | |
| 289 | const leaveReadout = (): void => { | |
| 290 | try { | |
| 291 | writeReadout(readoutDir, plannerReadout()); | |
| 292 | } catch (error) { | |
| 293 | if (!readoutFailed) console.warn(`planner readout not written: ${error instanceof Error ? error.message : error}`); | |
| 294 | readoutFailed = true; | |
| 295 | } | |
| 296 | }; | |
| 297 | const readoutMoved = (): void => { | |
| 298 | if (readoutDue !== null) return; | |
| 299 | readoutDue = setTimeout(() => { | |
| 300 | readoutDue = null; | |
| 301 | leaveReadout(); | |
| 302 | }, 1000); | |
| 303 | readoutDue.unref?.(); | |
| 304 | }; | |
| 305 | leaveReadout(); | |
| 306 | ||
| 258 | 307 | /** |
| 259 | 308 | * How often one caller may open a room. A room is a world stepped 64 times a |
| 260 | 309 | * second for as long as somebody is in it, so opening one is the most expensive |
| @@ -529,6 +578,7 @@ const RENDER: Record<string, Render> = { | ||
| 529 | 578 | // the page makes no request at all. |
| 530 | 579 | endpoints: planner === null ? [] : [{ id: PLAN_ID, href: "/api/plan" }], |
| 531 | 580 | planning: planner !== null, |
| 581 | plans: plannerReadout(), | |
| 532 | 582 | who: who(request, identityKey), asked: asked(request), |
| 533 | 583 | }), |
| 534 | 584 | extra, |
| @@ -875,8 +925,11 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re | ||
| 875 | 925 | const situation = parseSituation(parsed); |
| 876 | 926 | if (situation === null) return text("bad situation", 422); |
| 877 | 927 | const caller = `plan:${listener.requestIP(request)?.address ?? "?"}|${readSession(request) ?? "-"}`; |
| 928 | if (planner === null) unkeyed += 1; | |
| 878 | 929 | const mine = planner !== null && planLimit.take(caller) && planSpend.take("all"); |
| 879 | return json(mine ? await (planner as Planner).plan(situation) : localPlan(situation)); | |
| 930 | const plan = mine ? await (planner as Planner).plan(situation) : localPlan(situation); | |
| 931 | readoutMoved(); | |
| 932 | return json(plan); | |
| 880 | 933 | } |
| 881 | 934 | |
| 882 | 935 | /** |
src/terminal/screen.ts +76 −1
| @@ -22,6 +22,7 @@ import { type Daily, RANKED_BY } from "../daily.ts"; | ||
| 22 | 22 | import type { DayRun, Liveness, MatchRow, RunRow, Standing } from "../db.ts"; |
| 23 | 23 | import { displayName, headshotShare, hitShare, liveness, ratio } from "../db.ts"; |
| 24 | 24 | import { matchOf } from "../matchid.ts"; |
| 25 | import { failures, localPlans, minuteOf, plannedSaid, type Rate, type Readout } from "../plan/readout.ts"; | |
| 25 | 26 | |
| 26 | 27 | export type Tab = "standings" | "matches" | "policy" | "today" | "runs" | "weapons"; |
| 27 | 28 | |
| @@ -61,6 +62,12 @@ export type Data = { | ||
| 61 | 62 | /** Every range run ever recorded. */ |
| 62 | 63 | runTotal: number; |
| 63 | 64 | card: Card; |
| 65 | /** | |
| 66 | * What the bots' round planner has done since the server started, from the | |
| 67 | * copy the server leaves in FORGE_APP_DATA (`src/plan/readout.ts`). Null | |
| 68 | * when there is none to read — no server has run here yet. Absent is null. | |
| 69 | */ | |
| 70 | plans?: Readout | null; | |
| 64 | 71 | /** |
| 65 | 72 | * The moment those rows were read, which is the only clock this file has: |
| 66 | 73 | * it tells a match still posting from one that stopped (`liveness` in |
| @@ -136,6 +143,74 @@ function policyLines(card: Card): string[] { | ||
| 136 | 143 | ]; |
| 137 | 144 | } |
| 138 | 145 | |
| 146 | /** `{ burst: 12, per: 3000 }` as a person reads it. */ | |
| 147 | const rateSaid = ({ burst, per }: Rate): string => `${burst} back to back, then 1 every ${per / 1000} s`; | |
| 148 | ||
| 149 | /** | |
| 150 | * The round planner beside the policy card: what this deployment is set up | |
| 151 | * with, then what it did, then the page's own sentence about it. Every count is | |
| 152 | * since `since` and labelled so; the key is never in a readout to be shown. | |
| 153 | */ | |
| 154 | function plannerLines(r: Readout | null): string[] { | |
| 155 | const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 22)}${esc}0m${printable(v)}`; | |
| 156 | const head = ` ${esc}1mwho plans the bots' rounds${esc}0m`; | |
| 157 | if (r === null) return [head, "", ` ${esc}2mno readout yet: the server has not run in this environment${esc}0m`]; | |
| 158 | const t = r.tally; | |
| 159 | const n = (x: number): string => x.toLocaleString("en"); | |
| 160 | const statuses = Object.entries(t.failed.status).sort(([a], [b]) => a.localeCompare(b)); | |
| 161 | const lines = [ | |
| 162 | head, | |
| 163 | "", | |
| 164 | line("planner", r.on ? `TypeSafe AI, model ${r.model}` : "off — no TYPESAFE_API_KEY, every round is planned locally"), | |
| 165 | ]; | |
| 166 | if (r.on) | |
| 167 | lines.push( | |
| 168 | line("key", "set (never shown)"), | |
| 169 | line("per caller", rateSaid(r.limits.caller)), | |
| 170 | line("spend ceiling", `${rateSaid(r.limits.spend)}, whole process`), | |
| 171 | line("per attempt", `${r.limits.timeout} ms, ${r.limits.retries} retry`), | |
| 172 | line("answers remembered", `up to ${n(r.limits.cache)}`), | |
| 173 | ); | |
| 174 | lines.push( | |
| 175 | "", | |
| 176 | ` ${esc}2mcounts since the server started at ${printable(minuteOf(r.since))}; reset on restart${esc}0m`, | |
| 177 | line("read at", minuteOf(r.at)), | |
| 178 | line("rounds to the planner", n(t.asks)), | |
| 179 | line(" answered by model", n(t.answered)), | |
| 180 | line(" from memory", n(t.hits)), | |
| 181 | line(" joined in flight", n(t.joined)), | |
| 182 | line(" failed", n(failures(t))), | |
| 183 | line(" timeout", n(t.failed.timeout)), | |
| 184 | line(" connection", n(t.failed.connection)), | |
| 185 | ...statuses.map(([code, count]) => line(` HTTP ${code}`, n(count))), | |
| 186 | line(" not a plan", n(t.failed.invalid)), | |
| 187 | line(" other", n(t.failed.other)), | |
| 188 | line("refused, per caller", n(r.refused.caller)), | |
| 189 | line("refused, spend limit", n(r.refused.spend)), | |
| 190 | line("asked with no key", n(r.unkeyed)), | |
| 191 | line("local plans served", n(localPlans(r))), | |
| 192 | line("requests upstream", `${n(t.sent)} (retries included)`), | |
| 193 | line("tokens", `${n(t.tokens.input)} in, ${n(t.tokens.output)} out — tokens, not money`), | |
| 194 | "", | |
| 195 | ...wrap(plannedSaid(r), 76).map((l) => ` ${esc}2m${printable(l)}${esc}0m`), | |
| 196 | ); | |
| 197 | return lines; | |
| 198 | } | |
| 199 | ||
| 200 | /** Words into lines no wider than `width`, for a sentence under a table. */ | |
| 201 | function wrap(text: string, width: number): string[] { | |
| 202 | const lines: string[] = []; | |
| 203 | let line = ""; | |
| 204 | for (const word of text.split(" ")) { | |
| 205 | if (line !== "" && line.length + 1 + word.length > width) { | |
| 206 | lines.push(line); | |
| 207 | line = word; | |
| 208 | } else line = line === "" ? word : `${line} ${word}`; | |
| 209 | } | |
| 210 | if (line !== "") lines.push(line); | |
| 211 | return lines; | |
| 212 | } | |
| 213 | ||
| 139 | 214 | /** |
| 140 | 215 | * The weapons, the same table the page renders and out of the same data: the |
| 141 | 216 | * numbers the game is tuned by, and what one shot of each does to a person. |
| @@ -310,7 +385,7 @@ export function screen(view: View, data: Data): string[] { | ||
| 310 | 385 | if (view.tab === "runs") lines.push(...runLines(data.runs, cursor)); |
| 311 | 386 | else if (view.tab === "weapons") lines.push(...weaponLines()); |
| 312 | 387 | else if (view.tab === "today") lines.push(...todayLines(data.daily, data.today)); |
| 313 | else if (view.tab === "policy") lines.push(...policyLines(data.card)); | |
| 388 | else if (view.tab === "policy") lines.push(...policyLines(data.card), "", ...plannerLines(data.plans ?? null)); | |
| 314 | 389 | else if (view.tab === "standings") lines.push(...standingsLines(data.standings, cursor)); |
| 315 | 390 | else lines.push(...matchLines(data.matches, cursor, data.now)); |
| 316 | 391 | lines.push(""); |
src/tty.test.ts +83 −2
| @@ -20,6 +20,7 @@ import { tmpdir } from "node:os"; | ||
| 20 | 20 | import { join } from "node:path"; |
| 21 | 21 | import { matchCount, openDatabase, saveMatch, type Player } from "./db.ts"; |
| 22 | 22 | import type { Result } from "./result.ts"; |
| 23 | import { emptyTally, type Readout } from "./plan/readout.ts"; | |
| 23 | 24 | import { plain } from "./terminal/screen.ts"; |
| 24 | 25 | import { run, type Io } from "./tty.ts"; |
| 25 | 26 | |
| @@ -71,7 +72,11 @@ type Session = { | ||
| 71 | 72 | * test can decide what is on the screen when the person arrives. |
| 72 | 73 | */ |
| 73 | 74 | function session( |
| 74 | options: { env?: Record<string, string | undefined>; seed?: (db: Database) => void } = {}, | |
| 75 | options: { | |
| 76 | env?: Record<string, string | undefined>; | |
| 77 | seed?: (db: Database) => void; | |
| 78 | plans?: () => Readout | null; | |
| 79 | } = {}, | |
| 75 | 80 | ): Session { |
| 76 | 81 | const db = openDatabase(mkdtempSync(join(tmpdir(), "tty-"))); |
| 77 | 82 | options.seed?.(db); |
| @@ -93,7 +98,10 @@ function session( | ||
| 93 | 98 | // session, nothing reads it: `screen.ts` owns the columns. |
| 94 | 99 | const output = { write: (text: string) => { writes.push(text); }, get columns() { return state.columns; } }; |
| 95 | 100 | const resize = { on: (_event: "resize", listener: () => void) => { resizes.push(listener); } }; |
| 96 | const io: Io = { input, output, resize, exit: (code) => { exits.push(code); }, db, env: options.env ?? {} }; | |
| 101 | const io: Io = { | |
| 102 | input, output, resize, exit: (code) => { exits.push(code); }, db, env: options.env ?? {}, | |
| 103 | ...(options.plans === undefined ? {} : { plans: options.plans }), | |
| 104 | }; | |
| 97 | 105 | run(io); |
| 98 | 106 | |
| 99 | 107 | const frame = (): string[] => (writes.at(-1) ?? "").slice(CLEAR.length).split("\r\n").map(plain); |
| @@ -315,3 +323,76 @@ test("a name out of the database cannot move the cursor or clear the screen", () | ||
| 315 | 323 | expect(tty.frame().at(-1)).toBe(`removed ${attack.replace(/\x1b/g, "·")}'s match`); |
| 316 | 324 | expect(matchCount(tty.db)).toBe(0); |
| 317 | 325 | }); |
| 326 | ||
| 327 | // --- the round planner, beside the policy card ------------------------------ | |
| 328 | ||
| 329 | /** A readout as the server leaves it: the same counts the page is given. */ | |
| 330 | const readout = (over: Partial<Readout> = {}): Readout => ({ | |
| 331 | on: true, | |
| 332 | model: "jev-1", | |
| 333 | since: "2026-09-23T10:04:11.201Z", | |
| 334 | at: "2026-09-23T11:00:00.000Z", | |
| 335 | limits: { cache: 500, timeout: 2500, retries: 1, caller: { burst: 12, per: 3000 }, spend: { burst: 30, per: 2000 } }, | |
| 336 | tally: { | |
| 337 | ...emptyTally(), | |
| 338 | asks: 3, answered: 1, sent: 3, | |
| 339 | failed: { timeout: 0, connection: 0, status: { "500": 1 }, invalid: 1, other: 0 }, | |
| 340 | tokens: { input: 640, output: 16 }, | |
| 341 | }, | |
| 342 | refused: { caller: 1, spend: 0 }, | |
| 343 | unkeyed: 0, | |
| 344 | ...over, | |
| 345 | }); | |
| 346 | ||
| 347 | const policyTab = (tty: Session): string[] => { | |
| 348 | tty.press("\t"); | |
| 349 | tty.press("\t"); | |
| 350 | return tty.frame(); | |
| 351 | }; | |
| 352 | ||
| 353 | test("the policy tab shows what the planner did: an answer, a failure and a refusal each counted", () => { | |
| 354 | const key = "sk-live-do-not-print-me"; | |
| 355 | const tty = session({ env: { TYPESAFE_API_KEY: key }, plans: () => readout() }); | |
| 356 | const lines = policyTab(tty); | |
| 357 | expect(lines[4]).toBe(" the policy the bots fight with"); | |
| 358 | expect(lines).toContain(" who plans the bots' rounds"); | |
| 359 | expect(lines).toContain(" planner TypeSafe AI, model jev-1"); | |
| 360 | expect(lines).toContain(" key set (never shown)"); | |
| 361 | expect(lines).toContain(" spend ceiling 30 back to back, then 1 every 2 s, whole process"); | |
| 362 | expect(lines).toContain(" counts since the server started at 2026-09-23 10:04 UTC; reset on restart"); | |
| 363 | expect(lines).toContain(" read at 2026-09-23 11:00 UTC"); | |
| 364 | // Every line fits the 80 columns the other tabs keep to. | |
| 365 | for (const line of lines) expect(line.length).toBeLessThanOrEqual(80); | |
| 366 | expect(lines).toContain(" answered by model 1"); | |
| 367 | expect(lines).toContain(" failed 2"); | |
| 368 | expect(lines).toContain(" HTTP 500 1"); | |
| 369 | expect(lines).toContain(" not a plan 1"); | |
| 370 | expect(lines).toContain(" refused, per caller 1"); | |
| 371 | expect(lines).toContain(" local plans served 3"); | |
| 372 | expect(lines).toContain(" tokens 640 in, 16 out — tokens, not money"); | |
| 373 | // The key is in this session's environment and nowhere on its screen. | |
| 374 | expect(tty.writes.join("")).not.toContain(key); | |
| 375 | expect(tty.writes.join("")).not.toContain("sk-"); | |
| 376 | }); | |
| 377 | ||
| 378 | test("a deployment with no key reads as off, with nothing asked of a model", () => { | |
| 379 | const lines = policyTab(session({ plans: () => readout({ on: false, tally: emptyTally(), refused: { caller: 0, spend: 0 } }) })); | |
| 380 | expect(lines).toContain(" planner off — no TYPESAFE_API_KEY, every round is planned locally"); | |
| 381 | expect(lines).not.toContain(" key set (never shown)"); | |
| 382 | expect(lines.join(" ")).toContain("No TypeSafe key: no round has been asked of a model"); | |
| 383 | }); | |
| 384 | ||
| 385 | test("with no readout to read, the terminal says the server has not run rather than showing zeros", () => { | |
| 386 | const lines = policyTab(session()); | |
| 387 | expect(lines).toContain(" no readout yet: the server has not run in this environment"); | |
| 388 | expect(lines.join("\n")).not.toContain("rounds to the planner"); | |
| 389 | }); | |
| 390 | ||
| 391 | test("the readout is read again on every draw, so r shows the counts as they are now", () => { | |
| 392 | let asks = 0; | |
| 393 | const tty = session({ plans: () => readout({ tally: { ...emptyTally(), asks, answered: asks } }) }); | |
| 394 | policyTab(tty); | |
| 395 | asks = 7; | |
| 396 | tty.press("r"); | |
| 397 | expect(tty.frame()).toContain(" rounds to the planner 7"); | |
| 398 | }); |
src/tty.ts +10 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | * cs, in a terminal. The forge runs this on a pty when someone `ssh`es to the |
| 3 | 3 | * app, with FORGE_APP_DATA pointing at the same database the web page uses: |
| 4 | 4 | * the same leaderboard, the same matches. j/k move, tab cycles the standings, |
| 5 | * the matches, the policy card, the match of the day — the id everybody is | |
| 5 | * the matches, the policy card and what the round planner has done, the match of the day — the id everybody is | |
| 6 | 6 | * playing today and the board of who has played it best — and the range's runs, |
| 7 | 7 | * x removes the selected row from whichever of the two tables it is in, r |
| 8 | 8 | * refreshes, q leaves. Plain ANSI, no dependencies. |
| @@ -27,6 +27,7 @@ import { | ||
| 27 | 27 | } from "./db.ts"; |
| 28 | 28 | import { dailyOn } from "./daily.ts"; |
| 29 | 29 | import { CARD } from "./client/weights.ts"; |
| 30 | import { readReadout, type Readout } from "./plan/readout.ts"; | |
| 30 | 31 | import { printable, screen, type Data, type View } from "./terminal/screen.ts"; |
| 31 | 32 | import { press, removed } from "./terminal/keys.ts"; |
| 32 | 33 | |
| @@ -57,6 +58,12 @@ export type Io = { | ||
| 57 | 58 | db: Database; |
| 58 | 59 | /** FORGE_APP_ENV, FORGE_APP_PULL and FORGE_APP_SSH are all that is read. */ |
| 59 | 60 | env: Record<string, string | undefined>; |
| 61 | /** | |
| 62 | * What the round planner has done, as the server last left it: the real one | |
| 63 | * reads FORGE_APP_DATA/planner.json, a test hands back a readout of its own. | |
| 64 | * Absent reads as none. | |
| 65 | */ | |
| 66 | plans?(): Readout | null; | |
| 60 | 67 | }; |
| 61 | 68 | |
| 62 | 69 | /** Wipe the screen and put the cursor home. Written on every paint. */ |
| @@ -95,6 +102,7 @@ export function run(io: Io): void { | ||
| 95 | 102 | total: matchCount(io.db), |
| 96 | 103 | runTotal: runCount(io.db), |
| 97 | 104 | card: CARD, |
| 105 | plans: io.plans?.() ?? null, | |
| 98 | 106 | // Read with the rows, and only ever here: the screen tells a live match |
| 99 | 107 | // from an abandoned one against the moment it was handed. |
| 100 | 108 | now, |
| @@ -159,5 +167,6 @@ if (import.meta.main) { | ||
| 159 | 167 | exit: (code) => process.exit(code), |
| 160 | 168 | db: openDatabase(), |
| 161 | 169 | env: process.env, |
| 170 | plans: () => readReadout(process.env.FORGE_APP_DATA ?? "./data"), | |
| 162 | 171 | }); |
| 163 | 172 | } |