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

forked from trevato/cs · proposes

Start the engine package: math and tracing move to src/engine/, and a test keeps the game out #6

Merged opened this

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

Merged

torque/cs-engine:torque/2-start-the-engine-package-math-and-tracin → main 1 commit fork

  • Merged. torque/2-start-the-engine-package-math-and-tracin was merged into main as 8f0dd62.

Review

Sign in to review this change.
25 files changed +193 −24
README.md +6 −1
@@ -49,7 +49,12 @@ the rest.
4949
5050 ## Shape
5151
52 - `src/client/` — the game, in modules named for one thing each: `math`, `map`,
52 - `src/engine/` — the part a second game could use, and the rule is that it never
53 imports the game: `math` (vectors, matrices, angles, a seeded rng) and `trace`
54 (rays against boxes, and the `Box` they are traced against). `boundary.test.ts`
55 reads the imports of every file in here and fails if one of them points at
56 `src/client/`, at `src/rl/` or at a package.
57 - `src/client/` — the game, in modules named for one thing each: `map`,
5358 `physics`, `weapons`, `combat`, `match` (the rules), `world` (the simulation),
5459 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `textures`,
5560 `renderer`, `hud`, `input`, `audio`, `main`. Everything except the last five is
scripts/train.ts +1 −1
@@ -15,7 +15,7 @@ import { writeFileSync } from "node:fs";
1515 import { join } from "node:path";
1616 import { HEADS, decode } from "../src/client/action.ts";
1717 import { botThink, makeBrain } from "../src/client/bots.ts";
18 import { rng } from "../src/client/math.ts";
18 import { rng } from "../src/engine/math.ts";
1919 import { OBS_SIZE } from "../src/client/observe.ts";
2020 import {
2121 copyPolicy, createPolicy, forward, greedy, newActivations, pack, sample, unpack,
src/client/audio.ts +1 −1
@@ -4,7 +4,7 @@
44 * context is only created once the person has clicked something.
55 */
66
7 import { dist, type Vec3 } from "./math.ts";
7 import { dist, type Vec3 } from "../engine/math.ts";
88 import { WEAPONS, type WeaponId } from "./weapons.ts";
99
1010 export type Audio = {
src/client/bots.ts +1 −1
@@ -12,7 +12,7 @@
1212 */
1313
1414 import { decode, HEADS, STILL, type Intent } from "./action.ts";
15 import { angleDelta, clamp, dist, rng, type Vec3 } from "./math.ts";
15 import { angleDelta, clamp, dist, rng, type Vec3 } from "../engine/math.ts";
1616 import * as map from "./map.ts";
1717 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
1818 import {
src/client/combat.test.ts +1 −1
@@ -1,7 +1,7 @@
11 import { expect, test } from "bun:test";
22 import { applyHit, hitboxes, traceBullet, type Actor } from "./combat.ts";
33 import type { Box } from "./map.ts";
4 import type { Vec3 } from "./math.ts";
4 import type { Vec3 } from "../engine/math.ts";
55 import { WEAPONS } from "./weapons.ts";
66
77 const actor = (over: Partial<Actor> = {}): Actor => ({
src/client/combat.ts +2 −2
@@ -5,9 +5,9 @@
55 */
66
77 import type { Box } from "./map.ts";
8 import { dist, type Vec3 } from "./math.ts";
8 import { dist, type Vec3 } from "../engine/math.ts";
99 import { HULL } from "./physics.ts";
10 import { rayBox, traceWorld } from "./trace.ts";
10 import { rayBox, traceWorld } from "../engine/trace.ts";
1111 import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts";
1212
1313 export type Actor = {
src/client/hud.ts +1 −1
@@ -4,7 +4,7 @@
44 * it never changes anything.
55 */
66
7 import { clamp } from "./math.ts";
7 import { clamp } from "../engine/math.ts";
88 import { MOVE } from "./physics.ts";
99 import { inaccuracy, WEAPONS } from "./weapons.ts";
1010 import { currentWeapon, useProgress, type World } from "./world.ts";
src/client/input.ts +1 −1
@@ -4,7 +4,7 @@
44 * takes from a bot, and hands over accumulated mouse movement once a frame.
55 */
66
7 import { clamp } from "./math.ts";
7 import { clamp } from "../engine/math.ts";
88 import { NO_INPUT, type Input } from "./world.ts";
99
1010 export type Controls = {
src/client/main.ts +1 −1
@@ -8,7 +8,7 @@
88 import { createAudio } from "./audio.ts";
99 import { drawHud } from "./hud.ts";
1010 import { attachButtons, createControls } from "./input.ts";
11 import { clamp } from "./math.ts";
11 import { clamp } from "../engine/math.ts";
1212 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
1313 import { createRenderer, type Camera } from "./renderer.ts";
1414 import { buyable } from "./weapons.ts";
src/client/map.ts +5 −2
@@ -8,7 +8,8 @@
88 * corridor and a left-hand tunnel, T at the bottom of the grid, CT at the top.
99 */
1010
11 import type { Vec3 } from "./math.ts";
11 import type { Vec3 } from "../engine/math.ts";
12 import type { Box } from "../engine/trace.ts";
1213
1314 export const CELL = 128;
1415 export const GRID_W = 44;
@@ -85,7 +86,9 @@ export const rowOf = (y: number): number => Math.floor(GRID_H / 2 - y / CELL);
8586 // --- geometry -------------------------------------------------------------
8687
8788 export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const;
88 export type Box = { min: Vec3; max: Vec3; mat: number };
89
90 /** The tracer owns the box; the map hands it out, because that is where it is written. */
91 export type { Box } from "../engine/trace.ts";
8992
9093 /**
9194 * Greedy meshing: merge runs of identical solid cells across a row, then merge
src/client/models.ts +1 −1
@@ -5,7 +5,7 @@
55 */
66
77 import type { Box } from "./map.ts";
8 import type { Vec3 } from "./math.ts";
8 import type { Vec3 } from "../engine/math.ts";
99
1010 export type Mesh = {
1111 position: Float32Array;
src/client/observe.ts +1 −1
@@ -13,7 +13,7 @@
1313 */
1414
1515 import * as map from "./map.ts";
16 import { angleDelta, clamp, dist, type Vec3 } from "./math.ts";
16 import { angleDelta, clamp, dist, type Vec3 } from "../engine/math.ts";
1717 import { HULL } from "./physics.ts";
1818 import { inaccuracy, WEAPONS } from "./weapons.ts";
1919 import type { Player, World } from "./world.ts";
src/client/physics.test.ts +1 −1
@@ -1,6 +1,6 @@
11 import { expect, test } from "bun:test";
22 import type { Box } from "./map.ts";
3 import type { Vec3 } from "./math.ts";
3 import type { Vec3 } from "../engine/math.ts";
44 import { accelerate, airAccelerate, fallDamage, HULL, MOVE, move, type Body, type Wish } from "./physics.ts";
55
66 const ground: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 };
src/client/physics.ts +1 −1
@@ -9,7 +9,7 @@
99 */
1010
1111 import type { Box } from "./map.ts";
12 import { clamp, dot, normalize, type Vec3 } from "./math.ts";
12 import { clamp, dot, normalize, type Vec3 } from "../engine/math.ts";
1313
1414 export const HULL = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 };
1515
src/client/policy.test.ts +1 −1
@@ -1,6 +1,6 @@
11 import { expect, test } from "bun:test";
22 import { AXIS_BINS, decode, HEADS, PITCH_BINS, toInput, YAW_BINS } from "./action.ts";
3 import { rng } from "./math.ts";
3 import { rng } from "../engine/math.ts";
44 import {
55 createPolicy, forward, greedy, headOffset, logitCount, newActivations, pack, paramCount,
66 sample, softmax, unpack, type Shape,
src/client/renderer.ts +1 −1
@@ -5,7 +5,7 @@
55 * the frame loop except the numbers that change.
66 */
77
8 import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "./math.ts";
8 import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "../engine/math.ts";
99 import { bombMesh, levelMesh, playerMesh, viewModelMesh, type Mesh } from "./models.ts";
1010 import { LAYERS, TEX_SIZE, materialTextures } from "./textures.ts";
1111 import type { World } from "./world.ts";
src/client/weapons.ts +1 −1
@@ -8,7 +8,7 @@
88 * bullets every round, which is the property that makes spray control a skill.
99 */
1010
11 import { rng } from "./math.ts";
11 import { rng } from "../engine/math.ts";
1212
1313 export type WeaponId =
1414 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp";
src/client/world.ts +2 −2
@@ -9,10 +9,10 @@
99
1010 import { applyHit, traceBullet, type Actor } from "./combat.ts";
1111 import * as map from "./map.ts";
12 import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, type Vec3 } from "./math.ts";
12 import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, type Vec3 } from "../engine/math.ts";
1313 import * as rules from "./match.ts";
1414 import { eyeHeight, hullHeight, move, type Body, type Wish, MOVE } from "./physics.ts";
15 import { visible } from "./trace.ts";
15 import { visible } from "../engine/trace.ts";
1616 import {
1717 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset,
1818 type WeaponId, type Weapon,
src/engine/boundary.test.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * The boundary. `src/engine/` is the part a second game could use, so a module
3 * in here may import the engine and nothing else: not the game in
4 * `src/client/`, not the training in `src/rl/`, and no package — the engine has
5 * nothing to install. This test reads the sources rather than trusting the
6 * convention, because an import is one line and nobody reviews a line that
7 * compiles.
8 *
9 * A test in here may also reach for the runner and for node's builtins, which
10 * is how this file reads the sources; neither ships in the bundle.
11 */
12
13 import { expect, test } from "bun:test";
14 import { readFileSync, readdirSync } from "node:fs";
15 import { join, relative, resolve } from "node:path";
16
17 const ENGINE = import.meta.dir;
18
19 /** Every `.ts` under the engine, recursively, as paths relative to it. */
20 export function sources(dir = ENGINE): string[] {
21 const out: string[] = [];
22 for (const entry of readdirSync(dir, { withFileTypes: true })) {
23 const path = join(dir, entry.name);
24 if (entry.isDirectory()) out.push(...sources(path));
25 else if (entry.name.endsWith(".ts")) out.push(relative(ENGINE, path));
26 }
27 return out.sort();
28 }
29
30 /**
31 * The specifiers a file imports. Anything after a `from` keyword, which covers
32 * a plain import, a type-only import and a re-export; a side-effect import with
33 * no names; and a dynamic import call. A comment that happens to look like one
34 * is a false positive we can live with — it fails loudly and is a second to
35 * delete, as this docstring found out.
36 */
37 export function specifiersOf(source: string): string[] {
38 const out: string[] = [];
39 const pattern = /(?:\bfrom\s*|\bimport\s*\(?\s*)["']([^"']+)["']/g;
40 for (let m = pattern.exec(source); m !== null; m = pattern.exec(source))
41 out.push(m[1] as string);
42 return out;
43 }
44
45 /** Why this file may not import this specifier, or null if it may. */
46 export function leavesEngine(file: string, specifier: string): string | null {
47 const isTest = file.endsWith(".test.ts");
48 if (!specifier.startsWith(".")) {
49 if (isTest && (specifier === "bun:test" || specifier.startsWith("node:"))) return null;
50 return `"${specifier}" is not a path inside the engine`;
51 }
52 const inside = relative(ENGINE, resolve(ENGINE, join(file, ".."), specifier));
53 if (inside === "" || inside.startsWith("..")) return `"${specifier}" resolves outside src/engine/`;
54 return null;
55 }
56
57 test("the engine has the sources this test is about", () => {
58 const files = sources();
59 expect(files).toContain("math.ts");
60 expect(files).toContain("trace.ts");
61 });
62
63 test("no engine module imports the game, the training, or a package", () => {
64 const offences: string[] = [];
65 for (const file of sources()) {
66 const source = readFileSync(join(ENGINE, file), "utf8");
67 for (const specifier of specifiersOf(source)) {
68 const why = leavesEngine(file, specifier);
69 if (why !== null) offences.push(`${file}: ${why}`);
70 }
71 }
72 expect(offences).toEqual([]);
73 });
74
75 test("the specifiers come out of every shape an import takes", () => {
76 // The keyword is spelled at run time so this fixture is not itself an import
77 // the test above would read out of this file and hold against it.
78 const F = "fr" + "om";
79 const source = [
80 `import { a } ${F} "./one.ts";`,
81 `import type { B } ${F} './two.ts';`,
82 `export type { C } ${F} "../client/map.ts";`,
83 `import "./three.ts";`,
84 `const d = await import("./four.ts");`,
85 ].join("\n");
86 expect(specifiersOf(source)).toEqual([
87 "./one.ts", "./two.ts", "../client/map.ts", "./three.ts", "./four.ts",
88 ]);
89 });
90
91 test("the rule catches the imports it exists to catch", () => {
92 expect(leavesEngine("trace.ts", "../client/map.ts")).toMatch(/outside/);
93 expect(leavesEngine("trace.ts", "../rl/arena.ts")).toMatch(/outside/);
94 expect(leavesEngine("trace.ts", "gl-matrix")).toMatch(/inside the engine/);
95 expect(leavesEngine("trace.ts", "node:fs")).toMatch(/inside the engine/);
96 expect(leavesEngine("trace.ts", "bun:test")).toMatch(/inside the engine/);
97 expect(leavesEngine("trace.ts", "./math.ts")).toBeNull();
98 expect(leavesEngine("sub/deep.ts", "../math.ts")).toBeNull();
99 expect(leavesEngine("boundary.test.ts", "bun:test")).toBeNull();
100 expect(leavesEngine("boundary.test.ts", "node:path")).toBeNull();
101 expect(leavesEngine("boundary.test.ts", "../client/map.ts")).toMatch(/outside/);
102 });
src/client/math.test.ts → src/engine/math.test.ts renamed
No content changes.
src/client/math.ts → src/engine/math.ts renamed
No content changes.
src/engine/trace.test.ts added +57
@@ -0,0 +1,57 @@
1 import { expect, test } from "bun:test";
2 import { rayBox, traceWorld, visible, segmentLength, type Box } from "./trace.ts";
3
4 /** A unit-ish crate from (0,0,0) to (64,64,64), the shape everything here uses. */
5 const crate: Box = { min: [0, 0, 0], max: [64, 64, 64], mat: 1 };
6 const box = (min: [number, number, number], max: [number, number, number]): Box =>
7 ({ min, max, mat: 0 });
8
9 test("a ray straight at a face enters at the fraction of the way it is", () => {
10 // From x = -64 to x = 64 is 128 units; the face is at x = 0, so t = 0.5.
11 const hit = rayBox([-64, 32, 32], [64, 32, 32], crate.min, crate.max);
12 expect(hit).not.toBeNull();
13 expect(hit?.t).toBeCloseTo(0.5, 9);
14 expect(hit?.normal).toEqual([-1, 0, 0]);
15 });
16
17 test("a ray that stops short of the box does not hit it", () => {
18 expect(rayBox([-64, 32, 32], [-1, 32, 32], crate.min, crate.max)).toBeNull();
19 });
20
21 test("a ray beside the box misses on the axis it misses on", () => {
22 expect(rayBox([-64, -32, 32], [64, -32, 32], crate.min, crate.max)).toBeNull();
23 expect(rayBox([-64, 32, 128], [64, 32, 128], crate.min, crate.max)).toBeNull();
24 });
25
26 test("a ray from inside the box enters at zero", () => {
27 const hit = rayBox([32, 32, 32], [256, 32, 32], crate.min, crate.max);
28 expect(hit?.t).toBeCloseTo(0, 9);
29 });
30
31 test("a corner-on ray reports the axis it crossed last", () => {
32 // Towards (0,0) at 45°: x reaches 0 at t = 0.5, y at t = 0.75, so y is last.
33 const hit = rayBox([-64, -96, 32], [64, 32, 32], crate.min, crate.max);
34 expect(hit?.t).toBeCloseTo(0.75, 9);
35 expect(hit?.normal).toEqual([0, -1, 0]);
36 });
37
38 test("traceWorld returns the nearest box of several on the line", () => {
39 const near = box([100, -8, -8], [108, 8, 8]);
40 const far = box([200, -8, -8], [208, 8, 8]);
41 const hit = traceWorld([0, 0, 0], [400, 0, 0], [far, near]);
42 expect(hit?.what).toBe(near);
43 expect(hit?.t).toBeCloseTo(100 / 400, 9);
44 expect(hit?.point[0]).toBeCloseTo(100, 6);
45 expect(hit?.normal).toEqual([-1, 0, 0]);
46 });
47
48 test("visible is exactly an empty trace", () => {
49 const wall = box([100, -64, -64], [108, 64, 64]);
50 expect(visible([0, 0, 0], [400, 0, 0], [wall])).toBe(false);
51 expect(visible([0, 0, 0], [400, 0, 0], [])).toBe(true);
52 expect(visible([0, 0, 0], [99, 0, 0], [wall])).toBe(true);
53 });
54
55 test("segmentLength is the distance between the ends", () => {
56 expect(segmentLength([0, 0, 0], [3, 4, 12])).toBeCloseTo(13, 9);
57 });
src/client/trace.ts → src/engine/trace.ts renamed +3 −1
@@ -3,9 +3,11 @@
33 * same question: what is the first thing between here and there.
44 */
55
6 import type { Box } from "./map.ts";
76 import { sub, length, type Vec3 } from "./math.ts";
87
8 /** An axis-aligned solid. `mat` is whose surface it is, which only a renderer reads. */
9 export type Box = { min: Vec3; max: Vec3; mat: number };
10
911 export type Hit = { t: number; point: Vec3; normal: Vec3; what: unknown };
1012
1113 /** Slab test. Returns the entry fraction along `from → to`, or null. */
src/rl/arena.ts +1 −1
@@ -15,7 +15,7 @@
1515 import { decode, toInput, type Intent } from "../client/action.ts";
1616 import { makeBrain, botThink } from "../client/bots.ts";
1717 import * as map from "../client/map.ts";
18 import { angleDelta, clamp, dist, rng, type Vec3 } from "../client/math.ts";
18 import { angleDelta, clamp, dist, rng, type Vec3 } from "../engine/math.ts";
1919 import { observe, OBS_SIZE, type Memory } from "../client/observe.ts";
2020 import type { Input, Player, World } from "../client/world.ts";
2121 import { aimAt, applyInput, bodyCentre, canSee, createWorld, NO_INPUT } from "../client/world.ts";
src/rl/ppo.test.ts +1 −1
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { rng } from "../client/math.ts";
2 import { rng } from "../engine/math.ts";
33 import {
44 createPolicy, forward, newActivations, tensorsOf, type Policy, type Shape,
55 } from "../client/policy.ts";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs