Split The code is public; the app answers only to the crew.

A second map, chosen before the match #153

Merged opened this

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

Merged

vesper/cs:vesper/74-a-second-map-chosen-before-the-match → main 1 commit fork

  • Merged. vesper/74-a-second-map-chosen-before-the-match was merged into main as a3d302b.

Review

Sign in to review this change.
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.
410410 through, offered as a Map select beside Difficulty and validated the same way
411411 settings.ts validates difficulty. A third layout is a third table, a third
412412 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),
415417 `physics`, `weapons`, `combat`, `grenades` (a thrown thing's flight: gravity,
416418 bounce, fuse and how far a blast reaches), `match` (the rules), `world` (the simulation),
417419 `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
715717 matches), so neither site is one nobody can take; and every structural claim
716718 `map.test.ts` makes — both sites reachable from every spawn cell, two doorways
717719 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
719723 matched the first would be fitting the bots to a table. The map is faster and
720724 more lethal than the one the policy grew up on; that is what the table says,
721725 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) => {
4949 }
5050 });
5151
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
5273 test.each(CATALOGUE)("%s musters each side in its own spawn, on a cell you can walk to", (_name, map) => {
5374 for (const team of ["t", "ct"] as const) {
5475 const at = map.muster[team];
src/client/settings.test.ts +9 −2
@@ -7,6 +7,7 @@
77 import { expect, test } from "bun:test";
88 import { DEFAULT_CROSSHAIR } from "./crosshair.ts";
99 import { DEFAULT_BINDINGS } from "./input.ts";
10 import { MAP_NAMES } from "./map.ts";
1011 import { DEFAULT_SETTINGS, parseCrosshair, parseSettings, type Settings } from "./settings.ts";
1112
1213 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
140141 expect(parseSettings('{"map":"de_dust3"}').map).toBe("de_forge");
141142 });
142143
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);
145152 });
146153
147154 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";
22 import { GRENADE } from "./grenades.ts";
33 import { teleported } from "./interpolate.ts";
44 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";
77 import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts";
88 import { WEAPONS, type WeaponId } from "./weapons.ts";
99 import {
@@ -1073,3 +1073,38 @@ test("the arena's applyInput does not pay for the renderer's convenience", () =>
10731073 expect(world.me.prev.pos).toEqual(kept);
10741074 expect(dist(world.me.pos, kept)).toBeGreaterThan(1); // it did move
10751075 });
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);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs