Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The package says how to build a game on it: a guide beside the door, and no export left undocumented #55
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/44-the-package-says-how-to-build-a-game-on → main 1 commit fork
4 files
changed +476
−1
README.md +5
| @@ -145,6 +145,11 @@ from the angle it subtends, and you turn on the spot. | ||
| 145 | 145 | it is written down there too: a `.test.ts` may import another `.test.ts`, which |
| 146 | 146 | is how `src/range/independence.test.ts` reuses the source-reading helpers |
| 147 | 147 | rather than keeping a second copy of them. |
| 148 | - `src/engine/GUIDE.md` — how to build a game on it: what a game supplies (an | |
| 149 | entry, a host, a bindings table, a tick rate, its own recipes and its own | |
| 150 | tuning), a paragraph and one true line of code per group behind the door, and | |
| 151 | what the engine deliberately does not know. `guide.test.ts` holds it against | |
| 152 | the door's list of names in both directions. | |
| 148 | 153 | - `src/client/` — the game, in modules named for one thing each: `map` (de_forge |
| 149 | 154 | itself: its areas, its crates, its cell alphabet and its spawns and sites, laid |
| 150 | 155 | over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules), |
src/engine/GUIDE.md added +288
| @@ -0,0 +1,288 @@ | ||
| 1 | # Building a game on this engine | |
| 2 | ||
| 3 | `index.ts` is the *what*: the list of names the door offers, one line of comment | |
| 4 | per group. This is the *how*: what a game has to bring before those names are | |
| 5 | any use, in the order you would actually write it. | |
| 6 | ||
| 7 | The engine is not a framework. It never calls your game — your game calls it, | |
| 8 | from a frame it owns, with a page it opened and numbers it chose. Two games do | |
| 9 | that here already: the shooter in `src/client/` and the range in `src/range/`. | |
| 10 | Every line of code below is copied out of one of them. | |
| 11 | ||
| 12 | **Conventions.** A bare name in backticks — `rayBox`, `Mesh` — is the engine's, | |
| 13 | exported from `src/engine/index.ts`. A game's own name carries its module: | |
| 14 | `binding.readIntent`, `targets.step`, `main.boot`. A path is a path. Every code | |
| 15 | block names the file it was copied from on its first line. | |
| 16 | ||
| 17 | `guide.test.ts` holds all three: every name the door exports is named here, | |
| 18 | every bare engine name here is exported, and every line of every code block is | |
| 19 | a line of the file above it. So when a group lands behind the door that this | |
| 20 | page has no paragraph for — the physics of issue #3, one day — that test fails | |
| 21 | until the paragraph is written. That is the point. A name arrives with a page or | |
| 22 | it does not arrive. | |
| 23 | ||
| 24 | ## What a game brings | |
| 25 | ||
| 26 | ### 1. An entry | |
| 27 | ||
| 28 | One directory, `src/<game>/`, and one file in it that is the frame: | |
| 29 | `src/range/main.ts` is the whole of the range's wiring. It exports the function | |
| 30 | that boots so a test can boot it against a stub page, and it boots itself on the | |
| 31 | last two lines — the only place in the game a browser global is named. | |
| 32 | ||
| 33 | ts | |
| 34 | // src/range/main.ts | |
| 35 | if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); | |
| 36 | else boot(); | |
| 37 | ||
| 38 | ||
| 39 | Three more lines and the game is reachable: an entry with a size ceiling in | |
| 40 | `build.ENTRIES` in `scripts/build.ts`, a route in `src/server.ts`, and a page | |
| 41 | that server-renders the markup the entry looks up by id (`src/pages/range.ts`). | |
| 42 | ||
| 43 | ### 2. A host | |
| 44 | ||
| 45 | The engine touches a browser in exactly two places, and both take it as a | |
| 46 | parameter. Controls want a `Host` — `{ surface, window, document }`, where | |
| 47 | `Surface` is anything with a width that listens, `Listens` is a pair of | |
| 48 | `addEventListener`/`removeEventListener`, and `LockOwner` is anything with a | |
| 49 | `pointerLockElement`. The loop wants `Frames`: a `requestAnimationFrame`, its | |
| 50 | `cancelAnimationFrame`, and a `now()` in milliseconds. | |
| 51 | ||
| 52 | ts | |
| 53 | // src/client/main.ts | |
| 54 | const controls = createControls( | |
| 55 | { surface: scene, window: win, document: doc }, | |
| 56 | { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 }, | |
| 57 | ); | |
| 58 | ||
| 59 | ||
| 60 | Those two are the shooter's own `main.Host`: the real browser in production, | |
| 61 | and plain objects in `src/client/stage.test-helper.ts` under test. The range | |
| 62 | does the same with `src/range/dom.ts`. Nothing in the engine knows the | |
| 63 | difference, because those types are the members it reads and nothing wider. | |
| 64 | ||
| 65 | ### 3. A bindings table | |
| 66 | ||
| 67 | The engine collects action *names* and knows none of them. Which key says which | |
| 68 | name is the game's table, and it is data: | |
| 69 | ||
| 70 | ts | |
| 71 | // src/range/binding.ts | |
| 72 | export const RANGE_KEYS: Record<string, string> = { | |
| 73 | KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right", | |
| 74 | ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right", | |
| 75 | Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk", | |
| 76 | KeyF: "fire", KeyR: "reload", Enter: "start", | |
| 77 | }; | |
| 78 | ||
| 79 | ||
| 80 | Beside it go the mouse buttons, the on-screen buttons that are a tap rather than | |
| 81 | a hold, and one function from the held set to whatever struct the game's | |
| 82 | simulation takes — `binding.readIntent` in both games, returning two different | |
| 83 | shapes. The engine hands you a `ReadonlySet<string>` and a `Stick`; what | |
| 84 | "forward" means is yours. | |
| 85 | ||
| 86 | ### 4. A tick rate | |
| 87 | ||
| 88 | Pick the step the simulation runs at and never let a frame change it. The range | |
| 89 | and the shooter disagree on purpose: | |
| 90 | ||
| 91 | ts | |
| 92 | // src/range/main.ts | |
| 93 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ | |
| 94 | const TICK = 1 / 50; | |
| 95 | ||
| 96 | ||
| 97 | `Loop.advance(elapsed)` banks the frame and returns whole steps; a slow frame | |
| 98 | and a fast one then play the same run. `Loop.reset` drops the bank when a new | |
| 99 | world starts. | |
| 100 | ||
| 101 | ### 5. Its own recipes | |
| 102 | ||
| 103 | There are no asset files in this repository and the engine ships none. It ships | |
| 104 | three tools that turn a recipe into bytes — a box builder, an atlas painter and | |
| 105 | a sound device — and the recipes belong to the game: `src/client/models.ts` is | |
| 106 | the shooter's geometry, `src/client/materials.ts` its nine surfaces, | |
| 107 | `src/client/audio.ts` its voice. A second game writes its own three, or, like | |
| 108 | the range today, writes none and draws in 2D. | |
| 109 | ||
| 110 | ### 6. Its own tuning | |
| 111 | ||
| 112 | Every number that decides how the game feels is the game's: eye height, hull | |
| 113 | size, gravity, how long a round lasts, what a plate is worth. The engine holds | |
| 114 | no constants of its own beyond the loop's two safety clamps. Put them at the top | |
| 115 | of a module, named, and a reader can tune the game without reading the engine. | |
| 116 | ||
| 117 | ## What is behind the door | |
| 118 | ||
| 119 | ### math | |
| 120 | ||
| 121 | The arithmetic under everything, in `Vec3` (a plain `[x, y, z]`) and `Mat4` (a | |
| 122 | `Float32Array`, column-major, ready for `uniformMatrix4fv`). Vectors: `v3`, | |
| 123 | `add`, `sub`, `scale`, `mul`, `dot`, `cross`, `normalize`, `length`, `length2d`, | |
| 124 | `dist`, `dist2d`. Scalars: `lerp`, `clamp`. Angles in degrees, because that is | |
| 125 | what a view is stored in: `rad`, `deg`, `angleDelta` for the shortest way round, | |
| 126 | and `angleVectors` for the forward/right/up of a yaw and a pitch. Matrices: | |
| 127 | `mat4`, `multiply`, `perspective`, `viewMatrix`, `modelMatrix`. And `rng`, a | |
| 128 | seeded generator — every random draw in a game that wants to be reproducible | |
| 129 | comes from one of these, in a fixed order. | |
| 130 | ||
| 131 | ts | |
| 132 | // src/range/main.ts | |
| 133 | if (intent.fire && !firing) shoot(run, EYE, angleVectors(yaw, pitch).forward); | |
| 134 | ||
| 135 | ||
| 136 | ### trace | |
| 137 | ||
| 138 | Rays against axis-aligned boxes: `rayBox` for one box, `traceWorld` for a list | |
| 139 | of them, `visible` for the yes/no of an unobstructed line, `segmentLength` for | |
| 140 | how far the segment actually ran. A `Box` is `{ min, max, mat }` — the material | |
| 141 | index is carried so the thing you hit knows what it is made of — and a `Hit` | |
| 142 | carries the fraction along the segment, the point, the normal and the thing that | |
| 143 | was hit, which is whatever you put in the list. | |
| 144 | ||
| 145 | ts | |
| 146 | // src/range/targets.ts | |
| 147 | const hit = traceWorld(eye, to, run.targets.map((target) => target.box)); | |
| 148 | ||
| 149 | ||
| 150 | ### grid | |
| 151 | ||
| 152 | A `Grid` of cells of any type — the cell type is your alphabet, not the | |
| 153 | engine's. `inside` and `cellAt` read it, `worldOf`, `colOf` and `rowOf` are the | |
| 154 | cell-to-world arithmetic, `mergeSolid` greedily merges runs of matching cells | |
| 155 | into as few boxes as the shape allows, and `findPath` is A* with no | |
| 156 | corner-cutting. Which cells are solid and which are walkable arrive as | |
| 157 | predicates, so the engine never learns what a `"#"` means. | |
| 158 | ||
| 159 | ts | |
| 160 | // src/client/map.ts | |
| 161 | export const findPath = (from: Vec3, to: Vec3): Vec3[] => g.findPath(grid, passable, from, to); | |
| 162 | ||
| 163 | ||
| 164 | ### mesh | |
| 165 | ||
| 166 | `builder` opens a `Builder`, `addBox` writes six quads of positions, normals, | |
| 167 | uvs and a layer into it, and `finish` packs it into a `Mesh` of typed arrays a | |
| 168 | renderer uploads once. `quadMesh` is the full-screen quad for anything drawn | |
| 169 | without geometry. Models are functions, not files. | |
| 170 | ||
| 171 | ts | |
| 172 | // src/client/models.ts | |
| 173 | export function levelMesh(boxes: Box[]): Mesh { | |
| 174 | const b = builder(); | |
| 175 | for (const box of boxes) addBox(b, box.min, box.max, box.mat); | |
| 176 | return finish(b); | |
| 177 | } | |
| 178 | ||
| 179 | ||
| 180 | ### texture | |
| 181 | ||
| 182 | A `Shader` is `(u, v) => [r, g, b]`, and that is the whole of the interface. | |
| 183 | `hash` is the integer hash under it, `noise` is one octave of value noise at a | |
| 184 | period, `fbm` is a stack of them, and `paintAtlas` runs a list of shaders into | |
| 185 | one tightly packed RGBA buffer whose layers are the material indices the meshes | |
| 186 | already carry. | |
| 187 | ||
| 188 | ts | |
| 189 | // src/client/materials.ts | |
| 190 | export const materialTextures = (): Uint8Array => paintAtlas(MATERIALS, TEX_SIZE); | |
| 191 | ||
| 192 | ||
| 193 | ### sound | |
| 194 | ||
| 195 | `createSoundDevice` gives a `SoundDevice` that opens its `AudioContext` on the | |
| 196 | first sound, never at import — hand it a `Ctor` of your own and a test can count | |
| 197 | what was played. It takes a `Burst` (band-passed noise: a gunshot, an impact) | |
| 198 | and a `Tone` (a sine or square, optionally sliding). `attenuate` is the distance | |
| 199 | curve: full volume inside the near radius and falling off after it, and | |
| 200 | deliberately not inverse-square, because a game wants to hear the fight across | |
| 201 | the map. | |
| 202 | ||
| 203 | ts | |
| 204 | // src/client/audio.ts | |
| 205 | export const loudness = (at: Vec3, listener: Vec3): number => | |
| 206 | attenuate(dist(at, listener), EARSHOT, CLOSE); | |
| 207 | ||
| 208 | ||
| 209 | ### loop | |
| 210 | ||
| 211 | `createLoop` takes `LoopOptions` — the tick, and optionally the most steps one | |
| 212 | frame may run and the longest frame the clock is believed about — and `Frames`, | |
| 213 | the clock and the frame source. The `Loop` it returns banks elapsed time and | |
| 214 | hands back whole ticks, so a tab that slept for five seconds does not try to | |
| 215 | simulate five seconds in one frame. | |
| 216 | ||
| 217 | ts | |
| 218 | // src/range/main.ts | |
| 219 | const loop = createLoop({ tick: TICK }, { | |
| 220 | requestAnimationFrame: (callback) => requestAnimationFrame(callback), | |
| 221 | cancelAnimationFrame: (handle) => cancelAnimationFrame(handle), | |
| 222 | now: () => performance.now(), | |
| 223 | }); | |
| 224 | ||
| 225 | ||
| 226 | ### controls | |
| 227 | ||
| 228 | `createControls` takes the host and `Options` — the key table, the mouse table, | |
| 229 | the keys whose default the page must not take, the look scales, the sensitivity, | |
| 230 | the thumb-stick region and whether the mouse is ignored until the pointer is | |
| 231 | locked — and returns `Controls`: the live held set, the `Stick`, a | |
| 232 | `Controls.takeLook` that hands over the turn asked for since you last asked and | |
| 233 | then forgets it, a `Controls.locked` flag, the `Controls.onAction` and | |
| 234 | `Controls.onUnlock` callbacks, a `Controls.setAction` for pressing an action by | |
| 235 | name, and a `Controls.detach` for when the game is over. `attachButtons` wires | |
| 236 | every element carrying a `data-action` attribute to `Controls.setAction`, with | |
| 237 | `ButtonOptions` saying which of them are a tap rather than a hold, so a thumb | |
| 238 | reaches what a keyboard does. The event shapes it reads are exported too — | |
| 239 | `KeyEventish`, `MouseEventish`, `TouchEventish`, `Touchish`, `Button` — because | |
| 240 | a test drives the handlers with plain objects and no browser is in the room. | |
| 241 | ||
| 242 | ts | |
| 243 | // src/range/main.ts | |
| 244 | const controls = createControls( | |
| 245 | { surface: scene, window, document }, | |
| 246 | { keys: RANGE_KEYS, mouse: RANGE_MOUSE }, | |
| 247 | ); | |
| 248 | attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP }); | |
| 249 | ||
| 250 | ||
| 251 | ## The seam, named as pairs | |
| 252 | ||
| 253 | Two games exist here, and every place they differ is a place the engine chose to | |
| 254 | know nothing. Read down a column to see one game; read across a row to see the | |
| 255 | split. | |
| 256 | ||
| 257 | | the game supplies | the shooter | the range | | |
| 258 | | --- | --- | --- | | |
| 259 | | an entry | `src/client/main.ts` | `src/range/main.ts` | | |
| 260 | | a host | a stub page in `src/client/stage.test-helper.ts` | a stub page in `src/range/dom.ts` | | |
| 261 | | a bindings table | `src/client/binding.ts` — buy, scoreboard, weapon slots | `src/range/binding.ts` — a trigger and a start, nothing else | | |
| 262 | | a tick rate | 64 Hz | 50 Hz | | |
| 263 | | mesh recipes | `src/client/models.ts` | none: it draws no geometry yet | | |
| 264 | | texture recipes | `src/client/materials.ts` | none: the readout paints its own 2D | | |
| 265 | | sound recipes | `src/client/audio.ts` | none yet | | |
| 266 | | a renderer | WebGL2, `src/client/renderer.ts` | a 2D horizon, `src/range/readout.ts` | | |
| 267 | | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` | | |
| 268 | ||
| 269 | ## What the engine deliberately does not know | |
| 270 | ||
| 271 | - **The map layout.** The cell alphabet of `de_forge`, its areas, its crates, | |
| 272 | its spawns and its sites are `src/client/map.ts`. The engine has a grid of | |
| 273 | cells of an unknown type and two predicates. | |
| 274 | - **The materials.** Which surface is sand and which is plaster, and how many | |
| 275 | there are, is `src/client/materials.ts`. The engine has a function from a uv | |
| 276 | to three numbers. | |
| 277 | - **The rules of a round.** Freeze time, buy time, money, halves, what a kill is | |
| 278 | worth: `src/client/match.ts` and `src/client/world.ts`, or `src/range/targets.ts` | |
| 279 | for a thirty-second run of plates. The engine has a fixed step. | |
| 280 | - **The database.** Scores, sessions and the leaderboard are `src/db.ts` and the | |
| 281 | server. Nothing under `src/engine/` reads or writes anything. | |
| 282 | - **The page.** HTML, ids, overlays, the security headers: `src/pages/` and | |
| 283 | `src/server.ts`. The engine is handed elements and never looks one up. | |
| 284 | ||
| 285 | That list is the reason the second game is a directory rather than a fork, and | |
| 286 | it is worth defending: when something in a game starts to look general, move it behind | |
| 287 | the door with a test, and when something behind the door starts to need to know | |
| 288 | which game is running, it was never the engine's. |
src/engine/boundary.test.ts +4 −1
| @@ -215,8 +215,11 @@ test("no module in the engine goes through the engine's own door", () => { | ||
| 215 | 215 | * The surface. This list is the engine's public API written down: a name that |
| 216 | 216 | * arrives, leaves or is renamed fails here and is read in the pull. Sorted the |
| 217 | 217 | * way `Array.prototype.sort` sorts, so capitals come first. |
| 218 | * | |
| 219 | * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one | |
| 220 | * copy of the surface, read twice, rather than two that drift. | |
| 218 | 221 | */ |
| 219 | const SURFACE = [ | |
| 222 | export const SURFACE = [ | |
| 220 | 223 | "Box", "Builder", "Burst", "Button", "ButtonOptions", "Controls", "Ctor", "Frames", "Grid", |
| 221 | 224 | "Hit", "Host", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", |
| 222 | 225 | "MouseEventish", "Options", "Shader", "SoundDevice", "Stick", "Surface", "Tone", |
src/engine/guide.test.ts added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | /** | |
| 2 | * The guide, held honest. `GUIDE.md` is the engine's how — what a game has to | |
| 3 | * bring before the door's names are any use — and a page like that rots the | |
| 4 | * moment it is only prose. Three rules keep it true, and they are the whole of | |
| 5 | * this file: | |
| 6 | * | |
| 7 | * 1. Every name the door exports is named in the guide, so a group that lands | |
| 8 | * behind the door arrives with a paragraph or it fails here. | |
| 9 | * 2. Every engine name the guide writes in backticks is a name the door | |
| 10 | * exports, so a renamed export cannot leave a lie on the page. | |
| 11 | * 3. Every line of every code block is a line of the file named on the block's | |
| 12 | * first line, so the examples are copied out of a game that exists here | |
| 13 | * rather than invented for the page. | |
| 14 | * | |
| 15 | * **The backtick rule**, written here rather than left for a reader to guess: | |
| 16 | * only inline spans count, never fenced code, and a span counts as a claim | |
| 17 | * about the engine only when it is a bare identifier — letters, digits, `$` and | |
| 18 | * `_`, nothing else. So a path (`src/range/binding.ts`), a qualified name | |
| 19 | * (`binding.readIntent`, `Controls.takeLook`), a type applied to arguments and | |
| 20 | * any span with a space in it are prose and are not checked. That leaves the | |
| 21 | * words that are bare, not the engine's and have no module to carry them — | |
| 22 | * the platform's own vocabulary, and the map's name — and those are written out | |
| 23 | * in `NOT_THE_ENGINES` below, short enough to read. | |
| 24 | * | |
| 25 | * The list of exports is `SURFACE` from `boundary.test.ts`, imported rather | |
| 26 | * than parsed a second time: one copy of the door's surface in this repository. | |
| 27 | * Importing it registers that file's tests here again, which costs a | |
| 28 | * millisecond and is what `purity.test.ts` and `src/range/independence.test.ts` | |
| 29 | * already pay for the same reason. | |
| 30 | */ | |
| 31 | ||
| 32 | import { expect, test } from "bun:test"; | |
| 33 | import { readFileSync } from "node:fs"; | |
| 34 | import { join, resolve } from "node:path"; | |
| 35 | import { SURFACE } from "./boundary.test.ts"; | |
| 36 | ||
| 37 | const ENGINE = import.meta.dir; | |
| 38 | const ROOT = resolve(ENGINE, "..", ".."); | |
| 39 | const GUIDE = join(ENGINE, "GUIDE.md"); | |
| 40 | ||
| 41 | /** A bare identifier: the only shape of span this file reads as an engine name. */ | |
| 42 | const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; | |
| 43 | ||
| 44 | /** | |
| 45 | * Bare backticked words that are nobody's export: the platform's vocabulary, | |
| 46 | * and `de_forge`, which is the shooter's map and not a name at all. Anything | |
| 47 | * else that is not the engine's is written as `module.name` in the guide, which | |
| 48 | * this file ignores without needing to be told about it. | |
| 49 | */ | |
| 50 | const NOT_THE_ENGINES = new Set([ | |
| 51 | "AudioContext", "Float32Array", "addEventListener", "cancelAnimationFrame", "de_forge", | |
| 52 | "pointerLockElement", "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv", | |
| 53 | ]); | |
| 54 | ||
| 55 | const guide = (): string => readFileSync(GUIDE, "utf8"); | |
| 56 | ||
| 57 | /** The markdown with every fenced block taken out: what the backtick rule reads. */ | |
| 58 | export function withoutFences(markdown: string): string { | |
| 59 | return markdown.replace(/^```[\s\S]*?^```/gm, ""); | |
| 60 | } | |
| 61 | ||
| 62 | /** Every inline code span, in order, under the backticks that made it. */ | |
| 63 | export function spansOf(markdown: string): string[] { | |
| 64 | const out: string[] = []; | |
| 65 | const pattern = /`([^`\n]+)`/g; | |
| 66 | for (let m = pattern.exec(markdown); m !== null; m = pattern.exec(markdown)) | |
| 67 | out.push(m[1] as string); | |
| 68 | return out; | |
| 69 | } | |
| 70 | ||
| 71 | /** One fenced block: the file its first line names, and the lines under it. */ | |
| 72 | export type Block = { file: string; lines: string[] }; | |
| 73 | ||
| 74 | /** | |
| 75 | * Every fenced block, as the file it claims and the lines it quotes. A block | |
| 76 | * whose first line is not `// <path>` comes back with an empty file name, which | |
| 77 | * the test below turns into a sentence rather than passing quietly. | |
| 78 | */ | |
| 79 | export function blocksOf(markdown: string): Block[] { | |
| 80 | const out: Block[] = []; | |
| 81 | const pattern = /^```[a-z]*\n([\s\S]*?)^```/gm; | |
| 82 | for (let m = pattern.exec(markdown); m !== null; m = pattern.exec(markdown)) { | |
| 83 | const lines = (m[1] as string).split("\n"); | |
| 84 | const first = (lines[0] ?? "").trim(); | |
| 85 | const named = /^\/\/ (\S+)$/.exec(first); | |
| 86 | out.push({ | |
| 87 | file: named === null ? "" : (named[1] as string), | |
| 88 | lines: lines.slice(1).filter((line) => line.trim() !== ""), | |
| 89 | }); | |
| 90 | } | |
| 91 | return out; | |
| 92 | } | |
| 93 | ||
| 94 | test("the guide is there and has the shape the rest of this file reads", () => { | |
| 95 | const markdown = guide(); | |
| 96 | expect(markdown.length).toBeGreaterThan(2000); | |
| 97 | expect(blocksOf(markdown).length).toBeGreaterThanOrEqual(8); | |
| 98 | expect(spansOf(withoutFences(markdown)).length).toBeGreaterThan(50); | |
| 99 | }); | |
| 100 | ||
| 101 | test("the guide names every name the door offers", () => { | |
| 102 | const named = new Set(spansOf(withoutFences(guide()))); | |
| 103 | const offences = SURFACE.filter((name) => !named.has(name)).map( | |
| 104 | (name) => `GUIDE.md never writes \`${name}\`; every name behind the door gets its paragraph`, | |
| 105 | ); | |
| 106 | expect(offences).toEqual([]); | |
| 107 | }); | |
| 108 | ||
| 109 | test("every engine name the guide writes in backticks is one the door exports", () => { | |
| 110 | const offered = new Set(SURFACE); | |
| 111 | const offences: string[] = []; | |
| 112 | for (const span of new Set(spansOf(withoutFences(guide())))) { | |
| 113 | if (!BARE.test(span) || offered.has(span) || NOT_THE_ENGINES.has(span)) continue; | |
| 114 | offences.push( | |
| 115 | `GUIDE.md writes \`${span}\` as an engine name and the door does not export it:` + | |
| 116 | " qualify a game's own name as `module.name`, or add a platform word to NOT_THE_ENGINES", | |
| 117 | ); | |
| 118 | } | |
| 119 | expect(offences).toEqual([]); | |
| 120 | }); | |
| 121 | ||
| 122 | test("every line the guide quotes is a line of the file it names", () => { | |
| 123 | const offences: string[] = []; | |
| 124 | for (const block of blocksOf(guide())) { | |
| 125 | if (block.file === "") { | |
| 126 | offences.push("a code block in GUIDE.md does not name its source on its first line"); | |
| 127 | continue; | |
| 128 | } | |
| 129 | let lines: Set<string>; | |
| 130 | try { | |
| 131 | lines = new Set(readFileSync(join(ROOT, block.file), "utf8").split("\n").map((l) => l.trim())); | |
| 132 | } catch { | |
| 133 | offences.push(`GUIDE.md quotes ${block.file}, which is not a file in this repository`); | |
| 134 | continue; | |
| 135 | } | |
| 136 | for (const line of block.lines) | |
| 137 | if (!lines.has(line.trim())) | |
| 138 | offences.push(`GUIDE.md quotes "${line.trim()}" as ${block.file}; that file has no such line`); | |
| 139 | } | |
| 140 | expect(offences).toEqual([]); | |
| 141 | }); | |
| 142 | ||
| 143 | test("the guide names the seam as a pair, both sides of it", () => { | |
| 144 | const markdown = guide(); | |
| 145 | for (const pair of [ | |
| 146 | ["src/client/binding.ts", "src/range/binding.ts"], | |
| 147 | ["src/client/main.ts", "src/range/main.ts"], | |
| 148 | ["64 Hz", "50 Hz"], | |
| 149 | ["src/client/materials.ts", "src/range/readout.ts"], | |
| 150 | ]) | |
| 151 | for (const side of pair) expect(markdown).toContain(side); | |
| 152 | }); | |
| 153 | ||
| 154 | test("the readers read what they exist to read", () => { | |
| 155 | const fence = "```"; | |
| 156 | const markdown = [ | |
| 157 | "A path is `src/range/main.ts` and a member is `Controls.takeLook`.", | |
| 158 | `${fence}ts`, | |
| 159 | "// src/range/binding.ts", | |
| 160 | "export const RANGE_MOUSE: Record<number, string> = { 0: \"fire\" };", | |
| 161 | fence, | |
| 162 | "A name is `rayBox`.", | |
| 163 | ].join("\n"); | |
| 164 | // The fence and everything in it is gone before a span is read. | |
| 165 | expect(spansOf(withoutFences(markdown))).toEqual([ | |
| 166 | "src/range/main.ts", "Controls.takeLook", "rayBox", | |
| 167 | ]); | |
| 168 | expect(blocksOf(markdown)).toEqual([ | |
| 169 | { | |
| 170 | file: "src/range/binding.ts", | |
| 171 | lines: ["export const RANGE_MOUSE: Record<number, string> = { 0: \"fire\" };"], | |
| 172 | }, | |
| 173 | ]); | |
| 174 | expect(blocksOf(`${fence}ts\nconst a = 1;\n${fence}`)[0]?.file).toBe(""); | |
| 175 | expect(BARE.test("rayBox")).toBe(true); | |
| 176 | expect(BARE.test("binding.readIntent")).toBe(false); | |
| 177 | expect(BARE.test("src/range/main.ts")).toBe(false); | |
| 178 | expect(BARE.test("(u, v) => [r, g, b]")).toBe(false); | |
| 179 | }); |
new above
offline