Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The guide’s code runs: src/engine/example.ts is the smallest game on the package, and a test plays it #77
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/58-the-guide-s-code-runs-src-engine-example → main 2 commits fork
6 files
changed +452
−116
README.md +10 −1
| @@ -174,7 +174,16 @@ subtends — and the panel says which of the two you are looking at. | ||
| 174 | 174 | entry, a host, a bindings table, a tick rate, its own recipes and its own |
| 175 | 175 | tuning), a paragraph and one true line of code per group behind the door, and |
| 176 | 176 | what the engine deliberately does not know. `guide.test.ts` holds it against |
| 177 | the door's list of names in both directions. | |
| 177 | the door's list of names in both directions, and against the example below: | |
| 178 | every code block on that page is a run of lines of `src/engine/example.ts`. | |
| 179 | - `src/engine/example.ts` — the smallest game on the package, and the guide's | |
| 180 | code made runnable: under eighty lines with one import, a room to stand in, a | |
| 181 | plate to shoot, a bindings table, a 60 Hz tick and a `step(world, dt, held)` | |
| 182 | that takes plain data and gives plain data back. It has no page, no renderer | |
| 183 | and nothing saved — `src/range/` is the real second game — and | |
| 184 | `example.test.ts` plays it headlessly: a seeded run scores the same twice, a | |
| 185 | shot down the target's bearing hits and one twenty degrees off misses, and a | |
| 186 | second of elapsed time is sixty ticks however the frames are cut. | |
| 178 | 187 | - `src/client/` — the game, in modules named for one thing each: `map` (de_forge |
| 179 | 188 | itself: its areas, its crates, its cell alphabet and its spawns and sites, laid |
| 180 | 189 | over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules), |
src/engine/GUIDE.md +101 −107
| @@ -1,22 +1,29 @@ | ||
| 1 | 1 | # Building a game on this engine |
| 2 | 2 | |
| 3 | Read `src/engine/example.ts` first: it is a whole game in one file — a room to | |
| 4 | stand in, a plate to shoot, a score and a step — and every code block on this | |
| 5 | page is a slice of it, so none of this is prose that quietly stopped compiling. | |
| 6 | ||
| 3 | 7 | `index.ts` is the *what*: the list of names the door offers, one line of comment |
| 4 | 8 | per group. This is the *how*: what a game has to bring before those names are |
| 5 | 9 | any use, in the order you would actually write it. |
| 6 | 10 | |
| 7 | 11 | 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. | |
| 12 | from a frame it owns, with a page it opened and numbers it chose. Two real games | |
| 13 | do that here: the shooter in `src/client/` and the range in `src/range/`. The | |
| 14 | example is neither — it has no page, no renderer and nothing saved — and that is | |
| 15 | why it fits in one file and a test can play it in a millisecond. | |
| 11 | 16 | |
| 12 | 17 | **Conventions.** A bare name in backticks — `rayBox`, `Mesh` — is the engine's, |
| 13 | 18 | 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. | |
| 19 | `binding.readIntent`, `targets.step`, `example.play`. A path is a path. Every | |
| 20 | code block names the file it came from on its first line, and that file is | |
| 21 | always the example. | |
| 16 | 22 | |
| 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 | |
| 23 | `guide.test.ts` holds all four: every name the door exports is named here, every | |
| 24 | bare engine name here is exported, every line of every code block is a line of | |
| 25 | the file above it, and every block is a run of lines of `src/engine/example.ts` | |
| 26 | in the order that file has them. So when a group lands behind the door that this | |
| 20 | 27 | page has no paragraph for — the physics of issue #3, one day — that test fails |
| 21 | 28 | until the paragraph is written. That is the point. A name arrives with a page or |
| 22 | 29 | it does not arrive. |
| @@ -28,20 +35,24 @@ it does not arrive. | ||
| 28 | 35 | One directory, `src/<game>/`, and one file in it that is the frame: |
| 29 | 36 | `src/range/main.ts` is the whole of the range's wiring. It exports the function |
| 30 | 37 | 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. | |
| 38 | last two lines — the only place in the game a browser global is named. The | |
| 39 | example's frame is `example.play`, and it is that function without the page: | |
| 32 | 40 | |
| 33 | 41 | ts |
| 34 | // src/range/main.ts | |
| 35 | if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); | |
| 36 | else boot(); | |
| 42 | // src/engine/example.ts | |
| 43 | export function play(host: Host, frames: Frames, seed: number): { world(): World; stop(): void } { | |
| 44 | const controls = createControls(host, { keys: KEYS, requireLock: false }); | |
| 45 | const loop = createLoop({ tick: TICK }, frames); | |
| 46 | let world = createWorld(seed); | |
| 37 | 47 | |
| 38 | 48 | |
| 39 | Two more things and the game is reachable: one entry in the registry in | |
| 49 | Two more things and a real game is reachable: one entry in the registry in | |
| 40 | 50 | `src/games.ts` — its name, its path, its label, this source file and a size |
| 41 | 51 | ceiling — and a page that server-renders the markup the entry looks up by id |
| 42 | 52 | (`src/pages/range.ts`), named in the one map in `src/server.ts` that turns a |
| 43 | 53 | game's name into its page function. The build, the header's nav and the 404 |
| 44 | follow from the entry. | |
| 54 | follow from the entry. The example has neither, on purpose: it is read and | |
| 55 | played by a test, never shipped. | |
| 45 | 56 | |
| 46 | 57 | ### 2. A host |
| 47 | 58 | |
| @@ -53,16 +64,14 @@ parameter. Controls want a `Host` — `{ surface, window, document }`, where | ||
| 53 | 64 | `cancelAnimationFrame`, and a `now()` in milliseconds. |
| 54 | 65 | |
| 55 | 66 | ts |
| 56 | // src/client/main.ts | |
| 57 | const controls = createControls( | |
| 58 | { surface: sceneCanvas, window: win, document: doc }, | |
| 59 | { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 }, | |
| 60 | ); | |
| 67 | // src/engine/example.ts | |
| 68 | const controls = createControls(host, { keys: KEYS, requireLock: false }); | |
| 61 | 69 | |
| 62 | 70 | |
| 63 | Those two are the shooter's own `main.Host`: the real browser in production, | |
| 64 | and plain objects in `src/client/stage.test-helper.ts` under test. The range | |
| 65 | does the same with `src/range/dom.ts`. Nothing in the engine knows the | |
| 71 | That host is the real browser in production and plain objects under test: the | |
| 72 | shooter's are in `src/client/stage.test-helper.ts`, the range's in | |
| 73 | `src/range/dom.ts`, and the example's are a few objects that keep a list of | |
| 74 | handlers at the top of `example.test.ts`. Nothing in the engine knows the | |
| 66 | 75 | difference, because those types are the members it reads and nothing wider. |
| 67 | 76 | |
| 68 | 77 | ### 3. A bindings table |
| @@ -71,30 +80,29 @@ The engine collects action *names* and knows none of them. Which key says which | ||
| 71 | 80 | name is the game's table, and it is data: |
| 72 | 81 | |
| 73 | 82 | ts |
| 74 | // src/range/binding.ts | |
| 75 | export const RANGE_KEYS: Record<string, string> = { | |
| 83 | // src/engine/example.ts | |
| 84 | export const KEYS: Record<string, string> = { | |
| 76 | 85 | KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right", |
| 77 | ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right", | |
| 78 | Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk", | |
| 79 | KeyF: "fire", KeyR: "reload", Enter: "start", | |
| 86 | ArrowLeft: "turnLeft", ArrowRight: "turnRight", Space: "fire", | |
| 80 | 87 | }; |
| 81 | 88 | |
| 82 | 89 | |
| 83 | 90 | Beside it go the mouse buttons, the on-screen buttons that are a tap rather than |
| 84 | 91 | a hold, and one function from the held set to whatever struct the game's |
| 85 | simulation takes — `binding.readIntent` in both games, returning two different | |
| 86 | shapes. The engine hands you a `ReadonlySet<string>` and a `Stick`; what | |
| 87 | "forward" means is yours. | |
| 92 | simulation takes — `binding.readIntent` in both real games, returning two | |
| 93 | different shapes; in the example the step function reads the set itself, which | |
| 94 | is all a game this small needs. The engine hands you a `ReadonlySet<string>` and | |
| 95 | a `Stick`; what "forward" means is yours. | |
| 88 | 96 | |
| 89 | 97 | ### 4. A tick rate |
| 90 | 98 | |
| 91 | Pick the step the simulation runs at and never let a frame change it. The range | |
| 92 | and the shooter disagree on purpose: | |
| 99 | Pick the step the simulation runs at and never let a frame change it. The three | |
| 100 | games here disagree on purpose — 64 Hz, 50 Hz and 60 Hz: | |
| 93 | 101 | |
| 94 | 102 | ts |
| 95 | // src/range/main.ts | |
| 96 | /** The range's own step: 50 Hz, deliberately not the shooter's 64. */ | |
| 97 | const TICK = 1 / 50; | |
| 103 | // src/engine/example.ts | |
| 104 | /** The step the simulation runs at: 60 Hz, and no frame is allowed to change it. */ | |
| 105 | export const TICK = 1 / 60; | |
| 98 | 106 | |
| 99 | 107 | |
| 100 | 108 | `Loop.advance(elapsed)` banks the frame and returns whole steps; a slow frame |
| @@ -107,8 +115,10 @@ There are no asset files in this repository and the engine ships none. It ships | ||
| 107 | 115 | three tools that turn a recipe into bytes — a box builder, an atlas painter and |
| 108 | 116 | a sound device — and the recipes belong to the game: `src/client/models.ts` is |
| 109 | 117 | the shooter's geometry, `src/client/materials.ts` its nine surfaces, |
| 110 | `src/client/audio.ts` its voice. A second game writes its own three, or, like | |
| 111 | the range today, writes none and draws in 2D. | |
| 118 | `src/client/audio.ts` its voice. A second game writes its own three: the range's | |
| 119 | are `src/range/level.ts`, `src/range/paint.ts` and `src/range/audio.ts`, and | |
| 120 | they owe the shooter's nothing. The example writes none at all — it has one box | |
| 121 | and one plate, and never draws or sounds either. | |
| 112 | 122 | |
| 113 | 123 | ### 6. Its own tuning |
| 114 | 124 | |
| @@ -116,6 +126,13 @@ Every number that decides how the game feels is the game's: eye height, hull | ||
| 116 | 126 | size, gravity, how long a round lasts, what a plate is worth. The engine holds |
| 117 | 127 | no constants of its own beyond the loop's two safety clamps. Put them at the top |
| 118 | 128 | of a module, named, and a reader can tune the game without reading the engine. |
| 129 | The example's whole tuning is one line, and that is the shape to copy: | |
| 130 | ||
| 131 | ts | |
| 132 | // src/engine/example.ts | |
| 133 | /** The tuning, all of it: metres per second, degrees per second, metres, metres. */ | |
| 134 | const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40; | |
| 135 | ||
| 119 | 136 | |
| 120 | 137 | ## What is behind the door |
| 121 | 138 | |
| @@ -132,8 +149,8 @@ seeded generator — every random draw in a game that wants to be reproducible | ||
| 132 | 149 | comes from one of these, in a fixed order. |
| 133 | 150 | |
| 134 | 151 | ts |
| 135 | // src/range/main.ts | |
| 136 | const shot = shoot(run, EYE, angleVectors(view.yaw, view.pitch).forward); | |
| 152 | // src/engine/example.ts | |
| 153 | const to = add(world.pos, scale(angleVectors(world.yaw, 0).forward, REACH)); | |
| 137 | 154 | |
| 138 | 155 | |
| 139 | 156 | ### trace |
| @@ -145,9 +162,12 @@ index is carried so the thing you hit knows what it is made of — and a `Hit` | ||
| 145 | 162 | carries the fraction along the segment, the point, the normal and the thing that |
| 146 | 163 | was hit, which is whatever you put in the list. |
| 147 | 164 | |
| 165 | A shot in the example is one ray and one box, and it is the whole of hitscan: | |
| 166 | `src/range/targets.ts` does the same against a list with `traceWorld`. | |
| 167 | ||
| 148 | 168 | ts |
| 149 | // src/range/targets.ts | |
| 150 | const hit = traceWorld(eye, to, run.targets.map((target) => target.box)); | |
| 169 | // src/engine/example.ts | |
| 170 | if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world; | |
| 151 | 171 | |
| 152 | 172 | |
| 153 | 173 | ### grid |
| @@ -157,28 +177,18 @@ engine's. `inside` and `cellAt` read it, `worldOf`, `colOf` and `rowOf` are the | ||
| 157 | 177 | cell-to-world arithmetic, `mergeSolid` greedily merges runs of matching cells |
| 158 | 178 | into as few boxes as the shape allows, and `findPath` is A* with no |
| 159 | 179 | corner-cutting. Which cells are solid and which are walkable arrive as |
| 160 | predicates, so the engine never learns what a `"#"` means. | |
| 161 | ||
| 162 | ts | |
| 163 | // src/client/map.ts | |
| 164 | export const findPath = (from: Vec3, to: Vec3): Vec3[] => g.findPath(grid, passable, from, to); | |
| 165 | ||
| 180 | predicates, so the engine never learns what a `"#"` means. The example has no | |
| 181 | grid — one box is not a map — so read `src/client/map.ts`, which lays de_forge's | |
| 182 | cell alphabet over this and hands back the level's boxes and its paths. | |
| 166 | 183 | |
| 167 | 184 | ### mesh |
| 168 | 185 | |
| 169 | 186 | `builder` opens a `Builder`, `addBox` writes six quads of positions, normals, |
| 170 | 187 | uvs and a layer into it, and `finish` packs it into a `Mesh` of typed arrays a |
| 171 | 188 | renderer uploads once. `quadMesh` is the full-screen quad for anything drawn |
| 172 | without geometry. Models are functions, not files. | |
| 173 | ||
| 174 | ts | |
| 175 | // src/client/models.ts | |
| 176 | export function levelMesh(boxes: Box[]): Mesh { | |
| 177 | const b = builder(); | |
| 178 | for (const box of boxes) addBox(b, box.min, box.max, box.mat); | |
| 179 | return finish(b); | |
| 180 | } | |
| 181 | ||
| 189 | without geometry. Models are functions, not files: `src/client/models.ts` is the | |
| 190 | shooter's eight of them and `src/range/level.ts` the range's two. The example | |
| 191 | builds none, which is allowed — a game that never draws brings no geometry. | |
| 182 | 192 | |
| 183 | 193 | ### texture |
| 184 | 194 | |
| @@ -186,12 +196,9 @@ A `Shader` is `(u, v) => [r, g, b]`, and that is the whole of the interface. | ||
| 186 | 196 | `hash` is the integer hash under it, `noise` is one octave of value noise at a |
| 187 | 197 | period, `fbm` is a stack of them, and `paintAtlas` runs a list of shaders into |
| 188 | 198 | one tightly packed RGBA buffer whose layers are the material indices the meshes |
| 189 | already carry. | |
| 190 | ||
| 191 | ts | |
| 192 | // src/client/materials.ts | |
| 193 | export const materialTextures = (): Uint8Array => paintAtlas(MATERIALS, TEX_SIZE); | |
| 194 | ||
| 199 | already carry. `src/client/materials.ts` is the shooter's nine surfaces and | |
| 200 | `src/range/paint.ts` the range's three layers, each painted once at start-up; | |
| 201 | the example paints nothing, because it uploads no texture. | |
| 195 | 202 | |
| 196 | 203 | ### sound |
| 197 | 204 | |
| @@ -201,13 +208,9 @@ what was played. It takes a `Burst` (band-passed noise: a gunshot, an impact) | ||
| 201 | 208 | and a `Tone` (a sine or square, optionally sliding). `attenuate` is the distance |
| 202 | 209 | curve: full volume inside the near radius and falling off after it, and |
| 203 | 210 | deliberately not inverse-square, because a game wants to hear the fight across |
| 204 | the map. | |
| 205 | ||
| 206 | ts | |
| 207 | // src/client/audio.ts | |
| 208 | export const loudness = (at: Vec3, listener: Vec3): number => | |
| 209 | attenuate(dist(at, listener), EARSHOT, CLOSE); | |
| 210 | ||
| 211 | the map. `src/client/audio.ts` is the shooter's voice and `src/range/audio.ts` | |
| 212 | the range's, and they share no sound; the example opens no device at all, which | |
| 213 | is why a test can import it in a process with no `AudioContext` in it. | |
| 211 | 214 | |
| 212 | 215 | ### loop |
| 213 | 216 | |
| @@ -218,14 +221,19 @@ hands back whole ticks, so a tab that slept for five seconds does not try to | ||
| 218 | 221 | simulate five seconds in one frame. |
| 219 | 222 | |
| 220 | 223 | ts |
| 221 | // src/range/main.ts | |
| 222 | const loop = createLoop({ tick: TICK }, { | |
| 223 | requestAnimationFrame: (callback) => requestAnimationFrame(callback), | |
| 224 | cancelAnimationFrame: (handle) => cancelAnimationFrame(handle), | |
| 225 | now: () => performance.now(), | |
| 224 | // src/engine/example.ts | |
| 225 | const loop = createLoop({ tick: TICK }, frames); | |
| 226 | let world = createWorld(seed); | |
| 227 | loop.start((elapsed) => { | |
| 228 | for (let steps = loop.advance(elapsed); steps > 0; steps--) world = step(world, TICK, controls.held); | |
| 226 | 229 | }); |
| 227 | 230 | |
| 228 | 231 | |
| 232 | In a browser those `Frames` are three one-line functions over | |
| 233 | `requestAnimationFrame`, `cancelAnimationFrame` and `performance.now` — | |
| 234 | `src/range/main.ts` writes them out — and in `example.test.ts` they are a | |
| 235 | number the test moves, which is how a second of play takes a millisecond. | |
| 236 | ||
| 229 | 237 | ### controls |
| 230 | 238 | |
| 231 | 239 | `createControls` takes the host and `Options` — the key table, the mouse table, |
| @@ -242,13 +250,14 @@ reaches what a keyboard does. The event shapes it reads are exported too — | ||
| 242 | 250 | `KeyEventish`, `MouseEventish`, `TouchEventish`, `Touchish`, `Button` — because |
| 243 | 251 | a test drives the handlers with plain objects and no browser is in the room. |
| 244 | 252 | |
| 253 | What a game does with all that can be one line — read the held set every tick | |
| 254 | and be done — and `attachButtons` is a second line whenever there are on-screen | |
| 255 | buttons to wire, which `src/range/main.ts` has and the example does not: | |
| 256 | ||
| 245 | 257 | ts |
| 246 | // src/range/main.ts | |
| 247 | const controls = createControls( | |
| 248 | { surface: scene, window, document }, | |
| 249 | { keys: RANGE_KEYS, mouse: RANGE_MOUSE }, | |
| 250 | ); | |
| 251 | attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP }); | |
| 258 | // src/engine/example.ts | |
| 259 | export function step(world: World, dt: number, held: ReadonlySet<string>): World { | |
| 260 | const on = (action: string): number => (held.has(action) ? 1 : 0); | |
| 252 | 261 | |
| 253 | 262 | |
| 254 | 263 | ### overlay |
| @@ -268,13 +277,8 @@ tools touch, and not a whole context. A browser's is one; so is a recorder in a | ||
| 268 | 277 | test, which is how every pixel decision in `overlay.test.ts` is checked with no |
| 269 | 278 | canvas in the room, and how `src/range/readout.test.ts` checks the range's |
| 270 | 279 | panel. There is no colour in here a game chose, beyond one neutral scrim. |
| 271 | ||
| 272 | ts | |
| 273 | // src/range/readout.ts | |
| 274 | const o = overlay(paint, width, height); | |
| 275 | o.panel(pad, pad, width - pad * 2, step * lines.length + pad, "rgba(11,13,17,.72)"); | |
| 276 | o.crosshair(Math.round(width / 2), Math.round(height / 2), 0, Math.max(6, Math.round(width / 90)), "#8ea"); | |
| 277 | ||
| 280 | `src/range/readout.ts` is a whole panel drawn through it; the example draws | |
| 281 | nothing at all, because a game that draws is a game with a page. | |
| 278 | 282 | |
| 279 | 283 | ### renderer |
| 280 | 284 | |
| @@ -295,17 +299,10 @@ name, under the name its instances will ask for. | ||
| 295 | 299 | `RendererAssets.sky` is the horizon for a frame that names none, and |
| 296 | 300 | `RendererAssets.devicePixelRatio` is `window.devicePixelRatio` handed in as a |
| 297 | 301 | function, because the engine reads no global and a test drives a whole frame |
| 298 | with no window in the room. | |
| 299 | ||
| 300 | ts | |
| 301 | // src/client/main.ts | |
| 302 | createRenderer(canvas as unknown as HTMLCanvasElement, { | |
| 303 | atlas: { bytes: materialTextures(), size: TEX_SIZE, layers: LAYERS }, | |
| 304 | meshes: gameMeshes(), | |
| 305 | sky: SKY, | |
| 306 | devicePixelRatio: () => window.devicePixelRatio, | |
| 307 | }), | |
| 308 | ||
| 302 | with no window in the room. Two games here build all of that and both are the | |
| 303 | shape to copy: the shooter's atlas and eight meshes in `src/client/main.ts`, and | |
| 304 | the range's atlas and two in `src/range/scene.ts`, handed to the call in | |
| 305 | `src/range/main.ts`. | |
| 309 | 306 | |
| 310 | 307 | Then every frame is a `Scene`: a `Camera` — a position, a yaw, a pitch and a |
| 311 | 308 | horizontal field of view — the sky this frame clears to and fades into, a pool |
| @@ -317,18 +314,15 @@ gun in your hands. Fill one scene object every frame rather than making one — | ||
| 317 | 314 | `src/client/scene.ts` allocates nothing per frame and is worth copying — name a |
| 318 | 315 | mesh that was never handed in and the draw throws with that name in the |
| 319 | 316 | message, and remember that nothing in a draw animates itself: a bob, a fade or |
| 320 | a tint is a decision, and decisions are the game's. | |
| 321 | ||
| 322 | ts | |
| 323 | // src/client/main.ts | |
| 324 | renderer.draw(buildScene(world, camera, frameScene), elapsed); | |
| 325 | ||
| 317 | a tint is a decision, and decisions are the game's. One line a frame draws it, | |
| 318 | and `src/client/main.ts` and `src/range/main.ts` are where those two lines live. | |
| 326 | 319 | |
| 327 | 320 | ## The seam, named as pairs |
| 328 | 321 | |
| 329 | Two games exist here, and every place they differ is a place the engine chose to | |
| 322 | Two games ship here, and every place they differ is a place the engine chose to | |
| 330 | 323 | know nothing. Read down a column to see one game; read across a row to see the |
| 331 | split. | |
| 324 | split. The example is in no column: it brings a host, a table, a tick and its | |
| 325 | own tuning, and nothing else in this table at all. | |
| 332 | 326 | |
| 333 | 327 | | the game supplies | the shooter | the range | |
| 334 | 328 | | --- | --- | --- | |
src/engine/boundary.test.ts +24 −7
| @@ -25,6 +25,18 @@ const ROOT = resolve(ENGINE, "..", ".."); | ||
| 25 | 25 | /** The directories outside the engine whose imports this test is about. */ |
| 26 | 26 | const OUTSIDE = ["src", "scripts"]; |
| 27 | 27 | |
| 28 | /** | |
| 29 | * A file under the engine that is not a module of it: a test, the probe | |
| 30 | * `purity.test.ts` spawns, and `example.ts`, the guide's game — documentation | |
| 31 | * that ships in no bundle and is the one file in here that goes through the | |
| 32 | * door on purpose, because the whole point of it is to use this package the way | |
| 33 | * a game outside it does. The two tests that read this are the two it is an | |
| 34 | * exception to, and `example.ts` pays for its exception one test below: it | |
| 35 | * imports the door and nothing else. | |
| 36 | */ | |
| 37 | const notAModule = (file: string): boolean => | |
| 38 | file.endsWith(".test.ts") || file.endsWith(".probe.ts") || file === "example.ts"; | |
| 39 | ||
| 28 | 40 | /** Every `.ts` under the engine, recursively, as paths relative to it. */ |
| 29 | 41 | export function sources(dir = ENGINE): string[] { |
| 30 | 42 | const out: string[] = []; |
| @@ -186,11 +198,9 @@ test("the door rule is about the door and nothing else", () => { | ||
| 186 | 198 | test("every module in the engine is behind the door, and the door holds nothing else", () => { |
| 187 | 199 | const door = readFileSync(join(ENGINE, "index.ts"), "utf8"); |
| 188 | 200 | const behind = new Set(specifiersOf(door).map((s) => s.replace(/^\.\//, ""))); |
| 189 | // A `.probe.ts` is tooling the tests spawn, not a module that ships, so the | |
| 190 | // door neither offers it nor should: `purity.test.ts` draws the same line. | |
| 191 | const modules = sources().filter( | |
| 192 | (f) => !f.endsWith(".test.ts") && !f.endsWith(".probe.ts") && f !== "index.ts", | |
| 193 | ); | |
| 201 | // A `.probe.ts` is tooling the tests spawn and `example.ts` is the guide's | |
| 202 | // game, so the door offers neither and should not: `notAModule` says why. | |
| 203 | const modules = sources().filter((f) => !notAModule(f) && f !== "index.ts"); | |
| 194 | 204 | expect(modules.length).toBeGreaterThan(0); |
| 195 | 205 | expect([...behind].sort()).toEqual(modules); |
| 196 | 206 | // Nothing of its own: the door is re-exports, a docstring and nothing else. |
| @@ -201,8 +211,10 @@ test("every module in the engine is behind the door, and the door holds nothing | ||
| 201 | 211 | test("no module in the engine goes through the engine's own door", () => { |
| 202 | 212 | const offences: string[] = []; |
| 203 | 213 | for (const file of sources()) { |
| 204 | // The door itself, and a test that reaches through it to check what it holds. | |
| 205 | if (file === "index.ts" || file.endsWith(".test.ts")) continue; | |
| 214 | // The door itself; a test, which reaches through it to check what it holds; | |
| 215 | // and the example, which goes through it because it is a game. Everything | |
| 216 | // else in here, the probe included, imports its siblings directly. | |
| 217 | if (file === "index.ts" || file.endsWith(".test.ts") || file === "example.ts") continue; | |
| 206 | 218 | const source = readFileSync(join(ENGINE, file), "utf8"); |
| 207 | 219 | for (const specifier of specifiersOf(source)) |
| 208 | 220 | if (relative(ENGINE, resolve(ENGINE, join(file, ".."), specifier)) === "index.ts") |
| @@ -211,6 +223,11 @@ test("no module in the engine goes through the engine's own door", () => { | ||
| 211 | 223 | expect(offences).toEqual([]); |
| 212 | 224 | }); |
| 213 | 225 | |
| 226 | test("the example imports the door and nothing else, which is what it is for", () => { | |
| 227 | const source = readFileSync(join(ENGINE, "example.ts"), "utf8"); | |
| 228 | expect(specifiersOf(source)).toEqual(["./index.ts"]); | |
| 229 | }); | |
| 230 | ||
| 214 | 231 | /** |
| 215 | 232 | * The surface. This list is the engine's public API written down: a name that |
| 216 | 233 | * arrives, leaves or is renamed fails here and is read in the pull. Sorted the |
src/engine/example.test.ts added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | /** | |
| 2 | * The guide's game, played. `example.ts` is the smallest thing that can be | |
| 3 | * called a game on this package, and it is only worth keeping if it runs: this | |
| 4 | * file runs it with no browser in the room — a stub page, a stub clock and a | |
| 5 | * script of held keys — so a door whose exports still have the right names and | |
| 6 | * no longer fit together fails here rather than in a preview. | |
| 7 | * | |
| 8 | * Four things are pinned, and they are the four the example exists to show: a | |
| 9 | * seeded run plays the same twice, a shot down the target's bearing hits and | |
| 10 | * one twenty degrees off does not, a second of elapsed time is the same number | |
| 11 | * of fixed steps however the frames are cut, and the bindings table is what | |
| 12 | * turns a key press into the action `step` reads. | |
| 13 | */ | |
| 14 | ||
| 15 | import { expect, test } from "bun:test"; | |
| 16 | import { KEYS, ROOM, TICK, createWorld, play, step, type World } from "./example.ts"; | |
| 17 | import type { Frames, Host } from "./index.ts"; | |
| 18 | ||
| 19 | /** Degrees of turn one tick is worth: the example's 120°/s at its own tick. */ | |
| 20 | const TURN_PER_TICK = 120 * TICK; | |
| 21 | ||
| 22 | /** How many ticks a world has lived, read out of the only thing that counts them. */ | |
| 23 | const ticksOf = (world: World): number => Math.round((world.yaw - 90) / TURN_PER_TICK); | |
| 24 | ||
| 25 | /** The yaw, in degrees, from the eye to the middle of the plate. */ | |
| 26 | function bearingOf(world: World): number { | |
| 27 | const [x, y] = [ | |
| 28 | (world.target.min[0] + world.target.max[0]) / 2 - world.pos[0], | |
| 29 | (world.target.min[1] + world.target.max[1]) / 2 - world.pos[1], | |
| 30 | ]; | |
| 31 | return (Math.atan2(y, x) * 180) / Math.PI; | |
| 32 | } | |
| 33 | ||
| 34 | /** One target a page listens on, and a way to fire an event at it by hand. */ | |
| 35 | function target(extra: Record<string, unknown> = {}) { | |
| 36 | const handlers = new Map<string, ((event: unknown) => void)[]>(); | |
| 37 | return { | |
| 38 | ...extra, | |
| 39 | addEventListener(type: string, handler: (event: unknown) => void) { | |
| 40 | handlers.set(type, [...(handlers.get(type) ?? []), handler]); | |
| 41 | }, | |
| 42 | removeEventListener(type: string, handler: (event: unknown) => void) { | |
| 43 | handlers.set(type, (handlers.get(type) ?? []).filter((h) => h !== handler)); | |
| 44 | }, | |
| 45 | fire(type: string, event: Record<string, unknown>) { | |
| 46 | for (const handler of handlers.get(type) ?? []) handler({ preventDefault() {}, ...event }); | |
| 47 | }, | |
| 48 | listening: (): number => [...handlers.values()].reduce((n, list) => n + list.length, 0), | |
| 49 | }; | |
| 50 | } | |
| 51 | ||
| 52 | /** A page and a clock: everything `play` is handed, and none of it a browser. */ | |
| 53 | function stage() { | |
| 54 | const host = { | |
| 55 | surface: target({ clientWidth: 800 }), | |
| 56 | window: target(), | |
| 57 | document: target({ pointerLockElement: null }), | |
| 58 | }; | |
| 59 | let callback: ((now: number) => void) | null = null; | |
| 60 | let now = 0; | |
| 61 | let handle = 0; | |
| 62 | const frames: Frames = { | |
| 63 | requestAnimationFrame(next) { | |
| 64 | callback = next; | |
| 65 | return (handle += 1); | |
| 66 | }, | |
| 67 | cancelAnimationFrame() { | |
| 68 | callback = null; | |
| 69 | }, | |
| 70 | now: () => now, | |
| 71 | }; | |
| 72 | /** One frame of `ms` milliseconds, delivered the way a browser delivers one. */ | |
| 73 | const frame = (ms: number): void => { | |
| 74 | now += ms; | |
| 75 | callback?.(now); | |
| 76 | }; | |
| 77 | return { host: host as unknown as Host, page: host, frames, frame }; | |
| 78 | } | |
| 79 | ||
| 80 | /** A scripted player: turn onto the plate, then pull the trigger every other tick. */ | |
| 81 | function scripted(seed: number, ticks: number): World { | |
| 82 | let world = createWorld(seed); | |
| 83 | for (let i = 0; i < ticks; i++) { | |
| 84 | const off = ((bearingOf(world) - world.yaw + 540) % 360) - 180; | |
| 85 | const held = new Set<string>(); | |
| 86 | if (off > 1) held.add("turnLeft"); | |
| 87 | else if (off < -1) held.add("turnRight"); | |
| 88 | else if (i % 2 === 0) held.add("fire"); | |
| 89 | world = step(world, TICK, held); | |
| 90 | } | |
| 91 | return world; | |
| 92 | } | |
| 93 | ||
| 94 | test("a seeded run plays the same twice, down to the last plate", () => { | |
| 95 | const once = scripted(1234, 600); | |
| 96 | expect(once.score).toBeGreaterThan(0); | |
| 97 | expect(scripted(1234, 600)).toEqual(once); | |
| 98 | // And the seed is the run: a different one is a different set of plates. | |
| 99 | expect(scripted(9876, 600).target).not.toEqual(once.target); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a shot down the target's bearing hits, and one twenty degrees off misses", () => { | |
| 103 | const world = createWorld(42); | |
| 104 | const fire = new Set(["fire"]); | |
| 105 | expect(step({ ...world, yaw: bearingOf(world) }, TICK, fire).score).toBe(1); | |
| 106 | expect(step({ ...world, yaw: bearingOf(world) + 20 }, TICK, fire).score).toBe(0); | |
| 107 | expect(step({ ...world, yaw: bearingOf(world) - 20 }, TICK, fire).score).toBe(0); | |
| 108 | // A hit puts up a new plate; a miss leaves the one that was there. | |
| 109 | expect(step({ ...world, yaw: bearingOf(world) }, TICK, fire).target).not.toEqual(world.target); | |
| 110 | expect(step({ ...world, yaw: bearingOf(world) + 20 }, TICK, fire).target).toEqual(world.target); | |
| 111 | }); | |
| 112 | ||
| 113 | test("the trigger is an edge: a held key is one shot, not one a tick", () => { | |
| 114 | const aimed = { ...createWorld(42), yaw: bearingOf(createWorld(42)) }; | |
| 115 | const fire = new Set(["fire"]); | |
| 116 | let world = aimed; | |
| 117 | for (let i = 0; i < 30; i++) world = step({ ...world, yaw: bearingOf(world) }, TICK, fire); | |
| 118 | expect(world.score).toBe(1); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a second of elapsed time is the same number of ticks however it is cut", () => { | |
| 122 | const run = (frameMs: number, count: number): number => { | |
| 123 | const { host, page, frames, frame } = stage(); | |
| 124 | const session = play(host, frames, 7); | |
| 125 | page.window.fire("keydown", { code: "ArrowLeft" }); | |
| 126 | for (let i = 0; i < count; i++) frame(frameMs); | |
| 127 | const ticks = ticksOf(session.world()); | |
| 128 | session.stop(); | |
| 129 | return ticks; | |
| 130 | }; | |
| 131 | // A second at 60 Hz is sixty ticks, whether it arrives in ten frames or a | |
| 132 | // hundred. None of these is a frame exactly one tick long, which is a float | |
| 133 | // knife-edge the loop is allowed to bank rather than step. | |
| 134 | expect(run(100, 10)).toBe(60); | |
| 135 | expect(run(10, 100)).toBe(60); | |
| 136 | expect(run(20, 50)).toBe(60); | |
| 137 | // A frame nobody believes is clamped rather than simulated: at most the | |
| 138 | // loop's eight steps, which is why a slept tab does not catch up in one go. | |
| 139 | expect(run(5000, 1)).toBe(8); | |
| 140 | }); | |
| 141 | ||
| 142 | test("the bindings table is what turns a key into the action the step reads", () => { | |
| 143 | const { host, page, frames, frame } = stage(); | |
| 144 | const session = play(host, frames, 7); | |
| 145 | // KeyW is "forward" in the table and the world starts facing north. | |
| 146 | page.window.fire("keydown", { code: "KeyW" }); | |
| 147 | for (let i = 0; i < 10; i++) frame(100); | |
| 148 | const walked = session.world(); | |
| 149 | expect(walked.pos[1]).toBeGreaterThan(createWorld(7).pos[1]); | |
| 150 | expect(walked.pos[0]).toBeCloseTo(0, 6); | |
| 151 | // A key the table does not name is collected by nobody and moves nothing. | |
| 152 | page.window.fire("keyup", { code: "KeyW" }); | |
| 153 | page.window.fire("keydown", { code: "KeyQ" }); | |
| 154 | for (let i = 0; i < 10; i++) frame(100); | |
| 155 | expect(session.world().pos).toEqual(walked.pos); | |
| 156 | expect(KEYS.KeyQ).toBeUndefined(); | |
| 157 | session.stop(); | |
| 158 | }); | |
| 159 | ||
| 160 | test("the player stays in the one box there is to stand in", () => { | |
| 161 | const { host, page, frames, frame } = stage(); | |
| 162 | const session = play(host, frames, 7); | |
| 163 | page.window.fire("keydown", { code: "KeyW" }); | |
| 164 | for (let i = 0; i < 100; i++) frame(100); | |
| 165 | const pos = session.world().pos; | |
| 166 | expect(pos[1]).toBeCloseTo((ROOM.max[1] as number) - 0.5, 6); | |
| 167 | expect(pos[2]).toBe(1.6); | |
| 168 | session.stop(); | |
| 169 | }); | |
| 170 | ||
| 171 | test("the session lets the page go when it stops", () => { | |
| 172 | const { host, page, frames } = stage(); | |
| 173 | const session = play(host, frames, 7); | |
| 174 | expect(page.window.listening()).toBeGreaterThan(0); | |
| 175 | session.stop(); | |
| 176 | expect(page.window.listening()).toBe(0); | |
| 177 | expect(page.surface.listening()).toBe(0); | |
| 178 | expect(page.document.listening()).toBe(0); | |
| 179 | }); |
src/engine/example.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * The smallest game on this package: one room, one plate, one score and a step | |
| 3 | * a test can play ten thousand times. It exists so the guide beside it can be | |
| 4 | * executed — every fenced block in `GUIDE.md` is a slice of these lines, and | |
| 5 | * `guide.test.ts` fails the day the two drift apart. It is documentation and | |
| 6 | * not a third product: no renderer, no page, no persistence, nothing opened and | |
| 7 | * nothing run at import. `src/range/` is the real second game, and this file is | |
| 8 | * smaller than the wiring of one on purpose. | |
| 9 | */ | |
| 10 | ||
| 11 | import { | |
| 12 | add, angleVectors, clamp, createControls, createLoop, rayBox, rng, scale, | |
| 13 | type Box, type Frames, type Host, type Vec3, | |
| 14 | } from "./index.ts"; | |
| 15 | ||
| 16 | /** `KeyboardEvent.code` to action name: this game's whole table, and it is data. */ | |
| 17 | export const KEYS: Record<string, string> = { | |
| 18 | KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right", | |
| 19 | ArrowLeft: "turnLeft", ArrowRight: "turnRight", Space: "fire", | |
| 20 | }; | |
| 21 | ||
| 22 | /** The step the simulation runs at: 60 Hz, and no frame is allowed to change it. */ | |
| 23 | export const TICK = 1 / 60; | |
| 24 | ||
| 25 | /** The room, in metres: one box, and the player is held half a metre inside it. */ | |
| 26 | export const ROOM: Box = { min: [-4, -4, 0], max: [4, 4, 3], mat: 0 }; | |
| 27 | ||
| 28 | /** The tuning, all of it: metres per second, degrees per second, metres, metres. */ | |
| 29 | const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40; | |
| 30 | ||
| 31 | /** Everything this game is, and all of it plain: it prints, copies and compares. */ | |
| 32 | export type World = { | |
| 33 | seed: number; pos: Vec3; yaw: number; score: number; firing: boolean; target: Box; | |
| 34 | }; | |
| 35 | ||
| 36 | /** A plate across the far wall from one number, and the number after it: `rng` | |
| 37 | * hands back its own next state over 2^32, so the multiply recovers that state. */ | |
| 38 | function plate(seed: number): [Box, number] { | |
| 39 | const u = rng(seed)(); | |
| 40 | return [{ min: [u * 6 - 3.25, 3.7, 1.2], max: [u * 6 - 2.75, 3.8, 1.9], mat: 1 }, (u * 2 ** 32) >>> 0]; | |
| 41 | } | |
| 42 | ||
| 43 | export function createWorld(seed: number): World { | |
| 44 | const [target, next] = plate(seed); | |
| 45 | return { seed: next, pos: [0, -3, EYE], yaw: 90, score: 0, firing: false, target }; | |
| 46 | } | |
| 47 | ||
| 48 | /** One shot down the view: one ray, one box, and a point if it lands. */ | |
| 49 | function shoot(world: World): World { | |
| 50 | const to = add(world.pos, scale(angleVectors(world.yaw, 0).forward, REACH)); | |
| 51 | if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world; | |
| 52 | const [target, seed] = plate(world.seed); | |
| 53 | return { ...world, score: world.score + 1, target, seed }; | |
| 54 | } | |
| 55 | ||
| 56 | /** One tick: a world, the seconds and the held actions in, a new world out. */ | |
| 57 | export function step(world: World, dt: number, held: ReadonlySet<string>): World { | |
| 58 | const on = (action: string): number => (held.has(action) ? 1 : 0); | |
| 59 | const yaw = world.yaw + (on("turnLeft") - on("turnRight")) * TURN * dt; | |
| 60 | const { forward, right } = angleVectors(yaw, 0); | |
| 61 | const walk = add(scale(forward, on("forward") - on("back")), scale(right, on("right") - on("left"))); | |
| 62 | const put = (x: number, d: number): number => clamp(x + d * SPEED * dt, ROOM.min[0] + 0.5, ROOM.max[0] - 0.5); | |
| 63 | const pos: Vec3 = [put(world.pos[0], walk[0]), put(world.pos[1], walk[1]), EYE]; | |
| 64 | const fire = held.has("fire"); | |
| 65 | const next = { ...world, pos, yaw, firing: fire }; | |
| 66 | return fire && !world.firing ? shoot(next) : next; // one shot per press, not per tick | |
| 67 | } | |
| 68 | ||
| 69 | /** The frame: the host and the clock are handed in, the page's in a game and | |
| 70 | * plain objects in a test, so nothing in here looks anything up. */ | |
| 71 | export function play(host: Host, frames: Frames, seed: number): { world(): World; stop(): void } { | |
| 72 | const controls = createControls(host, { keys: KEYS, requireLock: false }); | |
| 73 | const loop = createLoop({ tick: TICK }, frames); | |
| 74 | let world = createWorld(seed); | |
| 75 | loop.start((elapsed) => { | |
| 76 | for (let steps = loop.advance(elapsed); steps > 0; steps--) world = step(world, TICK, controls.held); | |
| 77 | }); | |
| 78 | return { world: () => world, stop: () => { loop.stop(); controls.detach(); } }; | |
| 79 | } |
src/engine/guide.test.ts +59 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The guide, held honest. `GUIDE.md` is the engine's how — what a game has to |
| 3 | 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 | |
| 4 | * moment it is only prose. Four rules keep it true, and they are the whole of | |
| 5 | 5 | * this file: |
| 6 | 6 | * |
| 7 | 7 | * 1. Every name the door exports is named in the guide, so a group that lands |
| @@ -11,6 +11,18 @@ | ||
| 11 | 11 | * 3. Every line of every code block is a line of the file named on the block's |
| 12 | 12 | * first line, so the examples are copied out of a game that exists here |
| 13 | 13 | * rather than invented for the page. |
| 14 | * 4. Every code block is a run of lines of `example.ts` — the smallest game on | |
| 15 | * this package, which a test plays — so the page cannot drift away from code | |
| 16 | * that runs. | |
| 17 | * | |
| 18 | * **The block rule**, written here rather than left for a reader to guess: a | |
| 19 | * block matches when its lines, each trimmed and the blank ones dropped, appear | |
| 20 | * one after another in `example.ts`, whose lines are read the same way. So a | |
| 21 | * block may quote any contiguous slice of that file, long or short, indented | |
| 22 | * however markdown wanted it, and blocks may be quoted in any order the page | |
| 23 | * reads best in — but a block may not stitch two distant lines together and | |
| 24 | * imply they sit next to each other. When it fails, the message is the first | |
| 25 | * line of the block that is not where the block says it is. | |
| 14 | 26 | * |
| 15 | 27 | * **The backtick rule**, written here rather than left for a reader to guess: |
| 16 | 28 | * only inline spans count, never fenced code, and a span counts as a claim |
| @@ -37,6 +49,7 @@ import { SURFACE } from "./boundary.test.ts"; | ||
| 37 | 49 | const ENGINE = import.meta.dir; |
| 38 | 50 | const ROOT = resolve(ENGINE, "..", ".."); |
| 39 | 51 | const GUIDE = join(ENGINE, "GUIDE.md"); |
| 52 | const EXAMPLE = join(ENGINE, "example.ts"); | |
| 40 | 53 | |
| 41 | 54 | /** A bare identifier: the only shape of span this file reads as an engine name. */ |
| 42 | 55 | const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; |
| @@ -92,6 +105,28 @@ export function blocksOf(markdown: string): Block[] { | ||
| 92 | 105 | return out; |
| 93 | 106 | } |
| 94 | 107 | |
| 108 | /** A file's lines the way the block rule reads them: trimmed, the blank gone. */ | |
| 109 | export const codeLines = (source: string): string[] => | |
| 110 | source.split("\n").map((line) => line.trim()).filter((line) => line !== ""); | |
| 111 | ||
| 112 | /** | |
| 113 | * The first line of `block` that is not where the block says it is, reading the | |
| 114 | * block as a run of consecutive lines of `file`, or null when it is one. The | |
| 115 | * line reported is the one after the longest run that did match, which is the | |
| 116 | * line a reader has to look at. | |
| 117 | */ | |
| 118 | export function firstLineAstray(block: string[], file: string[]): string | null { | |
| 119 | if (block.length === 0) return null; | |
| 120 | let matched = 0; | |
| 121 | for (let i = 0; i < file.length; i++) { | |
| 122 | let run = 0; | |
| 123 | while (run < block.length && file[i + run] === block[run]) run += 1; | |
| 124 | if (run === block.length) return null; | |
| 125 | if (run > matched) matched = run; | |
| 126 | } | |
| 127 | return block[matched] as string; | |
| 128 | } | |
| 129 | ||
| 95 | 130 | test("the guide is there and has the shape the rest of this file reads", () => { |
| 96 | 131 | const markdown = guide(); |
| 97 | 132 | expect(markdown.length).toBeGreaterThan(2000); |
| @@ -141,6 +176,20 @@ test("every line the guide quotes is a line of the file it names", () => { | ||
| 141 | 176 | expect(offences).toEqual([]); |
| 142 | 177 | }); |
| 143 | 178 | |
| 179 | test("every block the guide quotes is a run of lines of the example that runs", () => { | |
| 180 | const file = codeLines(readFileSync(EXAMPLE, "utf8")); | |
| 181 | const offences: string[] = []; | |
| 182 | for (const block of blocksOf(guide())) { | |
| 183 | const astray = firstLineAstray(block.lines.map((line) => line.trim()), file); | |
| 184 | if (astray !== null) | |
| 185 | offences.push( | |
| 186 | `GUIDE.md quotes "${astray}", which src/engine/example.ts does not have` + | |
| 187 | " where the block puts it: every block is a run of that file's lines, in its order", | |
| 188 | ); | |
| 189 | } | |
| 190 | expect(offences).toEqual([]); | |
| 191 | }); | |
| 192 | ||
| 144 | 193 | test("the guide names the seam as a pair, both sides of it", () => { |
| 145 | 194 | const markdown = guide(); |
| 146 | 195 | for (const pair of [ |
| @@ -173,6 +222,15 @@ test("the readers read what they exist to read", () => { | ||
| 173 | 222 | }, |
| 174 | 223 | ]); |
| 175 | 224 | expect(blocksOf(`${fence}ts\nconst a = 1;\n${fence}`)[0]?.file).toBe(""); |
| 225 | const file = codeLines("const a = 1;\n\n const b = 2;\nconst c = 3;\n"); | |
| 226 | expect(file).toEqual(["const a = 1;", "const b = 2;", "const c = 3;"]); | |
| 227 | // A slice is a slice wherever it starts, and a blank line in the block is not | |
| 228 | // a gap; two lines that are not neighbours in the file are not a slice. | |
| 229 | expect(firstLineAstray(["const b = 2;", "const c = 3;"], file)).toBeNull(); | |
| 230 | expect(firstLineAstray(["const a = 1;", "const c = 3;"], file)).toBe("const c = 3;"); | |
| 231 | expect(firstLineAstray(["const c = 3;", "const b = 2;"], file)).toBe("const b = 2;"); | |
| 232 | expect(firstLineAstray(["const d = 4;"], file)).toBe("const d = 4;"); | |
| 233 | expect(firstLineAstray([], file)).toBeNull(); | |
| 176 | 234 | expect(BARE.test("rayBox")).toBe(true); |
| 177 | 235 | expect(BARE.test("binding.readIntent")).toBe(false); |
| 178 | 236 | expect(BARE.test("src/range/main.ts")).toBe(false); |
new above
offline