pike/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Engine: state the body shape the renderer needs, and check real snapshots aga… (#41)

Requested-by: benjivers <[email protected]>
Co-authored-by: benjivers <[email protected]>
Pike committed 967e12d Browse files
4 files changed +113 −0
src/body.test.ts added +25
@@ -0,0 +1,25 @@
1 /**
2 * The engine's body contract, checked against this game's own snapshot rather
3 * than one a test made up: a game that renames `alive` passes every render
4 * test and draws every enemy as a corpse. It lives here, not in `engine/`,
5 * because it needs the game.
6 */
7
8 import { expect, test } from "bun:test";
9 import { valGame } from "./arena.ts";
10 import { snapshotProblems } from "./engine/body.ts";
11 import { newMatch } from "./match.ts";
12
13 test("every body and `me` in a real snapshot of a running match fits the contract", () => {
14 const state = newMatch(7);
15 const game = valGame(state, () => {});
16 for (let i = 0; i < 90; i++) game.step(state, 1 / 30);
17 let bodies = 0;
18 for (const viewer of state.players) {
19 const seen = state.players.filter((p) => p.id === viewer.id || game.visible(state, viewer, p));
20 const snap = game.snapshot(state, viewer, seen) as { me: unknown; players: unknown[] };
21 expect(snapshotProblems(snap)).toEqual([]);
22 bodies += snap.players.length;
23 }
24 expect(bodies).toBeGreaterThan(state.players.length);
25 });
src/engine/arena.ts +3
@@ -17,6 +17,9 @@
1717 * - Every snapshot is built for one viewer, from the bodies the game says
1818 * that viewer can see. The client never learns a position it has no line
1919 * of sight to.
20 *
21 * What a snapshot's bodies and `me` must carry for the client renderer is in
22 * `body.ts`; a game checks its real snapshot against it.
2023 */
2124
2225 export const TICK = 1 / 30;
src/engine/body.test.ts added +14
@@ -0,0 +1,14 @@
1 /** The body contract: what it names, and what it refuses. */
2 import { expect, test } from "bun:test";
3 import { bodyProblems, meProblems } from "./body.ts";
4
5 const good = { id: "a", name: "A", x: 1, y: 2, angle: 0, alive: true, hp: 100, mate: false };
6
7 test("a well-formed body has no problems, and each missing or mistyped field is named", () => {
8 expect(bodyProblems(good)).toEqual([]);
9 const { alive: _, ...noAlive } = good;
10 expect(bodyProblems({ ...noAlive, down: false })).toEqual(["body.alive should be a boolean, is undefined"]);
11 expect(bodyProblems({ ...good, x: NaN })).toHaveLength(1);
12 expect(bodyProblems(null)).toEqual(["body is not an object"]);
13 expect(meProblems({ id: "a" })).toContain("me.ammo should be a number, is undefined");
14 });
src/engine/body.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * The body a snapshot must carry, stated once.
3 *
4 * The client renderer (`scene.js`, `topdown.js`, `presence.js`, `gun.js`)
5 * reads fixed fields off every body in `snap.players` and off `snap.me`. A
6 * game that renames one — `down` for `alive` — is not refused by anything at
7 * runtime: every enemy simply draws as a corpse. So the shape is a contract
8 * here, and each game tests its real snapshot against it.
9 *
10 * Every body in `snap.players` has:
11 * id: string who; `me` is skipped in the scene by this id
12 * name: string the tag over the body
13 * x, y: number where, in world units
14 * angle: number which way it faces, radians
15 * alive: boolean false draws a corpse
16 * hp: number 0..100; a bar shows under 100
17 * mate: boolean on the viewer's side
18 *
19 * `snap.me` has, besides id, x, y, angle, alive and hp:
20 * ammo: number rounds in the magazine
21 * reloading: boolean mid-reload
22 */
23
24 type Kind = "string" | "number" | "boolean";
25
26 const BODY: Record<string, Kind> = {
27 id: "string",
28 name: "string",
29 x: "number",
30 y: "number",
31 angle: "number",
32 alive: "boolean",
33 hp: "number",
34 mate: "boolean",
35 };
36
37 const ME: Record<string, Kind> = {
38 id: "string",
39 x: "number",
40 y: "number",
41 angle: "number",
42 alive: "boolean",
43 hp: "number",
44 ammo: "number",
45 reloading: "boolean",
46 };
47
48 function problems(value: unknown, shape: Record<string, Kind>, label: string): string[] {
49 if (typeof value !== "object" || value === null) return [`${label} is not an object`];
50 const out: string[] = [];
51 for (const [field, kind] of Object.entries(shape)) {
52 const got = (value as Record<string, unknown>)[field];
53 if (typeof got !== kind || (kind === "number" && !Number.isFinite(got))) out.push(`${label}.${field} should be a ${kind}, is ${String(got)}`);
54 }
55 return out;
56 }
57
58 /** What is wrong with one body of `snap.players`; empty when it is fit to draw. */
59 export const bodyProblems = (body: unknown, label = "body"): string[] => problems(body, BODY, label);
60
61 /** What is wrong with `snap.me`; empty when the gun and the camera can read it. */
62 export const meProblems = (me: unknown): string[] => problems(me, ME, "me");
63
64 /** Every problem in a snapshot's bodies and `me`. */
65 export function snapshotProblems(snap: { me?: unknown; players?: unknown }): string[] {
66 const players = Array.isArray(snap.players) ? snap.players : null;
67 return [
68 ...meProblems(snap.me),
69 ...(players ? players.flatMap((p, i) => bodyProblems(p, `players[${i}]`)) : ["players is not an array"]),
70 ];
71 }
↑↓ navigate ↵ open esc close

Keyboard shortcuts