Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

study typesafe.ai and integrate it into the bots for individual planning and… #235

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/228-study-typesafe-ai-and-integrate-it-into → main 1 commit fork

  • Merged. vesper/228-study-typesafe-ai-and-integrate-it-into was merged into main as 8a4655f.

Review

Sign in to review this change.
21 files changed +1557 −81
README.md +39
@@ -30,6 +30,45 @@ bun run scripts/mutate-result.ts # break each rule in src
3030 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
3131 ```
3232
33
34 ## The bots' round: planning with TypeSafe AI
35
36 The bots have had two layers for a while — A* solves the objective, a trained
37 policy runs the fight — and nothing in between: every bot flipped its own coin
38 for a site, so five attackers arrived at two sites in ones and twos. There is
39 now a third layer, the round itself.
40
41 A **round plan** is one site, one shape (`stack`, `split` or `fake`), a tempo
42 and a save, per side. `src/plan/plan.ts` cuts it into one **job** per bot — the
43 site it walks to, whether it goes in first or gives the entry its beat, how far
44 off the site it is willing to stand, and whether it buys at all — and
45 `src/client/bots.ts` plays it. The fight is untouched: the policy and its
46 weights are exactly what they were.
47
48 Where the plan comes from depends on one environment variable:
49
50 - **No `TYPESAFE_API_KEY`** (every preview, and production until somebody sets
51 one): the page plans every round itself with `localPlan`, which is
52 arithmetic over the situation. Nothing is sent anywhere, and the bots are
53 still a team.
54 - **With a key**: the page posts the round's situation — the map, the round
55 number, the score, what each side can afford, and nothing about you — to
56 `POST /api/plan` on this server, which puts it to
57 [TypeSafe AI](https://typesafe.ai) as *typed questions*: a choice between the
58 two sites, a rubric for the tempo, a yes/no for the save. The answer is
59 validated back into a plan or dropped for the local one. The key is read from
60 this process's environment at start-up and never reaches a page.
61
62 What is real and what is not: the plan is real — a model really answers it when
63 a key is set, and you can watch the call in the round's line on the HUD, which
64 says which side is doing what. Everything else about the game is still local:
65 the match, the fight, the physics and the weights all run in your browser.
66
67 `TYPESAFE_MODEL` overrides the model (the SDK's default is `jev-latest`).
68 Planning is rate-limited per caller and under a spend ceiling for the whole
69 process; over either, or on any failure, the round gets the local plan, which is
70 a plan and not an error.
71
3372 ## What is real and what is not
3473
3574 - **Real.** The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style
bun.lock +5
@@ -4,6 +4,9 @@
44 "workspaces": {
55 "": {
66 "name": "cs-engine",
7 "dependencies": {
8 "@typesafe-ai/sdk": "^0.6.0",
9 },
710 "devDependencies": {
811 "@types/bun": "^1.4.2",
912 "typescript": "^7.0.2",
@@ -15,6 +18,8 @@
1518
1619 "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
1720
21 "@typesafe-ai/sdk": ["@typesafe-ai/[email protected]", "", {}, "sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw=="],
22
1823 "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
1924
2025 "@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
package.json +3
@@ -11,5 +11,8 @@
1111 "devDependencies": {
1212 "@types/bun": "^1.4.2",
1313 "typescript": "^7.0.2"
14 },
15 "dependencies": {
16 "@typesafe-ai/sdk": "^0.6.0"
1417 }
1518 }
src/client/bots.test.ts +102 −2
@@ -2,9 +2,10 @@ import { expect, test } from "bun:test";
22 import { PACKED, useShipped } from "./shipped.ts";
33 import { unpack, type Policy } from "../engine/index.ts";
44 import {
5 applyIntent, blindness, botThink, dry, flashed, makeBrain, nextWeapon, NO_POLICY, pathTo, policy, usePolicy,
6 ride, think,
5 applyIntent, blindness, botBuy, botThink, dry, flashed, makeBrain, nextWeapon, NO_POLICY, pathTo, policy,
6 ride, roundPlan, think, usePlan, usePolicy,
77 } from "./bots.ts";
8 import type { RoundPlan, TeamPlan } from "../plan/plan.ts";
89 import { grip } from "./spray.ts";
910 import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts";
1011 import { dist, type Vec3 } from "../engine/index.ts";
@@ -327,3 +328,102 @@ test("a bot with bullets left plays exactly as it did: no swap, no knife", () =>
327328 expect(d.bot.weapon).toBe("ak47");
328329 expect(shots).toBeGreaterThan(0);
329330 });
331
332 // --- the round's plan -------------------------------------------------------
333
334 const planned = (over: Partial<TeamPlan> = {}): RoundPlan => ({
335 round: 1,
336 source: "typesafe",
337 model: "jev-1",
338 t: { site: "A", shape: "stack", tempo: 2, save: false, ...over },
339 ct: { site: "A", shape: "stack", tempo: 2, save: false, ...over },
340 });
341
342 /** A full ten-bot round, frozen on the first tick, with every brain made. */
343 const round = (seed = 77): ReturnType<typeof createWorld> => {
344 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 5, seed);
345 world.match.phase = "freeze";
346 return world;
347 };
348
349 const bots = (world: ReturnType<typeof createWorld>, team: "t" | "ct"): Player[] =>
350 world.players.filter((p) => p.team === team && p !== world.me);
351
352 test("with no plan a bot is what it always was: its own coin flip, no wait", () => {
353 const world = round();
354 const bot = bots(world, "t")[0] as Player;
355 botThink(world, bot, 1 / 64);
356 expect(roundPlan(world)).toBeNull();
357 expect(bot.brain?.job.role).toBe("support");
358 expect(bot.brain?.job.delay).toBe(0);
359 expect(["A", "B"]).toContain(bot.brain?.job.site ?? "");
360 });
361
362 test("a plan makes a team a team: one site between them, and one of them first", () => {
363 const world = round();
364 usePlan(world, planned({ site: "B" }));
365 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
366 const attackers = bots(world, "t");
367 expect(attackers.every((bot) => bot.brain?.job.site === "B")).toBe(true);
368 expect(attackers.filter((bot) => bot.brain?.job.role === "entry")).toHaveLength(1);
369 // The entry goes now and the rest give it its beat: that is the coordination.
370 const entry = attackers.find((bot) => bot.brain?.job.role === "entry") as Player;
371 expect(entry.brain?.job.delay).toBe(0);
372 expect(attackers.some((bot) => (bot.brain?.job.delay ?? 0) > 0)).toBe(true);
373 });
374
375 test("a split sends the attackers to both sites at once", () => {
376 const world = round();
377 usePlan(world, planned({ site: "A", shape: "split" }));
378 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
379 const sites = bots(world, "t").map((bot) => bot.brain?.job.site);
380 expect(sites.filter((site) => site === "A").length).toBeGreaterThan(0);
381 expect(sites.filter((site) => site === "B").length).toBeGreaterThan(0);
382 });
383
384 test("a bot waiting for its beat stands still, and goes when the beat comes", () => {
385 const world = round();
386 usePlan(world, planned());
387 world.match.phase = "freeze";
388 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
389 const waiting = bots(world, "t").find((bot) => (bot.brain?.job.delay ?? 0) > 0) as Player;
390 waiting.hasBomb = false;
391 world.match.phase = "live";
392 world.time += 0.5;
393 const held = botThink(world, waiting, 1 / 64);
394 expect(held.forward).toBe(0);
395 expect(held.side).toBe(0);
396 // Past the wait it moves off towards the site like any other bot.
397 world.time = (waiting.brain?.pushAt ?? 0) + 0.1;
398 const moving = botThink(world, waiting, 1 / 64);
399 expect(Math.abs(moving.forward) + Math.abs(moving.side)).toBeGreaterThan(0);
400 });
401
402 test("a plan that says save is a round nothing is spent on a rifle", () => {
403 const world = round();
404 const bot = bots(world, "t")[0] as Player;
405 bot.money = 2400;
406 usePlan(world, planned({ save: true }));
407 botBuy(world, bot);
408 expect(bot.primary).toBeNull();
409 expect(bot.money).toBe(2400);
410
411 // The same money with the same plan, on a side that is not saving: it buys.
412 const spending = round(78);
413 const other = bots(spending, "t")[0] as Player;
414 other.money = 2400;
415 usePlan(spending, planned({ save: false }));
416 botBuy(spending, other);
417 expect(other.money).toBeLessThan(2400);
418 });
419
420 test("a plan that lands mid-round re-tasks the team it was cut for", () => {
421 const world = round();
422 usePlan(world, planned({ site: "A" }));
423 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
424 expect(bots(world, "t").every((bot) => bot.brain?.job.site === "A")).toBe(true);
425 usePlan(world, planned({ site: "B" }));
426 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
427 expect(bots(world, "t").every((bot) => bot.brain?.job.site === "B")).toBe(true);
428 expect(roundPlan(world)?.t.site).toBe("B");
429 });
src/client/bots.ts +118 −7
@@ -9,6 +9,16 @@
99 * reflex is what reinforcement learning is good at: src/rl trains this policy
1010 * on millions of duels in the same simulation you are playing.
1111 *
12 * Between the two there is now a third thing, and it is the round: `usePlan`
13 * hands the bots a `RoundPlan` — a site, a shape, a tempo and a save for each
14 * side — and `src/plan/plan.ts` cuts it into one job per bot, so five attackers
15 * arrive at one site together, or at two sites in two groups, instead of each
16 * flipping its own coin. Where that plan came from is not this file's business:
17 * the page asks its own server, and the server asks TypeSafe AI when it has a
18 * key and answers with the local plan when it has not. Bots nobody planned for
19 * — the arena's, the trainer's — behave exactly as they did before, down to the
20 * coin flip, because `usePlan` was never called on them.
21 *
1222 * The weights are not in here and are not imported: they arrive as data. In a
1323 * browser `src/client/main.ts` downloads `weights.json` as this game's own
1424 * asset and calls `usePolicy` before Play is pressable; in Bun
@@ -22,6 +32,7 @@ import {
2232 angleDelta, clamp, dist, forward, newActivations, rng, sample,
2333 type Activations, type Policy, type Vec3,
2434 } from "../engine/index.ts";
35 import { assign, DEFAULT_JOB, type Job, type RoundPlan } from "../plan/plan.ts";
2536 import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts";
2637 import { CS_HULL } from "./movement.ts";
2738 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
@@ -58,6 +69,24 @@ export function policy(): Policy {
5869 return learned;
5970 }
6071
72 /**
73 * Hand one world's bots the round they are playing. `main.ts` does this every
74 * round: the local plan the moment the round starts, and the planned one if a
75 * better one arrives while it is still that round.
76 *
77 * It is the world's and not this module's, unlike the policy above, because a
78 * plan is one match's round and a policy is the whole game's: a plan in a
79 * module would be one match's orders reaching the next match's bots, and the
80 * arena's duels and the trainer's rounds — worlds nobody plans for — would be
81 * playing whatever the last page happened to ask for.
82 */
83 export function usePlan(world: World, plan: RoundPlan | null): void {
84 world.plan = plan;
85 }
86
87 /** The round this world's bots are playing, or null when nobody planned one. */
88 export const roundPlan = (world: World): RoundPlan | null => world.plan;
89
6190 /** The policy decides at 16 Hz and holds it, exactly as it was trained. */
6291 const SKIP = 4;
6392
@@ -82,7 +111,13 @@ export type BotBrain = {
82111 strafeUntil: number;
83112 suspect: Vec3 | null;
84113 plannedFor: string;
85 site: "A" | "B";
114 /** This bot's share of the round: a site, a role, a beat to wait, a spread. */
115 job: Job;
116 /** The plan the job was cut from, and the round it was cut for. */
117 jobPlan: RoundPlan | null;
118 jobRound: number;
119 /** The clock the job's wait is up at. Set while the round is still frozen. */
120 pushAt: number;
86121 reset: boolean;
87122 random: () => number;
88123 // What the policy needs to run, one set per bot so nothing is shared.
@@ -111,7 +146,8 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
111146 goal: null, path: [], step: 0, repathAt: 0,
112147 targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0,
113148 strafe: 0, strafeUntil: 0, suspect: null, plannedFor: "",
114 site: "A", reset: true,
149 job: DEFAULT_JOB, jobPlan: null, jobRound: -1, pushAt: 0,
150 reset: true,
115151 random: rng(seed),
116152 obs: new Float32Array(OBS_SIZE),
117153 // Sized from the shape the game asks for rather than from a policy: a
@@ -194,9 +230,23 @@ export function ride(bot: Player, brain: BotBrain, grip: number, dt: number): vo
194230 /** How far a bot notices an enemy at all, in units, with its eyes working. */
195231 const SIGHT = 4000;
196232
233 /**
234 * A bot rich enough that a save costs it nothing buys anyway, which is what a
235 * person does with a round's worth of money already in the bank.
236 */
237 const FORCE = 5000;
238
197239 /** What a bot spends its money on, roughly in the order a person would. */
198240 export function botBuy(world: World, bot: Player): void {
199241 const brain = brainOf(world, bot);
242 const job = jobOf(world, bot, brain);
243 // The economy half of a plan: a round the side is saving is a round nothing
244 // leaves the bank, so the next one is a full buy for five rather than a rifle
245 // for two. Armour is still worth it to whoever can spare the change.
246 if (job.save && bot.money < FORCE) {
247 if (bot.armor < 100 && bot.money >= 2000) buy(world, bot, "armor");
248 return;
249 }
200250 const rich = bot.money >= 4000;
201251 const wants: WeaponId[] = [];
202252 if (rich && brain.random() < 0.15 && bot.money >= WEAPONS.awp.price + 1000) wants.push("awp");
@@ -258,11 +308,48 @@ function brainOf(world: World, bot: Player): BotBrain {
258308 return bot.brain;
259309 }
260310
311 /**
312 * This bot's job, cut from the round's plan the first time anyone asks for it
313 * after that plan arrived. A job belongs to a team and not to a bot — an entry
314 * only means anything if somebody else is the support — so asking for one bot's
315 * re-tasks its whole side at once, in one `assign`, and the other four find
316 * theirs already waiting.
317 *
318 * With no plan handed in this is whatever `botThink` put there at the start of
319 * the round, which is the coin flip these bots have always made. Nothing about
320 * the arena or the trainer changes until somebody calls `usePlan`.
321 */
322 function jobOf(world: World, bot: Player, brain: BotBrain): Job {
323 const plan = world.plan;
324 if (plan === null) return brain.job;
325 // The plan itself is the stamp: a new one is a new object, and a bot holding
326 // a job cut from another one is a bot with yesterday's orders.
327 if (brain.jobPlan === plan && brain.jobRound === world.match.round) return brain.job;
328 const mates = world.players.filter((p) => p.team === bot.team);
329 const brains = mates.map((mate) => brainOf(world, mate));
330 const jobs = assign(plan[bot.team], bot.team, mates.map((mate, at) => ({
331 id: mate.id,
332 skill: brains[at]?.skill ?? 0,
333 })));
334 brains.forEach((mate, at) => {
335 mate.job = jobs[at] ?? DEFAULT_JOB;
336 mate.jobPlan = plan;
337 mate.jobRound = world.match.round;
338 // The wait runs from the moment the round goes live, and the freeze time is
339 // still running: `botThink` pushes this forward on every frozen tick.
340 mate.pushAt = world.time + mate.job.delay;
341 // A goal cut for the site this bot is no longer going to is not a goal.
342 mate.plannedFor = "";
343 });
344 return brain.job;
345 }
346
261347 /** Where this bot is trying to be, given what the round is doing. */
262348 function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 {
263349 const bomb = world.bomb;
264350 const level = world.level;
265 const site = level.sites[brain.site];
351 const job = brain.job;
352 const site = level.sites[job.site];
266353 if (bot.team === "t") {
267354 if (bomb.state === "planted") {
268355 // Hold near the bomb rather than walk onto it.
@@ -270,10 +357,12 @@ function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 {
270357 }
271358 if (bot.hasBomb) return site;
272359 if (bomb.state === "dropped") return bomb.pos;
273 return nearby(level, brain, site, 200);
360 // An entry walks onto the site; a lurker is happy five hundred units off
361 // it, watching the way somebody would come. That is what a role is worth.
362 return nearby(level, brain, site, job.spread * 0.8);
274363 }
275364 if (bomb.state === "planted") return bomb.pos;
276 return nearby(level, brain, site, 250);
365 return nearby(level, brain, site, job.spread);
277366 }
278367
279368 const nearby = (level: Level, brain: BotBrain, at: Vec3, spread: number): Vec3 => {
@@ -466,7 +555,12 @@ export function botThink(world: World, bot: Player, dt: number): Input {
466555 const input: Input = { ...NO_INPUT };
467556 if (brain.reset) {
468557 brain.reset = false;
469 brain.site = brain.random() < 0.5 ? "A" : "B";
558 // The job a bot has when no round was planned for it: its own coin flip for
559 // a site, no wait and the spread these bots have always used. A plan, if one
560 // arrived, replaces this on the next line and for the rest of the round.
561 brain.job = { ...DEFAULT_JOB, site: brain.random() < 0.5 ? "A" : "B" };
562 brain.jobPlan = null;
563 brain.pushAt = 0;
470564 brain.goal = null;
471565 brain.path = [];
472566 brain.targetId = null;
@@ -479,7 +573,12 @@ export function botThink(world: World, bot: Player, dt: number): Input {
479573 brain.pull = [0, 0];
480574 input.switchTo = bot.primary ?? bot.secondary;
481575 }
576 const job = jobOf(world, bot, brain);
482577 if (world.match.phase === "freeze") {
578 // Nobody moves in the freeze time, so a job's wait cannot start running
579 // inside it: it is pushed along in front of the clock and starts the moment
580 // the round goes live, which is the beat an entry is ahead by.
581 brain.pushAt = world.time + job.delay;
483582 return input;
484583 }
485584
@@ -532,9 +631,21 @@ export function botThink(world: World, bot: Player, dt: number): Input {
532631 return withBomb(world, bot, input);
533632 }
534633
634 // --- waiting. A support does not walk out with the entry; it gives it its
635 // beat and follows. The bomb never waits — a round where the bomb stands at
636 // spawn for two seconds is two seconds off the clock — and neither does a bot
637 // with something in front of it, because the fight above returned already.
638 if (world.time < brain.pushAt && !bot.hasBomb && world.bomb.state === "carried") {
639 if (brain.suspect !== null) {
640 const look = aimAt(bot, brain.suspect);
641 turnTowards(bot, { yaw: look.yaw, pitch: clamp(look.pitch, -20, 20) }, brain.aimSpeed * 0.5, dt);
642 }
643 return withBomb(world, bot, input);
644 }
645
535646 // --- moving. A path is asked for when there is none, when it runs out, when
536647 // it goes stale, or when the objective itself changed under the bot's feet.
537 const objective = `${world.bomb.state}:${brain.site}`;
648 const objective = `${world.bomb.state}:${job.site}`;
538649 const stale =
539650 brain.path.length === 0 || brain.step >= brain.path.length ||
540651 world.time > brain.repathAt || objective !== brain.plannedFor;
src/client/main.test.ts +39 −1
@@ -40,6 +40,7 @@ import { WEAPONS } from "./weapons.ts";
4040 import { matchOf } from "../matchid.ts";
4141 import { parseResult } from "../result.ts";
4242 import { fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts";
43 import { parseSituation } from "../plan/situation.ts";
4344 import { createWorld, type World } from "./world.ts";
4445
4546 /** Every frame booted here, so a stage can be asked what world it is running. */
@@ -69,7 +70,9 @@ function painted(stage: FakeStage): FakeStage {
6970
7071 /** A booted page, its atlas painted, and the match already started on it. */
7172 function playing(
72 options: { webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean } = {},
73 options: {
74 webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean; planEndpoint?: string;
75 } = {},
7376 ): FakeStage {
7477 const stage = painted(start(fakeStage(options)));
7578 stage.by("play").fire("click");
@@ -1234,3 +1237,38 @@ test("the match being played is on the page: the id, the map, the link and the p
12341237 expect(stage.by("mapname").textContent).toBe("de_forge");
12351238 expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=de_forge`);
12361239 });
1240
1241 // --- the round's plan -------------------------------------------------------
1242
1243 test("a round is planned in the page, and the bots are a team from the first tick", () => {
1244 const stage = playing();
1245 stage.step(FRAME);
1246 const world = worldOf(stage) as World;
1247 const plan = world.plan;
1248 expect(plan).not.toBeNull();
1249 expect(plan?.source).toBe("local");
1250 expect(plan?.round).toBe(world.match.round);
1251 // The call is on the screen where a round's call belongs.
1252 expect(world.messages[world.messages.length - 1]?.text).toContain(world.me.team.toUpperCase());
1253 // A page that named no endpoint asks nobody: the only post is the result.
1254 expect(stage.posts.filter((post) => post.url === "/api/plan")).toHaveLength(0);
1255 });
1256
1257 test("a page that names a planner asks it, with the situation and nothing else", async () => {
1258 const stage = playing({ planEndpoint: "/api/plan" });
1259 stage.step(FRAME);
1260 const posted = stage.posts.filter((post) => post.url === "/api/plan");
1261 expect(posted).toHaveLength(1);
1262 const sent = JSON.parse(String(posted[0]?.body)) as Record<string, unknown>;
1263 expect(parseSituation(sent)).not.toBeNull();
1264 expect(sent.round).toBe(1);
1265 expect(Object.keys(sent).sort()).toEqual(["lastWinner", "map", "money", "round", "score"]);
1266 expect(JSON.stringify(sent)).not.toContain("matchId");
1267 // The stage answers every post with the leaderboard, which is not a plan: the
1268 // local plan stays in force and the round is played on it.
1269 await Promise.resolve();
1270 stage.step(FRAME);
1271 const world = worldOf(stage) as World;
1272 expect(world.plan?.source).toBe("local");
1273 expect(stepsRun(stage)).toBeGreaterThan(0);
1274 });
src/client/main.ts +87 −3
@@ -60,21 +60,23 @@ import {
6060 import { generateLevel, seedFrom } from "./generate.ts";
6161 import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts";
6262 import { matchFrom, playKey } from "../matchid.ts";
63 import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts";
64 import type { Situation } from "../plan/situation.ts";
6365 import { DE_FORGE } from "./map.ts";
6466 import type { Level } from "./level.ts";
6567 import { FOV, fovFor, lookAt } from "./look.ts";
66 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
68 import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts";
6769 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
6870 import { meshFor } from "./models.ts";
6971 import { parsePacked } from "./packed.ts";
7072 import { nextStep, type Answer } from "./pending.ts";
7173 import { drawPreview, previewText } from "./preview.ts";
7274 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
73 import { usePolicy } from "./bots.ts";
75 import { usePlan, usePolicy } from "./bots.ts";
7476 import { createSpectator, cycle, follow } from "./spectate.ts";
7577 import { buyable, WEAPONS } from "./weapons.ts";
7678 import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
77 import { createWorld, eyePos, update, type Player, type World } from "./world.ts";
79 import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts";
7880 import { unpack } from "../engine/index.ts";
7981
8082 const TICK = 1 / 64;
@@ -328,6 +330,14 @@ export function boot(host: Host): Frame {
328330 // Where this game's weights are: the shell put the link in the head with the
329331 // content-hashed URL on it, so this is the only place the path is known.
330332 const weightsLink = doc.getElementById("weights") as Link | null;
333 /**
334 * Where this deployment plans a round, if it plans one anywhere but here: the
335 * `<meta>` the shell renders for an endpoint, read by its id, so no path to
336 * this server is written into this file. A page that named none — every
337 * deployment without a TypeSafe key — plans every round in the browser.
338 */
339 const planMeta = doc.getElementById("botplan") as Link | null;
340 const planEndpoint = planMeta === null ? null : planMeta.getAttribute("content");
331341 const resumeButton = by("resume");
332342 // The line in the pause panel that says what Escape just did on this browser.
333343 const escHint = by("esc");
@@ -851,6 +861,7 @@ export function boot(host: Host): Frame {
851861 pauseId.textContent = `Match ${matchId} on ${level.name}.`;
852862 share.setAttribute("href", `/?match=${matchId}&map=${choice}`);
853863 world = createWorld(level, rules, name, team, 5, seed);
864 plannedRound = 0;
854865 posted = -1;
855866 loop.reset();
856867 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
@@ -908,6 +919,75 @@ export function boot(host: Host): Frame {
908919 lastSound = current.time;
909920 }
910921
922 /**
923 * The round the bots have been planned for, and whether a request for a
924 * better plan is in the air. Both are per match: a new world is round one
925 * again, and `planFor` below is what puts the first plan in place before the
926 * first freeze time is over.
927 */
928 let plannedRound = 0;
929 let planning = false;
930
931 /**
932 * Plan the round about to be played. The local plan goes in at once, so the
933 * bots are a team from the first tick whatever happens next; then, on a
934 * deployment that named an endpoint for it, the same situation is put to the
935 * server, which puts it to TypeSafe AI. Whatever comes back is validated
936 * (`parsePlan`) and replaces the local one, and anything else — a refusal, a
937 * 500, junk, a connection that died — leaves the local plan exactly where it
938 * is. Nothing here is awaited by the frame.
939 */
940 function planFor(current: World): void {
941 const match = current.match;
942 plannedRound = match.round;
943 const situation: Situation = {
944 map: mapFrom(current.level.name),
945 round: match.round,
946 score: { t: match.score.t, ct: match.score.ct },
947 money: { t: teamMoney(current, "t"), ct: teamMoney(current, "ct") },
948 lastWinner: match.winner,
949 };
950 const local = localPlan(situation);
951 usePlan(current, local);
952 announce(current, local);
953 if (planEndpoint === null || planning) return;
954 planning = true;
955 void (async (): Promise<void> => {
956 try {
957 const response = await host.fetch(planEndpoint, {
958 method: "POST",
959 headers: { "content-type": "application/json" },
960 body: JSON.stringify(situation),
961 });
962 const plan = response.ok ? parsePlan(JSON.parse(await response.text())) : null;
963 // A plan that arrived for a round that has already ended is last
964 // round's plan: the bots keep the one they are playing.
965 if (plan !== null && world === current && current.match.round === plan.round) {
966 usePlan(current, plan);
967 announce(current, plan);
968 }
969 } catch {
970 // The local plan is already in force. A round is not lost because a
971 // planner was.
972 }
973 planning = false;
974 })();
975 }
976
977 /** What each side has to spend, per player, which is what a plan is told. */
978 const teamMoney = (current: World, team: Team): number => {
979 const side = current.players.filter((p) => p.team === team);
980 const total = side.reduce((sum, p) => sum + p.money, 0);
981 return side.length === 0 ? 0 : Math.max(0, Math.min(16_000, Math.round(total / side.length)));
982 };
983
984 /** The plan, in the words the HUD's message line shows for a few seconds. */
985 const announce = (current: World, plan: RoundPlan): void => {
986 // `say` in this file is the line by the leaderboard; this is the line in
987 // the middle of the screen, which is where a round's call belongs.
988 showMessage(current, describe(plan[current.me.team], current.me.team));
989 };
990
911991 function frame(elapsed: number): void {
912992 frameAverage += (elapsed * 1000 - frameAverage) * 0.05;
913993 // One layer of the atlas per frame, and the browser has the rest of it: the
@@ -933,6 +1013,10 @@ export function boot(host: Host): Frame {
9331013 }
9341014 if (world === null || !running) return;
9351015
1016 // A round nobody has planned yet — the first of a match, or the one that
1017 // just started — is planned here, on the frame that noticed it.
1018 if (world.match.round !== plannedRound) planFor(world);
1019
9361020 const me = world.me;
9371021 const [dx, dy] = controls.takeLook();
9381022 if (me.alive && controls.locked) {
src/client/stage.test-helper.ts +12 −1
@@ -107,6 +107,9 @@ export const ORIGIN = "https://cs.example";
107107 */
108108 export const WEIGHTS_URL = "/static/weights.0123456789.json";
109109
110 /** The id of the meta that names where a round is planned, as the shell writes it. */
111 export const PLAN_ID = "botplan";
112
110113 /** The committed policy document, which is the file the build copies to static/. */
111114 const WEIGHTS_TEXT = readFileSync(join(import.meta.dir, "weights.json"), "utf8");
112115
@@ -232,10 +235,16 @@ export function fakeStage(
232235 hold?: boolean;
233236 /** Leave the `#weights` link off the page entirely: a page that named nothing. */
234237 unlinked?: boolean;
238 /**
239 * The endpoint meta a deployment with a TypeSafe key renders: where the page
240 * asks for a round to be planned. Absent is every other deployment, which
241 * plans its rounds in the browser and posts nothing.
242 */
243 planEndpoint?: string;
235244 } = {},
236245 ): FakeStage {
237246 const page = fakePage({
238 ids: PAGE_IDS,
247 ids: options.planEndpoint === undefined ? PAGE_IDS : [...PAGE_IDS, PLAN_ID],
239248 canvases: ["scene", "hud", "plan"],
240249 hidden: HIDDEN,
241250 actions: PAGE_ACTIONS,
@@ -248,6 +257,8 @@ export function fakeStage(
248257 // The link the shell renders in the head, with the content-hashed URL on it.
249258 // `unlinked: true` leaves it blank: a page that named no weights at all.
250259 if (options.unlinked !== true) page.by("weights").setAttribute("href", WEIGHTS_URL);
260 // And the meta the shell renders for an endpoint, when this page has one.
261 if (options.planEndpoint !== undefined) page.by(PLAN_ID).setAttribute("content", options.planEndpoint);
251262 // The same switch on both sides of the seam: `webgl: false` is a browser with
252263 // no context to give, whether the frame asks this page's `host` for a renderer
253264 // or — as the committed bundle does — asks the canvas itself.
src/client/world.ts +16 −2
@@ -22,6 +22,7 @@ import {
2222 type Grenade, type GrenadeSound,
2323 } from "./grenade.ts";
2424 import type { Level } from "./level.ts";
25 import type { RoundPlan } from "../plan/plan.ts";
2526 import {
2627 angleVectors, angleDelta, clamp, createEffects, dist, expireEffects, eyeHeight, hullHeight, move,
2728 normalize, pushEffect, rng, sub, visible,
@@ -173,6 +174,14 @@ export type World = {
173174 grenades: Grenade[];
174175 feed: FeedEntry[];
175176 messages: { at: number; text: string }[];
177 /**
178 * The round these bots are playing, or null when nobody planned one. It hangs
179 * on the world rather than in a module because a plan is one match's: the
180 * arena's duels and the trainer's rounds are worlds nobody planned for, and a
181 * plan left in a module would be last match's orders arriving in this one.
182 * `src/client/bots.ts` puts it here (`usePlan`) and reads it back.
183 */
184 plan: RoundPlan | null;
176185 random: () => number;
177186 /**
178187 * The map this round is played on, whole. Everything that needs to know where
@@ -252,7 +261,7 @@ export function createWorld(
252261 const world: World = {
253262 match, players, me, time: 0,
254263 bomb: newBomb(),
255 effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], feed: [], messages: [],
264 effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], feed: [], messages: [], plan: null,
256265 random, level, bombPlanted: false, pendingBuy: false,
257266 };
258267 startRound(world);
@@ -317,7 +326,12 @@ export const alive = (world: World, team: Team): Player[] =>
317326
318327 export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, CS_HULL)];
319328
320 function say(world: World, text: string): void {
329 /**
330 * A line in the middle of the screen for a few seconds. Exported because the
331 * round's plan is announced the same way the round's start is: `src/client/main.ts`
332 * says what the bots were told to do the moment they are told it.
333 */
334 export function say(world: World, text: string): void {
321335 world.messages.push({ at: world.time, text });
322336 if (world.messages.length > 6) world.messages.shift();
323337 }
src/games.ts +10
@@ -60,6 +60,16 @@ export type Game = {
6060 /**
6161 * In the order the header lists them, which is also the order they are built.
6262 *
63 * #228 gave the bots a round to play rather than five separate ones — a site, a
64 * shape, a tempo and a save per side, and a job per bot cut from it — and the
65 * app grew 3,804 raw bytes for it (92,978 raw / 36,716 gzipped now): `src/plan/plan.ts` (the plan,
66 * the jobs and the parser, all of which ship), the situation the page sends, and
67 * the wiring in the entry that asks for one a round. It fits today's 98,000 /
68 * 39,000 with 5,022 raw and 2,284 gzipped bytes to spare, so no ceiling moved.
69 * The planner itself — `src/plan/typesafe.ts` and `@typesafe-ai/sdk` — is behind
70 * the door like the trainer and the digest: it holds the key, it runs on the
71 * server, and no bundle gained a byte of it. The range and the example are
72 * unchanged to the byte: they have no bots to plan for. Measured 2026-09-21.
6373 * The ceilings are today's sizes plus about 10%, rounded up to a round number:
6474 * enough headroom that an honest feature lands without a ceiling edit, tight
6575 * enough that a stray import of something large trips the test. Measured
src/pages/game.ts +35 −3
@@ -69,13 +69,20 @@ export const namedMap = (map: MapChoice): string | null =>
6969 map === "de_forge" || map === "generated" ? null : map;
7070
7171 /** What the shell needs that this page cannot know: the environment it runs in. */
72 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "assets" | "ssh"> & {
72 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "assets" | "endpoints" | "ssh"> & {
7373 /** The forge identity the request carried, verified in `src/who.ts`. */
7474 who: Who;
7575 /** What `/?match=…&map=…` asked for. Absent is `NOTHING_ASKED`. */
7676 asked?: Asked;
7777 /** The policy card the prose is made of. Absent is the shipped weights'. */
7878 card?: Card;
79 /**
80 * Whether this deployment plans the bots' rounds with TypeSafe AI — which is
81 * whether it was given a key. It changes one sentence and one `<meta>`: with
82 * no key the page never asks the server to plan anything and the bots plan
83 * every round in the page, which is what the paragraph then says.
84 */
85 planning?: boolean;
7986 };
8087
8188 /**
@@ -250,10 +257,34 @@ export const policyHtml = (card: Card): string =>
250257 still wins ${num(card.unseenWinRate)}% against the same bots' ${num(card.unseenBaselineWinRate)}%, which is
251258 what a map made the moment you press play asks of it. Its weights are about eight kilobytes
252259 of int8, downloaded once beside the bundle and cached under their own name — there is no
253 inference server and nothing is sent anywhere. Each
260 inference server and nothing about the fight is sent anywhere. Each
254261 bot's own skill still decides how fast it can turn, how steady its hands are and how long it
255262 takes to react, so there is still a ladder from fodder to uncomfortable.</p>`;
256263
264 /**
265 * What the page says about the layer above the fight: who decides the round.
266 * Two sentences, and which second one is true is whether this deployment was
267 * given a key — the page never claims a model is planning anything when the
268 * only planner running is the arithmetic in the page.
269 */
270 export const planHtml = (planning: boolean): string =>
271 `<p>Above the fight the bots now plan a round rather than five separate ones. Each side gets a
272 site, a shape — everyone through one site, a split across both, or a fake with a lurker on the
273 other — a tempo and a decision about whether to save, and every bot gets a job cut from it:
274 which site it walks to, whether it goes in first or gives the entry its beat, and how far off
275 the site it is willing to stand. ${
276 planning
277 ? `Which round each side plays is asked of <a href="https://typesafe.ai">TypeSafe AI</a> once
278 a round, as typed questions — a choice between the two sites, a rubric for the tempo, a yes or
279 no for the save — and the answer is validated back into the same shape the page can play, or
280 dropped for the local plan. The request is this server's, made from this server: the key is in
281 this deployment's environment, nothing about you is in the question, and the fight itself never
282 leaves your browser.`
283 : `This deployment has no TypeSafe key, so every round is planned in your browser by the same
284 arithmetic the server would use as its fallback: nothing is sent anywhere, and the plan is
285 still a plan.`
286 }</p>`;
287
257288 /**
258289 * The pause panel. Its line about Escape is rendered with the sentence that is
259290 * true before any key has been pressed, and `src/client/main.ts` replaces it on
@@ -308,7 +339,7 @@ const SAVED_LINE = `<p class="hint" id="saved" role="status" aria-live="polite">
308339
309340 export function gamePage(
310341 db: Database,
311 { who: visitor, asked = NOTHING_ASKED, card = CARD, ...options }: PageOptions,
342 { who: visitor, asked = NOTHING_ASKED, card = CARD, planning = false, ...options }: PageOptions,
312343 ): string {
313344 return page({
314345 ...options,
@@ -359,6 +390,7 @@ export function gamePage(
359390 Everything you see is generated — the map, the textures, the models and the sounds — so the
360391 whole game is one small download and no asset files.</p>
361392 ${policyHtml(card)}
393 ${planHtml(planning)}
362394 <p>The match runs entirely in your browser; the leaderboard here is what your browser
363395 reported about it, checked against what a match can produce. The name on a row is not
364396 the browser's word, though: signed in to the forge, your results are recorded under
src/pages/shell.ts +15 −1
@@ -160,6 +160,12 @@ export const preloads = (assets: readonly Linked[] = []): string =>
160160 )
161161 .join("");
162162
163 /** The endpoint metas, in the head: one per endpoint, each read by its id. */
164 export const endpoints = (posts: readonly Linked[] = []): string =>
165 posts
166 .map((post) => `<meta name="endpoint" id="${escape(post.id)}" content="${escape(post.href)}">\n`)
167 .join("");
168
163169 /** One touch button: the action `src/engine/controls.ts` holds, and its label. */
164170 export type TouchButton = { action: string; label: string };
165171
@@ -258,6 +264,14 @@ export type PageOptions = {
258264 * by its id. Absent, or empty, is a page that downloads nothing but its script.
259265 */
260266 assets?: readonly Linked[];
267 /**
268 * The endpoints this page's game posts to, each findable by its id — the
269 * same bargain as an asset, one step further: no path to this server is ever
270 * written into a client. A `<meta>` rather than a preload link, because a
271 * POST is not a file and there is nothing to fetch ahead of time. Absent, or
272 * empty, is a page that posts nowhere it was not already told about.
273 */
274 endpoints?: readonly Linked[];
261275 ssh: string | undefined;
262276 /** The sentence in front of the ssh command, when there is one. */
263277 terminalLead: string;
@@ -281,7 +295,7 @@ export function page(options: PageOptions): string {
281295 <meta charset="utf-8">
282296 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
283297 <link rel="icon" href="${FAVICON}">
284 ${preloads(options.assets)}<title>${escape(options.title)}</title>
298 ${preloads(options.assets)}${endpoints(options.endpoints)}<title>${escape(options.title)}</title>
285299 <meta name="description" content="${escape(options.description)}">
286300 <style>${styleFor(options.extraStyle)}</style>
287301 <header>
src/plan/plan.test.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * The planning layer, without a network anywhere near it: the local plan is
3 * arithmetic, the jobs are a function of a plan and a roster, and everything
4 * that arrives from outside goes through `parsePlan` first.
5 */
6
7 import { expect, test } from "bun:test";
8 import { assign, delayOf, describe, DEFAULT_JOB, hash, localPlan, parsePlan, type RoundPlan } from "./plan.ts";
9 import { keyOf, parseSituation, type Situation } from "./situation.ts";
10
11 const situation = (over: Partial<Situation> = {}): Situation => ({
12 map: "de_forge",
13 round: 1,
14 score: { t: 0, ct: 0 },
15 money: { t: 800, ct: 800 },
16 lastWinner: null,
17 ...over,
18 });
19
20 const roster = (n: number): { id: number; skill: number }[] =>
21 Array.from({ length: n }, (_, i) => ({ id: i + 1, skill: (i + 1) / n }));
22
23 test("the local plan is the same plan everywhere, and a different one each round", () => {
24 const first = localPlan(situation());
25 expect(localPlan(situation())).toEqual(first);
26 expect(first.source).toBe("local");
27 expect(first.model).toBe("local");
28 // Over a match's worth of rounds both sites and all three shapes come up:
29 // a fallback that always answered A would be a coin that is not a plan.
30 const sites = new Set<string>();
31 const shapes = new Set<string>();
32 for (let round = 1; round <= 24; round++) {
33 const plan = localPlan(situation({ round }));
34 sites.add(plan.t.site);
35 shapes.add(plan.t.shape);
36 expect(plan.round).toBe(round);
37 }
38 expect([...sites].sort()).toEqual(["A", "B"]);
39 expect([...shapes].sort()).toEqual(["fake", "split", "stack"]);
40 });
41
42 test("a side with no money saves, and a side that is behind plays faster", () => {
43 expect(localPlan(situation({ money: { t: 800, ct: 4000 } })).t.save).toBe(true);
44 expect(localPlan(situation({ money: { t: 800, ct: 4000 } })).ct.save).toBe(false);
45 const level = localPlan(situation({ round: 7, score: { t: 3, ct: 3 }, money: { t: 5000, ct: 5000 } }));
46 const behind = localPlan(situation({ round: 7, score: { t: 1, ct: 5 }, money: { t: 5000, ct: 5000 } }));
47 expect(behind.t.tempo).toBeGreaterThan(level.t.tempo);
48 });
49
50 test("a hash is a hash: the same string, the same number, and different strings differ", () => {
51 expect(hash("de_forge|1|t")).toBe(hash("de_forge|1|t"));
52 expect(hash("de_forge|1|t")).not.toBe(hash("de_forge|1|ct"));
53 expect(hash("")).toBe(0x811c9dc5);
54 });
55
56 test("a stack sends everyone to one site, best in first", () => {
57 const jobs = assign({ site: "B", shape: "stack", tempo: 2, save: false }, "t", roster(5));
58 expect(jobs.every((job) => job.site === "B")).toBe(true);
59 // The roster's last entry is its most skilled, so that is the one that entries.
60 expect(jobs[4]?.role).toBe("entry");
61 expect(jobs[4]?.delay).toBe(0);
62 expect(jobs.filter((job) => job.role === "support")).toHaveLength(4);
63 // Support follows rather than arrives together: that beat is the coordination.
64 expect(jobs[0]?.delay).toBeGreaterThan(0);
65 });
66
67 test("a split puts a group on each site, and each group has its own entry", () => {
68 const jobs = assign({ site: "A", shape: "split", tempo: 1, save: false }, "t", roster(4));
69 expect(jobs.filter((job) => job.site === "A")).toHaveLength(2);
70 expect(jobs.filter((job) => job.site === "B")).toHaveLength(2);
71 expect(jobs.filter((job) => job.role === "entry")).toHaveLength(2);
72 });
73
74 test("a fake leaves one bot alone on the other site, late", () => {
75 const plan = { site: "A", shape: "fake", tempo: 2, save: false } as const;
76 const jobs = assign(plan, "t", roster(5));
77 const lurkers = jobs.filter((job) => job.role === "lurk");
78 expect(lurkers).toHaveLength(1);
79 expect(lurkers[0]?.site).toBe("B");
80 expect(lurkers[0]?.delay).toBeGreaterThan(jobs.filter((job) => job.role === "support")[0]?.delay ?? 0);
81 expect(jobs.filter((job) => job.site === "A")).toHaveLength(4);
82 });
83
84 test("defenders take their positions at once, whatever the tempo says", () => {
85 const jobs = assign({ site: "A", shape: "stack", tempo: 0, save: false }, "ct", roster(5));
86 expect(jobs.every((job) => job.delay === 0)).toBe(true);
87 expect(jobs.some((job) => job.role === "anchor")).toBe(true);
88 expect(jobs.some((job) => job.role === "entry")).toBe(false);
89 expect(delayOf("entry", 0, "ct")).toBe(0);
90 });
91
92 test("a faster tempo is a shorter wait", () => {
93 expect(delayOf("support", 3, "t")).toBeLessThan(delayOf("support", 0, "t"));
94 expect(delayOf("support", 0, "t")).toBeGreaterThan(0);
95 // A tempo from outside is clamped rather than believed.
96 expect(delayOf("support", 99, "t")).toBe(delayOf("support", 3, "t"));
97 expect(delayOf("support", -5, "t")).toBe(delayOf("support", 0, "t"));
98 });
99
100 test("jobs come back in the order the bots were handed over", () => {
101 const bots = [
102 { id: 9, skill: 0.1 },
103 { id: 3, skill: 0.9 },
104 { id: 7, skill: 0.5 },
105 ];
106 const jobs = assign({ site: "A", shape: "stack", tempo: 1, save: false }, "t", bots);
107 expect(jobs).toHaveLength(3);
108 expect(jobs[1]?.role).toBe("entry"); // id 3 is the best of them
109 expect(assign({ site: "A", shape: "stack", tempo: 1, save: false }, "t", [])).toEqual([]);
110 });
111
112 test("the default job is what a bot does with no plan at all", () => {
113 expect(DEFAULT_JOB.delay).toBe(0);
114 expect(DEFAULT_JOB.spread).toBe(250);
115 });
116
117 test("a plan says what it is in one line", () => {
118 expect(describe({ site: "B", shape: "split", tempo: 3, save: false }, "t")).toBe("T: split B, rush");
119 expect(describe({ site: "A", shape: "stack", tempo: 1, save: true }, "ct")).toBe("CT: stack A, default, saving");
120 });
121
122 const good: RoundPlan = {
123 round: 4,
124 source: "typesafe",
125 model: "jev-latest",
126 t: { site: "A", shape: "stack", tempo: 2, save: false },
127 ct: { site: "B", shape: "split", tempo: 1, save: true },
128 };
129
130 test("a plan from outside is checked field by field", () => {
131 expect(parsePlan(good)).toEqual(good);
132 expect(parsePlan(JSON.parse(JSON.stringify(good)))).toEqual(good);
133 expect(parsePlan(null)).toBeNull();
134 expect(parsePlan("a plan")).toBeNull();
135 expect(parsePlan({ ...good, source: "guess" })).toBeNull();
136 expect(parsePlan({ ...good, round: 0 })).toBeNull();
137 expect(parsePlan({ ...good, round: 1.5 })).toBeNull();
138 expect(parsePlan({ ...good, model: "x".repeat(65) })).toBeNull();
139 expect(parsePlan({ ...good, t: { ...good.t, site: "Middle" } })).toBeNull();
140 expect(parsePlan({ ...good, t: { ...good.t, site: "a" } })).toBeNull();
141 expect(parsePlan({ ...good, t: { ...good.t, shape: "rush" } })).toBeNull();
142 expect(parsePlan({ ...good, t: { ...good.t, save: "yes" } })).toBeNull();
143 expect(parsePlan({ ...good, t: { ...good.t, tempo: Number.NaN } })).toBeNull();
144 expect(parsePlan({ ...good, ct: undefined })).toBeNull();
145 // A tempo outside the rubric is clamped into it, not refused: the number is
146 // the only field where a wide answer still means something.
147 expect(parsePlan({ ...good, t: { ...good.t, tempo: 12 } })?.t.tempo).toBe(3);
148 });
149
150 test("a situation is checked at the boundary too", () => {
151 expect(parseSituation(situation())).toEqual(situation());
152 expect(parseSituation({ ...situation(), map: "gen_14u5j6g" })?.map).toBe("gen_14u5j6g");
153 expect(parseSituation({ ...situation(), map: "generated" })).toBeNull();
154 expect(parseSituation({ ...situation(), map: "../../etc/passwd" })).toBeNull();
155 expect(parseSituation({ ...situation(), map: "gen_0014" })).toBeNull();
156 expect(parseSituation({ ...situation(), round: 0 })).toBeNull();
157 expect(parseSituation({ ...situation(), round: 61 })).toBeNull();
158 expect(parseSituation({ ...situation(), money: { t: 800, ct: 99_000 } })).toBeNull();
159 expect(parseSituation({ ...situation(), money: { t: -1, ct: 0 } })).toBeNull();
160 expect(parseSituation({ ...situation(), score: { t: 1 } })).toBeNull();
161 expect(parseSituation({ ...situation(), lastWinner: "T" })).toBeNull();
162 expect(parseSituation({ ...situation(), lastWinner: "ct" })?.lastWinner).toBe("ct");
163 expect(parseSituation(null)).toBeNull();
164 expect(parseSituation([])).toBeNull();
165 });
166
167 test("the cache key holds a situation and buckets the money", () => {
168 expect(keyOf(situation())).toBe(keyOf(situation()));
169 expect(keyOf(situation({ money: { t: 4100, ct: 800 } }))).toBe(keyOf(situation({ money: { t: 4900, ct: 800 } })));
170 expect(keyOf(situation({ money: { t: 4100, ct: 800 } }))).not.toBe(keyOf(situation({ money: { t: 5200, ct: 800 } })));
171 expect(keyOf(situation({ round: 2 }))).not.toBe(keyOf(situation()));
172 });
src/plan/plan.ts added +231
@@ -0,0 +1,231 @@
1 /**
2 * A round's plan: what each side does with this round, and what each bot's job
3 * in it is. Plain data in, plain data out — no world, no level, no network — so
4 * both ends of the wire and every test speak the same three functions.
5 *
6 * The split this adds is the one the bots were missing. `src/client/bots.ts`
7 * already has two layers: A* solves the objective and a trained policy runs the
8 * fight. Between them there was nothing — every bot flipped its own coin for a
9 * site, so five attackers arrived at two sites in ones and twos and no round
10 * was ever *played* a particular way. This is that missing layer: one team plan
11 * (a site, a shape, a tempo, whether the round is a save) and one job per bot
12 * cut from it (a site of its own, a role, how long it waits, how wide it
13 * spreads). The fight is untouched.
14 *
15 * Where the plan comes from is a different question, answered twice.
16 * `localPlan` is here: deterministic arithmetic over the situation, the same
17 * answer on the server and in the page, and the only planner a deployment with
18 * no API key ever runs. `src/plan/typesafe.ts` is the other: TypeSafe AI is
19 * asked the same questions as typed choices and scores, and whatever it answers
20 * is validated back into this shape or dropped for the local one. A plan is
21 * always a `RoundPlan` by the time anything acts on it.
22 */
23
24 import type { Situation } from "./situation.ts";
25
26 /** The two bombsites every map in this game has. */
27 export type SiteName = "A" | "B";
28
29 export type Side = "t" | "ct";
30
31 /**
32 * How a side arrives. `stack` is everyone through one site, `split` is two
33 * groups on the two sites, and `fake` is the team at one site with one player
34 * making the other side's life complicated at the other.
35 */
36 export type Shape = "stack" | "split" | "fake";
37
38 /**
39 * What one bot is doing. `entry` goes first and goes straight in, `support`
40 * follows a beat later, `lurk` plays the other site alone, `anchor` holds the
41 * site it is standing on.
42 */
43 export type Role = "entry" | "support" | "lurk" | "anchor";
44
45 /** One side's round. */
46 export type TeamPlan = {
47 site: SiteName;
48 shape: Shape;
49 /** 0 slow to 3 rushing: how fast the team commits. */
50 tempo: number;
51 /** True when the round is an eco: pistols and armour, nothing else. */
52 save: boolean;
53 };
54
55 /** Both sides' rounds, and where the answer came from. */
56 export type RoundPlan = {
57 round: number;
58 /** `typesafe` when a model answered, `local` when this file did. */
59 source: "typesafe" | "local";
60 /** The model that answered, or `local` — shown, never trusted. */
61 model: string;
62 t: TeamPlan;
63 ct: TeamPlan;
64 };
65
66 /** One bot's share of its side's plan. */
67 export type Job = {
68 site: SiteName;
69 role: Role;
70 /** Seconds after the round goes live before this bot moves off. */
71 delay: number;
72 /** How far from the site the bot is willing to stand, in units. */
73 spread: number;
74 /** True when this bot's side is saving: it buys nothing this round. */
75 save: boolean;
76 };
77
78 /** The job a bot has when nobody has planned anything: today's behaviour. */
79 export const DEFAULT_JOB: Job = { site: "A", role: "support", delay: 0, spread: 250, save: false };
80
81 export const SHAPES: Shape[] = ["stack", "split", "fake"];
82
83 /** The fastest tempo, and the slowest. A tempo outside this is not a tempo. */
84 export const TEMPO_MAX = 3;
85
86 /** Money below which a side cannot arm itself, and so should not try. */
87 export const ECO = 2500;
88
89 /**
90 * A number out of a string, FNV-1a, 32 bits. The plan has to be the same in the
91 * page and on the server — a bot that planned one thing while the page
92 * announced another is worse than no plan — so the fallback is arithmetic over
93 * the situation rather than any `Math.random`.
94 */
95 export function hash(text: string): number {
96 let h = 0x811c9dc5;
97 for (let i = 0; i < text.length; i++) {
98 h ^= text.charCodeAt(i);
99 h = Math.imul(h, 0x01000193) >>> 0;
100 }
101 return h >>> 0;
102 }
103
104 /**
105 * The plan when nothing was asked: a site from the map, the round and the side,
106 * a shape and a tempo from the same number, and a save when the side cannot
107 * afford a rifle. It is not clever and it is not meant to be — it is what makes
108 * a team a team without a network in the loop, and it is what the page plays
109 * while a request is still in the air.
110 */
111 export function localPlan(situation: Situation): RoundPlan {
112 return {
113 round: situation.round,
114 source: "local",
115 model: "local",
116 t: localTeam(situation, "t"),
117 ct: localTeam(situation, "ct"),
118 };
119 }
120
121 function localTeam(situation: Situation, side: Side): TeamPlan {
122 const mix = hash(`${situation.map}|${situation.round}|${side}`);
123 const behind = situation.score[side] < situation.score[side === "t" ? "ct" : "t"];
124 return {
125 site: (mix & 1) === 0 ? "A" : "B",
126 shape: SHAPES[(mix >>> 1) % SHAPES.length] as Shape,
127 // A side that is behind plays faster, which is what a person does.
128 tempo: ((mix >>> 3) % 2) + (behind ? 2 : 1),
129 save: situation.money[side] < ECO,
130 };
131 }
132
133 /**
134 * Cut a team plan into one job per bot. The bots arrive in whatever order the
135 * world holds them and come back in that same order, so the caller can hand the
136 * result straight back to its own list; inside, they are ranked by skill, then
137 * by id, so the same roster always gets the same jobs and the best shot goes in
138 * first.
139 *
140 * The shapes are the whole of the coordination:
141 * stack — everyone on the plan's site, the best in first and the rest behind.
142 * split — the roster cut in half across both sites, each half with its own
143 * entry, which is the only way both sites are ever pressured at once.
144 * fake — the team on the plan's site and one bot alone on the other, late.
145 *
146 * Defenders never wait: a CT that stands at spawn for two seconds has lost the
147 * site it was supposed to be standing on. They get the same sites and the same
148 * shapes, and their roles are anchors and lurkers rather than entries.
149 */
150 export function assign(plan: TeamPlan, side: Side, bots: { id: number; skill: number }[]): Job[] {
151 const order = bots
152 .map((bot, at) => ({ at, id: bot.id, skill: bot.skill }))
153 .sort((a, b) => b.skill - a.skill || a.id - b.id);
154 const other: SiteName = plan.site === "A" ? "B" : "A";
155 const jobs: Job[] = new Array(bots.length).fill(DEFAULT_JOB);
156 order.forEach((bot, rank) => {
157 const role = roleOf(plan.shape, rank, order.length, side);
158 const site = role === "lurk" ? other : plan.shape === "split" && rank % 2 === 1 ? other : plan.site;
159 jobs[bot.at] = { site, role, delay: delayOf(role, plan.tempo, side), spread: SPREAD[role], save: plan.save };
160 });
161 return jobs;
162 }
163
164 function roleOf(shape: Shape, rank: number, size: number, side: Side): Role {
165 if (shape === "fake" && rank === size - 1 && size > 1) return "lurk";
166 if (side === "ct") return rank === 0 || shape === "split" ? "anchor" : rank === size - 1 && size > 2 ? "lurk" : "support";
167 // Split sends two groups, so the top of each half is the one that goes in.
168 if (rank === 0 || (shape === "split" && rank === 1)) return "entry";
169 return "support";
170 }
171
172 /** How far from its site a role is content to stand. */
173 const SPREAD: Record<Role, number> = { entry: 130, support: 300, lurk: 520, anchor: 200 };
174
175 /** What a role waits before it moves off, before the tempo speeds it up. */
176 const WAIT: Record<Role, number> = { entry: 0, support: 2.2, lurk: 6, anchor: 0 };
177
178 /**
179 * The seconds a job waits. Tempo 0 is a team playing for the clock and tempo 3
180 * is a team through the door on the buzzer, so the wait is scaled from one and
181 * a half times the role's own to none of it. Defenders take their positions at
182 * once whatever the tempo says.
183 */
184 export function delayOf(role: Role, tempo: number, side: Side): number {
185 if (side === "ct") return 0;
186 const speed = 1.5 - clampTempo(tempo) * 0.5;
187 return Math.round(WAIT[role] * speed * 10) / 10;
188 }
189
190 const clampTempo = (tempo: number): number => Math.min(TEMPO_MAX, Math.max(0, Math.round(tempo)));
191
192 /** One side's plan in the words the HUD and the terminal print. */
193 export function describe(plan: TeamPlan, side: Side): string {
194 const pace = ["slow", "default", "fast", "rush"][clampTempo(plan.tempo)] ?? "default";
195 const what = side === "t" ? plan.shape : plan.shape === "fake" ? "bait" : plan.shape;
196 return `${side.toUpperCase()}: ${what} ${plan.site}, ${pace}${plan.save ? ", saving" : ""}`;
197 }
198
199 // --- the boundary ---------------------------------------------------------
200
201 const isSite = (value: unknown): value is SiteName => value === "A" || value === "B";
202 const isShape = (value: unknown): value is Shape => SHAPES.includes(value as Shape);
203
204 function parseTeam(value: unknown): TeamPlan | null {
205 if (typeof value !== "object" || value === null) return null;
206 const team = value as Record<string, unknown>;
207 if (!isSite(team.site) || !isShape(team.shape)) return null;
208 if (typeof team.tempo !== "number" || !Number.isFinite(team.tempo)) return null;
209 if (typeof team.save !== "boolean") return null;
210 return { site: team.site, shape: team.shape, tempo: clampTempo(team.tempo), save: team.save };
211 }
212
213 /**
214 * A plan as it arrives from somewhere else, or null. The page reads its own
215 * server's answer through this and the server reads a model's answer through
216 * the two parsers around it: nothing acts on a plan that was not checked field
217 * by field, because "the site is A" arriving as `"a"` or `"Middle"` is a bot
218 * pathing to undefined.
219 */
220 export function parsePlan(value: unknown): RoundPlan | null {
221 if (typeof value !== "object" || value === null) return null;
222 const plan = value as Record<string, unknown>;
223 const round = plan.round;
224 if (typeof round !== "number" || !Number.isInteger(round) || round < 1 || round > 1000) return null;
225 if (plan.source !== "typesafe" && plan.source !== "local") return null;
226 if (typeof plan.model !== "string" || plan.model.length > 64) return null;
227 const t = parseTeam(plan.t);
228 const ct = parseTeam(plan.ct);
229 if (t === null || ct === null) return null;
230 return { round, source: plan.source, model: plan.model, t, ct };
231 }
src/plan/situation.ts added +87
@@ -0,0 +1,87 @@
1 /**
2 * The round a plan is asked about: which map, which round, the score, what each
3 * side can afford, and who won the last one. It is everything the planner is
4 * told and it is the whole of what crosses the wire — no positions, no names,
5 * no identity, nothing about the person playing.
6 *
7 * That shortness is deliberate on three counts. It is a request body a page
8 * sends every round, so it is a few hundred bytes. It is the cache key, so two
9 * players in the same situation cost one call upstream and not two. And it is
10 * what a third party is shown, so there is nothing in it worth showing them.
11 *
12 * `parseSituation` is the boundary: the page's claim about a map is checked
13 * against `src/mapname.ts` like every other map name this server is handed, and
14 * every number has to be an integer inside a range a match can actually reach.
15 */
16
17 import { mapFrom } from "../mapname.ts";
18 import type { Side } from "./plan.ts";
19
20 export type Situation = {
21 /** `de_forge` or a generated map's canonical name, `gen_14u5j6g`. */
22 map: string;
23 round: number;
24 score: Record<Side, number>;
25 /** What each side has to spend, averaged over its players. */
26 money: Record<Side, number>;
27 lastWinner: Side | null;
28 };
29
30 /** The longest match this server will plan for: a round number past it is junk. */
31 export const MAX_ROUND = 60;
32
33 /** The most a player can hold in this game's economy. */
34 export const MAX_MONEY = 16_000;
35
36 const int = (value: unknown, max: number): number | null => {
37 if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > max) return null;
38 return value;
39 };
40
41 const pair = (value: unknown, max: number): Record<Side, number> | null => {
42 if (typeof value !== "object" || value === null) return null;
43 const both = value as Record<string, unknown>;
44 const t = int(both.t, max);
45 const ct = int(both.ct, max);
46 return t === null || ct === null ? null : { t, ct };
47 };
48
49 /**
50 * A situation as it arrived, or null. A map name that is not one is not
51 * corrected into the default here the way a query string's is: this body was
52 * written by our own page, so a name nothing recognises is a caller making
53 * things up, and the answer to that is 422 rather than a plan for de_forge.
54 */
55 export function parseSituation(value: unknown): Situation | null {
56 if (typeof value !== "object" || value === null) return null;
57 const asked = value as Record<string, unknown>;
58 // A match is played on a map, never on the menu's word for "carve me one":
59 // `generated` is a choice, not a place, so it is not a situation either.
60 if (typeof asked.map !== "string" || asked.map === "generated" || mapFrom(asked.map) !== asked.map) return null;
61 const round = int(asked.round, MAX_ROUND);
62 if (round === null || round < 1) return null;
63 const score = pair(asked.score, MAX_ROUND);
64 const money = pair(asked.money, MAX_MONEY);
65 if (score === null || money === null) return null;
66 const last = asked.lastWinner;
67 if (last !== null && last !== "t" && last !== "ct") return null;
68 return { map: asked.map, round, score, money, lastWinner: last };
69 }
70
71 /**
72 * One string for one situation, which is what the planner's cache and its spend
73 * ceiling are keyed by. Two browsers in the same round of the same match ask the
74 * same question, and the second one is free.
75 */
76 export const keyOf = (situation: Situation): string =>
77 [
78 situation.map,
79 situation.round,
80 situation.score.t,
81 situation.score.ct,
82 // Money is bucketed: a plan does not change between $4,100 and $4,150, and
83 // a key that moves with every dollar is a cache that never hits.
84 Math.floor(situation.money.t / 1000),
85 Math.floor(situation.money.ct / 1000),
86 situation.lastWinner ?? "-",
87 ].join("|");
src/plan/typesafe.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * The TypeSafe planner, against a stub transport. Nothing here reaches the
3 * network and nothing here needs a key that works: the SDK takes its `fetch` as
4 * an option, so every path — a good answer, a bad one, a 500, a timeout, no key
5 * at all — is a function of what this file hands back.
6 */
7
8 import { expect, test } from "bun:test";
9 import { localPlan } from "./plan.ts";
10 import { createPlanner } from "./typesafe.ts";
11 import type { Situation } from "./situation.ts";
12
13 const situation = (over: Partial<Situation> = {}): Situation => ({
14 map: "de_forge",
15 round: 3,
16 score: { t: 1, ct: 1 },
17 money: { t: 4200, ct: 3800 },
18 lastWinner: "t",
19 ...over,
20 });
21
22 type Answers = Record<string, unknown>;
23
24 /** What the API sends back for the eight questions this planner asks. */
25 const answers = (over: Answers = {}): Answers => ({
26 tSite: { type: "choice", choice: "B", confidence: 0.7, probabilities: { A: 0.3, B: 0.7 } },
27 tShape: { type: "choice", choice: "split", confidence: 0.6, probabilities: { stack: 0.2, split: 0.6, fake: 0.2 } },
28 tTempo: { type: "score", score: 2.4, confidence: 0.5, legend: {}, probabilities: {} },
29 tSave: { type: "noul", noul: 0.1 },
30 ctSite: { type: "choice", choice: "A", confidence: 0.8, probabilities: { A: 0.8, B: 0.2 } },
31 ctShape: { type: "choice", choice: "stack", confidence: 0.6, probabilities: { stack: 0.6, split: 0.2, fake: 0.2 } },
32 ctTempo: { type: "score", score: 1, confidence: 0.5, legend: {}, probabilities: {} },
33 ctSave: { type: "noul", noul: 0.9 },
34 ...over,
35 });
36
37 const reply = (over: Answers = {}, model = "jev-1"): Response =>
38 new Response(JSON.stringify({ model, answers: answers(over), usage: { input_tokens: 1, output_tokens: 1 } }), {
39 headers: { "content-type": "application/json" },
40 });
41
42 test("no key is no planner: a deployment that was given nothing runs the local plan", () => {
43 expect(createPlanner({})).toBeNull();
44 expect(createPlanner({ apiKey: "" })).toBeNull();
45 expect(createPlanner({ apiKey: " " })).toBeNull();
46 });
47
48 test("the answers become a plan, and the question set is the round", async () => {
49 const sent: { url: string; body: unknown }[] = [];
50 const planner = createPlanner({
51 apiKey: "sk-test",
52 fetch: async (url, init) => {
53 sent.push({ url, body: JSON.parse(String(init?.body)) });
54 return reply();
55 },
56 });
57 const plan = await planner?.plan(situation());
58 expect(plan).toEqual({
59 round: 3,
60 source: "typesafe",
61 model: "jev-1",
62 t: { site: "B", shape: "split", tempo: 2, save: false },
63 ct: { site: "A", shape: "stack", tempo: 1, save: true },
64 });
65
66 expect(sent).toHaveLength(1);
67 expect(sent[0]?.url).toBe("https://api.typesafe.ai/v1/systemone");
68 const body = sent[0]?.body as { state: Record<string, unknown>; questions: Record<string, { type: string }> };
69 // Both sides in one request, because they are one round.
70 expect(Object.keys(body.questions).sort()).toEqual(
71 ["ctSave", "ctShape", "ctSite", "ctTempo", "tSave", "tShape", "tSite", "tTempo"],
72 );
73 expect(body.questions.tSite?.type).toBe("choice");
74 expect(body.questions.tTempo?.type).toBe("score");
75 expect(body.questions.tSave?.type).toBe("noul");
76 // The state is the situation and nothing else: no name, no identity, no id.
77 expect(body.state.map).toBe("de_forge");
78 expect(body.state.round).toBe(3);
79 expect(JSON.stringify(body.state)).not.toContain("session");
80 });
81
82 test("the same situation is asked once, and two callers at once share the one request", async () => {
83 let calls = 0;
84 let release = (): void => {};
85 const held = new Promise<void>((resolve) => {
86 release = resolve;
87 });
88 const planner = createPlanner({
89 apiKey: "sk-test",
90 fetch: async () => {
91 calls += 1;
92 await held;
93 return reply();
94 },
95 });
96 const both = Promise.all([planner?.plan(situation()), planner?.plan(situation())]);
97 release();
98 const [first, second] = await both;
99 expect(calls).toBe(1);
100 expect(first).toEqual(second);
101
102 // And again after it has landed: the answer is remembered.
103 await planner?.plan(situation());
104 expect(calls).toBe(1);
105 expect(planner?.size()).toBe(1);
106
107 // A different round is a different question.
108 await planner?.plan(situation({ round: 4 }));
109 expect(calls).toBe(2);
110 expect(planner?.size()).toBe(2);
111 });
112
113 test("a failed call is the local plan, and the local plan is not cached", async () => {
114 let calls = 0;
115 const planner = createPlanner({
116 apiKey: "sk-test",
117 fetch: async () => {
118 calls += 1;
119 return new Response("nope", { status: 500 });
120 },
121 });
122 const plan = await planner?.plan(situation());
123 expect(plan?.source).toBe("local");
124 expect(plan?.round).toBe(3);
125 expect(planner?.size()).toBe(0);
126 // One retry behind the attempt, so a 500 is two calls and never more.
127 expect(calls).toBe(2);
128 });
129
130 test("an answer that is not a plan this game can play is refused whole", async () => {
131 const planner = createPlanner({
132 apiKey: "sk-test",
133 fetch: async () => reply({ tSite: { type: "choice", choice: "Middle", confidence: 1, probabilities: {} } }),
134 });
135 const plan = await planner?.plan(situation());
136 expect(plan?.source).toBe("local");
137 // The half that did validate is not kept: both sides play the same round.
138 expect(plan?.ct).toEqual(localPlan(situation()).ct);
139 expect(planner?.size()).toBe(0);
140 });
141
142 test("junk on the wire is the local plan too, not a crash", async () => {
143 const planner = createPlanner({
144 apiKey: "sk-test",
145 fetch: async () => new Response("<html>a proxy said something</html>", { headers: { "content-type": "text/html" } }),
146 });
147 expect((await planner?.plan(situation()))?.source).toBe("local");
148 });
149
150 test("a model name from the wire is bounded before it is ever shown", async () => {
151 const planner = createPlanner({
152 apiKey: "sk-test",
153 fetch: async () => reply({}, "m".repeat(200)),
154 });
155 expect((await planner?.plan(situation()))?.model).toBe("typesafe");
156 });
157
158 test("the cache is bounded: past the ceiling it is dropped rather than grown", async () => {
159 const planner = createPlanner({ apiKey: "sk-test", ceiling: 2, fetch: async () => reply() });
160 await planner?.plan(situation({ round: 1 }));
161 await planner?.plan(situation({ round: 2 }));
162 expect(planner?.size()).toBe(2);
163 await planner?.plan(situation({ round: 3 }));
164 expect(planner?.size()).toBe(1);
165 });
166
src/plan/typesafe.ts added +206
@@ -0,0 +1,206 @@
1 /**
2 * The bots' planner, asked of TypeSafe AI. Server-side only: the key lives in
3 * this process's environment and never in a page.
4 *
5 * ## What TypeSafe is, and why it is the one thing here that is a dependency
6 *
7 * TypeSafe (typesafe.ai) does not answer with prose. You hand it a state — text
8 * or a JSON object — and *named questions* of three kinds, and it answers each
9 * one inside the shape the question declared:
10 *
11 * choice — one of the labels you listed, with a probability for each
12 * noul — yes or no, as the probability of yes
13 * score — a rubric you wrote, indexed from zero, and an expected score
14 *
15 * That is exactly the shape of a round's plan. "Which site does this team take"
16 * is a choice over the two sites the map has; "does this side save" is a noul;
17 * "how fast do they commit" is a four-step rubric from holding for the clock to
18 * rushing from the gate. Nothing comes back that has to be parsed out of a
19 * sentence, and nothing comes back that could be a site this map does not have.
20 *
21 * `@typesafe-ai/sdk` is this repository's only runtime dependency. It is the
22 * vendor's own client, MIT, with no dependencies of its own, and what it buys
23 * is the half of an HTTP client nobody should write twice: the retry policy
24 * with jittered backoff, `Retry-After`, per-attempt timeouts, an error class
25 * per status, and answer types inferred from the questions — the product's
26 * whole point, which a hand-rolled `fetch` would throw away. It takes its
27 * `fetch` as an option, so every test below runs against a stub and this file
28 * is exercised without a key and without the network.
29 *
30 * ## What it is allowed to cost, and what happens when it fails
31 *
32 * A round asks one question set, once. Identical situations share one answer
33 * (`keyOf`), requests in flight for the same key are joined rather than
34 * duplicated, and the cache is bounded. Anything that goes wrong — no key, a
35 * timeout, a 500, a rate limit, an answer that does not validate — is
36 * `localPlan`: the deterministic plan in `src/plan/plan.ts`, which is what a
37 * deployment with no key runs all the time. The game never waits on this and
38 * never fails because of it.
39 */
40
41 import { choice, noul, score, TypeSafeClient, type Fetch } from "@typesafe-ai/sdk";
42 import { localPlan, type RoundPlan, type Shape, type SiteName, type TeamPlan } from "./plan.ts";
43 import { keyOf, type Situation } from "./situation.ts";
44
45 export type Planner = {
46 /** The plan for this round: a model's, or the local one when it could not be. */
47 plan(situation: Situation): Promise<RoundPlan>;
48 /** How many answers are being remembered. For the tests. */
49 size(): number;
50 };
51
52 export type PlannerOptions = {
53 /** The key, from the environment. Absent, empty or blank is no planner. */
54 apiKey?: string | undefined;
55 /** Defaults to the SDK's `jev-latest`. */
56 model?: string | undefined;
57 /** Milliseconds for one attempt. A round's freeze time is eight seconds. */
58 timeout?: number;
59 /** How many answers are remembered before the oldest are dropped. */
60 ceiling?: number;
61 /** The transport, so a test is not the network. */
62 fetch?: Fetch;
63 };
64
65 /** One attempt gets this long, and there is one retry behind it. */
66 const TIMEOUT = 2500;
67
68 const CEILING = 500;
69
70 /**
71 * The planner, or null when this deployment has no key — which is the state
72 * every preview is in, and the reason the caller has to handle it rather than
73 * be handed something that quietly does nothing.
74 */
75 export function createPlanner(options: PlannerOptions): Planner | null {
76 if (typeof options.apiKey !== "string" || options.apiKey.trim() === "") return null;
77 const { model, timeout = TIMEOUT, ceiling = CEILING, fetch } = options;
78 const client = new TypeSafeClient({
79 apiKey: options.apiKey,
80 ...(model === undefined ? {} : { defaultModel: model }),
81 timeout,
82 retry: { maxRetries: 1 },
83 ...(fetch === undefined ? {} : { fetch }),
84 });
85 const answers = new Map<string, RoundPlan>();
86 const flight = new Map<string, Promise<RoundPlan>>();
87
88 return {
89 size: () => answers.size,
90 async plan(situation) {
91 const key = keyOf(situation);
92 // The key is the situation, round included, so two browsers playing the
93 // same round of the same match ask upstream once between them.
94 const held = answers.get(key);
95 if (held !== undefined) return held;
96 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 });
102 flight.set(key, asking);
103 const plan = await asking;
104 flight.delete(key);
105 // A local plan is not worth remembering: it is free to compute, and
106 // caching it would keep a deployment on the fallback after one timeout.
107 if (plan.source === "typesafe") {
108 if (answers.size >= ceiling) answers.clear();
109 answers.set(key, plan);
110 }
111 return plan;
112 },
113 };
114 }
115
116 const describeError = (error: unknown): string =>
117 error instanceof Error ? `${error.name}: ${error.message}` : "the planner threw something that is not an error";
118
119 /**
120 * The questions, and the answers turned back into a plan. Both sides are asked
121 * in one request because they are one round: a defence that stacks A is an
122 * answer about the attack it expects, and two requests could not know that
123 * about each other.
124 */
125 async function ask(client: TypeSafeClient, situation: Situation): Promise<RoundPlan> {
126 const { answers, model } = await client.systemOne({
127 state: {
128 game: "a five-a-side bomb-defusal round, Counter-Strike rules, on a map with two bombsites called A and B",
129 map: situation.map,
130 round: situation.round,
131 score: situation.score,
132 averageMoneyPerPlayer: situation.money,
133 lastRoundWonBy: situation.lastWinner,
134 note: "T attack and carry the bomb; CT defend both sites. A side under $2,500 a player cannot arm itself.",
135 },
136 questions: {
137 tSite: choice("Which bombsite should the attacking side (T) commit to this round?", {
138 A: "the A site",
139 B: "the B site",
140 }),
141 tShape: choice("How should the attackers arrive?", SHAPE_CRITERIA.t),
142 tTempo: score("How fast should the attackers commit?", TEMPO_CRITERIA),
143 tSave: noul("Should the attackers save their money this round instead of buying?", SAVE_CRITERIA),
144 ctSite: choice("Which bombsite should the defending side (CT) weight this round?", {
145 A: "the A site",
146 B: "the B site",
147 }),
148 ctShape: choice("How should the defenders set up?", SHAPE_CRITERIA.ct),
149 ctTempo: score("How far forward should the defenders play?", TEMPO_CRITERIA),
150 ctSave: noul("Should the defenders save their money this round instead of buying?", SAVE_CRITERIA),
151 },
152 });
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);
155 // An answer that is not a plan is not half-used: the round gets the local one
156 // 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");
158 return { round: situation.round, source: "typesafe", model: modelName(model), t, ct };
159 }
160
161 const SHAPE_CRITERIA = {
162 t: {
163 stack: "all five through one site together",
164 split: "two groups, one at each site, pressuring both at once",
165 fake: "the team at one site and one player alone at the other",
166 },
167 ct: {
168 stack: "weight the defence heavily onto one site",
169 split: "hold both sites evenly",
170 fake: "hold one site and leave a player deep on the other to catch a rotation",
171 },
172 } as const;
173
174 const TEMPO_CRITERIA = [
175 "play for the clock and take the round late",
176 "default: move up, trade information, decide later",
177 "commit early and take the site on the timer",
178 "rush it from the gate",
179 ] as const;
180
181 const SAVE_CRITERIA = {
182 true: "the side cannot arm itself and should keep the money for next round",
183 false: "the side should buy what it can afford and play the round",
184 } as const;
185
186 /** A model name is a string from somewhere else: it is shown, so it is bounded. */
187 const modelName = (model: unknown): string =>
188 typeof model === "string" && model.length > 0 && model.length <= 64 ? model : "typesafe";
189
190 /**
191 * One side's answers, validated into a plan, or null. The SDK types these by
192 * the questions above, and the checks are here anyway: types are what this
193 * process compiled, and the body is what arrived.
194 */
195 function teamFrom(site: unknown, shape: unknown, tempo: unknown, save: unknown): TeamPlan | null {
196 if (site !== "A" && site !== "B") return null;
197 if (shape !== "stack" && shape !== "split" && shape !== "fake") return null;
198 if (typeof tempo !== "number" || !Number.isFinite(tempo)) return null;
199 if (typeof save !== "number" || !Number.isFinite(save) || save < 0 || save > 1) return null;
200 return {
201 site: site as SiteName,
202 shape: shape as Shape,
203 tempo: Math.min(3, Math.max(0, Math.round(tempo))),
204 save: save >= 0.5,
205 };
206 }
src/server.test.ts +79 −2
@@ -9,7 +9,9 @@ import { GAMES } from "./games.ts";
99 import { matchOf, playKey } from "./matchid.ts";
1010 import { NOTHING_POSTED } from "./client/pending.ts";
1111 import { BAD_ID, BAD_MAP, UNDRAWN } from "./pages/game.ts";
12 import { escape } from "./pages/shell.ts";
12 import { endpoints, escape } from "./pages/shell.ts";
13 import { localPlan, parsePlan } from "./plan/plan.ts";
14 import { parseSituation, type Situation } from "./plan/situation.ts";
1315 import { sign } from "./who.ts";
1416 import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
1517 import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts";
@@ -19,7 +21,7 @@ process.env.PORT = "0";
1921 // The test is the forge: it holds the same key and signs identities with it.
2022 const KEY = "the-key-the-forge-signs-with";
2123 process.env.FORGE_APP_IDENTITY_KEY = KEY;
22 const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, onError, findScripts } = await import("./server.ts");
24 const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, PLAN_ID, onError, findScripts } = await import("./server.ts");
2325 const base = `http://${server.hostname}:${server.port}`;
2426
2527 /** The two headers the forge puts on a request it proxies for a signed-in person. */
@@ -830,3 +832,78 @@ test("a guest cannot buy a fresh bucket by rewriting the cookie it was given", a
830832 // And a signed-in player is not behind that bucket: guests cannot lock them out.
831833 expect((await post(result({ name: "steady" }), "", as("u-50", "steady"))).status).toBe(200);
832834 });
835
836 // --- POST /api/plan ---------------------------------------------------------
837
838 /**
839 * The round the bots are about to play. This process has no TYPESAFE_API_KEY —
840 * no test may spend anybody's money — so every answer here is the local plan,
841 * which is the whole point: the route answers with a plan whatever is or is not
842 * configured behind it, and `src/plan/typesafe.test.ts` is where the model's
843 * half is exercised, against a stub.
844 */
845 const situation = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
846 map: "de_forge",
847 round: 3,
848 score: { t: 1, ct: 1 },
849 money: { t: 4200, ct: 3800 },
850 lastWinner: "t",
851 ...over,
852 });
853
854 const askPlan = (body: string, headers: Record<string, string> = {}): Promise<Response> =>
855 fetch(base + "/api/plan", { method: "POST", headers: { "content-type": "application/json", ...headers }, body });
856
857 test("a round is planned, and the plan is a plan this game can play", async () => {
858 const response = await askPlan(JSON.stringify(situation()));
859 expect(response.status).toBe(200);
860 expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8");
861 expect(response.headers.get("cache-control")).toBe("no-store");
862 // The floor is on it like it is on everything else here.
863 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
864 const plan = parsePlan(await response.json());
865 expect(plan).not.toBeNull();
866 expect(plan?.round).toBe(3);
867 // No key in this process, so the planner is the local one, named as such.
868 expect(plan?.source).toBe("local");
869 expect(plan?.model).toBe("local");
870 expect(plan).toEqual(localPlan(parseSituation(situation()) as Situation));
871 });
872
873 test("a situation that is not one is refused, and junk is not a plan", async () => {
874 expect((await askPlan("{")).status).toBe(400);
875 expect((await askPlan(JSON.stringify(situation({ map: "de_dust2" })))).status).toBe(422);
876 expect((await askPlan(JSON.stringify(situation({ round: 0 })))).status).toBe(422);
877 expect((await askPlan(JSON.stringify(situation({ money: { t: 99_999, ct: 0 } })))).status).toBe(422);
878 expect((await askPlan("[]")).status).toBe(422);
879 // The body is capped by the bytes read, not by anything the caller declared.
880 expect((await askPlan(`{"map":"de_forge","pad":"${"x".repeat(4096)}"}`)).status).toBe(413);
881 });
882
883 test("planning a round is a POST, and a POST from another site is refused", async () => {
884 const cross = await fetch(base + "/api/plan", {
885 method: "POST",
886 headers: { "content-type": "application/json", origin: "https://evil.example" },
887 body: JSON.stringify(situation()),
888 });
889 expect(cross.status).toBe(403);
890 // And it is not a read: nothing plans a round for a GET.
891 expect((await fetch(base + "/api/plan")).status).toBe(404);
892 });
893
894 test("a page with no planner behind it never tells a browser to ask for one", async () => {
895 const body = await (await fetch(base + "/")).text();
896 expect(body).not.toContain(`id="${PLAN_ID}"`);
897 expect(body).not.toContain(`name="endpoint"`);
898 // And it says so, rather than claiming a model is planning anything.
899 expect(body).toContain("no TypeSafe key");
900 expect(body).toContain(escape("Above the fight the bots now plan a round"));
901 });
902
903 test("the endpoint meta is how a page names where a plan comes from", () => {
904 expect(endpoints([{ id: PLAN_ID, href: "/api/plan" }]))
905 .toBe(`<meta name="endpoint" id="${PLAN_ID}" content="/api/plan">\n`);
906 expect(endpoints()).toBe("");
907 // Escaped like everything else the shell is handed.
908 expect(endpoints([{ id: 'x"y', href: '/a"b' }])).toContain('id="x&quot;y" content="/a&quot;b"');
909 });
src/server.ts +77 −1
@@ -41,6 +41,9 @@ import { createLimiter } from "./limit.ts";
4141 import { examplePage } from "./pages/example.ts";
4242 import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts";
4343 import { mapFrom } from "./mapname.ts";
44 import { localPlan } from "./plan/plan.ts";
45 import { parseSituation } from "./plan/situation.ts";
46 import { createPlanner, type Planner } from "./plan/typesafe.ts";
4447 import { matchFrom } from "./matchid.ts";
4548 import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts";
4649 import { rangePage } from "./pages/range.ts";
@@ -60,6 +63,18 @@ const ssh = process.env.FORGE_APP_SSH;
6063 // every visitor is a guest, and a guest's score is labelled as one.
6164 const identityKey = process.env.FORGE_APP_IDENTITY_KEY;
6265
66 /**
67 * Who plans the bots\' rounds. With `TYPESAFE_API_KEY` in this deployment\'s
68 * environment the round is put to TypeSafe AI as typed questions
69 * (`src/plan/typesafe.ts`); without one this is null and every round is planned
70 * by `localPlan`, which is arithmetic in this process and costs nothing. The key
71 * is read here, once, and never reaches a page: the browser asks this server.
72 */
73 const planner = createPlanner({
74 apiKey: process.env.TYPESAFE_API_KEY,
75 ...(process.env.TYPESAFE_MODEL === undefined ? {} : { model: process.env.TYPESAFE_MODEL }),
76 });
77
6378 // The security floor every response stands on. Everything comes from this
6479 // origin, nothing frames us, browsers never guess a content type, and referrers
6580 // stay on this site. The app's public address is what the forge told us, never
@@ -103,6 +118,12 @@ const html = (body: string, security: Headers = SECURITY, status = 200, extra: H
103118 headers: { "content-type": "text/html; charset=utf-8", ...security, ...extra },
104119 });
105120
121 /** The floor under a document that is data: never stored, never a page. */
122 const json = (value: unknown): Response =>
123 new Response(JSON.stringify(value), {
124 headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...SECURITY },
125 });
126
106127 // A browser on another site can make a person's browser POST here; it cannot
107128 // make that request look same-origin. Browsers say where a request came from
108129 // (Sec-Fetch-Site, or Origin); a request that names another site is refused.
@@ -119,6 +140,8 @@ const sameOrigin = (request: Request): boolean => {
119140 // cap the rest is drained and dropped so the connection stays usable for
120141 // the next request; past eight times the cap it is cut off instead.
121142 const BODY_MAX = 16 * 1024;
143 /** A situation is a map name and five numbers; a kilobyte is already generous. */
144 const PLAN_BODY_MAX = 1024;
122145 async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> {
123146 const reader = request.body?.getReader();
124147 if (reader === undefined) return new Uint8Array();
@@ -198,6 +221,24 @@ const mayWrite = (visitor: Who, session: string | null, address: string | undefi
198221 return matchLimit.take(limitKey(visitor, session, address));
199222 };
200223
224 /**
225 * How often one caller may ask for a round to be planned, and how much this
226 * deployment will spend on planning at all.
227 *
228 * A match asks once a round and a round is minutes long, so twelve back to back
229 * and one more every three seconds is a player nobody notices and a script that
230 * gets nowhere. The second bucket is not per caller: it is the whole process,
231 * and it is the ceiling on what a third party can be asked for in an hour —
232 * thirty answers back to back, one more every two seconds. Past it the round is
233 * planned locally, which is a plan and not a refusal, so a flood costs money it
234 * cannot spend and costs a player nothing.
235 */
236 export const PLAN_ID = "botplan";
237 export const PLAN_LIMIT = { burst: 12, per: 3000 };
238 const planLimit = createLimiter(PLAN_LIMIT);
239 export const PLAN_SPEND = { burst: 30, per: 2000 };
240 const planSpend = createLimiter(PLAN_SPEND);
241
201242 /**
202243 * Who a result is recorded as. A verified forge identity plays under its own
203244 * name and the browser's claim is ignored; everyone else is a guest, whose
@@ -326,6 +367,11 @@ const RENDER: Record<string, Render> = {
326367 return {
327368 body: gamePage(db, {
328369 env, pull, commit, script, assets: linked, ssh,
370 // The page is told where to ask for a plan only when there is
371 // something here to ask: with no key the bots plan in the browser and
372 // the page makes no request at all.
373 endpoints: planner === null ? [] : [{ id: PLAN_ID, href: "/api/plan" }],
374 planning: planner !== null,
329375 who: who(request, identityKey), asked: asked(request),
330376 }),
331377 extra,
@@ -434,6 +480,35 @@ export const server = Bun.serve({
434480 return html(statsHtml(db), SECURITY, 200, { "set-cookie": sessionCookie(session) });
435481 }
436482
483 /**
484 * The round the bots are about to play. The page sends the situation — the
485 * map, the round, the score and what each side can afford, and nothing about
486 * whoever is playing — and gets back a plan for both sides.
487 *
488 * It is a POST because it is not free: it may cost this deployment a call
489 * to a third party, so it is same-origin (refused above), rate-limited per
490 * caller, under a spend ceiling for the process, and capped at a body no
491 * honest situation comes close to. Every answer is a plan: a caller over
492 * its limit, a deployment with no key and an upstream that fell over all
493 * get the local one rather than an error, because a bot with no plan is a
494 * bot standing still.
495 */
496 if (request.method === "POST" && url.pathname === "/api/plan") {
497 const body = await readBody(request, PLAN_BODY_MAX);
498 if (body === undefined) return new Response("too large", { status: 413, headers: SECURITY });
499 let parsed: unknown;
500 try {
501 parsed = JSON.parse(new TextDecoder().decode(body));
502 } catch {
503 return new Response("bad json", { status: 400, headers: SECURITY });
504 }
505 const situation = parseSituation(parsed);
506 if (situation === null) return new Response("bad situation", { status: 422, headers: SECURITY });
507 const caller = `plan:${listener.requestIP(request)?.address ?? "?"}|${readSession(request) ?? "-"}`;
508 const mine = planner !== null && planLimit.take(caller) && planSpend.take("all");
509 return json(mine ? await (planner as Planner).plan(situation) : localPlan(situation));
510 }
511
437512 // The games, one table rather than one branch each. HEAD is GET without a
438513 // body, so a health check on `/` sees the app up.
439514 if (isRead(request)) {
@@ -465,5 +540,6 @@ if (listen.unusable !== undefined)
465540
466541 console.log(
467542 `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${[...scripts.values()].join(", ")}` +
468 `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}`,
543 `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}` +
544 `${planner ? "" : " — no TYPESAFE_API_KEY: the bots plan their rounds locally"}`,
469545 );
static/app.07fd7f4c9f.js added +58

Diff not shown — a line 73,606 characters wide (limit 2,000). +58

View file
static/app.df3a822521.js deleted −58

Diff not shown — a line 69,802 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs