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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/228-study-typesafe-ai-and-integrate-it-into → main 1 commit fork
21 files
changed +1557
−81
README.md +39
| @@ -30,6 +30,45 @@ bun run scripts/mutate-result.ts # break each rule in src | ||
| 30 | 30 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface |
| 31 | 31 | |
| 32 | 32 | |
| 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 | [](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 | ||
| 33 | 72 | ## What is real and what is not |
| 34 | 73 | |
| 35 | 74 | - **Real.** The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style |
bun.lock +5
| @@ -4,6 +4,9 @@ | ||
| 4 | 4 | "workspaces": { |
| 5 | 5 | "": { |
| 6 | 6 | "name": "cs-engine", |
| 7 | "dependencies": { | |
| 8 | "@typesafe-ai/sdk": "^0.6.0", | |
| 9 | }, | |
| 7 | 10 | "devDependencies": { |
| 8 | 11 | "@types/bun": "^1.4.2", |
| 9 | 12 | "typescript": "^7.0.2", |
| @@ -15,6 +18,8 @@ | ||
| 15 | 18 | |
| 16 | 19 | "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], |
| 17 | 20 | |
| 21 | "@typesafe-ai/sdk": ["@typesafe-ai/[email protected]", "", {}, "sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw=="], | |
| 22 | ||
| 18 | 23 | "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], |
| 19 | 24 | |
| 20 | 25 | "@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], |
package.json +3
| @@ -11,5 +11,8 @@ | ||
| 11 | 11 | "devDependencies": { |
| 12 | 12 | "@types/bun": "^1.4.2", |
| 13 | 13 | "typescript": "^7.0.2" |
| 14 | }, | |
| 15 | "dependencies": { | |
| 16 | "@typesafe-ai/sdk": "^0.6.0" | |
| 14 | 17 | } |
| 15 | 18 | } |
src/client/bots.test.ts +102 −2
| @@ -2,9 +2,10 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { PACKED, useShipped } from "./shipped.ts"; |
| 3 | 3 | import { unpack, type Policy } from "../engine/index.ts"; |
| 4 | 4 | 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, | |
| 7 | 7 | } from "./bots.ts"; |
| 8 | import type { RoundPlan, TeamPlan } from "../plan/plan.ts"; | |
| 8 | 9 | import { grip } from "./spray.ts"; |
| 9 | 10 | import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts"; |
| 10 | 11 | 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", () => | ||
| 327 | 328 | expect(d.bot.weapon).toBe("ak47"); |
| 328 | 329 | expect(shots).toBeGreaterThan(0); |
| 329 | 330 | }); |
| 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 @@ | ||
| 9 | 9 | * reflex is what reinforcement learning is good at: src/rl trains this policy |
| 10 | 10 | * on millions of duels in the same simulation you are playing. |
| 11 | 11 | * |
| 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 | * | |
| 12 | 22 | * The weights are not in here and are not imported: they arrive as data. In a |
| 13 | 23 | * browser `src/client/main.ts` downloads `weights.json` as this game's own |
| 14 | 24 | * asset and calls `usePolicy` before Play is pressable; in Bun |
| @@ -22,6 +32,7 @@ import { | ||
| 22 | 32 | angleDelta, clamp, dist, forward, newActivations, rng, sample, |
| 23 | 33 | type Activations, type Policy, type Vec3, |
| 24 | 34 | } from "../engine/index.ts"; |
| 35 | import { assign, DEFAULT_JOB, type Job, type RoundPlan } from "../plan/plan.ts"; | |
| 25 | 36 | import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts"; |
| 26 | 37 | import { CS_HULL } from "./movement.ts"; |
| 27 | 38 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| @@ -58,6 +69,24 @@ export function policy(): Policy { | ||
| 58 | 69 | return learned; |
| 59 | 70 | } |
| 60 | 71 | |
| 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 | ||
| 61 | 90 | /** The policy decides at 16 Hz and holds it, exactly as it was trained. */ |
| 62 | 91 | const SKIP = 4; |
| 63 | 92 | |
| @@ -82,7 +111,13 @@ export type BotBrain = { | ||
| 82 | 111 | strafeUntil: number; |
| 83 | 112 | suspect: Vec3 | null; |
| 84 | 113 | 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; | |
| 86 | 121 | reset: boolean; |
| 87 | 122 | random: () => number; |
| 88 | 123 | // 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 | ||
| 111 | 146 | goal: null, path: [], step: 0, repathAt: 0, |
| 112 | 147 | targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0, |
| 113 | 148 | 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, | |
| 115 | 151 | random: rng(seed), |
| 116 | 152 | obs: new Float32Array(OBS_SIZE), |
| 117 | 153 | // 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 | ||
| 194 | 230 | /** How far a bot notices an enemy at all, in units, with its eyes working. */ |
| 195 | 231 | const SIGHT = 4000; |
| 196 | 232 | |
| 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 | ||
| 197 | 239 | /** What a bot spends its money on, roughly in the order a person would. */ |
| 198 | 240 | export function botBuy(world: World, bot: Player): void { |
| 199 | 241 | 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 | } | |
| 200 | 250 | const rich = bot.money >= 4000; |
| 201 | 251 | const wants: WeaponId[] = []; |
| 202 | 252 | 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 { | ||
| 258 | 308 | return bot.brain; |
| 259 | 309 | } |
| 260 | 310 | |
| 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 | ||
| 261 | 347 | /** Where this bot is trying to be, given what the round is doing. */ |
| 262 | 348 | function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { |
| 263 | 349 | const bomb = world.bomb; |
| 264 | 350 | const level = world.level; |
| 265 | const site = level.sites[brain.site]; | |
| 351 | const job = brain.job; | |
| 352 | const site = level.sites[job.site]; | |
| 266 | 353 | if (bot.team === "t") { |
| 267 | 354 | if (bomb.state === "planted") { |
| 268 | 355 | // Hold near the bomb rather than walk onto it. |
| @@ -270,10 +357,12 @@ function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { | ||
| 270 | 357 | } |
| 271 | 358 | if (bot.hasBomb) return site; |
| 272 | 359 | 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); | |
| 274 | 363 | } |
| 275 | 364 | if (bomb.state === "planted") return bomb.pos; |
| 276 | return nearby(level, brain, site, 250); | |
| 365 | return nearby(level, brain, site, job.spread); | |
| 277 | 366 | } |
| 278 | 367 | |
| 279 | 368 | 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 { | ||
| 466 | 555 | const input: Input = { ...NO_INPUT }; |
| 467 | 556 | if (brain.reset) { |
| 468 | 557 | 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; | |
| 470 | 564 | brain.goal = null; |
| 471 | 565 | brain.path = []; |
| 472 | 566 | brain.targetId = null; |
| @@ -479,7 +573,12 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 479 | 573 | brain.pull = [0, 0]; |
| 480 | 574 | input.switchTo = bot.primary ?? bot.secondary; |
| 481 | 575 | } |
| 576 | const job = jobOf(world, bot, brain); | |
| 482 | 577 | 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; | |
| 483 | 582 | return input; |
| 484 | 583 | } |
| 485 | 584 | |
| @@ -532,9 +631,21 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 532 | 631 | return withBomb(world, bot, input); |
| 533 | 632 | } |
| 534 | 633 | |
| 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 | ||
| 535 | 646 | // --- moving. A path is asked for when there is none, when it runs out, when |
| 536 | 647 | // 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}`; | |
| 538 | 649 | const stale = |
| 539 | 650 | brain.path.length === 0 || brain.step >= brain.path.length || |
| 540 | 651 | world.time > brain.repathAt || objective !== brain.plannedFor; |
src/client/main.test.ts +39 −1
| @@ -40,6 +40,7 @@ import { WEAPONS } from "./weapons.ts"; | ||
| 40 | 40 | import { matchOf } from "../matchid.ts"; |
| 41 | 41 | import { parseResult } from "../result.ts"; |
| 42 | 42 | import { fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts"; |
| 43 | import { parseSituation } from "../plan/situation.ts"; | |
| 43 | 44 | import { createWorld, type World } from "./world.ts"; |
| 44 | 45 | |
| 45 | 46 | /** Every frame booted here, so a stage can be asked what world it is running. */ |
| @@ -69,7 +70,9 @@ function painted(stage: FakeStage): FakeStage { | ||
| 69 | 70 | |
| 70 | 71 | /** A booted page, its atlas painted, and the match already started on it. */ |
| 71 | 72 | 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 | } = {}, | |
| 73 | 76 | ): FakeStage { |
| 74 | 77 | const stage = painted(start(fakeStage(options))); |
| 75 | 78 | 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 | ||
| 1234 | 1237 | expect(stage.by("mapname").textContent).toBe("de_forge"); |
| 1235 | 1238 | expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=de_forge`); |
| 1236 | 1239 | }); |
| 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 { | ||
| 60 | 60 | import { generateLevel, seedFrom } from "./generate.ts"; |
| 61 | 61 | import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts"; |
| 62 | 62 | 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"; | |
| 63 | 65 | import { DE_FORGE } from "./map.ts"; |
| 64 | 66 | import type { Level } from "./level.ts"; |
| 65 | 67 | 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"; | |
| 67 | 69 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 68 | 70 | import { meshFor } from "./models.ts"; |
| 69 | 71 | import { parsePacked } from "./packed.ts"; |
| 70 | 72 | import { nextStep, type Answer } from "./pending.ts"; |
| 71 | 73 | import { drawPreview, previewText } from "./preview.ts"; |
| 72 | 74 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 73 | import { usePolicy } from "./bots.ts"; | |
| 75 | import { usePlan, usePolicy } from "./bots.ts"; | |
| 74 | 76 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 75 | 77 | import { buyable, WEAPONS } from "./weapons.ts"; |
| 76 | 78 | 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"; | |
| 78 | 80 | import { unpack } from "../engine/index.ts"; |
| 79 | 81 | |
| 80 | 82 | const TICK = 1 / 64; |
| @@ -328,6 +330,14 @@ export function boot(host: Host): Frame { | ||
| 328 | 330 | // Where this game's weights are: the shell put the link in the head with the |
| 329 | 331 | // content-hashed URL on it, so this is the only place the path is known. |
| 330 | 332 | 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"); | |
| 331 | 341 | const resumeButton = by("resume"); |
| 332 | 342 | // The line in the pause panel that says what Escape just did on this browser. |
| 333 | 343 | const escHint = by("esc"); |
| @@ -851,6 +861,7 @@ export function boot(host: Host): Frame { | ||
| 851 | 861 | pauseId.textContent = `Match ${matchId} on ${level.name}.`; |
| 852 | 862 | share.setAttribute("href", `/?match=${matchId}&map=${choice}`); |
| 853 | 863 | world = createWorld(level, rules, name, team, 5, seed); |
| 864 | plannedRound = 0; | |
| 854 | 865 | posted = -1; |
| 855 | 866 | loop.reset(); |
| 856 | 867 | controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12); |
| @@ -908,6 +919,75 @@ export function boot(host: Host): Frame { | ||
| 908 | 919 | lastSound = current.time; |
| 909 | 920 | } |
| 910 | 921 | |
| 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 | ||
| 911 | 991 | function frame(elapsed: number): void { |
| 912 | 992 | frameAverage += (elapsed * 1000 - frameAverage) * 0.05; |
| 913 | 993 | // 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 { | ||
| 933 | 1013 | } |
| 934 | 1014 | if (world === null || !running) return; |
| 935 | 1015 | |
| 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 | ||
| 936 | 1020 | const me = world.me; |
| 937 | 1021 | const [dx, dy] = controls.takeLook(); |
| 938 | 1022 | if (me.alive && controls.locked) { |
src/client/stage.test-helper.ts +12 −1
| @@ -107,6 +107,9 @@ export const ORIGIN = "https://cs.example"; | ||
| 107 | 107 | */ |
| 108 | 108 | export const WEIGHTS_URL = "/static/weights.0123456789.json"; |
| 109 | 109 | |
| 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 | ||
| 110 | 113 | /** The committed policy document, which is the file the build copies to static/. */ |
| 111 | 114 | const WEIGHTS_TEXT = readFileSync(join(import.meta.dir, "weights.json"), "utf8"); |
| 112 | 115 | |
| @@ -232,10 +235,16 @@ export function fakeStage( | ||
| 232 | 235 | hold?: boolean; |
| 233 | 236 | /** Leave the `#weights` link off the page entirely: a page that named nothing. */ |
| 234 | 237 | 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; | |
| 235 | 244 | } = {}, |
| 236 | 245 | ): FakeStage { |
| 237 | 246 | const page = fakePage({ |
| 238 | ids: PAGE_IDS, | |
| 247 | ids: options.planEndpoint === undefined ? PAGE_IDS : [...PAGE_IDS, PLAN_ID], | |
| 239 | 248 | canvases: ["scene", "hud", "plan"], |
| 240 | 249 | hidden: HIDDEN, |
| 241 | 250 | actions: PAGE_ACTIONS, |
| @@ -248,6 +257,8 @@ export function fakeStage( | ||
| 248 | 257 | // The link the shell renders in the head, with the content-hashed URL on it. |
| 249 | 258 | // `unlinked: true` leaves it blank: a page that named no weights at all. |
| 250 | 259 | 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); | |
| 251 | 262 | // The same switch on both sides of the seam: `webgl: false` is a browser with |
| 252 | 263 | // no context to give, whether the frame asks this page's `host` for a renderer |
| 253 | 264 | // or — as the committed bundle does — asks the canvas itself. |
src/client/world.ts +16 −2
| @@ -22,6 +22,7 @@ import { | ||
| 22 | 22 | type Grenade, type GrenadeSound, |
| 23 | 23 | } from "./grenade.ts"; |
| 24 | 24 | import type { Level } from "./level.ts"; |
| 25 | import type { RoundPlan } from "../plan/plan.ts"; | |
| 25 | 26 | import { |
| 26 | 27 | angleVectors, angleDelta, clamp, createEffects, dist, expireEffects, eyeHeight, hullHeight, move, |
| 27 | 28 | normalize, pushEffect, rng, sub, visible, |
| @@ -173,6 +174,14 @@ export type World = { | ||
| 173 | 174 | grenades: Grenade[]; |
| 174 | 175 | feed: FeedEntry[]; |
| 175 | 176 | 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; | |
| 176 | 185 | random: () => number; |
| 177 | 186 | /** |
| 178 | 187 | * The map this round is played on, whole. Everything that needs to know where |
| @@ -252,7 +261,7 @@ export function createWorld( | ||
| 252 | 261 | const world: World = { |
| 253 | 262 | match, players, me, time: 0, |
| 254 | 263 | bomb: newBomb(), |
| 255 | effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], feed: [], messages: [], | |
| 264 | effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], feed: [], messages: [], plan: null, | |
| 256 | 265 | random, level, bombPlanted: false, pendingBuy: false, |
| 257 | 266 | }; |
| 258 | 267 | startRound(world); |
| @@ -317,7 +326,12 @@ export const alive = (world: World, team: Team): Player[] => | ||
| 317 | 326 | |
| 318 | 327 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, CS_HULL)]; |
| 319 | 328 | |
| 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 { | |
| 321 | 335 | world.messages.push({ at: world.time, text }); |
| 322 | 336 | if (world.messages.length > 6) world.messages.shift(); |
| 323 | 337 | } |
src/games.ts +10
| @@ -60,6 +60,16 @@ export type Game = { | ||
| 60 | 60 | /** |
| 61 | 61 | * In the order the header lists them, which is also the order they are built. |
| 62 | 62 | * |
| 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. | |
| 63 | 73 | * The ceilings are today's sizes plus about 10%, rounded up to a round number: |
| 64 | 74 | * enough headroom that an honest feature lands without a ceiling edit, tight |
| 65 | 75 | * 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 => | ||
| 69 | 69 | map === "de_forge" || map === "generated" ? null : map; |
| 70 | 70 | |
| 71 | 71 | /** 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"> & { | |
| 73 | 73 | /** The forge identity the request carried, verified in `src/who.ts`. */ |
| 74 | 74 | who: Who; |
| 75 | 75 | /** What `/?match=…&map=…` asked for. Absent is `NOTHING_ASKED`. */ |
| 76 | 76 | asked?: Asked; |
| 77 | 77 | /** The policy card the prose is made of. Absent is the shipped weights'. */ |
| 78 | 78 | 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; | |
| 79 | 86 | }; |
| 80 | 87 | |
| 81 | 88 | /** |
| @@ -250,10 +257,34 @@ export const policyHtml = (card: Card): string => | ||
| 250 | 257 | still wins ${num(card.unseenWinRate)}% against the same bots' ${num(card.unseenBaselineWinRate)}%, which is |
| 251 | 258 | what a map made the moment you press play asks of it. Its weights are about eight kilobytes |
| 252 | 259 | 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 | |
| 254 | 261 | bot's own skill still decides how fast it can turn, how steady its hands are and how long it |
| 255 | 262 | takes to react, so there is still a ladder from fodder to uncomfortable.</p>`; |
| 256 | 263 | |
| 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 | ||
| 257 | 288 | /** |
| 258 | 289 | * The pause panel. Its line about Escape is rendered with the sentence that is |
| 259 | 290 | * 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"> | ||
| 308 | 339 | |
| 309 | 340 | export function gamePage( |
| 310 | 341 | db: Database, |
| 311 | { who: visitor, asked = NOTHING_ASKED, card = CARD, ...options }: PageOptions, | |
| 342 | { who: visitor, asked = NOTHING_ASKED, card = CARD, planning = false, ...options }: PageOptions, | |
| 312 | 343 | ): string { |
| 313 | 344 | return page({ |
| 314 | 345 | ...options, |
| @@ -359,6 +390,7 @@ export function gamePage( | ||
| 359 | 390 | Everything you see is generated — the map, the textures, the models and the sounds — so the |
| 360 | 391 | whole game is one small download and no asset files.</p> |
| 361 | 392 | ${policyHtml(card)} |
| 393 | ${planHtml(planning)} | |
| 362 | 394 | <p>The match runs entirely in your browser; the leaderboard here is what your browser |
| 363 | 395 | reported about it, checked against what a match can produce. The name on a row is not |
| 364 | 396 | 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 => | ||
| 160 | 160 | ) |
| 161 | 161 | .join(""); |
| 162 | 162 | |
| 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 | ||
| 163 | 169 | /** One touch button: the action `src/engine/controls.ts` holds, and its label. */ |
| 164 | 170 | export type TouchButton = { action: string; label: string }; |
| 165 | 171 | |
| @@ -258,6 +264,14 @@ export type PageOptions = { | ||
| 258 | 264 | * by its id. Absent, or empty, is a page that downloads nothing but its script. |
| 259 | 265 | */ |
| 260 | 266 | 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[]; | |
| 261 | 275 | ssh: string | undefined; |
| 262 | 276 | /** The sentence in front of the ssh command, when there is one. */ |
| 263 | 277 | terminalLead: string; |
| @@ -281,7 +295,7 @@ export function page(options: PageOptions): string { | ||
| 281 | 295 | <meta charset="utf-8"> |
| 282 | 296 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| 283 | 297 | <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> | |
| 285 | 299 | <meta name="description" content="${escape(options.description)}"> |
| 286 | 300 | <style>${styleFor(options.extraStyle)}</style> |
| 287 | 301 | <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"; | ||
| 9 | 9 | import { matchOf, playKey } from "./matchid.ts"; |
| 10 | 10 | import { NOTHING_POSTED } from "./client/pending.ts"; |
| 11 | 11 | 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"; | |
| 13 | 15 | import { sign } from "./who.ts"; |
| 14 | 16 | import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts"; |
| 15 | 17 | import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts"; |
| @@ -19,7 +21,7 @@ process.env.PORT = "0"; | ||
| 19 | 21 | // The test is the forge: it holds the same key and signs identities with it. |
| 20 | 22 | const KEY = "the-key-the-forge-signs-with"; |
| 21 | 23 | 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"); | |
| 23 | 25 | const base = `http://${server.hostname}:${server.port}`; |
| 24 | 26 | |
| 25 | 27 | /** 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 | ||
| 830 | 832 | // And a signed-in player is not behind that bucket: guests cannot lock them out. |
| 831 | 833 | expect((await post(result({ name: "steady" }), "", as("u-50", "steady"))).status).toBe(200); |
| 832 | 834 | }); |
| 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"y" content="/a"b"'); | |
| 909 | }); |
src/server.ts +77 −1
| @@ -41,6 +41,9 @@ import { createLimiter } from "./limit.ts"; | ||
| 41 | 41 | import { examplePage } from "./pages/example.ts"; |
| 42 | 42 | import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts"; |
| 43 | 43 | 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"; | |
| 44 | 47 | import { matchFrom } from "./matchid.ts"; |
| 45 | 48 | import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts"; |
| 46 | 49 | import { rangePage } from "./pages/range.ts"; |
| @@ -60,6 +63,18 @@ const ssh = process.env.FORGE_APP_SSH; | ||
| 60 | 63 | // every visitor is a guest, and a guest's score is labelled as one. |
| 61 | 64 | const identityKey = process.env.FORGE_APP_IDENTITY_KEY; |
| 62 | 65 | |
| 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 | ||
| 63 | 78 | // The security floor every response stands on. Everything comes from this |
| 64 | 79 | // origin, nothing frames us, browsers never guess a content type, and referrers |
| 65 | 80 | // 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 | ||
| 103 | 118 | headers: { "content-type": "text/html; charset=utf-8", ...security, ...extra }, |
| 104 | 119 | }); |
| 105 | 120 | |
| 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 | ||
| 106 | 127 | // A browser on another site can make a person's browser POST here; it cannot |
| 107 | 128 | // make that request look same-origin. Browsers say where a request came from |
| 108 | 129 | // (Sec-Fetch-Site, or Origin); a request that names another site is refused. |
| @@ -119,6 +140,8 @@ const sameOrigin = (request: Request): boolean => { | ||
| 119 | 140 | // cap the rest is drained and dropped so the connection stays usable for |
| 120 | 141 | // the next request; past eight times the cap it is cut off instead. |
| 121 | 142 | 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; | |
| 122 | 145 | async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> { |
| 123 | 146 | const reader = request.body?.getReader(); |
| 124 | 147 | if (reader === undefined) return new Uint8Array(); |
| @@ -198,6 +221,24 @@ const mayWrite = (visitor: Who, session: string | null, address: string | undefi | ||
| 198 | 221 | return matchLimit.take(limitKey(visitor, session, address)); |
| 199 | 222 | }; |
| 200 | 223 | |
| 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 | ||
| 201 | 242 | /** |
| 202 | 243 | * Who a result is recorded as. A verified forge identity plays under its own |
| 203 | 244 | * name and the browser's claim is ignored; everyone else is a guest, whose |
| @@ -326,6 +367,11 @@ const RENDER: Record<string, Render> = { | ||
| 326 | 367 | return { |
| 327 | 368 | body: gamePage(db, { |
| 328 | 369 | 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, | |
| 329 | 375 | who: who(request, identityKey), asked: asked(request), |
| 330 | 376 | }), |
| 331 | 377 | extra, |
| @@ -434,6 +480,35 @@ export const server = Bun.serve({ | ||
| 434 | 480 | return html(statsHtml(db), SECURITY, 200, { "set-cookie": sessionCookie(session) }); |
| 435 | 481 | } |
| 436 | 482 | |
| 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 | ||
| 437 | 512 | // The games, one table rather than one branch each. HEAD is GET without a |
| 438 | 513 | // body, so a health check on `/` sees the app up. |
| 439 | 514 | if (isRead(request)) { |
| @@ -465,5 +540,6 @@ if (listen.unusable !== undefined) | ||
| 465 | 540 | |
| 466 | 541 | console.log( |
| 467 | 542 | `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"}`, | |
| 469 | 545 | ); |
static/app.07fd7f4c9f.js added +58
Diff not shown — a line 73,606 characters wide (limit 2,000). +58
View filestatic/app.df3a822521.js deleted −58
Diff not shown — a line 69,802 characters wide (limit 2,000). −58
View file
new above
offline