Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
A second profile proves the solver is general: half gravity, a taller hull, a bigger step — and one apex checked by hand #152
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/124-a-second-profile-proves-the-solver-is-ge → main 1 commit fork
4 files
changed +421
−138
README.md +8 −2
| @@ -186,7 +186,11 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 186 | 186 | Quake-lineage mover: friction, a capped ground acceleration, the air |
| 187 | 187 | acceleration that makes strafe-jumping work, walls, steps and the crouch — it |
| 188 | 188 | reads no tuned number of its own, because a `MoveTuning` and a `Hull` arrive as |
| 189 | arguments on every call, so the feel of a game is the pair it hands in), | |
| 189 | arguments on every call, so the feel of a game is the pair it hands in, and | |
| 190 | `physics.test.ts` walks two profiles that share no number to say so: half the | |
| 191 | gravity, a taller and wider hull, twice the step and a different top speed, | |
| 192 | every assertion computed from the tuning it was handed and the apex checked | |
| 193 | against `v²/2g` by hand), | |
| 190 | 194 | `grid` (a grid of |
| 191 | 195 | cells of any type, the cell-to-world arithmetic, greedy meshing and A* with no |
| 192 | 196 | corner-cutting — which cells are solid or walkable arrive as predicates, so it |
| @@ -295,7 +299,9 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 295 | 299 | refuses a layout a round cannot be played on), |
| 296 | 300 | `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL` |
| 297 | 301 | and `fallDamage`, handed to the engine's mover on every call — the solver |
| 298 | itself went behind the door in #123 and holds none of them), `weapons`, | |
| 302 | itself went behind the door in #123 and holds none of them, and | |
| 303 | `movement.test.ts` is now only about whether this game still feels like this | |
| 304 | game, the generality having moved to `src/engine/physics.test.ts`), `weapons`, | |
| 299 | 305 | `combat`, `match` |
| 300 | 306 | (the rules), |
| 301 | 307 | `world` (the simulation), |
src/client/movement.test.ts +32 −132
| @@ -6,8 +6,11 @@ | ||
| 6 | 6 | * game's, because every assertion below is about the pair `src/client/ |
| 7 | 7 | * movement.ts` hands in. |
| 8 | 8 | * |
| 9 | * The second half runs the same functions on a profile that shares none of the | |
| 10 | * values under test, which is what says the tuning is really an argument. | |
| 9 | * What is deliberately not here is the proof that the solver is general. That | |
| 10 | * is `src/engine/physics.test.ts`, which runs the same functions on two | |
| 11 | * profiles of its own — half the gravity, a taller hull, twice the step — with | |
| 12 | * no game and none of the numbers below anywhere in it. This file is only ever | |
| 13 | * about whether Counter-Strike still feels like Counter-Strike. | |
| 11 | 14 | */ |
| 12 | 15 | |
| 13 | 16 | import { expect, test } from "bun:test"; |
| @@ -139,137 +142,34 @@ test("strafing in the air gains speed a straight line cannot", () => { | ||
| 139 | 142 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(CS_MOVE.maxSpeed + 2); |
| 140 | 143 | }); |
| 141 | 144 | |
| 145 | test("the eyes are 64 units up standing and 46 crouched", () => { | |
| 146 | const standing = body(), ducked = { ...body(), duckAmount: 1 }; | |
| 147 | expect(eyeHeight(standing, CS_HULL)).toBe(64); | |
| 148 | expect(eyeHeight(ducked, CS_HULL)).toBe(46); | |
| 149 | expect(hullHeight(standing, CS_HULL)).toBe(72); | |
| 150 | expect(hullHeight(ducked, CS_HULL)).toBe(54); | |
| 151 | }); | |
| 152 | ||
| 153 | test("walking is 130 u/s and crouching is 85, the two speeds CS players know", () => { | |
| 154 | const top = (over: Partial<Wish>): number => { | |
| 155 | const b = body(); | |
| 156 | run(b, wish(over), 4, [ground], CS_MOVE, CS_HULL); | |
| 157 | return Math.hypot(b.vel[0], b.vel[1]); | |
| 158 | }; | |
| 159 | expect(top({ forward: 1, walk: true })).toBeCloseTo(130, 5); | |
| 160 | expect(top({ forward: 1, duck: true })).toBeCloseTo(85, 5); | |
| 161 | }); | |
| 162 | ||
| 163 | test("a body that lets go of everything is halved in three ticks", () => { | |
| 164 | // 40 u/s is under CS's 80 u/s stop speed, so friction bites at the stop | |
| 165 | // speed rather than at the speed: 40 − 3 × 80 × 5.2/64 ≈ 20.5 u/s. | |
| 166 | const b = body(); | |
| 167 | b.vel = [40, 0, 0]; | |
| 168 | for (let i = 0; i < 3; i++) move(b, wish(), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 169 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(20.5, 5); | |
| 170 | }); | |
| 171 | ||
| 142 | 172 | test("fall damage starts only after a long drop", () => { |
| 143 | 173 | expect(fallDamage(400)).toBe(0); |
| 144 | 174 | expect(fallDamage(1000)).toBeGreaterThan(30); |
| 145 | 175 | }); |
| 146 | ||
| 147 | // --- a second tuning ------------------------------------------------------ | |
| 148 | // | |
| 149 | // Everything above runs the solver on Counter-Strike's numbers, which is also | |
| 150 | // what it would do if those numbers were still read out of module scope. The | |
| 151 | // tests below run the same functions on a profile that shares none of the | |
| 152 | // values under test, so re-baking any one of them — gravity, the walk and duck | |
| 153 | // ratios, the duck rate, the air cap, the acceleration, the friction, the stop | |
| 154 | // speed, the step size, the hull — fails one of them. I checked by doing it. | |
| 155 | ||
| 156 | /** Half the gravity, a higher jump, twice the step, a lazier crouch. */ | |
| 157 | const MOON: MoveTuning = { | |
| 158 | ...CS_MOVE, | |
| 159 | gravity: 400, jumpVelocity: 600, maxSpeed: 500, duckSpeed: 200, walkSpeed: 250, | |
| 160 | airAccelerate: 6, airSpeedCap: 60, stepSize: 36, duckRate: 2.5, | |
| 161 | }; | |
| 162 | ||
| 163 | /** Three times as wide and twice as tall as a CS player. */ | |
| 164 | const GIANT: Hull = { radius: 48, stand: 144, duck: 108, eyeStand: 128, eyeDuck: 92 }; | |
| 165 | ||
| 166 | const jump = (tuning: MoveTuning, hull: Hull): { peak: number; ticks: number } => { | |
| 167 | const b = body(); | |
| 168 | move(b, wish({ jump: true }), 1 / 64, [ground], tuning, hull); | |
| 169 | // The first tick leaves the ground at the jump velocity, less the half step | |
| 170 | // of gravity the integrator takes on the way out — both of them arguments. | |
| 171 | expect(b.vel[2]).toBeCloseTo(tuning.jumpVelocity - tuning.gravity / 128, 10); | |
| 172 | let peak = 0, ticks = 0; | |
| 173 | while (!b.onGround) { | |
| 174 | move(b, wish(), 1 / 64, [ground], tuning, hull); | |
| 175 | peak = Math.max(peak, b.pos[2]); | |
| 176 | ticks += 1; | |
| 177 | } | |
| 178 | return { peak, ticks }; | |
| 179 | }; | |
| 180 | ||
| 181 | test("the apex and the hang time are the profile's gravity and jump velocity", () => { | |
| 182 | // v²/2g and 2v/g, by hand: 301.993² / 1600 ≈ 57.0 units and 0.755 s under CS, | |
| 183 | // 600² / 800 = 450 units and 3 s under half gravity and a 600 u/s jump. | |
| 184 | const cs = jump(CS_MOVE, CS_HULL); | |
| 185 | const moon = jump(MOON, GIANT); | |
| 186 | expect(cs.peak).toBeCloseTo(CS_MOVE.jumpVelocity ** 2 / (2 * CS_MOVE.gravity), 1); | |
| 187 | expect(moon.peak).toBeCloseTo(MOON.jumpVelocity ** 2 / (2 * MOON.gravity), 1); | |
| 188 | // Hang time to within a tick and a half of 2v/g: 48 ticks against 192. | |
| 189 | const hang = (t: MoveTuning): number => ((2 * t.jumpVelocity) / t.gravity) * 64; | |
| 190 | expect(Math.abs(cs.ticks - hang(CS_MOVE))).toBeLessThan(1.5); | |
| 191 | expect(Math.abs(moon.ticks - hang(MOON))).toBeLessThan(1.5); | |
| 192 | }); | |
| 193 | ||
| 194 | test("the walk, duck and run speeds are the profile's own", () => { | |
| 195 | const top = (w: Partial<Wish>, tuning: MoveTuning): number => { | |
| 196 | const b = body(); | |
| 197 | run(b, { ...wish(w), maxSpeed: tuning.maxSpeed }, 4, [ground], tuning, CS_HULL); | |
| 198 | return Math.hypot(b.vel[0], b.vel[1]); | |
| 199 | }; | |
| 200 | expect(top({ forward: 1 }, MOON)).toBeCloseTo(MOON.maxSpeed, 5); | |
| 201 | expect(top({ forward: 1, walk: true }, MOON)).toBeCloseTo(MOON.walkSpeed, 5); | |
| 202 | expect(top({ forward: 1, duck: true }, MOON)).toBeCloseTo(MOON.duckSpeed, 5); | |
| 203 | expect(top({ forward: 1, walk: true }, CS_MOVE)).toBeCloseTo(CS_MOVE.walkSpeed, 5); | |
| 204 | }); | |
| 205 | ||
| 206 | test("the crouch closes at the rate the profile names", () => { | |
| 207 | // Twenty ticks of duck: 20/64 s × 2.5 a second is 0.78125 closed, and CS's | |
| 208 | // 5 a second is shut in half that. | |
| 209 | const lazy = body(), quick = body(); | |
| 210 | for (let i = 0; i < 20; i++) { | |
| 211 | move(lazy, wish({ duck: true }), 1 / 64, [ground], MOON, GIANT); | |
| 212 | move(quick, wish({ duck: true }), 1 / 64, [ground], CS_MOVE, CS_HULL); | |
| 213 | } | |
| 214 | expect(lazy.duckAmount).toBeCloseTo((20 / 64) * MOON.duckRate, 10); | |
| 215 | expect(quick.duckAmount).toBe(1); | |
| 216 | }); | |
| 217 | ||
| 218 | test("the hull handed in is the hull that collides and the eyes that see", () => { | |
| 219 | const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 }; | |
| 220 | const giant = body(); | |
| 221 | run(giant, { ...wish({ forward: 1 }), maxSpeed: MOON.maxSpeed }, 3, [ground, wall], MOON, GIANT); | |
| 222 | expect(giant.pos[0]).toBeCloseTo(200 - GIANT.radius, 10); | |
| 223 | ||
| 224 | const standing = body(), ducked = { ...body(), duckAmount: 1 }; | |
| 225 | expect(eyeHeight(standing, GIANT)).toBe(GIANT.eyeStand); | |
| 226 | expect(eyeHeight(ducked, GIANT)).toBe(GIANT.eyeDuck); | |
| 227 | expect(hullHeight(standing, GIANT)).toBe(GIANT.stand); | |
| 228 | expect(hullHeight(ducked, GIANT)).toBe(GIANT.duck); | |
| 229 | }); | |
| 230 | ||
| 231 | test("a 36-unit step climbs a ledge an 18-unit step refuses", () => { | |
| 232 | const ledge: Box = { min: [100, -100, 0], max: [400, 100, 30], mat: 2 }; | |
| 233 | const tall = body(), short = body(); | |
| 234 | run(tall, { ...wish({ forward: 1 }), maxSpeed: MOON.maxSpeed }, 1, [ground, ledge], MOON, GIANT); | |
| 235 | run(short, wish({ forward: 1 }), 1, [ground, ledge], CS_MOVE, CS_HULL); | |
| 236 | expect(tall.pos[2]).toBe(30); | |
| 237 | expect(short.pos[2]).toBe(0); | |
| 238 | expect(short.pos[0]).toBeLessThan(100); | |
| 239 | }); | |
| 240 | ||
| 241 | test("the air cap and the air acceleration come from the tuning", () => { | |
| 242 | // A whole second across the wish direction: the full cap, whatever it is. | |
| 243 | const vel: Vec3 = [250, 0, 0]; | |
| 244 | airAccelerate(vel, [0, 1, 0], 250, 1, MOON); | |
| 245 | expect(vel[1]).toBeCloseTo(MOON.airSpeedCap, 5); | |
| 246 | // One tick of it: accel × the capped wish speed × dt, short of the cap. | |
| 247 | const tick: Vec3 = [250, 0, 0]; | |
| 248 | airAccelerate(tick, [0, 1, 0], 250, 1 / 64, MOON); | |
| 249 | expect(tick[1]).toBeCloseTo((MOON.airAccelerate * MOON.airSpeedCap) / 64, 10); | |
| 250 | }); | |
| 251 | ||
| 252 | test("acceleration and friction settle where the tuning says they do", () => { | |
| 253 | // Running holds where what acceleration adds is what friction takes: | |
| 254 | // accel × wishSpeed = speed × friction, so 2.8 × 250 / 5.2 ≈ 134.6 u/s. | |
| 255 | const half: MoveTuning = { ...CS_MOVE, accelerate: 2.8 }; | |
| 256 | const b = body(); | |
| 257 | run(b, wish({ forward: 1 }), 3, [ground], CS_MOVE, CS_HULL); | |
| 258 | const slower = body(); | |
| 259 | run(slower, wish({ forward: 1 }), 3, [ground], half, CS_HULL); | |
| 260 | expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(CS_MOVE.maxSpeed, 5); | |
| 261 | expect(Math.hypot(slower.vel[0], slower.vel[1])) | |
| 262 | .toBeCloseTo((half.accelerate * CS_MOVE.maxSpeed) / half.friction, 2); | |
| 263 | ||
| 264 | // And a drifting body: no friction keeps every unit of its speed, while a | |
| 265 | // stop speed five times CS's takes all of it inside three ticks. | |
| 266 | const drift = (tuning: MoveTuning): number => { | |
| 267 | const d = body(); | |
| 268 | d.vel = [40, 0, 0]; | |
| 269 | for (let i = 0; i < 3; i++) move(d, wish(), 1 / 64, [ground], tuning, CS_HULL); | |
| 270 | return Math.hypot(d.vel[0], d.vel[1]); | |
| 271 | }; | |
| 272 | expect(drift({ ...CS_MOVE, friction: 0 })).toBe(40); | |
| 273 | expect(drift({ ...CS_MOVE, stopSpeed: 400 })).toBe(0); | |
| 274 | expect(drift(CS_MOVE)).toBeCloseTo(20.5, 5); | |
| 275 | }); |
src/engine/GUIDE.md +13 −4
| @@ -200,9 +200,17 @@ call takes the tuning and the hull, in that order, last — subject, situation, | ||
| 200 | 200 | world, tuning, hull. So the same solver walks two games that share no number, |
| 201 | 201 | and swapping a profile is an argument rather than an edit. The shooter's pair is |
| 202 | 202 | `movement.CS_MOVE` and `movement.CS_HULL` in `src/client/movement.ts`, and |
| 203 | `src/client/movement.test.ts` runs these functions on a second profile with half | |
| 204 | the gravity and a hull twice the size to prove the arithmetic never learned the | |
| 205 | first one. | |
| 203 | `src/client/movement.test.ts` holds those to what a player feels. The proof that | |
| 204 | the arithmetic never learned them is `src/engine/physics.test.ts`: two profiles | |
| 205 | of the engine's own, one with half the other's gravity, a taller and wider hull, | |
| 206 | twice the step and a different top speed, walking a world of plain boxes with no | |
| 207 | game in it. Every assertion in there is computed from the tuning it was handed — | |
| 208 | the apex against `v²/2g` and the climb against `v/g`, written out with their | |
| 209 | units — so a number baked back into the solver fails arithmetic rather than a | |
| 210 | snapshot. The one number `physics.ts` does own is its contact tolerance, a | |
| 211 | couple of units of slack under the feet, and it is in the caller's units: two | |
| 212 | profiles of one scale share it, and a profile at another scale would want it to | |
| 213 | move with them. | |
| 206 | 214 | |
| 207 | 215 | The pieces under `move` are exported because they are worth using and testing on |
| 208 | 216 | their own: `accelerate` adds along a wish direction up to a wish speed and never |
| @@ -632,7 +640,8 @@ own tuning, and nothing else in this table at all. | ||
| 632 | 640 | | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` | |
| 633 | 641 | | a pinned run | `src/client/replay.ts` — a script of held inputs, a round and a duel | `src/range/pin.ts` — a script of trigger pulls, one digest per drill | |
| 634 | 642 | | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays | |
| 635 | | the tuning | `src/client/movement.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` | | |
| 643 | | the mover's tuning and hull | `movement.CS_MOVE` and `movement.CS_HULL` in `src/client/movement.ts` | none — nobody walks in the range, the eye stands still | | |
| 644 | | the rest of the tuning | `src/client/weapons.ts`, and `movement.fallDamage` | its own: `targets.STANDARD`, `targets.EYE` and the drills beside them in `src/range/targets.ts` | | |
| 636 | 645 | | a policy's observation, heads and weights | `src/client/observe.ts`, `src/client/action.ts`, `src/client/weights.ts` | none — the range has no agent and bundles none of the net | |
| 637 | 646 | |
| 638 | 647 | ## What the engine deliberately does not know |
src/engine/physics.test.ts added +368
| @@ -0,0 +1,368 @@ | ||
| 1 | /** | |
| 2 | * The solver on two profiles that share no number, in a world of plain boxes. | |
| 3 | * | |
| 4 | * Every test below runs `move` and its pieces twice — once under `WALKER` and | |
| 5 | * once under `DRIFTER`, which has half the gravity, a taller and wider hull, a | |
| 6 | * higher jump, twice the step and a different top speed — and asserts the | |
| 7 | * result against arithmetic on the tuning it was handed, never against a number | |
| 8 | * typed twice. A constant baked back into the module would pass under one | |
| 9 | * profile and fail under the other, which is the whole point of the file. | |
| 10 | * | |
| 11 | * **Units are the caller's.** These two profiles are written in the units the | |
| 12 | * lineage is usually written in — u for length, u/s for speed, u/s² for | |
| 13 | * gravity — and the tick is 1/64 s throughout, so a hand calculation in this | |
| 14 | * file reads: `v = 300 u/s`, `g = 600 u/s²`, apex `v²/2g = 75 u` at `t = v/g = | |
| 15 | * 0.5 s`. The solver reads no scale except one: its contact tolerance, the | |
| 16 | * couple of units of slack under the feet that keeps a body that landed from | |
| 17 | * spending the next tick a hair above the floor, is in the caller's units and | |
| 18 | * is the solver's own — `physics.ts` says why it is not in a `MoveTuning`. A | |
| 19 | * profile at a wildly different scale (metres, say) would want that number to | |
| 20 | * move with it; the two here are of one scale, so it does not come up. | |
| 21 | * | |
| 22 | * No game appears in this file: no weapon, no round, no map, and no tuning any | |
| 23 | * game here ships. The shooter's own numbers, and what a player feels them as, | |
| 24 | * are `src/client/movement.test.ts`. | |
| 25 | */ | |
| 26 | ||
| 27 | import { expect, test } from "bun:test"; | |
| 28 | import { | |
| 29 | accelerate, airAccelerate, eyeHeight, groundUnder, hullHeight, move, stuck, | |
| 30 | type Body, type Box, type Hull, type MoveTuning, type Vec3, type Wish, | |
| 31 | } from "./index.ts"; | |
| 32 | ||
| 33 | /** The step every run below takes, in seconds: 64 a second. */ | |
| 34 | const TICK = 1 / 64; | |
| 35 | ||
| 36 | /** A floor at z = 0 wide enough that nothing in here walks off it. */ | |
| 37 | const FLOOR: Box = { min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 }; | |
| 38 | ||
| 39 | /** The reference profile: speeds in u/s, gravity in u/s², a 20-unit step. */ | |
| 40 | const WALKER: MoveTuning = { | |
| 41 | gravity: 600, | |
| 42 | maxSpeed: 200, | |
| 43 | duckSpeed: 70, | |
| 44 | walkSpeed: 120, | |
| 45 | accelerate: 6, | |
| 46 | airAccelerate: 10, | |
| 47 | airSpeedCap: 25, | |
| 48 | friction: 4, | |
| 49 | stopSpeed: 60, | |
| 50 | jumpVelocity: 300, | |
| 51 | stepSize: 20, | |
| 52 | duckRate: 4, | |
| 53 | }; | |
| 54 | ||
| 55 | /** Half the gravity, a higher jump, a faster body, twice the step. */ | |
| 56 | const DRIFTER: MoveTuning = { | |
| 57 | gravity: WALKER.gravity / 2, | |
| 58 | maxSpeed: 320, | |
| 59 | duckSpeed: 110, | |
| 60 | walkSpeed: 180, | |
| 61 | accelerate: 8, | |
| 62 | airAccelerate: 5, | |
| 63 | airSpeedCap: 50, | |
| 64 | friction: 2, | |
| 65 | stopSpeed: 30, | |
| 66 | jumpVelocity: 450, | |
| 67 | stepSize: WALKER.stepSize * 2, | |
| 68 | duckRate: 2, | |
| 69 | }; | |
| 70 | ||
| 71 | /** A 40×40×64 hull. */ | |
| 72 | const SHORT: Hull = { radius: 20, stand: 64, duck: 40, eyeStand: 56, eyeDuck: 34 }; | |
| 73 | ||
| 74 | /** A 60×60×120 hull: half again as wide as `SHORT` and nearly twice as tall. */ | |
| 75 | const TALL: Hull = { radius: 30, stand: 120, duck: 76, eyeStand: 108, eyeDuck: 66 }; | |
| 76 | ||
| 77 | type Profile = { name: string; tuning: MoveTuning; hull: Hull }; | |
| 78 | ||
| 79 | const PROFILES: Profile[] = [ | |
| 80 | { name: "the walker", tuning: WALKER, hull: SHORT }, | |
| 81 | { name: "the drifter", tuning: DRIFTER, hull: TALL }, | |
| 82 | ]; | |
| 83 | ||
| 84 | const body = (pos: Vec3 = [0, 0, 0]): Body => ({ | |
| 85 | pos: [...pos] as Vec3, | |
| 86 | vel: [0, 0, 0], | |
| 87 | onGround: true, | |
| 88 | ducking: false, | |
| 89 | duckAmount: 0, | |
| 90 | }); | |
| 91 | ||
| 92 | /** A tick's intent. The carry speed defaults to the profile's own top speed. */ | |
| 93 | const wish = (tuning: MoveTuning, over: Partial<Wish> = {}): Wish => ({ | |
| 94 | forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, | |
| 95 | maxSpeed: tuning.maxSpeed, ...over, | |
| 96 | }); | |
| 97 | ||
| 98 | const run = (b: Body, w: Wish, seconds: number, boxes: Box[], tuning: MoveTuning, hull: Hull): void => { | |
| 99 | for (let t = 0; t < seconds; t += TICK) move(b, w, TICK, boxes, tuning, hull); | |
| 100 | }; | |
| 101 | ||
| 102 | // --- the arc, checked by hand --------------------------------------------- | |
| 103 | ||
| 104 | /** One jump from the floor: its peak, the tick it peaked on, and the flight. */ | |
| 105 | function jumpArc(tuning: MoveTuning, hull: Hull): { peak: number; peakTick: number; ticks: number } { | |
| 106 | const b = body(); | |
| 107 | move(b, wish(tuning, { jump: true }), TICK, [FLOOR], tuning, hull); | |
| 108 | expect(b.onGround).toBe(false); | |
| 109 | // Leaving the ground: the jump velocity less the half step of gravity the | |
| 110 | // integrator takes on the way out — v₀ − g·dt/2, both of them arguments. | |
| 111 | expect(b.vel[2]).toBeCloseTo(tuning.jumpVelocity - (tuning.gravity * TICK) / 2, 10); | |
| 112 | let peak = b.pos[2], peakTick = 1, ticks = 1; | |
| 113 | while (!b.onGround) { | |
| 114 | move(b, wish(tuning), TICK, [FLOOR], tuning, hull); | |
| 115 | ticks += 1; | |
| 116 | if (b.pos[2] > peak) { peak = b.pos[2]; peakTick = ticks; } | |
| 117 | } | |
| 118 | return { peak, peakTick, ticks }; | |
| 119 | } | |
| 120 | ||
| 121 | test("the apex is v²/2g and the climb is v/g, in the profile's own units", () => { | |
| 122 | // By hand, from the two numbers each profile brings and nothing else: | |
| 123 | // | |
| 124 | // walker v₀ = 300 u/s, g = 600 u/s² | |
| 125 | // apex v₀²/2g = 90000 / 1200 = 75 u | |
| 126 | // climb v₀/g = 300 / 600 = 0.5 s = 32 ticks at 64 Hz | |
| 127 | // flight 2v₀/g = 1.0 s = 64 ticks | |
| 128 | // drifter v₀ = 450 u/s, g = 300 u/s² — half the gravity | |
| 129 | // apex v₀²/2g = 202500 / 600 = 337.5 u | |
| 130 | // climb v₀/g = 1.5 s = 96 ticks | |
| 131 | // flight 2v₀/g = 3.0 s = 192 ticks | |
| 132 | // | |
| 133 | // The half-step integrator samples the parabola v₀t − ½gt² exactly at every | |
| 134 | // tick boundary, and both climbs above are whole numbers of ticks, so the | |
| 135 | // sampled peak *is* the analytic apex rather than a value just under it. | |
| 136 | // The assertions compute all six from the tuning; the six numbers above are | |
| 137 | // the arithmetic, written out so the test can be checked without running it. | |
| 138 | for (const { name, tuning, hull } of PROFILES) { | |
| 139 | const arc = jumpArc(tuning, hull); | |
| 140 | const apex = tuning.jumpVelocity ** 2 / (2 * tuning.gravity); | |
| 141 | const climb = tuning.jumpVelocity / tuning.gravity; | |
| 142 | expect(arc.peak, `${name} reaches v²/2g`).toBeCloseTo(apex, 6); | |
| 143 | expect(arc.peakTick, `${name} reaches it at v/g`).toBe(Math.round(climb / TICK)); | |
| 144 | expect(arc.ticks, `${name} is back down at 2v/g`).toBe(Math.round((2 * climb) / TICK)); | |
| 145 | } | |
| 146 | }); | |
| 147 | ||
| 148 | test("half the gravity is twice the hang time, and the jump that is 1.5× as fast is 4.5× as high", () => { | |
| 149 | // The apex ratio is (v'/v)² × (g/g') = (450/300)² × (600/300) = 2.25 × 2 = | |
| 150 | // 4.5, and the flight ratio is (v'/v) × (g/g') = 1.5 × 2 = 3: 337.5 u over | |
| 151 | // 75 u, and 3.0 s over 1.0 s. Ratios, so a solver that had learned either | |
| 152 | // profile's numbers fails one side of them. | |
| 153 | const walker = jumpArc(WALKER, SHORT); | |
| 154 | const drifter = jumpArc(DRIFTER, TALL); | |
| 155 | expect(drifter.peak / walker.peak).toBeCloseTo(4.5, 9); | |
| 156 | expect(drifter.ticks / walker.ticks).toBe(3); | |
| 157 | }); | |
| 158 | ||
| 159 | // --- friction, the air cap and the step, each under both profiles ---------- | |
| 160 | ||
| 161 | for (const { name, tuning, hull } of PROFILES) { | |
| 162 | test(`${name} accelerates at the rate it was handed and stops at the carry speed`, () => { | |
| 163 | // From a standstill, one tick adds accel × wishSpeed × dt and friction | |
| 164 | // takes nothing, because a body under a tenth of a unit a second is stopped. | |
| 165 | const first = body(); | |
| 166 | move(first, wish(tuning, { forward: 1 }), TICK, [FLOOR], tuning, hull); | |
| 167 | expect(first.vel[0]).toBeCloseTo(tuning.accelerate * tuning.maxSpeed * TICK, 9); | |
| 168 | ||
| 169 | // Left running, it settles where what acceleration adds is what friction | |
| 170 | // takes — accel × wishSpeed / friction — or at the wish speed if that comes | |
| 171 | // first, which it does for both of these profiles. | |
| 172 | const settle = (t: MoveTuning): number => { | |
| 173 | const b = body(); | |
| 174 | run(b, wish(t, { forward: 1 }), 6, [FLOOR], t, hull); | |
| 175 | return Math.hypot(b.vel[0], b.vel[1]); | |
| 176 | }; | |
| 177 | expect(settle(tuning)).toBeCloseTo(tuning.maxSpeed, 6); | |
| 178 | // Halve the acceleration against the friction and the equilibrium is the | |
| 179 | // one the formula names — half the carry speed — approached rather than | |
| 180 | // reached, which is why this one is held to a hundredth of a unit. | |
| 181 | const sluggish: MoveTuning = { ...tuning, accelerate: tuning.friction / 2 }; | |
| 182 | expect(settle(sluggish)).toBeCloseTo((sluggish.accelerate * tuning.maxSpeed) / tuning.friction, 2); | |
| 183 | }); | |
| 184 | ||
| 185 | test(`${name} loses friction × dt of its speed each tick, and stop speed is the floor under that`, () => { | |
| 186 | // One tick of drift with nothing held: the drop is control × friction × dt, | |
| 187 | // where control is the speed itself, or the stop speed when the body is | |
| 188 | // slower than that — which is what makes the last few units go quickly. | |
| 189 | const drift = (from: number): number => { | |
| 190 | const b = body(); | |
| 191 | b.vel = [from, 0, 0]; | |
| 192 | move(b, wish(tuning), TICK, [FLOOR], tuning, hull); | |
| 193 | return b.vel[0]; | |
| 194 | }; | |
| 195 | const fast = tuning.stopSpeed * 2, slow = tuning.stopSpeed / 2; | |
| 196 | expect(drift(fast)).toBeCloseTo(fast - fast * tuning.friction * TICK, 9); | |
| 197 | expect(drift(slow)).toBeCloseTo(slow - tuning.stopSpeed * tuning.friction * TICK, 9); | |
| 198 | // And with friction switched off, a drifting body keeps every unit of it. | |
| 199 | const free = body(); | |
| 200 | free.vel = [fast, 0, 0]; | |
| 201 | move(free, wish(tuning), TICK, [FLOOR], { ...tuning, friction: 0 }, hull); | |
| 202 | expect(free.vel[0]).toBe(fast); | |
| 203 | }); | |
| 204 | ||
| 205 | test(`${name} gains at most its air cap across the direction it is already going`, () => { | |
| 206 | // Along the way it is already moving, faster than the cap: nothing to add. | |
| 207 | const along: Vec3 = [tuning.maxSpeed, 0, 0]; | |
| 208 | airAccelerate(along, [1, 0, 0], tuning.maxSpeed, 1, tuning); | |
| 209 | expect(along[0]).toBe(tuning.maxSpeed); | |
| 210 | // Across it, given a whole second: the cap, whatever the wish speed was. | |
| 211 | const across: Vec3 = [tuning.maxSpeed, 0, 0]; | |
| 212 | airAccelerate(across, [0, 1, 0], tuning.maxSpeed, 1, tuning); | |
| 213 | expect(across[1]).toBeCloseTo(tuning.airSpeedCap, 9); | |
| 214 | // And one tick of it: airAccelerate × the capped wish speed × dt, which is | |
| 215 | // short of the cap and is where the turn-while-accelerating gain comes from. | |
| 216 | const tick: Vec3 = [tuning.maxSpeed, 0, 0]; | |
| 217 | airAccelerate(tick, [0, 1, 0], tuning.maxSpeed, TICK, tuning); | |
| 218 | expect(tick[1]).toBeCloseTo(tuning.airAccelerate * tuning.airSpeedCap * TICK, 10); | |
| 219 | expect(tick[1]).toBeLessThan(tuning.airSpeedCap); | |
| 220 | }); | |
| 221 | ||
| 222 | test(`${name} steps onto a ledge inside its step size and is stopped by one above it`, () => { | |
| 223 | const ledge = (height: number): Box => ({ min: [150, -200, -64], max: [600, 200, height], mat: 1 }); | |
| 224 | const low = body(), high = body(); | |
| 225 | run(low, wish(tuning, { forward: 1 }), 2, [FLOOR, ledge(tuning.stepSize - 5)], tuning, hull); | |
| 226 | run(high, wish(tuning, { forward: 1 }), 2, [FLOOR, ledge(tuning.stepSize + 10)], tuning, hull); | |
| 227 | // Under the step size: up and onto it, still walking. | |
| 228 | expect(low.pos[2]).toBe(tuning.stepSize - 5); | |
| 229 | expect(low.pos[0]).toBeGreaterThan(150); | |
| 230 | // Over it: stopped flat against the face, a hull's radius short of it. | |
| 231 | expect(high.pos[2]).toBe(0); | |
| 232 | expect(high.pos[0]).toBeCloseTo(150 - hull.radius, 9); | |
| 233 | }); | |
| 234 | ||
| 235 | test(`${name} walks, ducks and runs at the three speeds its profile names`, () => { | |
| 236 | const top = (over: Partial<Wish>): number => { | |
| 237 | const b = body(); | |
| 238 | run(b, wish(tuning, over), 6, [FLOOR], tuning, hull); | |
| 239 | return Math.hypot(b.vel[0], b.vel[1]); | |
| 240 | }; | |
| 241 | expect(top({ forward: 1 })).toBeCloseTo(tuning.maxSpeed, 6); | |
| 242 | expect(top({ forward: 1, walk: true })).toBeCloseTo(tuning.walkSpeed, 6); | |
| 243 | expect(top({ forward: 1, duck: true })).toBeCloseTo(tuning.duckSpeed, 6); | |
| 244 | }); | |
| 245 | ||
| 246 | test(`${name} closes its crouch at the rate its profile names`, () => { | |
| 247 | // Sixteen ticks is a quarter of a second, so the crouch is duckRate/4 | |
| 248 | // closed — 1.0 at 4 a second, 0.5 at 2 — and clamped at shut either way. | |
| 249 | const b = body(); | |
| 250 | for (let i = 0; i < 16; i++) move(b, wish(tuning, { duck: true }), TICK, [FLOOR], tuning, hull); | |
| 251 | expect(b.duckAmount).toBeCloseTo(Math.min(1, (16 / 64) * tuning.duckRate), 10); | |
| 252 | }); | |
| 253 | } | |
| 254 | ||
| 255 | // --- the hull is an argument too ------------------------------------------- | |
| 256 | ||
| 257 | test("the hull handed in is the box that collides, and a wider one stops sooner", () => { | |
| 258 | const wall: Box = { min: [300, -400, -64], max: [400, 400, 500], mat: 1 }; | |
| 259 | const stops = (tuning: MoveTuning, hull: Hull): number => { | |
| 260 | const b = body(); | |
| 261 | run(b, wish(tuning, { forward: 1 }), 4, [FLOOR, wall], tuning, hull); | |
| 262 | return b.pos[0]; | |
| 263 | }; | |
| 264 | for (const { tuning, hull } of PROFILES) expect(stops(tuning, hull)).toBeCloseTo(300 - hull.radius, 9); | |
| 265 | // Same profile, two hulls: the wide one comes to rest 10 units further back. | |
| 266 | expect(stops(WALKER, SHORT) - stops(WALKER, TALL)).toBeCloseTo(TALL.radius - SHORT.radius, 9); | |
| 267 | }); | |
| 268 | ||
| 269 | test("stuck reads the hull it is given, not one it remembers", () => { | |
| 270 | // A post 25 units out: the 30-unit radius is inside it, the 20-unit one is not. | |
| 271 | const post: Box = { min: [25, -50, -64], max: [100, 50, 10], mat: 1 }; | |
| 272 | expect(stuck([0, 0, 0], TALL.stand, [post], TALL)).toBe(true); | |
| 273 | expect(stuck([0, 0, 0], SHORT.stand, [post], SHORT)).toBe(false); | |
| 274 | // A slab from 100 to 140 up: the tall hull's head is in it standing (120 > 100) | |
| 275 | // and clear of it crouched (76 < 100), which is the same box read two ways. | |
| 276 | const slab: Box = { min: [-100, -100, 100], max: [100, 100, 140], mat: 1 }; | |
| 277 | expect(stuck([0, 0, 0], TALL.stand, [slab], TALL)).toBe(true); | |
| 278 | expect(stuck([0, 0, 0], TALL.duck, [slab], TALL)).toBe(false); | |
| 279 | expect(stuck([0, 0, 0], SHORT.stand, [slab], SHORT)).toBe(false); | |
| 280 | }); | |
| 281 | ||
| 282 | test("groundUnder is the highest top within the reach it is asked for", () => { | |
| 283 | const under: Box = { min: [-50, -50, -200], max: [50, 50, -15], mat: 0 }; | |
| 284 | const higher: Box = { min: [-50, -50, -200], max: [50, 50, -5], mat: 0 }; | |
| 285 | const boxes = [under, higher]; | |
| 286 | expect(groundUnder([0, 0, 0], TALL.stand, boxes, 20, TALL)).toBe(-5); | |
| 287 | expect(groundUnder([0, 0, 0], TALL.stand, [under], 20, TALL)).toBe(-15); | |
| 288 | // Nothing within three units of the feet: nothing holding this hull up. | |
| 289 | expect(groundUnder([0, 0, 0], TALL.stand, boxes, 3, TALL)).toBeNull(); | |
| 290 | // A shelf whose near edge is 25 units out: the wide hull stands on it and the | |
| 291 | // narrow one is over thin air, with every other argument the same. | |
| 292 | const shelf: Box = { min: [25, -50, -200], max: [400, 50, -2], mat: 0 }; | |
| 293 | expect(groundUnder([0, 0, 0], TALL.stand, [shelf], 10, TALL)).toBe(-2); | |
| 294 | expect(groundUnder([0, 0, 0], SHORT.stand, [shelf], 10, SHORT)).toBeNull(); | |
| 295 | }); | |
| 296 | ||
| 297 | test("the eyes and the hull height interpolate the hull's own two numbers", () => { | |
| 298 | const standing = body(), half = { ...body(), duckAmount: 0.5 }, ducked = { ...body(), duckAmount: 1 }; | |
| 299 | for (const hull of [SHORT, TALL]) { | |
| 300 | expect(eyeHeight(standing, hull)).toBe(hull.eyeStand); | |
| 301 | expect(eyeHeight(ducked, hull)).toBe(hull.eyeDuck); | |
| 302 | expect(eyeHeight(half, hull)).toBeCloseTo((hull.eyeStand + hull.eyeDuck) / 2, 9); | |
| 303 | expect(hullHeight(standing, hull)).toBe(hull.stand); | |
| 304 | expect(hullHeight(ducked, hull)).toBe(hull.duck); | |
| 305 | expect(hullHeight(half, hull)).toBeCloseTo((hull.stand + hull.duck) / 2, 9); | |
| 306 | } | |
| 307 | }); | |
| 308 | ||
| 309 | test("a hull opens only as far as the headroom over it allows", () => { | |
| 310 | // 90 units of headroom: the tall hull fits crouched (76 u) and not standing | |
| 311 | // (120 u), so the crouch opens to where the hull is 90 tall and stops — | |
| 312 | // (120 − 90) / (120 − 76) = 0.682 closed — rather than to 0 or staying shut. | |
| 313 | const headroom = 90; | |
| 314 | const slab: Box = { min: [-200, -200, headroom], max: [200, 200, 300], mat: 1 }; | |
| 315 | const b = body(); | |
| 316 | b.duckAmount = 1; | |
| 317 | run(b, wish(DRIFTER, { duck: false }), 2, [FLOOR, slab], DRIFTER, TALL); | |
| 318 | const fits = (TALL.stand - headroom) / (TALL.stand - TALL.duck); | |
| 319 | expect(hullHeight(b, TALL)).toBeLessThanOrEqual(headroom); | |
| 320 | expect(b.duckAmount).toBeGreaterThanOrEqual(fits); | |
| 321 | // And it got there: no more than one tick's worth of crouch short of it. | |
| 322 | expect(b.duckAmount - fits).toBeLessThan(DRIFTER.duckRate * TICK); | |
| 323 | // Step out from under the slab and the same profile stands straight back up. | |
| 324 | run(b, wish(DRIFTER, { duck: false }), 2, [FLOOR], DRIFTER, TALL); | |
| 325 | expect(b.duckAmount).toBe(0); | |
| 326 | }); | |
| 327 | ||
| 328 | // --- the lineage's signature, under a profile that never saw a shooter ------ | |
| 329 | ||
| 330 | test("ground acceleration adds along the wish direction and never past the wish speed", () => { | |
| 331 | // One enormous step: the add is clamped to what is left, so it lands on the | |
| 332 | // wish speed exactly rather than overshooting it. | |
| 333 | const far: Vec3 = [0, 0, 0]; | |
| 334 | accelerate(far, [1, 0, 0], 250, 5, 10); | |
| 335 | expect(far[0]).toBe(250); | |
| 336 | // One tick: accel × wishSpeed × dt, and nothing across the direction asked for. | |
| 337 | const tick: Vec3 = [0, 0, 0]; | |
| 338 | accelerate(tick, [1, 0, 0], 250, 5, TICK); | |
| 339 | expect(tick[0]).toBeCloseTo(5 * 250 * TICK, 10); | |
| 340 | expect(tick[1]).toBe(0); | |
| 341 | // Already faster along it than asked: nothing added, and nothing taken. | |
| 342 | const fast: Vec3 = [300, 0, 0]; | |
| 343 | accelerate(fast, [1, 0, 0], 250, 5, TICK); | |
| 344 | expect(fast[0]).toBe(300); | |
| 345 | }); | |
| 346 | ||
| 347 | test("turning while accelerating in the air gains speed a straight line cannot", () => { | |
| 348 | const gain = (tuning: MoveTuning, hull: Hull): number => { | |
| 349 | const b = body(); | |
| 350 | b.vel = [tuning.maxSpeed, 0, 0]; | |
| 351 | move(b, wish(tuning, { forward: 1, jump: true }), TICK, [FLOOR], tuning, hull); | |
| 352 | let yaw = 0; | |
| 353 | while (!b.onGround) { | |
| 354 | yaw += 0.35; | |
| 355 | move(b, wish(tuning, { side: -1, yaw }), TICK, [FLOOR], tuning, hull); | |
| 356 | } | |
| 357 | return Math.hypot(b.vel[0], b.vel[1]) - tuning.maxSpeed; | |
| 358 | }; | |
| 359 | // Held strafe, turning into it the whole flight: faster on landing than the | |
| 360 | // profile's own top speed, which is the air cap applying to the new direction | |
| 361 | // rather than to the speed. Straight ahead, the same flight gains nothing. | |
| 362 | for (const { name, tuning, hull } of PROFILES) expect(gain(tuning, hull), name).toBeGreaterThan(2); | |
| 363 | const straight = body(); | |
| 364 | straight.vel = [WALKER.maxSpeed, 0, 0]; | |
| 365 | move(straight, wish(WALKER, { forward: 1, jump: true }), TICK, [FLOOR], WALKER, SHORT); | |
| 366 | while (!straight.onGround) move(straight, wish(WALKER, { forward: 1 }), TICK, [FLOOR], WALKER, SHORT); | |
| 367 | expect(Math.hypot(straight.vel[0], straight.vel[1])).toBeCloseTo(WALKER.maxSpeed, 6); | |
| 368 | }); |
new above
offline