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

forked from trevato/cs

A planned round is still the same round: a match id keeps its promise when a deployment has a planner key #397

Merged opened this

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

Merged

torque/cs-engine:torque/238-a-planned-round-is-still-the-same-round → main 2 commits fork

  • Merged. torque/238-a-planned-round-is-still-the-same-round was merged into main as 9682627.

Review

Sign in to review this change.
17 files changed +638 −59
README.md +24 −1
@@ -69,6 +69,27 @@ a key is set, and you can watch the call in the round's line on the HUD, which
6969 says which side is doing what. Everything else about the game is still local:
7070 the match, the fight, the physics and the weights all run in your browser.
7171
72 **A planned match is still the match its id names** (#238). A model asked the
73 same question twice may answer it two ways, so with a key the answer is made
74 part of the match: the page posts the match id beside the situation, and
75 `src/plan/served.ts` answers each round of a match once — the model's plan, or
76 the local one it fell back to — records it in the `plan` table under
77 `FORGE_APP_DATA` keyed by the match id and the situation, and hands every later
78 playing of that id in that situation the recorded plan without asking again.
79 The record is SQLite, not a process's memory, so a restart keeps it. The page
80 never waits for it: the local plan is in force from the first tick, an answer
81 is *held* and played from the tick the freeze ends (`offerPlan` in
82 `src/client/bots.ts`), so when it arrived is never part of the round, and one
83 that arrives after the freeze is dropped. The server gives the model five
84 seconds, retries included, of an eight-second freeze. What is not guaranteed:
85 if the first playing's answer crosses the network after that freeze ended,
86 that playing ran the local plan while the record holds the model's, and later
87 playings get the record. The chosen alternative — "a pasted id plans locally" —
88 was cheaper and airtight, but it would have spent the planner on nobody's
89 shared match and made a fresh match's link replay as a different one. The pin
90 is `PLANNED_ROUND` in `src/client/replay.test.ts`. Without a key, nothing is
91 recorded and nothing changed.
92
7293 `TYPESAFE_MODEL` overrides the model (the SDK's default is `jev-latest`).
7394 Planning is rate-limited per caller and under a spend ceiling for the whole
7495 process; over either, or on any failure, the round gets the local plan, which is
@@ -233,7 +254,9 @@ bug report.
233254 which it leaves alone. A whole link from this page fills in the id *and* the
234255 map, so you get the match you were given rather than its round on somebody
235256 else's layout. Leave the field empty and you get a new match, exactly as
236 before.
257 before. On a deployment where a model plans the bots, the plans come with the
258 id too: each round's plan is kept with the match the first time it is asked
259 (see *A planned match is still the match its id names* above).
237260 - **Link to it.** `/?match=<id>&map=<de_forge|generated|gen_…>` preselects both.
238261 The page's own **Link to this match**, under the stage, is that URL for
239262 whatever you are playing: a plain `<a href>` a keyboard reaches and a thumb
src/client/bots.ts +14
@@ -94,6 +94,20 @@ export function usePlan(world: World, plan: RoundPlan | null): void {
9494 world.plan = plan;
9595 }
9696
97 /**
98 * Hand a world's bots a plan that came from somewhere slower than arithmetic —
99 * the server, and a model behind it. It is held, not played: `updateWith` in
100 * `world.ts` puts it in force on the tick this round's freeze ends, so two
101 * playings handed the same plan play the same round however long either
102 * answer took. A plan for another round, or one that arrives once the round is
103 * live, is refused and the round keeps the plan it has. True when it was taken.
104 */
105 export function offerPlan(world: World, plan: RoundPlan): boolean {
106 if (world.match.phase !== "freeze" || plan.round !== world.match.round) return false;
107 world.planned = plan;
108 return true;
109 }
110
97111 /** The round this world's bots are playing, or null when nobody planned one. */
98112 export const roundPlan = (world: World): RoundPlan | null => world.plan;
99113
src/client/main.test.ts +64 −3
@@ -49,6 +49,7 @@ import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts";
4949 import { parseResult, type Result } from "../result.ts";
5050 import { DRIVER, fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts";
5151 import { parseSituation } from "../plan/situation.ts";
52 import type { RoundPlan } from "../plan/plan.ts";
5253 import { createWorld, type World } from "./world.ts";
5354
5455 /** Every frame booted here, so a stage can be asked what world it is running. */
@@ -1724,7 +1725,7 @@ test("a round is planned in the page, and the bots are a team from the first tic
17241725 expect(stage.posts.filter((post) => post.url === "/api/plan")).toHaveLength(0);
17251726 });
17261727
1727 test("a page that names a planner asks it, with the situation and nothing else", async () => {
1728 test("a page that names a planner asks it, with the situation and the match, and nothing else", async () => {
17281729 const stage = playing({ planEndpoint: "/api/plan" });
17291730 stage.step(FRAME);
17301731 const posted = stage.posts.filter((post) => post.url === "/api/plan");
@@ -1732,8 +1733,11 @@ test("a page that names a planner asks it, with the situation and nothing else",
17321733 const sent = JSON.parse(String(posted[0]?.body)) as Record<string, unknown>;
17331734 expect(parseSituation(sent)).not.toBeNull();
17341735 expect(sent.round).toBe(1);
1735 expect(Object.keys(sent).sort()).toEqual(["lastWinner", "map", "money", "round", "score"]);
1736 expect(JSON.stringify(sent)).not.toContain("matchId");
1736 // #238: the match id goes with it, because the plan is kept with the match —
1737 // the id being played, not the key of this playing and nothing about you.
1738 expect(Object.keys(sent).sort()).toEqual(["lastWinner", "map", "match", "money", "round", "score"]);
1739 expect(sent.match).toBe(stage.by("playing").textContent);
1740 expect(String(sent.match)).not.toContain("--");
17371741 // The stage answers every post with the leaderboard, which is not a plan: the
17381742 // local plan stays in force and the round is played on it.
17391743 await Promise.resolve();
@@ -1743,6 +1747,63 @@ test("a page that names a planner asks it, with the situation and nothing else",
17431747 expect(stepsRun(stage)).toBeGreaterThan(0);
17441748 });
17451749
1750 /** A plan the model might have answered, for whatever round is asked. */
1751 const modelPlan = (round: number): RoundPlan => ({
1752 round, source: "typesafe", model: "jev-1",
1753 t: { site: "B", shape: "stack", tempo: 3, save: false },
1754 ct: { site: "B", shape: "stack", tempo: 0, save: false },
1755 });
1756
1757 /** Answer the plan endpoint with `plan`, everything else as the stage would. */
1758 const answeringPlan = (stage: FakeStage, plan: RoundPlan): void => {
1759 const usual = stage.answer;
1760 stage.answer = (post) =>
1761 post.url === "/api/plan"
1762 ? Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(plan)) })
1763 : usual(post);
1764 };
1765
1766 test("a planned answer is held through the freeze and played from the tick it ends, not before", async () => {
1767 const stage = painted(start(fakeStage({ planEndpoint: "/api/plan" })));
1768 answeringPlan(stage, modelPlan(1));
1769 stage.by("play").fire("click");
1770 stage.step(FRAME);
1771 await Bun.sleep(0);
1772 const world = worldOf(stage) as World;
1773 // It arrived in the freeze: it is waiting, and the round is still the local plan.
1774 expect(world.match.phase).toBe("freeze");
1775 expect(world.planned).toEqual(modelPlan(1));
1776 expect(world.plan?.source).toBe("local");
1777 // The freeze ends, and from that tick the round is the planned one.
1778 while (world.match.phase === "freeze") stage.step(FRAME);
1779 expect(world.plan).toEqual(modelPlan(1));
1780 expect(world.planned).toBeNull();
1781 });
1782
1783 test("an answer that arrives once the round is live does not change it", async () => {
1784 const stage = painted(start(fakeStage({ planEndpoint: "/api/plan" })));
1785 let release = (): void => {};
1786 const late = new Promise<void>((resolve) => {
1787 release = resolve;
1788 });
1789 const usual = stage.answer;
1790 stage.answer = (post) =>
1791 post.url === "/api/plan"
1792 ? late.then(() => ({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(modelPlan(1))) }))
1793 : usual(post);
1794 stage.by("play").fire("click");
1795 stage.step(FRAME);
1796 const world = worldOf(stage) as World;
1797 while (world.match.phase === "freeze") stage.step(FRAME);
1798 const played = world.plan;
1799 release();
1800 await Bun.sleep(0);
1801 stage.step(FRAME);
1802 expect(world.plan).toBe(played);
1803 expect(world.plan?.source).toBe("local");
1804 expect(world.planned).toBeNull();
1805 });
1806
17461807 // --- the match of the day -------------------------------------------------
17471808
17481809 test("today's match fills the menu's own controls, and is the match that is played", () => {
src/client/main.ts +20 −21
@@ -96,7 +96,7 @@ import { createReporter, type Reply } from "./report.ts";
9696 import { boardHtml, headline, resultOf, scoreLine, summarize, yourLine } from "./summary.ts";
9797 import { drawPreview, previewText } from "./preview.ts";
9898 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
99 import { usePlan, usePolicy } from "./bots.ts";
99 import { offerPlan, usePlan, usePolicy } from "./bots.ts";
100100 import { callOf, hears, radio } from "./radio.ts";
101101 import type { Call } from "../plan/call.ts";
102102 import { createSpectator, cycle, follow } from "./spectate.ts";
@@ -1500,22 +1500,25 @@ export function boot(host: Host): Frame {
15001500 }
15011501
15021502 /**
1503 * The round the bots have been planned for, and whether a request for a
1504 * better plan is in the air. Both are per match: a new world is round one
1505 * again, and `planFor` below is what puts the first plan in place before the
1506 * first freeze time is over.
1503 * The round the bots have been planned for. It is per match: a new world is
1504 * round one again, and `planFor` below is what puts the first plan in place
1505 * before the first freeze time is over.
15071506 */
15081507 let plannedRound = 0;
1509 let planning = false;
15101508
15111509 /**
15121510 * Plan the round about to be played. The local plan goes in at once, so the
15131511 * bots are a team from the first tick whatever happens next; then, on a
1514 * deployment that named an endpoint for it, the same situation is put to the
1515 * server, which puts it to TypeSafe AI. Whatever comes back is validated
1516 * (`parsePlan`) and replaces the local one, and anything else — a refusal, a
1517 * 500, junk, a connection that died — leaves the local plan exactly where it
1518 * is. Nothing here is awaited by the frame.
1512 * deployment that named an endpoint for it, the situation and the match id
1513 * are put to the server, which answers with the plan this round of this
1514 * match was given the first time anybody played it — asking TypeSafe AI only
1515 * if nobody has (`src/plan/served.ts`). Whatever comes back is validated
1516 * (`parsePlan`) and *offered* (`offerPlan`): held until the freeze ends and
1517 * played from that tick, so when it arrived is not part of the round. One
1518 * that arrives after the freeze, a refusal, a 500, junk or a connection that
1519 * died leaves the local plan exactly where it is. Nothing here is awaited by
1520 * the frame, and every round asks — one request per round, never skipped
1521 * because the last match's was still in the air.
15191522 */
15201523 function planFor(current: World): void {
15211524 const match = current.match;
@@ -1530,27 +1533,23 @@ export function boot(host: Host): Frame {
15301533 const local = localPlan(situation);
15311534 usePlan(current, local);
15321535 announce(current, local);
1533 if (planEndpoint === null || planning) return;
1534 planning = true;
1536 if (planEndpoint === null) return;
1537 const body = JSON.stringify({ ...situation, match: matchId });
15351538 void (async (): Promise<void> => {
15361539 try {
15371540 const response = await host.fetch(planEndpoint, {
15381541 method: "POST",
15391542 headers: { "content-type": "application/json" },
1540 body: JSON.stringify(situation),
1543 body,
15411544 });
15421545 const plan = response.ok ? parsePlan(JSON.parse(await response.text())) : null;
1543 // A plan that arrived for a round that has already ended is last
1544 // round's plan: the bots keep the one they are playing.
1545 if (plan !== null && world === current && current.match.round === plan.round) {
1546 usePlan(current, plan);
1547 announce(current, plan);
1548 }
1546 // A plan for a world that is no longer being played, another round, or
1547 // a round already live is not taken: the bots keep the one they have.
1548 if (plan !== null && world === current && offerPlan(current, plan)) announce(current, plan);
15491549 } catch {
15501550 // The local plan is already in force. A round is not lost because a
15511551 // planner was.
15521552 }
1553 planning = false;
15541553 })();
15551554 }
15561555
src/client/replay.test.ts +97 −1
@@ -32,7 +32,7 @@ import { DE_FORGE } from "./map.ts";
3232 import { SHORT } from "./match.ts";
3333 import { CHECKPOINT_CHARS } from "../engine/index.ts";
3434 import {
35 CALL_SCRIPT, compare, digest, fold, reader, snapshot, SMOKE_SCRIPT, SWITCH_SCRIPT, TICK, TICKS_PER_SECOND,
35 CALL_SCRIPT, compare, digest, fold, plannedDigest, reader, snapshot, SMOKE_SCRIPT, SWITCH_SCRIPT, TICK, TICKS_PER_SECOND,
3636 withoutEntropy, type Pin, type Run,
3737 } from "./replay.ts";
3838 import { colOf, rowOf, walkable, type Level } from "./level.ts";
@@ -44,6 +44,14 @@ import { useShipped } from "./shipped.ts";
4444 import { usePlan } from "./bots.ts";
4545 import { localPlan, type RoundPlan } from "../plan/plan.ts";
4646 import { CALL_LIFE } from "../plan/call.ts";
47 import { mkdtempSync } from "node:fs";
48 import { tmpdir } from "node:os";
49 import { join } from "node:path";
50 import { openDatabase, recordedPlan, recordPlan } from "../db.ts";
51 import { servePlan, type Ledger } from "../plan/served.ts";
52 import type { Situation } from "../plan/situation.ts";
53 import type { Planner } from "../plan/typesafe.ts";
54 import { emptyTally } from "../plan/readout.ts";
4755
4856 /**
4957 * The bots in every run below think with the policy this repository ships, and
@@ -457,6 +465,94 @@ test("the call is what moves the round: the same plan unsaid is the same round u
457465 expect(CALL_TICKS).toBeGreaterThan(576 + CALL_LIFE * TICKS_PER_SECOND);
458466 });
459467
468 // --- a planned round -----------------------------------------------------
469
470 /**
471 * #238: a round a model planned is still the round its match id promises. The
472 * run is the call round's match — seed 7, de_forge, the local plan from the
473 * first tick — with a model's answer offered during the freeze, the way the
474 * page offers the server's. Thirty-six seconds, so the planned sides are well
475 * into the live round.
476 */
477 const PLANNED_ROUND = "96c00fa61bc968de6ef4ab0b2fa7466a1f0dffd07a3174f82937f043e4053e1f";
478 const PLANNED_ROUND_SECONDS: readonly string[] = [
479 "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5",
480 "0fab6fec7329", "46cbc192435f", "db863794da65", "0be77a818fff",
481 "efaf27373808", "1d49702d8698", "fbda3654e1ed", "2acac3a49145",
482 "2929eb6de269", "942530408bfc", "dbeb92daa801", "6a2223265562",
483 "41ba37d78717", "d47d22aec919", "84e93a4750ed", "5db1cd517b25",
484 "b4e7cd13d2cc", "ea6866ed1502", "5c821f3ae201", "f39bf68b5634",
485 "d3d66cc23ba0", "5d8631088130", "6c69c18151a8", "56ffe6959a06",
486 "73352838c5aa", "f32d4b631118", "4be7f13206b0", "962082e789f3",
487 "de47d38b9c9b", "8632437c4de5", "cb5464a84f19", "d3d2509b7e1d",
488 ];
489
490 const PLANNED_PIN: Pin = {
491 label: "round a model planned", constant: "PLANNED_ROUND",
492 digest: PLANNED_ROUND, seconds: PLANNED_ROUND_SECONDS,
493 };
494
495 /** Two answers a model might give round one of this match, on two askings. */
496 const TOLD: Record<"A" | "B", RoundPlan> = {
497 A: { ...ROUND_ONE, source: "typesafe", model: "jev-1",
498 t: { site: "A", shape: "stack", tempo: 3, save: false }, ct: { site: "A", shape: "stack", tempo: 0, save: false } },
499 B: { ...ROUND_ONE, source: "typesafe", model: "jev-1",
500 t: { site: "B", shape: "split", tempo: 1, save: false }, ct: { site: "B", shape: "fake", tempo: 2, save: false } },
501 };
502
503 /** The script of the call round with nobody saying anything. */
504 const QUIET = CALL_SCRIPT.map(({ call: _, ...beat }) => beat);
505
506 const plannedRound = (offered: RoundPlan, arrives: number): Run =>
507 withoutEntropy(() =>
508 plannedDigest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), CALL_TICKS, ROUND_ONE, offered, arrives, QUIET));
509
510 test("a seeded round a model planned plays exactly as it played", () => {
511 const run = plannedRound(TOLD.A, 64);
512 must(run, PLANNED_PIN);
513 expect(run.digest).toBe(PLANNED_ROUND);
514 expect(run.seconds).toEqual([...PLANNED_ROUND_SECONDS]);
515 });
516
517 test("when a plan arrived is not part of the round: any tick of the freeze is the same round", () => {
518 const early = plannedRound(TOLD.A, 1);
519 for (const arrives of [300, 511]) expect(plannedRound(TOLD.A, arrives).digest).toBe(early.digest);
520 expect(early.digest).toBe(PLANNED_ROUND);
521 // And it is played: the planned round is not the local one.
522 expect(early.digest).not.toBe(plannedRound(ROUND_ONE, 1).digest);
523 }, 30_000);
524
525 test("a plan that arrives once the round is live is not a plan that changes it", () => {
526 const local = plannedRound(ROUND_ONE, 1).digest;
527 expect(plannedRound(TOLD.A, 513).digest).toBe(local);
528 expect(plannedRound(TOLD.A, 900).digest).toBe(local);
529 }, 30_000);
530
531 test("the same id, planned twice by a model that changes its mind, is the same round", async () => {
532 // The model's two answers are two different rounds, or this proves nothing.
533 const told = plannedRound(TOLD.A, 64).digest;
534 expect(plannedRound(TOLD.B, 64).digest).not.toBe(told);
535 let asked = 0;
536 const fickle: Planner = { size: () => 0, tally: emptyTally, model: "stub", plan: async () => (asked++ === 0 ? TOLD.A : TOLD.B) };
537 const db = openDatabase(mkdtempSync(join(tmpdir(), "replay-")));
538 const ledger: Ledger = {
539 read: (match, key) => recordedPlan(db, match, key),
540 keep: (match, key, plan) => recordPlan(db, match, key, plan),
541 };
542 const situation: Situation = {
543 map: "de_forge", round: 1, score: { t: 0, ct: 0 }, money: { t: 800, ct: 800 }, lastWinner: null,
544 };
545 const match = "00000007";
546 const first = await servePlan({ situation, match }, fickle, ledger, () => true);
547 const second = await servePlan({ situation, match }, fickle, ledger, () => true);
548 // Asked once: the second playing read the record, and it is the first answer.
549 expect(asked).toBe(1);
550 expect(first).toEqual(TOLD.A);
551 // And played, the answer landing at another moment of the freeze, it is the
552 // same round the first playing was.
553 expect(plannedRound(second, 400).digest).toBe(told);
554 }, 30_000);
555
460556 test("a seeded duel in the training arena plays exactly as it played", () => {
461557 const run = duelRun();
462558 must(run, DUEL_PIN);
src/client/replay.ts +21
@@ -24,6 +24,8 @@ import { compare as complain, fold as foldTicks, quantise } from "../engine/inde
2424 import { buy, type BuyItem } from "./economy.ts";
2525 import { radio } from "./radio.ts";
2626 import type { Call } from "../plan/call.ts";
27 import type { RoundPlan } from "../plan/plan.ts";
28 import { offerPlan, usePlan } from "./bots.ts";
2729 import { NO_INPUT, update, type Input, type Player, type World } from "./world.ts";
2830 import { cloudRadius, type Cloud, type Grenade } from "./grenade.ts";
2931 import type { ThrownId } from "./weapons.ts";
@@ -344,6 +346,25 @@ export function digest(world: World, ticks: number, script: readonly Beat[] = SC
344346 return fold(world, ticks, (tick) => update(world, TICK, next(tick, world, world.me)));
345347 }
346348
349 /**
350 * A planned round (#238): the local plan in force from the first tick, as the
351 * page puts it there, and a slower plan — the server's, a model's behind it —
352 * offered at tick `arrives`, whenever the answer happened to come back. The
353 * page does exactly this (`offerPlan`), so a run here is the planned path and
354 * not a stand-in for it: the offered plan is held and played from the tick the
355 * freeze ends, and one that arrives after that is refused.
356 */
357 export function plannedDigest(
358 world: World, ticks: number, local: RoundPlan, offered: RoundPlan, arrives: number, script: readonly Beat[] = SCRIPT,
359 ): Run {
360 usePlan(world, local);
361 const next = reader(script);
362 return fold(world, ticks, (tick) => {
363 if (tick === arrives) offerPlan(world, offered);
364 update(world, TICK, next(tick, world, world.me));
365 });
366 }
367
347368 // --- comparing ------------------------------------------------------------
348369
349370 /** A digest committed in the repository, and what to call it when it moves. */
src/client/world.ts +18 −2
@@ -258,6 +258,14 @@ export type World = {
258258 * `src/client/bots.ts` puts it here (`usePlan`) and reads it back.
259259 */
260260 plan: RoundPlan | null;
261 /**
262 * A plan that arrived during this round's freeze time and has not been played
263 * yet. It becomes `plan` on the tick the freeze ends and not a tick before, so
264 * when an answer arrived is never part of the round — only what it said is.
265 * A plan offered after that tick is refused (`offerPlan` in `bots.ts`): a
266 * late answer is not a plan that changes a round already being played.
267 */
268 planned: RoundPlan | null;
261269 /**
262270 * The last call anybody made on the radio, or null. It stands in front of
263271 * `plan` for its own side while it is live (`src/plan/call.ts`) and is simply
@@ -344,7 +352,7 @@ export function createWorld(
344352 match, players, me, time: 0,
345353 bomb: newBomb(),
346354 effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], clouds: [],
347 feed: [], messages: [], plan: null, radio: null,
355 feed: [], messages: [], plan: null, planned: null, radio: null,
348356 random, level, bombPlanted: false, pendingBuy: false,
349357 };
350358 startRound(world);
@@ -488,6 +496,8 @@ export function startRound(world: World): void {
488496 // one side starting a round blind for something nobody did.
489497 world.clouds = [];
490498 world.feed = [];
499 // Last round's answer, if it came too late to be played, is not this one's.
500 world.planned = null;
491501 for (const p of world.players) if (p.bot) botBuy(world, p);
492502 }
493503
@@ -971,7 +981,13 @@ export function updateWith(world: World, dt: number, inputs: Inputs): void {
971981 world.time += dt;
972982 const before = world.match.phase;
973983 for (const event of rules.tick(world.match, dt)) {
974 if (event.type === "round_start") say(world, "Go go go!");
984 if (event.type === "round_start") {
985 // The one tick a planned round starts on: whatever arrived in the
986 // freeze is played from here, the same tick on every playing of it.
987 if (world.planned !== null && world.planned.round === world.match.round) world.plan = world.planned;
988 world.planned = null;
989 say(world, "Go go go!");
990 }
975991 if (event.type === "bomb_timeout") {
976992 world.bomb.state = "detonated";
977993 for (const p of world.players) {
src/db.test.ts +37 −1
@@ -11,8 +11,9 @@ import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import {
1313 dayBoard, deleteMatch, displayName, IDLE_SECONDS, liveness, type MatchRow, openDatabase, type Player, recentMatches,
14 recentRuns, runBoard, saveMatch, saveRun, standings,
14 recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, standings,
1515 } from "./db.ts";
16 import type { RoundPlan } from "./plan/plan.ts";
1617 import { statsHtml } from "./pages/game.ts";
1718 import { createHub } from "./rooms/hub.ts";
1819 import type { Room } from "./rooms/room.ts";
@@ -87,6 +88,41 @@ test("a database written before identities existed keeps its matches and calls t
8788 // And the migrated database takes new rows as usual.
8889 expect(saveMatch(db, player("u-1", "kestrel"), result())).toBe(true);
8990 expect(standings(db).length).toBe(2);
91 // It gains the plans table on the way (#238), empty and ready to keep one.
92 expect(recordedPlan(db, "1234abcd", "de_forge|1")).toBeNull();
93 expect(recordPlan(db, "1234abcd", "de_forge|1", planned("A"))).toEqual(planned("A"));
94 });
95
96 /** A plan a model might have given, sending both sides to `site`. */
97 const planned = (site: "A" | "B"): RoundPlan => ({
98 round: 1, source: "typesafe", model: "jev-1",
99 t: { site, shape: "stack", tempo: 2, save: false },
100 ct: { site, shape: "split", tempo: 1, save: false },
101 });
102
103 test("a round's plan is kept with its match: the first answer is the answer, and it outlives the process", () => {
104 const home = dir();
105 const db = openDatabase(home);
106 expect(recordedPlan(db, "1234abcd", "k")).toBeNull();
107 expect(recordPlan(db, "1234abcd", "k", planned("A"))).toEqual(planned("A"));
108 // A second answer for the same round of the same match is not kept: the
109 // first one is handed back, which is what the second playing is then given.
110 expect(recordPlan(db, "1234abcd", "k", planned("B"))).toEqual(planned("A"));
111 // Another match, or another situation of this one, is its own question.
112 expect(recordPlan(db, "5678abcd", "k", planned("B"))).toEqual(planned("B"));
113 expect(recordPlan(db, "1234abcd", "k2", planned("B"))).toEqual(planned("B"));
114 db.close();
115 // Not a cache in one process: a fresh open of the same directory reads it.
116 expect(recordedPlan(openDatabase(home), "1234abcd", "k")).toEqual(planned("A"));
117 });
118
119 test("a kept plan that is not a plan any more reads as never kept", () => {
120 const db = openDatabase(dir());
121 db.query("INSERT INTO plan (match, situation, plan) VALUES ('1234abcd', 'k', ?1)")
122 .run(JSON.stringify({ ...planned("A"), t: { site: "Middle" } }));
123 expect(recordedPlan(db, "1234abcd", "k")).toBeNull();
124 db.query("INSERT INTO plan (match, situation, plan) VALUES ('1234abcd', 'j', '{')").run();
125 expect(recordedPlan(db, "1234abcd", "j")).toBeNull();
90126 });
91127
92128 test("a match records the map it was played on, and a generated one comes back whole", () => {
src/db.ts +40
@@ -23,6 +23,7 @@ import { Database } from "bun:sqlite";
2323 import { mkdirSync } from "node:fs";
2424 import { join } from "node:path";
2525 import { dayFiled } from "./daily.ts";
26 import { parsePlan, type RoundPlan } from "./plan/plan.ts";
2627 import type { Result } from "./result.ts";
2728 import type { RangeRun } from "./run.ts";
2829
@@ -158,6 +159,17 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
158159 // everyone's, and the write grows with the table the board was built to spare.
159160 db.exec("CREATE INDEX IF NOT EXISTS match_by_player ON match (user_id, name)");
160161 db.exec("CREATE INDEX IF NOT EXISTS standing_by_rank ON standing (kills DESC, damage DESC, user_id, name)");
162 // The plans a planned match was played to (#238): one row per round of a
163 // match, keyed by the match id and the situation the round was asked in
164 // (`keyOf`), holding the plan it was given. Additive like the run table: a
165 // database from before it opens today and simply has no plans yet.
166 db.exec(`CREATE TABLE IF NOT EXISTS plan (
167 match TEXT NOT NULL,
168 situation TEXT NOT NULL,
169 plan TEXT NOT NULL,
170 created_at TEXT NOT NULL DEFAULT (datetime('now')),
171 PRIMARY KEY (match, situation)
172 )`);
161173 return db;
162174 }
163175
@@ -484,6 +496,34 @@ export const deleteRun = (db: Database, id: string): boolean =>
484496 export const hitShare = (hits: number, shots: number): string =>
485497 shots === 0 ? "—" : `${Math.round((hits / shots) * 100)}%`;
486498
499 /**
500 * The plan this round of this match was given, or null when it has not been
501 * asked. What is stored is validated again on the way out: a row is text, and
502 * text that is not a plan is a round that was never recorded.
503 */
504 export function recordedPlan(db: Database, match: string, situation: string): RoundPlan | null {
505 const row = db.query<{ plan: string }, [string, string]>(
506 "SELECT plan FROM plan WHERE match = ?1 AND situation = ?2",
507 ).get(match, situation);
508 if (row === null) return null;
509 try {
510 return parsePlan(JSON.parse(row.plan));
511 } catch {
512 return null;
513 }
514 }
515
516 /**
517 * Keep `plan` as this round of this match, unless one is kept already, and
518 * return whichever is kept: the first answer is the answer, for ever. Two
519 * playings that asked at once both get the one that landed first.
520 */
521 export function recordPlan(db: Database, match: string, situation: string, plan: RoundPlan): RoundPlan {
522 db.query("INSERT OR IGNORE INTO plan (match, situation, plan) VALUES (?1, ?2, ?3)")
523 .run(match, situation, JSON.stringify(plan));
524 return recordedPlan(db, match, situation) ?? plan;
525 }
526
487527 export const matchCount = (db: Database): number =>
488528 db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0;
489529
src/pages/game.test.ts +14 −1
@@ -35,7 +35,7 @@ import { ALL, damageRows, gunsOf } from "../sheet.ts";
3535 import { GAMES } from "../games.ts";
3636 import { digitsIn, proseOf, unsourced } from "./digits.test-helper.ts";
3737 import {
38 gamePage, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, COPY_ROOM, NOTHING_ASKED, type PageOptions,
38 gamePage, planHtml, PLANNED_ID, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, COPY_ROOM, NOTHING_ASKED, type PageOptions,
3939 } from "./game.ts";
4040 import { roomLink } from "../roomlink.ts";
4141 import { WEAPONS_HREF, WEAPONS_SECTION, WEAPONS_SUMMARY } from "./weapons.ts";
@@ -275,6 +275,8 @@ test("the id's sentence promises what an id keeps, and says the link is what kee
275275 expect(said).toContain("the same spawns, the same bots and the same seed");
276276 expect(said).toContain("a whole link from this page belongs here too");
277277 expect(said).toContain("A bare id plays on whatever map is chosen above");
278 // With no planner there is nothing about plans to promise, and nothing is said.
279 expect(said).not.toContain(escape(PLANNED_ID));
278280 // And the field says so too: it is as long as a link and asks for one.
279281 expect(html).toContain(`id="matchid" maxlength="${LONGEST_PASTE}" placeholder="paste an id or a link`);
280282 });
@@ -458,3 +460,14 @@ test("a tuning that moves takes the prose with it, and the rule catches one that
458460 expect(complaints[0]).toContain(`/: ${HE.blast.damage} is in no module`);
459461 expect(complaints[0]).toContain("damage at the centre falling off over");
460462 });
463
464 test("where a model plans the rounds, the id's sentence says what an id keeps of the plan (#238)", () => {
465 const html = gamePage(db(), { ...options, planning: true });
466 const said = html.slice(html.indexOf(`id="onid"`), html.indexOf(`id="onmap"`)).replace(/\s+/g, " ");
467 expect(said).toContain(escape(PLANNED_ID));
468 expect(PLANNED_ID).toContain("kept with the id");
469 expect(PLANNED_ID).toContain("that same plan");
470 // And the paragraph about the planner says the same thing in its own words.
471 expect(planHtml(true).replace(/\s+/g, " ")).toContain("kept with the match it was asked for");
472 expect(planHtml(false)).not.toContain("kept with the match");
473 });
src/pages/game.ts +19 −4
@@ -354,7 +354,19 @@ const summary = (visitor: Who): string => `
354354 * `src/daily.ts`, rendered here from the server's clock and written again by the
355355 * page from the browser's, because both derive the day and neither is told it.
356356 */
357 const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
357 /**
358 * What an id keeps of the bots' plans, said only where a model plans them
359 * (#238). The words are `src/plan/served.ts`'s behaviour: a round of a match is
360 * asked once, kept under the id, and read back on every later playing — and an
361 * answer is played from the end of the freeze, so a late one is not played.
362 */
363 export const PLANNED_ID =
364 "Here a model plans the bots' rounds, and each round's plan is kept with the id the first time it is " +
365 "asked, so every later playing of the id in the same situation gets that same plan, not a fresh " +
366 "opinion. A plan is played from the end of the freeze; one not back by then leaves that round to " +
367 "the local plan.";
368
369 const menu = (visitor: Who, asked: Asked, daily: Daily, planning: boolean): string => `
358370 <h2>Defuse or detonate</h2>
359371 <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated
360372 map you can name. Bomb rounds, CS movement and economy, bots that push the sites, and a radar
@@ -388,7 +400,8 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
388400 : `An id is the round: the same spawns, the same bots and the same seed. The map is the
389401 Map control's, not the id's, so a whole link from this page belongs here too — paste one
390402 and it fills in the id <i>and</i> the map, which is the match you were given. A bare id
391 plays on whatever map is chosen above; empty is a new match, as ever.`}</p>
403 plays on whatever map is chosen above; empty is a new match, as ever.${
404 planning ? ` ${escape(PLANNED_ID)}` : ""}`}</p>
392405 <p class="hint" id="onmap">${asked.badMap
393406 ? escape(BAD_MAP)
394407 : `A generated map is carved from a seed and answers to a name: two bombsites, two spawns and
@@ -489,7 +502,9 @@ export const planHtml = (planning: boolean, plans?: Readout): string =>
489502 ? `Which round each side plays is asked of <a href="https://typesafe.ai">TypeSafe AI</a> once
490503 a round, as typed questions — a choice between the two sites, a rubric for the tempo, a yes or
491504 no for the save — and the answer is validated back into the same shape the page can play, or
492 dropped for the local plan. The request is this server's, made from this server: the key is in
505 dropped for the local plan. The answer is kept with the match it was asked for, so a match id
506 played again is planned the same way rather than asked again. The request is this server's,
507 made from this server: the key is in
493508 this deployment's environment, nothing about you is in the question, and the fight itself never
494509 leaves your browser.`
495510 : `This deployment has no TypeSafe key, so every round is planned in your browser by the same
@@ -605,7 +620,7 @@ export function gamePage(
605620 ],
606621 ],
607622 overlays: [
608 { id: "menu", html: menu(visitor, asked, daily), support: true },
623 { id: "menu", html: menu(visitor, asked, daily, planning), support: true },
609624 { id: "pause", html: PAUSE, hidden: true },
610625 // After the pause, so a match that ends while it is up is read over it
611626 // rather than under it. `src/client/main.ts` hides the pause anyway.
src/plan/served.test.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * What a round of a match is given (#238): a planner that answers differently
3 * every time it is asked, and a match that is played to the same plan anyway.
4 */
5
6 import { expect, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { openDatabase, recordedPlan, recordPlan } from "../db.ts";
11 import { localPlan, type RoundPlan, type SiteName } from "./plan.ts";
12 import { servePlan, type Ledger } from "./served.ts";
13 import { keyOf, type Situation } from "./situation.ts";
14 import type { Planner } from "./typesafe.ts";
15 import { emptyTally } from "./readout.ts";
16
17 const situation = (over: Partial<Situation> = {}): Situation => ({
18 map: "de_forge", round: 3, score: { t: 1, ct: 1 }, money: { t: 4200, ct: 3800 }, lastWinner: "t", ...over,
19 });
20
21 const answer = (round: number, site: SiteName): RoundPlan => ({
22 round, source: "typesafe", model: "jev-1",
23 t: { site, shape: "stack", tempo: 3, save: false },
24 ct: { site, shape: "split", tempo: 1, save: false },
25 });
26
27 /** A model with no memory: A the first time, B the second, A again, and so on. */
28 const fickle = (): Planner & { asked: number } => {
29 const planner = {
30 asked: 0,
31 size: () => 0,
32 tally: emptyTally,
33 model: "stub",
34 plan: async (s: Situation) => answer(s.round, planner.asked++ % 2 === 0 ? "A" : "B"),
35 };
36 return planner;
37 };
38
39 const ledgerOf = (): Ledger => {
40 const db = openDatabase(mkdtempSync(join(tmpdir(), "plan-")));
41 return {
42 read: (match, key) => recordedPlan(db, match, key),
43 keep: (match, key, plan) => recordPlan(db, match, key, plan),
44 };
45 };
46
47 const yes = (): boolean => true;
48
49 test("one match id, asked twice of a planner that answers differently, is one plan", async () => {
50 const planner = fickle();
51 const ledger = ledgerOf();
52 const asked = { situation: situation(), match: "1234abcd" };
53 const first = await servePlan(asked, planner, ledger, yes);
54 const second = await servePlan(asked, planner, ledger, yes);
55 expect(first).toEqual(answer(3, "A"));
56 expect(second).toEqual(first);
57 // It asked once: the second playing read the record and never asked.
58 expect(planner.asked).toBe(1);
59 // The same planner, asked directly, has changed its mind — which is the point.
60 expect(await planner.plan(situation())).toEqual(answer(3, "B"));
61 });
62
63 test("another match, or another situation in this one, is its own question", async () => {
64 const planner = fickle();
65 const ledger = ledgerOf();
66 await servePlan({ situation: situation(), match: "1234abcd" }, planner, ledger, yes);
67 expect(await servePlan({ situation: situation(), match: "5678abcd" }, planner, ledger, yes))
68 .toEqual(answer(3, "B"));
69 expect(await servePlan({ situation: situation({ score: { t: 2, ct: 0 } }), match: "1234abcd" }, planner, ledger, yes))
70 .toEqual(answer(3, "A"));
71 expect(planner.asked).toBe(3);
72 });
73
74 test("a round the limit kept local is kept local: the fallback is what the match was given", async () => {
75 const planner = fickle();
76 const ledger = ledgerOf();
77 const asked = { situation: situation(), match: "1234abcd" };
78 expect(await servePlan(asked, planner, ledger, () => false)).toEqual(localPlan(situation()));
79 expect(await servePlan(asked, planner, ledger, yes)).toEqual(localPlan(situation()));
80 expect(planner.asked).toBe(0);
81 expect(ledger.read("1234abcd", keyOf(situation()))).toEqual(localPlan(situation()));
82 });
83
84 test("a recorded round costs nothing: the limit is only charged for a question that goes upstream", async () => {
85 const ledger = ledgerOf();
86 let charged = 0;
87 const mayAsk = (): boolean => (charged++, true);
88 const asked = { situation: situation(), match: "1234abcd" };
89 await servePlan(asked, fickle(), ledger, mayAsk);
90 await servePlan(asked, fickle(), ledger, mayAsk);
91 expect(charged).toBe(1);
92 });
93
94 test("no planner, or no match named, is the local plan and nothing recorded — as before", async () => {
95 const ledger = ledgerOf();
96 const planner = fickle();
97 expect(await servePlan({ situation: situation(), match: "1234abcd" }, null, ledger, yes))
98 .toEqual(localPlan(situation()));
99 expect(await servePlan({ situation: situation(), match: null }, planner, ledger, yes))
100 .toEqual(localPlan(situation()));
101 expect(planner.asked).toBe(0);
102 expect(ledger.read("1234abcd", keyOf(situation()))).toBeNull();
103 });
src/plan/served.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * The plan a round of a match is given, which is the plan it is given every
3 * time that match is played (#238).
4 *
5 * A match id promises the same round twice: the same map, spawns, bots and
6 * seed. A model does not keep promises — asked the same question tomorrow it
7 * may take the other site — so on a deployment with a planner the answer is
8 * made part of the match. The first time a round of a match is asked about,
9 * the answer (the model's, or the local plan it fell back to) is recorded
10 * against the match id and the situation (`keyOf`), and every later playing of
11 * that id in that situation reads the record and never asks again. Nothing is
12 * re-asked and nothing is cached in one process's memory only: the record is
13 * the database under FORGE_APP_DATA, so a restart or a second machine reading
14 * it gets the same round.
15 *
16 * Why the situation and not only the round number: a person does not play a
17 * match the same way twice. Round three at 2–0 and round three at 0–2 are
18 * different questions, and each is answered once for that match.
19 *
20 * Why this and not "a seeded match plans locally": the matches people share
21 * are the ones somebody most wants to see planned, and a fresh match whose
22 * link is sent on would be planned by the model the first time and by
23 * arithmetic after, which is two different matches under one id.
24 *
25 * A deployment with no planner records nothing and answers with `localPlan`,
26 * exactly as before; so does a question that names no match, because a model's
27 * answer is only ever handed out once it is kept.
28 */
29
30 import { localPlan, type RoundPlan } from "./plan.ts";
31 import { keyOf, type Situation } from "./situation.ts";
32 import type { Planner } from "./typesafe.ts";
33
34 /** Where a match's plans are kept: `recordedPlan` and `recordPlan` in `src/db.ts`. */
35 export type Ledger = {
36 read(match: string, situation: string): RoundPlan | null;
37 /** Keep this unless one is kept already; return whichever is kept. */
38 keep(match: string, situation: string, plan: RoundPlan): RoundPlan;
39 };
40
41 export type Asked = {
42 situation: Situation;
43 /** The match, validated by `matchFrom`, or null when the page named none. */
44 match: string | null;
45 };
46
47 /**
48 * The plan for this round of this match. `mayAsk` is the rate limit and the
49 * spend ceiling, taken only when a question would actually go upstream: a
50 * round that is already recorded costs nothing, so it is not charged for.
51 */
52 export async function servePlan(
53 asked: Asked,
54 planner: Planner | null,
55 ledger: Ledger,
56 mayAsk: () => boolean,
57 ): Promise<RoundPlan> {
58 const { situation, match } = asked;
59 if (planner === null || match === null) return localPlan(situation);
60 const key = keyOf(situation);
61 const held = ledger.read(match, key);
62 if (held !== null) return held;
63 // Whatever is served is recorded, the local plan included: a round that was
64 // played to the fallback is played to it again, not to whatever a model
65 // says the next time somebody pastes the id.
66 const plan = mayAsk() ? await planner.plan(situation) : localPlan(situation);
67 return ledger.keep(match, key, plan);
68 }
src/plan/typesafe.test.ts +28
@@ -167,6 +167,34 @@ test("the cache is bounded: past the ceiling it is dropped rather than grown", a
167167 });
168168
169169
170 test("an answer that has not come back by the deadline is the local plan, and is still kept when it lands", async () => {
171 let release = (): void => {};
172 const held = new Promise<void>((resolve) => {
173 release = resolve;
174 });
175 let landed = false;
176 const planner = createPlanner({
177 apiKey: "sk-test",
178 deadline: 20,
179 timeout: 10_000,
180 fetch: async () => {
181 await held;
182 landed = true;
183 return reply();
184 },
185 });
186 // The round gets the local plan on time: nothing waits on an upstream.
187 const late = await planner?.plan(situation());
188 expect(late).toEqual(localPlan(situation()));
189 expect(landed).toBe(false);
190 // The answer that turns up afterwards is remembered for the next asker.
191 release();
192 await Bun.sleep(5);
193 expect(landed).toBe(true);
194 expect(planner?.size()).toBe(1);
195 expect((await planner?.plan(situation()))?.source).toBe("typesafe");
196 });
197
170198 // --- what it did: the counts ------------------------------------------------
171199
172200 test("an answer, then an error, then a refusal: three rounds, three different counts", async () => {
src/plan/typesafe.ts +47 −20
@@ -74,6 +74,8 @@ export type PlannerOptions = {
7474 timeout?: number;
7575 /** How many answers are remembered before the oldest are dropped. */
7676 ceiling?: number;
77 /** Milliseconds before a round gets the local plan, whatever is still running. */
78 deadline?: number;
7779 /** The transport, so a test is not the network. */
7880 fetch?: Fetch;
7981 };
@@ -84,6 +86,16 @@ export const RETRIES = 1;
8486
8587 export const CEILING = 500;
8688
89 /**
90 * The longest a round waits for its answer, retries and a `Retry-After`
91 * included. A round's freeze time is eight seconds and a planned round is only
92 * played from the freeze's last tick, so the answer has to be back with room
93 * to cross the network; past this the round is the local plan, and that is the
94 * answer the match keeps (`src/plan/served.ts`). An answer that lands after
95 * this is still remembered below, for the next match to ask the same thing.
96 */
97 const DEADLINE = 5000;
98
8799 /**
88100 * The planner, or null when this deployment has no key — which is the state
89101 * every preview is in, and the reason the caller has to handle it rather than
@@ -91,7 +103,7 @@ export const CEILING = 500;
91103 */
92104 export function createPlanner(options: PlannerOptions): Planner | null {
93105 if (typeof options.apiKey !== "string" || options.apiKey.trim() === "") return null;
94 const { model, timeout = TIMEOUT, ceiling = CEILING, fetch } = options;
106 const { model, timeout = TIMEOUT, ceiling = CEILING, deadline = DEADLINE, fetch } = options;
95107 const counts = emptyTally();
96108 const send = fetch ?? ((url, init) => globalThis.fetch(url, init));
97109 const client = new TypeSafeClient({
@@ -126,32 +138,47 @@ export function createPlanner(options: PlannerOptions): Planner | null {
126138 const running = flight.get(key);
127139 if (running !== undefined) {
128140 counts.joined += 1;
129 return running;
141 return within(running, deadline, () => localPlan(situation));
130142 }
131 const asking = ask(client, situation, counts).then(
132 (plan) => {
133 counts.answered += 1;
143 // An answer that lands after the deadline is counted as answered when
144 // it lands, and remembered: the round that asked was already given the
145 // local plan, but the next asker of the same situation is not.
146 const asking = ask(client, situation, counts)
147 .then(
148 (plan) => {
149 counts.answered += 1;
150 return plan;
151 },
152 (error: unknown) => {
153 fail(counts, error);
154 return localPlan(situation);
155 },
156 )
157 .then((plan) => {
158 flight.delete(key);
159 // A local plan is not worth remembering: it is free to compute, and
160 // caching it would keep a deployment on the fallback after one timeout.
161 if (plan.source === "typesafe") {
162 if (answers.size >= ceiling) answers.clear();
163 answers.set(key, plan);
164 }
134165 return plan;
135 },
136 (error: unknown) => {
137 fail(counts, error);
138 return localPlan(situation);
139 },
140 );
166 });
141167 flight.set(key, asking);
142 const plan = await asking;
143 flight.delete(key);
144 // A local plan is not worth remembering: it is free to compute, and
145 // caching it would keep a deployment on the fallback after one timeout.
146 if (plan.source === "typesafe") {
147 if (answers.size >= ceiling) answers.clear();
148 answers.set(key, plan);
149 }
150 return plan;
168 return within(asking, deadline, () => localPlan(situation));
151169 },
152170 };
153171 }
154172
173 /** `promise`, or `late()` if it has not settled in `ms` milliseconds. */
174 function within<T>(promise: Promise<T>, ms: number, late: () => T): Promise<T> {
175 let timer: ReturnType<typeof setTimeout> | undefined;
176 const clock = new Promise<T>((resolve) => {
177 timer = setTimeout(() => resolve(late()), ms);
178 });
179 return Promise.race([promise, clock]).finally(() => clearTimeout(timer));
180 }
181
155182 /** An answer arrived and was not a plan: counted apart from the wire failing. */
156183 class NotAPlan extends Error {}
157184
src/server.test.ts +9
@@ -1329,6 +1329,15 @@ test("a round is planned, and the plan is a plan this game can play", async () =
13291329 expect(plan).toEqual(localPlan(parseSituation(situation()) as Situation));
13301330 });
13311331
1332 test("with no key, a round named by its match is planned exactly as before (#238)", async () => {
1333 const local = localPlan(parseSituation(situation()) as Situation);
1334 for (const match of ["1234abcd-0000", "not an id", 7, null]) {
1335 const response = await askPlan(JSON.stringify({ ...situation(), match }));
1336 expect(response.status).toBe(200);
1337 expect(parsePlan(await response.json())).toEqual(local);
1338 }
1339 });
1340
13321341 test("a situation that is not one is refused, and junk is not a plan", async () => {
13331342 expect((await askPlan("{")).status).toBe(400);
13341343 expect((await askPlan(JSON.stringify(situation({ map: "de_dust2" })))).status).toBe(422);
src/server.ts +15 −5
@@ -55,7 +55,7 @@ import { buildBundles, bundlesDir, buildSummary } from "./bundles.ts";
5555 import { kB } from "./bytes.ts";
5656 import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts";
5757 import { measureExample } from "./cost.ts";
58 import { openDatabase, saveMatch, saveRun } from "./db.ts";
58 import { openDatabase, recordedPlan, recordPlan, saveMatch, saveRun } from "./db.ts";
5959 import { admit, type Gate, playerOf, readBody, refused } from "./gate.ts";
6060 import { GAMES } from "./games.ts";
6161 import { createLimiter } from "./limit.ts";
@@ -65,10 +65,10 @@ import { examplePage } from "./pages/example.ts";
6565 import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts";
6666 import { mapFrom } from "./mapname.ts";
6767 import { seedFrom } from "./client/generate.ts";
68 import { localPlan } from "./plan/plan.ts";
6968 import { parseSituation } from "./plan/situation.ts";
70 import { CEILING, createPlanner, type Planner, RETRIES, TIMEOUT } from "./plan/typesafe.ts";
69 import { CEILING, createPlanner, RETRIES, TIMEOUT } from "./plan/typesafe.ts";
7170 import { emptyTally, type Readout, writeReadout } from "./plan/readout.ts";
71 import { servePlan, type Ledger } from "./plan/served.ts";
7272 import { matchFrom } from "./matchid.ts";
7373 import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts";
7474 import { boardsHtml, rangePage } from "./pages/range.ts";
@@ -87,6 +87,12 @@ import { who, type Who } from "./who.ts";
8787
8888 const db = openDatabase();
8989
90 /** Where a planned match keeps the plans its rounds were given. */
91 const ledger: Ledger = {
92 read: (match, situation) => recordedPlan(db, match, situation),
93 keep: (match, situation, plan) => recordPlan(db, match, situation, plan),
94 };
95
9096 const env = process.env.FORGE_APP_ENV ?? "local";
9197 const pull = process.env.FORGE_APP_PULL;
9298 const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
@@ -924,10 +930,14 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re
924930 }
925931 const situation = parseSituation(parsed);
926932 if (situation === null) return text("bad situation", 422);
933 // The match the round belongs to, through the same door every other id
934 // comes in by. A round of a match is answered once and kept with it
935 // (`src/plan/served.ts`), so the same id is the same plans on every playing.
936 const match = matchFrom((parsed as Record<string, unknown>).match);
927937 const caller = `plan:${listener.requestIP(request)?.address ?? "?"}|${readSession(request) ?? "-"}`;
928938 if (planner === null) unkeyed += 1;
929 const mine = planner !== null && planLimit.take(caller) && planSpend.take("all");
930 const plan = mine ? await (planner as Planner).plan(situation) : localPlan(situation);
939 const mayAsk = (): boolean => planLimit.take(caller) && planSpend.take("all");
940 const plan = await servePlan({ situation, match }, planner, ledger, mayAsk);
931941 readoutMoved();
932942 return json(plan);
933943 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs