Split The code is public; the app answers only to the crew.
A second map, chosen before the match #153
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/74-a-second-map-chosen-before-the-match → main 1 commit fork
4 files
changed +74
−7
README.md +7 −3
| @@ -410,8 +410,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured. | ||
| 410 | 410 | through, offered as a Map select beside Difficulty and validated the same way |
| 411 | 411 | settings.ts validates difficulty. A third layout is a third table, a third |
| 412 | 412 | key and nothing else: the structural tests in `map.test.ts` run over every |
| 413 | map in the catalogue, so a site inside a crate or a room nothing can walk to | |
| 414 | fails the moment it is added), | |
| 413 | map in the catalogue, so a site inside a crate, a crate stood in rock or a | |
| 414 | room nothing can walk to fails the moment it is added — and `world.test.ts` | |
| 415 | plays a seeded headless match out on each map in turn, so a layout the bots | |
| 416 | cannot path is a red test rather than a match that never ends), | |
| 415 | 417 | `physics`, `weapons`, `combat`, `grenades` (a thrown thing's flight: gravity, |
| 416 | 418 | bounce, fuse and how far a blast reaches), `match` (the rules), `world` (the simulation), |
| 417 | 419 | `buy` (the buy panel's rows — what a side can afford, what it already owns |
| @@ -715,7 +717,9 @@ across A and B the same way on both maps (16/32 and 15/26 over twelve seeded | ||
| 715 | 717 | matches), so neither site is one nobody can take; and every structural claim |
| 716 | 718 | `map.test.ts` makes — both sites reachable from every spawn cell, two doorways |
| 717 | 719 | into each site, one connected walkable region — holds for `de_cinder` as it |
| 718 | does for `de_forge`. Tuning `plan.ts` or the policy until the second map | |
| 720 | does for `de_forge`, with a seeded headless match on each map played out to a | |
| 721 | winner in `world.test.ts` rather than only in this script. Tuning `plan.ts` or | |
| 722 | the policy until the second map | |
| 719 | 723 | matched the first would be fitting the bots to a table. The map is faster and |
| 720 | 724 | more lethal than the one the policy grew up on; that is what the table says, |
| 721 | 725 | and it is what it feels like to play. |
src/client/map.test.ts +21
| @@ -49,6 +49,27 @@ test.each(CATALOGUE)("%s plants on floor, not inside a crate", (_name, map) => { | ||
| 49 | 49 | } |
| 50 | 50 | }); |
| 51 | 51 | |
| 52 | // A crate is cover: a sightline broken, something to walk round and hold. | |
| 53 | // One placed on a wall cell builds just as well — the grid takes the mark and | |
| 54 | // the mesh takes the box — and the only sign is cover nobody can use, because | |
| 55 | // a table of coordinates typed by hand says nothing about what was underneath. | |
| 56 | test.each(CATALOGUE)("%s stands every crate on the floor, with room to walk round it", (_name, map) => { | |
| 57 | let crates = 0; | |
| 58 | for (let r = 0; r < map.gridH; r++) | |
| 59 | for (let c = 0; c < map.gridW; c++) { | |
| 60 | const cell = cellAt(map, c, r); | |
| 61 | if (cell !== "x" && cell !== "X") continue; | |
| 62 | crates++; | |
| 63 | let open = 0; | |
| 64 | for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][]) | |
| 65 | if (walkable(map, c + dc, r + dr)) open++; | |
| 66 | // Two open sides, not one: cover you can only reach head-on is a dead | |
| 67 | // end, and cover with none at all is a box inside a wall. | |
| 68 | expect(open).toBeGreaterThan(1); | |
| 69 | } | |
| 70 | expect(crates).toBeGreaterThan(10); // and the map really is furnished | |
| 71 | }); | |
| 72 | ||
| 52 | 73 | test.each(CATALOGUE)("%s musters each side in its own spawn, on a cell you can walk to", (_name, map) => { |
| 53 | 74 | for (const team of ["t", "ct"] as const) { |
| 54 | 75 | const at = map.muster[team]; |
src/client/settings.test.ts +9 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | 8 | import { DEFAULT_CROSSHAIR } from "./crosshair.ts"; |
| 9 | 9 | import { DEFAULT_BINDINGS } from "./input.ts"; |
| 10 | import { MAP_NAMES } from "./map.ts"; | |
| 10 | 11 | import { DEFAULT_SETTINGS, parseCrosshair, parseSettings, type Settings } from "./settings.ts"; |
| 11 | 12 | |
| 12 | 13 | test("nothing stored yet is the default", () => { |
| @@ -140,8 +141,14 @@ test("a map name that was valid last week but is not in today's catalogue is the | ||
| 140 | 141 | expect(parseSettings('{"map":"de_dust3"}').map).toBe("de_forge"); |
| 141 | 142 | }); |
| 142 | 143 | |
| 143 | test("a valid map is kept exactly", () => { | |
| 144 | expect(parseSettings('{"map":"de_forge"}').map).toBe("de_forge"); | |
| 144 | // Not just the default one: a validator that fell back on everything would | |
| 145 | // pass `'{"map":"de_forge"}'` and quietly send every player who chose the | |
| 146 | // second map back to the first. | |
| 147 | test("every map in the catalogue survives being stored and read back", () => { | |
| 148 | for (const name of MAP_NAMES) { | |
| 149 | expect(parseSettings(JSON.stringify({ map: name })).map).toBe(name); | |
| 150 | } | |
| 151 | expect(MAP_NAMES.length).toBeGreaterThan(1); | |
| 145 | 152 | }); |
| 146 | 153 | |
| 147 | 154 | test("no stored bindings at all is precisely today's map", () => { |
src/client/world.test.ts +37 −2
| @@ -2,8 +2,8 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { GRENADE } from "./grenades.ts"; |
| 3 | 3 | import { teleported } from "./interpolate.ts"; |
| 4 | 4 | import { dist } from "./math.ts"; |
| 5 | import { DE_FORGE, siteAt } from "./map.ts"; | |
| 6 | import { SHORT, winReward } from "./match.ts"; | |
| 5 | import { DE_FORGE, MAP_NAMES, MAPS, siteAt } from "./map.ts"; | |
| 6 | import { SHORT, winReward, type Rules } from "./match.ts"; | |
| 7 | 7 | import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts"; |
| 8 | 8 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 9 | 9 | import { |
| @@ -1073,3 +1073,38 @@ test("the arena's applyInput does not pay for the renderer's convenience", () => | ||
| 1073 | 1073 | expect(world.me.prev.pos).toEqual(kept); |
| 1074 | 1074 | expect(dist(world.me.pos, kept)).toBeGreaterThan(1); // it did move |
| 1075 | 1075 | }); |
| 1076 | ||
| 1077 | /** | |
| 1078 | * Two rounds to win rather than five, so the whole catalogue plays out inside | |
| 1079 | * a couple of seconds a map. Nothing else about the rules moves: the freeze, | |
| 1080 | * the clock, the fuse and the money are the ones a browser plays under. | |
| 1081 | */ | |
| 1082 | const SPRINT: Rules = { ...SHORT, winScore: 2 }; | |
| 1083 | ||
| 1084 | // The bar for a map is not that it builds. It is that ten players dropped on | |
| 1085 | // it find each other, the sites and the clock with nothing in `world.ts`, | |
| 1086 | // `plan.ts` or `findPath` told which map it is on — a layout that paths badly | |
| 1087 | // throws nothing, it just leaves a match that never reaches a result. The | |
| 1088 | // person stands back, so both sides here are bots and one policy. | |
| 1089 | test.each([...MAP_NAMES])("a seeded headless match on %s reaches a result", (name) => { | |
| 1090 | const world = createWorld(SPRINT, "me", "ct", 5, 13, "normal", MAPS[name]); | |
| 1091 | world.me.alive = false; | |
| 1092 | let planted = false; | |
| 1093 | for (let tick = 0; tick < 64 * 60 * 20 && world.match.phase !== "match_over"; tick++) { | |
| 1094 | update(world, TICK, NO_INPUT); | |
| 1095 | planted ||= world.bomb.state === "planted"; | |
| 1096 | } | |
| 1097 | expect(world.match.phase).toBe("match_over"); | |
| 1098 | expect(world.match.matchWinner === "t" || world.match.matchWinner === "ct").toBe(true); | |
| 1099 | expect(Math.max(world.match.score.t, world.match.score.ct)).toBe(SPRINT.winScore); | |
| 1100 | // Rounds that were fought rather than run down: somebody found somebody. | |
| 1101 | expect(world.players.some((p) => p.kills > 0)).toBe(true); | |
| 1102 | // And nobody walked off the grid on the way there. | |
| 1103 | for (const p of world.players) expect(p.pos.every(Number.isFinite)).toBe(true); | |
| 1104 | // The bomb went down at least once in the two or three rounds this lasts. | |
| 1105 | // That is a seeded claim about a coin flip and not a rate — the plant rate | |
| 1106 | // per map is `scripts/measure.ts`'s job — but a plan that cannot pick a site | |
| 1107 | // on an unfamiliar layout, or an escort that cannot walk to one, plants on | |
| 1108 | // no seed at all. | |
| 1109 | expect(planted).toBe(true); | |
| 1110 | }, 30_000); |
new above
offline