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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/238-a-planned-round-is-still-the-same-round → main 2 commits fork
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 | ||
| 69 | 69 | says which side is doing what. Everything else about the game is still local: |
| 70 | 70 | the match, the fight, the physics and the weights all run in your browser. |
| 71 | 71 | |
| 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 | ||
| 72 | 93 | `TYPESAFE_MODEL` overrides the model (the SDK's default is `jev-latest`). |
| 73 | 94 | Planning is rate-limited per caller and under a spend ceiling for the whole |
| 74 | 95 | process; over either, or on any failure, the round gets the local plan, which is |
| @@ -233,7 +254,9 @@ bug report. | ||
| 233 | 254 | which it leaves alone. A whole link from this page fills in the id *and* the |
| 234 | 255 | map, so you get the match you were given rather than its round on somebody |
| 235 | 256 | 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). | |
| 237 | 260 | - **Link to it.** `/?match=<id>&map=<de_forge|generated|gen_…>` preselects both. |
| 238 | 261 | The page's own **Link to this match**, under the stage, is that URL for |
| 239 | 262 | 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 { | ||
| 94 | 94 | world.plan = plan; |
| 95 | 95 | } |
| 96 | 96 | |
| 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 | ||
| 97 | 111 | /** The round this world's bots are playing, or null when nobody planned one. */ |
| 98 | 112 | export const roundPlan = (world: World): RoundPlan | null => world.plan; |
| 99 | 113 |
src/client/main.test.ts +64 −3
| @@ -49,6 +49,7 @@ import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts"; | ||
| 49 | 49 | import { parseResult, type Result } from "../result.ts"; |
| 50 | 50 | import { DRIVER, fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts"; |
| 51 | 51 | import { parseSituation } from "../plan/situation.ts"; |
| 52 | import type { RoundPlan } from "../plan/plan.ts"; | |
| 52 | 53 | import { createWorld, type World } from "./world.ts"; |
| 53 | 54 | |
| 54 | 55 | /** 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 | ||
| 1724 | 1725 | expect(stage.posts.filter((post) => post.url === "/api/plan")).toHaveLength(0); |
| 1725 | 1726 | }); |
| 1726 | 1727 | |
| 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 () => { | |
| 1728 | 1729 | const stage = playing({ planEndpoint: "/api/plan" }); |
| 1729 | 1730 | stage.step(FRAME); |
| 1730 | 1731 | 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", | ||
| 1732 | 1733 | const sent = JSON.parse(String(posted[0]?.body)) as Record<string, unknown>; |
| 1733 | 1734 | expect(parseSituation(sent)).not.toBeNull(); |
| 1734 | 1735 | 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("--"); | |
| 1737 | 1741 | // The stage answers every post with the leaderboard, which is not a plan: the |
| 1738 | 1742 | // local plan stays in force and the round is played on it. |
| 1739 | 1743 | await Promise.resolve(); |
| @@ -1743,6 +1747,63 @@ test("a page that names a planner asks it, with the situation and nothing else", | ||
| 1743 | 1747 | expect(stepsRun(stage)).toBeGreaterThan(0); |
| 1744 | 1748 | }); |
| 1745 | 1749 | |
| 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 | ||
| 1746 | 1807 | // --- the match of the day ------------------------------------------------- |
| 1747 | 1808 | |
| 1748 | 1809 | 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"; | ||
| 96 | 96 | import { boardHtml, headline, resultOf, scoreLine, summarize, yourLine } from "./summary.ts"; |
| 97 | 97 | import { drawPreview, previewText } from "./preview.ts"; |
| 98 | 98 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 99 | import { usePlan, usePolicy } from "./bots.ts"; | |
| 99 | import { offerPlan, usePlan, usePolicy } from "./bots.ts"; | |
| 100 | 100 | import { callOf, hears, radio } from "./radio.ts"; |
| 101 | 101 | import type { Call } from "../plan/call.ts"; |
| 102 | 102 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| @@ -1500,22 +1500,25 @@ export function boot(host: Host): Frame { | ||
| 1500 | 1500 | } |
| 1501 | 1501 | |
| 1502 | 1502 | /** |
| 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. | |
| 1507 | 1506 | */ |
| 1508 | 1507 | let plannedRound = 0; |
| 1509 | let planning = false; | |
| 1510 | 1508 | |
| 1511 | 1509 | /** |
| 1512 | 1510 | * Plan the round about to be played. The local plan goes in at once, so the |
| 1513 | 1511 | * 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. | |
| 1519 | 1522 | */ |
| 1520 | 1523 | function planFor(current: World): void { |
| 1521 | 1524 | const match = current.match; |
| @@ -1530,27 +1533,23 @@ export function boot(host: Host): Frame { | ||
| 1530 | 1533 | const local = localPlan(situation); |
| 1531 | 1534 | usePlan(current, local); |
| 1532 | 1535 | 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 }); | |
| 1535 | 1538 | void (async (): Promise<void> => { |
| 1536 | 1539 | try { |
| 1537 | 1540 | const response = await host.fetch(planEndpoint, { |
| 1538 | 1541 | method: "POST", |
| 1539 | 1542 | headers: { "content-type": "application/json" }, |
| 1540 | body: JSON.stringify(situation), | |
| 1543 | body, | |
| 1541 | 1544 | }); |
| 1542 | 1545 | 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); | |
| 1549 | 1549 | } catch { |
| 1550 | 1550 | // The local plan is already in force. A round is not lost because a |
| 1551 | 1551 | // planner was. |
| 1552 | 1552 | } |
| 1553 | planning = false; | |
| 1554 | 1553 | })(); |
| 1555 | 1554 | } |
| 1556 | 1555 |
src/client/replay.test.ts +97 −1
| @@ -32,7 +32,7 @@ import { DE_FORGE } from "./map.ts"; | ||
| 32 | 32 | import { SHORT } from "./match.ts"; |
| 33 | 33 | import { CHECKPOINT_CHARS } from "../engine/index.ts"; |
| 34 | 34 | 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, | |
| 36 | 36 | withoutEntropy, type Pin, type Run, |
| 37 | 37 | } from "./replay.ts"; |
| 38 | 38 | import { colOf, rowOf, walkable, type Level } from "./level.ts"; |
| @@ -44,6 +44,14 @@ import { useShipped } from "./shipped.ts"; | ||
| 44 | 44 | import { usePlan } from "./bots.ts"; |
| 45 | 45 | import { localPlan, type RoundPlan } from "../plan/plan.ts"; |
| 46 | 46 | 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"; | |
| 47 | 55 | |
| 48 | 56 | /** |
| 49 | 57 | * 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 | ||
| 457 | 465 | expect(CALL_TICKS).toBeGreaterThan(576 + CALL_LIFE * TICKS_PER_SECOND); |
| 458 | 466 | }); |
| 459 | 467 | |
| 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 | ||
| 460 | 556 | test("a seeded duel in the training arena plays exactly as it played", () => { |
| 461 | 557 | const run = duelRun(); |
| 462 | 558 | must(run, DUEL_PIN); |
src/client/replay.ts +21
| @@ -24,6 +24,8 @@ import { compare as complain, fold as foldTicks, quantise } from "../engine/inde | ||
| 24 | 24 | import { buy, type BuyItem } from "./economy.ts"; |
| 25 | 25 | import { radio } from "./radio.ts"; |
| 26 | 26 | import type { Call } from "../plan/call.ts"; |
| 27 | import type { RoundPlan } from "../plan/plan.ts"; | |
| 28 | import { offerPlan, usePlan } from "./bots.ts"; | |
| 27 | 29 | import { NO_INPUT, update, type Input, type Player, type World } from "./world.ts"; |
| 28 | 30 | import { cloudRadius, type Cloud, type Grenade } from "./grenade.ts"; |
| 29 | 31 | import type { ThrownId } from "./weapons.ts"; |
| @@ -344,6 +346,25 @@ export function digest(world: World, ticks: number, script: readonly Beat[] = SC | ||
| 344 | 346 | return fold(world, ticks, (tick) => update(world, TICK, next(tick, world, world.me))); |
| 345 | 347 | } |
| 346 | 348 | |
| 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 | ||
| 347 | 368 | // --- comparing ------------------------------------------------------------ |
| 348 | 369 | |
| 349 | 370 | /** 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 = { | ||
| 258 | 258 | * `src/client/bots.ts` puts it here (`usePlan`) and reads it back. |
| 259 | 259 | */ |
| 260 | 260 | 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; | |
| 261 | 269 | /** |
| 262 | 270 | * The last call anybody made on the radio, or null. It stands in front of |
| 263 | 271 | * `plan` for its own side while it is live (`src/plan/call.ts`) and is simply |
| @@ -344,7 +352,7 @@ export function createWorld( | ||
| 344 | 352 | match, players, me, time: 0, |
| 345 | 353 | bomb: newBomb(), |
| 346 | 354 | effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], clouds: [], |
| 347 | feed: [], messages: [], plan: null, radio: null, | |
| 355 | feed: [], messages: [], plan: null, planned: null, radio: null, | |
| 348 | 356 | random, level, bombPlanted: false, pendingBuy: false, |
| 349 | 357 | }; |
| 350 | 358 | startRound(world); |
| @@ -488,6 +496,8 @@ export function startRound(world: World): void { | ||
| 488 | 496 | // one side starting a round blind for something nobody did. |
| 489 | 497 | world.clouds = []; |
| 490 | 498 | world.feed = []; |
| 499 | // Last round's answer, if it came too late to be played, is not this one's. | |
| 500 | world.planned = null; | |
| 491 | 501 | for (const p of world.players) if (p.bot) botBuy(world, p); |
| 492 | 502 | } |
| 493 | 503 | |
| @@ -971,7 +981,13 @@ export function updateWith(world: World, dt: number, inputs: Inputs): void { | ||
| 971 | 981 | world.time += dt; |
| 972 | 982 | const before = world.match.phase; |
| 973 | 983 | 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 | } | |
| 975 | 991 | if (event.type === "bomb_timeout") { |
| 976 | 992 | world.bomb.state = "detonated"; |
| 977 | 993 | for (const p of world.players) { |
src/db.test.ts +37 −1
| @@ -11,8 +11,9 @@ import { tmpdir } from "node:os"; | ||
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { |
| 13 | 13 | 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, | |
| 15 | 15 | } from "./db.ts"; |
| 16 | import type { RoundPlan } from "./plan/plan.ts"; | |
| 16 | 17 | import { statsHtml } from "./pages/game.ts"; |
| 17 | 18 | import { createHub } from "./rooms/hub.ts"; |
| 18 | 19 | import type { Room } from "./rooms/room.ts"; |
| @@ -87,6 +88,41 @@ test("a database written before identities existed keeps its matches and calls t | ||
| 87 | 88 | // And the migrated database takes new rows as usual. |
| 88 | 89 | expect(saveMatch(db, player("u-1", "kestrel"), result())).toBe(true); |
| 89 | 90 | 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(); | |
| 90 | 126 | }); |
| 91 | 127 | |
| 92 | 128 | 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"; | ||
| 23 | 23 | import { mkdirSync } from "node:fs"; |
| 24 | 24 | import { join } from "node:path"; |
| 25 | 25 | import { dayFiled } from "./daily.ts"; |
| 26 | import { parsePlan, type RoundPlan } from "./plan/plan.ts"; | |
| 26 | 27 | import type { Result } from "./result.ts"; |
| 27 | 28 | import type { RangeRun } from "./run.ts"; |
| 28 | 29 | |
| @@ -158,6 +159,17 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 158 | 159 | // everyone's, and the write grows with the table the board was built to spare. |
| 159 | 160 | db.exec("CREATE INDEX IF NOT EXISTS match_by_player ON match (user_id, name)"); |
| 160 | 161 | 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 | )`); | |
| 161 | 173 | return db; |
| 162 | 174 | } |
| 163 | 175 | |
| @@ -484,6 +496,34 @@ export const deleteRun = (db: Database, id: string): boolean => | ||
| 484 | 496 | export const hitShare = (hits: number, shots: number): string => |
| 485 | 497 | shots === 0 ? "—" : `${Math.round((hits / shots) * 100)}%`; |
| 486 | 498 | |
| 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 | ||
| 487 | 527 | export const matchCount = (db: Database): number => |
| 488 | 528 | db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0; |
| 489 | 529 |
src/pages/game.test.ts +14 −1
| @@ -35,7 +35,7 @@ import { ALL, damageRows, gunsOf } from "../sheet.ts"; | ||
| 35 | 35 | import { GAMES } from "../games.ts"; |
| 36 | 36 | import { digitsIn, proseOf, unsourced } from "./digits.test-helper.ts"; |
| 37 | 37 | 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, | |
| 39 | 39 | } from "./game.ts"; |
| 40 | 40 | import { roomLink } from "../roomlink.ts"; |
| 41 | 41 | 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 | ||
| 275 | 275 | expect(said).toContain("the same spawns, the same bots and the same seed"); |
| 276 | 276 | expect(said).toContain("a whole link from this page belongs here too"); |
| 277 | 277 | 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)); | |
| 278 | 280 | // And the field says so too: it is as long as a link and asks for one. |
| 279 | 281 | expect(html).toContain(`id="matchid" maxlength="${LONGEST_PASTE}" placeholder="paste an id or a link`); |
| 280 | 282 | }); |
| @@ -458,3 +460,14 @@ test("a tuning that moves takes the prose with it, and the rule catches one that | ||
| 458 | 460 | expect(complaints[0]).toContain(`/: ${HE.blast.damage} is in no module`); |
| 459 | 461 | expect(complaints[0]).toContain("damage at the centre falling off over"); |
| 460 | 462 | }); |
| 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 => ` | ||
| 354 | 354 | * `src/daily.ts`, rendered here from the server's clock and written again by the |
| 355 | 355 | * page from the browser's, because both derive the day and neither is told it. |
| 356 | 356 | */ |
| 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 => ` | |
| 358 | 370 | <h2>Defuse or detonate</h2> |
| 359 | 371 | <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated |
| 360 | 372 | 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 => ` | ||
| 388 | 400 | : `An id is the round: the same spawns, the same bots and the same seed. The map is the |
| 389 | 401 | Map control's, not the id's, so a whole link from this page belongs here too — paste one |
| 390 | 402 | 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> | |
| 392 | 405 | <p class="hint" id="onmap">${asked.badMap |
| 393 | 406 | ? escape(BAD_MAP) |
| 394 | 407 | : `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 => | ||
| 489 | 502 | ? `Which round each side plays is asked of <a href="https://typesafe.ai">TypeSafe AI</a> once |
| 490 | 503 | a round, as typed questions — a choice between the two sites, a rubric for the tempo, a yes or |
| 491 | 504 | 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 | |
| 493 | 508 | this deployment's environment, nothing about you is in the question, and the fight itself never |
| 494 | 509 | leaves your browser.` |
| 495 | 510 | : `This deployment has no TypeSafe key, so every round is planned in your browser by the same |
| @@ -605,7 +620,7 @@ export function gamePage( | ||
| 605 | 620 | ], |
| 606 | 621 | ], |
| 607 | 622 | overlays: [ |
| 608 | { id: "menu", html: menu(visitor, asked, daily), support: true }, | |
| 623 | { id: "menu", html: menu(visitor, asked, daily, planning), support: true }, | |
| 609 | 624 | { id: "pause", html: PAUSE, hidden: true }, |
| 610 | 625 | // After the pause, so a match that ends while it is up is read over it |
| 611 | 626 | // 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 | ||
| 167 | 167 | }); |
| 168 | 168 | |
| 169 | 169 | |
| 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 | ||
| 170 | 198 | // --- what it did: the counts ------------------------------------------------ |
| 171 | 199 | |
| 172 | 200 | 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 = { | ||
| 74 | 74 | timeout?: number; |
| 75 | 75 | /** How many answers are remembered before the oldest are dropped. */ |
| 76 | 76 | ceiling?: number; |
| 77 | /** Milliseconds before a round gets the local plan, whatever is still running. */ | |
| 78 | deadline?: number; | |
| 77 | 79 | /** The transport, so a test is not the network. */ |
| 78 | 80 | fetch?: Fetch; |
| 79 | 81 | }; |
| @@ -84,6 +86,16 @@ export const RETRIES = 1; | ||
| 84 | 86 | |
| 85 | 87 | export const CEILING = 500; |
| 86 | 88 | |
| 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 | ||
| 87 | 99 | /** |
| 88 | 100 | * The planner, or null when this deployment has no key — which is the state |
| 89 | 101 | * every preview is in, and the reason the caller has to handle it rather than |
| @@ -91,7 +103,7 @@ export const CEILING = 500; | ||
| 91 | 103 | */ |
| 92 | 104 | export function createPlanner(options: PlannerOptions): Planner | null { |
| 93 | 105 | 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; | |
| 95 | 107 | const counts = emptyTally(); |
| 96 | 108 | const send = fetch ?? ((url, init) => globalThis.fetch(url, init)); |
| 97 | 109 | const client = new TypeSafeClient({ |
| @@ -126,32 +138,47 @@ export function createPlanner(options: PlannerOptions): Planner | null { | ||
| 126 | 138 | const running = flight.get(key); |
| 127 | 139 | if (running !== undefined) { |
| 128 | 140 | counts.joined += 1; |
| 129 | return running; | |
| 141 | return within(running, deadline, () => localPlan(situation)); | |
| 130 | 142 | } |
| 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 | } | |
| 134 | 165 | return plan; |
| 135 | }, | |
| 136 | (error: unknown) => { | |
| 137 | fail(counts, error); | |
| 138 | return localPlan(situation); | |
| 139 | }, | |
| 140 | ); | |
| 166 | }); | |
| 141 | 167 | 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)); | |
| 151 | 169 | }, |
| 152 | 170 | }; |
| 153 | 171 | } |
| 154 | 172 | |
| 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 | ||
| 155 | 182 | /** An answer arrived and was not a plan: counted apart from the wire failing. */ |
| 156 | 183 | class NotAPlan extends Error {} |
| 157 | 184 |
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 () = | ||
| 1329 | 1329 | expect(plan).toEqual(localPlan(parseSituation(situation()) as Situation)); |
| 1330 | 1330 | }); |
| 1331 | 1331 | |
| 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 | ||
| 1332 | 1341 | test("a situation that is not one is refused, and junk is not a plan", async () => { |
| 1333 | 1342 | expect((await askPlan("{")).status).toBe(400); |
| 1334 | 1343 | 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"; | ||
| 55 | 55 | import { kB } from "./bytes.ts"; |
| 56 | 56 | import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts"; |
| 57 | 57 | import { measureExample } from "./cost.ts"; |
| 58 | import { openDatabase, saveMatch, saveRun } from "./db.ts"; | |
| 58 | import { openDatabase, recordedPlan, recordPlan, saveMatch, saveRun } from "./db.ts"; | |
| 59 | 59 | import { admit, type Gate, playerOf, readBody, refused } from "./gate.ts"; |
| 60 | 60 | import { GAMES } from "./games.ts"; |
| 61 | 61 | import { createLimiter } from "./limit.ts"; |
| @@ -65,10 +65,10 @@ import { examplePage } from "./pages/example.ts"; | ||
| 65 | 65 | import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts"; |
| 66 | 66 | import { mapFrom } from "./mapname.ts"; |
| 67 | 67 | import { seedFrom } from "./client/generate.ts"; |
| 68 | import { localPlan } from "./plan/plan.ts"; | |
| 69 | 68 | 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"; | |
| 71 | 70 | import { emptyTally, type Readout, writeReadout } from "./plan/readout.ts"; |
| 71 | import { servePlan, type Ledger } from "./plan/served.ts"; | |
| 72 | 72 | import { matchFrom } from "./matchid.ts"; |
| 73 | 73 | import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts"; |
| 74 | 74 | import { boardsHtml, rangePage } from "./pages/range.ts"; |
| @@ -87,6 +87,12 @@ import { who, type Who } from "./who.ts"; | ||
| 87 | 87 | |
| 88 | 88 | const db = openDatabase(); |
| 89 | 89 | |
| 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 | ||
| 90 | 96 | const env = process.env.FORGE_APP_ENV ?? "local"; |
| 91 | 97 | const pull = process.env.FORGE_APP_PULL; |
| 92 | 98 | 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 | ||
| 924 | 930 | } |
| 925 | 931 | const situation = parseSituation(parsed); |
| 926 | 932 | 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); | |
| 927 | 937 | const caller = `plan:${listener.requestIP(request)?.address ?? "?"}|${readSession(request) ?? "-"}`; |
| 928 | 938 | 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); | |
| 931 | 941 | readoutMoved(); |
| 932 | 942 | return json(plan); |
| 933 | 943 | } |