refactor: move world concepts into engine

This commit is contained in:
Toad 2026-08-24 15:17:53 +02:00
parent eeedcb8e48
commit 2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions

38
tests/actors.test.ts Normal file
View file

@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
import { Actor, type Actor as RuntimeActor, type ActorDefinition } from "../engine/scene/Actor"
type World = { updates: string[] }
const numberDefinition: ActorDefinition<{ value: number }, World> = {
prototype: { groups: [], radius: 1, minY: 0, maxY: 1 },
update: (state, _dt, world) => {
state.value++
world.updates.push(String(state.value))
},
transform: (state) => ({ x: state.value, y: 0, z: 0, heading: 0, scale: 1 }),
}
const textDefinition: ActorDefinition<{ value: string }, World> = {
prototype: { groups: [], radius: 1, minY: 0, maxY: 1 },
update: (state, _dt, world) => {
state.value += "!"
world.updates.push(state.value)
},
transform: (state) => ({ x: state.value.length, y: 0, z: 0, heading: 0, scale: 1 }),
}
test("one actor collection safely erases unrelated state types", () => {
const actors: RuntimeActor<World>[] = [
Actor.create(numberDefinition, { value: 1 }),
Actor.create(textDefinition, { value: "a" }),
]
const world: World = { updates: [] }
for (const actor of actors) {
Actor.update(actor, 1, world)
}
expect(world.updates).toEqual(["2", "a!"])
expect(Actor.transform(actors[0]).x).toBe(2)
expect(Actor.transform(actors[1]).x).toBe(2)
})

View file

@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join } from "node:path"
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
import path from "node:path"
// The engine is the reusable, content-agnostic layer: it must import nothing from
// game (content) or app (browser glue). game -> engine and app -> game -> engine are
@ -9,7 +9,7 @@ import { join } from "node:path"
function tsFiles(dir: string): string[] {
const out: string[] = []
for (const name of readdirSync(dir)) {
const p = join(dir, name)
const p = path.join(dir, name)
if (statSync(p).isDirectory()) {
out.push(...tsFiles(p))
} else if (p.endsWith(".ts")) {
@ -19,14 +19,42 @@ function tsFiles(dir: string): string[] {
return out
}
function repositoryRoot(): string {
const parent = new URL("..", import.meta.url).pathname
return existsSync(path.join(parent, "app/renderer.ts")) ? parent : path.join(parent, "..")
}
function importsLayer(source: string, layers: string): boolean {
return new RegExp(`(?:from\\s+|import\\s*(?:\\(\\s*)?)["'][^"']*/(?:${layers})/`).test(source)
}
test("engine imports nothing from game or app", () => {
const engineDir = new URL("../engine", import.meta.url).pathname
const offenders = tsFiles(engineDir).filter((f) => /from\s+["'][^"']*\/(?:game|app)\//.test(readFileSync(f, "utf8")))
const engineDir = path.join(repositoryRoot(), "engine")
const offenders = tsFiles(engineDir).filter((file) => importsLayer(readFileSync(file, "utf8"), "game|app"))
expect(offenders).toEqual([])
})
test("game imports nothing from app", () => {
const gameDir = new URL("../game", import.meta.url).pathname
const offenders = tsFiles(gameDir).filter((f) => /from\s+["'][^"']*\/app\//.test(readFileSync(f, "utf8")))
const gameDir = path.join(repositoryRoot(), "game")
const offenders = tsFiles(gameDir).filter((file) => importsLayer(readFileSync(file, "utf8"), "app"))
expect(offenders).toEqual([])
})
test("render driver and worker know no game content", () => {
const root = repositoryRoot()
const files = [path.join(root, "app/renderer.ts"), path.join(root, "app/render-worker.ts")]
const offenders = files.filter((file) => importsLayer(readFileSync(file, "utf8"), "game"))
expect(offenders).toEqual([])
})
test("game exports no closed content-kind registries", () => {
const gameDir = path.join(repositoryRoot(), "game")
const offenders = tsFiles(gameDir).filter((file) => {
const source = readFileSync(file, "utf8")
return (
/\b(?:export\s+)?(?:type|enum)\s+\w+Kind\b/.test(source) ||
/\b(?:const|let|var)\s+\w*_(?:KINDS|REGISTRY)\b/.test(source)
)
})
expect(offenders).toEqual([])
})

72
tests/level.test.ts Normal file
View file

@ -0,0 +1,72 @@
import { expect, test } from "bun:test"
import type { Texture } from "../engine/render/Texture"
import { buildLevel } from "../game/level"
import type { Textures } from "../game/textures"
const texture: Texture = { width: 1, height: 1, data: new Uint32Array([0xFFFFFFFF]) }
const textures: Textures = {
floor: texture,
grass: texture,
bark: texture,
birch: texture,
leaf: texture,
needle: texture,
rock: texture,
flower: texture,
wall: texture,
crate: texture,
npc: texture,
frog: texture,
bee: texture,
robin: texture,
skybox: texture,
}
test("game data compiles into a clone-safe engine render scene", () => {
const level = buildLevel(textures)
expect(level.actors).toHaveLength(60)
expect(Object.isFrozen(level.actors)).toBe(true)
expect(Object.isFrozen(level.render)).toBe(true)
expect(Object.isFrozen(level.render.prototypes)).toBe(true)
expect(level.render.prototypes).toHaveLength(3)
expect(level.render.maxInstances).toBe(level.actors.length)
expect(() => structuredClone(level.render)).not.toThrow()
})
test("actor prototype bounds cover local geometry", () => {
const level = buildLevel(textures)
for (const prototype of level.render.prototypes) {
let covered = true
for (const group of prototype.groups) {
const vertices = group.mesh.verts
for (let i = 0; i < vertices.length; i += 5) {
covered &&=
Math.abs(vertices[i]) <= prototype.radius + 1e-6 &&
vertices[i + 1] >= prototype.minY - 1e-6 &&
vertices[i + 1] <= prototype.maxY + 1e-6 &&
Math.abs(vertices[i + 2]) <= prototype.radius + 1e-6
}
}
expect(covered).toBe(true)
}
})
test("chunk bounds cover geometry in every LOD", () => {
const level = buildLevel(textures)
for (const chunk of level.render.chunks) {
let covered = true
for (const group of [...chunk.near, ...chunk.far]) {
const vertices = group.mesh.verts
for (let i = 0; i < vertices.length; i += 5) {
covered &&=
vertices[i] >= chunk.minX &&
vertices[i] <= chunk.maxX &&
vertices[i + 1] >= chunk.minY &&
vertices[i + 1] <= chunk.maxY &&
vertices[i + 2] >= chunk.minZ &&
vertices[i + 2] <= chunk.maxZ
}
}
expect(covered).toBe(true)
}
})

View file

@ -1,21 +0,0 @@
import { expect, test } from "bun:test"
import { MOB_KINDS, Mob } from "../game/actors/Mob"
// The mob SAB packs a kind as its index in MOB_KINDS; the main thread and every
// render worker must agree on that order. Freeze it here: appending a kind is fine,
// but reordering or removing an existing one silently corrupts which mesh/texture a
// worker draws.
test("MOB_KINDS order is frozen (mob SAB ids)", () => {
expect(MOB_KINDS).toEqual(["frog", "bee", "robin"])
})
test("every kind resolves to a complete definition", () => {
for (const kind of MOB_KINDS) {
const d = Mob.def(kind)
expect(d.name).toBe(kind)
expect(typeof d.build).toBe("function")
expect(typeof d.update).toBe("function")
expect(d.boundingRadius).toBeGreaterThan(0)
expect(d.bodyHeight).toBeGreaterThan(0)
}
})

View file

@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
import { RenderProtocol } from "../engine/render/RenderProtocol"
import type { RenderInstance } from "../engine/render/RenderScene"
import type { Camera } from "../engine/scene/Camera"
test("render camera protocol owns its complete shared layout", () => {
const input: Camera = { position: { x: 1, y: 2, z: 3 }, yaw: 4, pitch: 5, fov: 6 }
const data = new Float64Array(RenderProtocol.CAMERA_LENGTH)
RenderProtocol.writeCamera(data, input, 7)
const output = RenderProtocol.readCamera(data)
expect(output).toEqual({ camera: input, time: 7 })
expect(RenderProtocol.VIEW_PROJECTION_LENGTH).toBe(16)
})
test("render instance protocol round-trips scene-local prototype indexes", () => {
const input: RenderInstance[] = [
{ prototype: 2, x: 1.25, y: -2, z: 3.5, heading: 0.75, scale: 1.5 },
{ prototype: 0, x: -4, y: 5.25, z: 6, heading: -0.5, scale: 0.625 },
]
const ids = new Int32Array(2)
const transforms = new Float32Array(2 * RenderProtocol.TRANSFORM_FLOATS)
const output: RenderInstance[] = []
const count = RenderProtocol.writeInstances(ids, transforms, input)
RenderProtocol.readInstances(ids, transforms, count, output)
expect(output).toHaveLength(input.length)
for (let i = 0; i < input.length; i++) {
expect(output[i].prototype).toBe(input[i].prototype)
expect(output[i].x).toBeCloseTo(input[i].x)
expect(output[i].y).toBeCloseTo(input[i].y)
expect(output[i].z).toBeCloseTo(input[i].z)
expect(output[i].heading).toBeCloseTo(input[i].heading)
expect(output[i].scale).toBeCloseTo(input[i].scale)
}
})

View file

@ -1,18 +0,0 @@
import { expect, test } from "bun:test"
import { Tree, TREE_KINDS } from "../game/actors/Tree"
// The chunk baker (level.ts) accumulates geometry into a mesh per material key and
// only draws keys listed in MAT_ORDER. A tree species that declares a trunk/foliage
// material outside that palette would bake geometry that is silently never drawn.
// Freeze the palette here so a typo'd or unregistered material key fails a test.
const MATERIAL_KEYS = new Set(["grass", "rock", "bark", "birch", "leaf", "needle", "flower"])
test("every tree species maps to known chunk materials", () => {
for (const kind of TREE_KINDS) {
const s = Tree.species(kind)
expect(s.kind).toBe(kind)
expect(typeof s.build).toBe("function")
expect(MATERIAL_KEYS.has(s.trunk)).toBe(true)
expect(MATERIAL_KEYS.has(s.foliage)).toBe(true)
}
})