Add test coverage retrofit for Phase 1/2 (worker, api, frontend)
Requested after Phase 2: from here on, MCMapper development follows TDD (test-first) — this retrofits the pieces already built before that request landed. worker: unit tests for the tile rasterizer (background fill, exact upscaled-block boundaries, full-grid painting, out-of-bounds columns) and the block-color palette (distinctness checks, including that the "unmapped block" placeholder never accidentally collides with a real block's color). 16 tests total alongside the existing mesher tests. api: wired up `bun test`. Unit tests for chunkOf's coordinate math. Integration tests (real Postgres/Redis/MinIO, see README's new "Running tests" section) for wsGateway.message() — auth accept/reject, upsert + dedup on columns/sections, not-authenticated/invalid-JSON handling — and for the tile/mesh/servers HTTP routes, driven through Elysia's in-process `.handle()` rather than a bound port (sidesteps the stale dev-server port-collision issue hit repeatedly this session). index.ts now exports `app` and only calls `.listen()` when run directly, specifically so tests can drive it this way. frontend: extracted mesh.js's binary-format parser into its own ESM module (mesh-format.js) so it's unit-testable without a browser/Babylon; mesh.js now imports it. Tests build a buffer independently of the parser (mirroring worker's encoder layout) so a mismatch in either direction — Rust producer or JS consumer drifting — would be caught.
This commit is contained in:
@@ -76,6 +76,31 @@ tiles appear at `GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY.png` (z
|
|||||||
for now — see `worker/src/render/cpu.rs`) and the frontend's Leaflet viewer picks them up
|
for now — see `worker/src/render/cpu.rs`) and the frontend's Leaflet viewer picks them up
|
||||||
automatically from `GET /api/servers`.
|
automatically from `GET /api/servers`.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
`worker`'s tests (`cargo test`, in `worker/`) are pure unit tests (greedy mesher, tile
|
||||||
|
rasterizer, block-color palette) and need nothing running. `api`'s and `frontend`'s (`bun test`,
|
||||||
|
in each directory) are integration tests against real infra — start it first:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -d --name mcmapper-test-pg -e POSTGRES_USER=mcmapper -e POSTGRES_PASSWORD=mcmapper -e POSTGRES_DB=mcmapper -p 15432:5432 postgres:17-alpine
|
||||||
|
docker run -d --name mcmapper-test-redis -p 16379:6379 redis:7-alpine
|
||||||
|
docker run -d --name mcmapper-test-minio -e MINIO_ROOT_USER=mcmapper -e MINIO_ROOT_PASSWORD=mcmapper-dev-only -p 19000:9000 minio/minio:latest server /data
|
||||||
|
cd api && DATABASE_URL=postgres://mcmapper:mcmapper@localhost:15432/mcmapper bun run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
then, from `api/` or `frontend/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgres://mcmapper:mcmapper@localhost:15432/mcmapper \
|
||||||
|
REDIS_URL=redis://localhost:16379 \
|
||||||
|
MINIO_ENDPOINT=localhost MINIO_PORT=19000 MINIO_ACCESS_KEY=mcmapper MINIO_SECRET_KEY=mcmapper-dev-only \
|
||||||
|
bun test
|
||||||
|
```
|
||||||
|
|
||||||
|
Each test file creates and tears down its own server row (random token per run) so runs never
|
||||||
|
collide with each other or with real dev data — see `api/src/test-helpers.ts`.
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
See `THIRD_PARTY_NOTICES.md`.
|
See `THIRD_PARTY_NOTICES.md`.
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,8 @@
|
|||||||
"dev": "bun run --watch src/index.ts",
|
"dev": "bun run --watch src/index.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"migrate": "bun run src/db/migrate.ts",
|
"migrate": "bun run src/db/migrate.ts",
|
||||||
"seed": "bun run scripts/seed-server.ts"
|
"seed": "bun run scripts/seed-server.ts",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^2.0.1",
|
"cookie": "^2.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { app } from "./index";
|
||||||
|
import { db } from "./db/client";
|
||||||
|
import { meshPointers, tilePointers } from "./db/schema";
|
||||||
|
import { minio, TILE_BUCKET } from "./minio";
|
||||||
|
import { createTestServer, deleteTestServer } from "./test-helpers";
|
||||||
|
|
||||||
|
// Elysia's `.handle()` drives the app in-process against a plain Request/Response, without
|
||||||
|
// binding a real port — avoids racing a real running instance for the port (see MCMapper's
|
||||||
|
// memory notes on stale dev-server processes double-binding a port on Windows) and is faster.
|
||||||
|
function get(path: string) {
|
||||||
|
return app.handle(new Request(`http://localhost${path}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /health", () => {
|
||||||
|
test("reports ok", async () => {
|
||||||
|
const res = await get("/health");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ status: "ok" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/servers", () => {
|
||||||
|
let server: { id: string; token: string };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await createTestServer("servers-route");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteTestServer(server.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes the seeded server without leaking its token", async () => {
|
||||||
|
const res = await get("/api/servers");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const rows = (await res.json()) as any[];
|
||||||
|
const found = rows.find((r) => r.id === server.id);
|
||||||
|
expect(found).toEqual({ id: server.id, name: "test-servers-route" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY", () => {
|
||||||
|
let server: { id: string; token: string };
|
||||||
|
const storageKey = "test-tile-object.png";
|
||||||
|
const pngBytes = new Uint8Array([1, 2, 3, 4]);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await createTestServer("tiles-route");
|
||||||
|
await minio.putObject(TILE_BUCKET, storageKey, Buffer.from(pngBytes));
|
||||||
|
await db.insert(tilePointers).values({
|
||||||
|
serverId: server.id,
|
||||||
|
dimension: 0,
|
||||||
|
zoom: 0,
|
||||||
|
tileX: 5,
|
||||||
|
tileZ: -3, // stored as chunkZ; the route negates the requested tileY to get here
|
||||||
|
storageKey,
|
||||||
|
contentHash: "test-hash",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteTestServer(server.id);
|
||||||
|
await minio.removeObject(TILE_BUCKET, storageKey).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("negates tileY back to the stored chunkZ and streams the object", async () => {
|
||||||
|
// chunkZ = -3, so the frontend's tileY (see public/js/map.js) is 3.
|
||||||
|
const res = await get(`/api/tiles/${server.id}/0/0/5/3.png`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("content-type")).toBe("image/png");
|
||||||
|
const body = new Uint8Array(await res.arrayBuffer());
|
||||||
|
expect(body).toEqual(pngBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("404s for a tile that hasn't been rendered", async () => {
|
||||||
|
const res = await get(`/api/tiles/${server.id}/0/0/999/999.png`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/meshes/...", () => {
|
||||||
|
let server: { id: string; token: string };
|
||||||
|
const storageKey = "test-mesh-object.bin";
|
||||||
|
const meshBytes = new Uint8Array([9, 9, 9]);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await createTestServer("meshes-route");
|
||||||
|
await minio.putObject(TILE_BUCKET, storageKey, Buffer.from(meshBytes));
|
||||||
|
await db.insert(meshPointers).values({
|
||||||
|
serverId: server.id,
|
||||||
|
dimension: 0,
|
||||||
|
x: 2,
|
||||||
|
z: 3,
|
||||||
|
sectionY: 4,
|
||||||
|
storageKey,
|
||||||
|
contentHash: "test-hash",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteTestServer(server.id);
|
||||||
|
await minio.removeObject(TILE_BUCKET, storageKey).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists the rendered sections for a chunk", async () => {
|
||||||
|
const res = await get(`/api/meshes/${server.id}/0/2/3`);
|
||||||
|
expect(await res.json()).toEqual([4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists nothing for a chunk with no meshes yet", async () => {
|
||||||
|
const res = await get(`/api/meshes/${server.id}/0/999/999`);
|
||||||
|
expect(await res.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves the mesh binary with the right content type", async () => {
|
||||||
|
const res = await get(`/api/meshes/${server.id}/0/2/3/4.bin`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("content-type")).toBe("application/octet-stream");
|
||||||
|
const body = new Uint8Array(await res.arrayBuffer());
|
||||||
|
expect(body).toEqual(meshBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("404s for a section that hasn't been rendered", async () => {
|
||||||
|
const res = await get(`/api/meshes/${server.id}/0/2/3/15.bin`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
+8
-3
@@ -7,7 +7,7 @@ import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
|
|||||||
|
|
||||||
await ensureTileBucket();
|
await ensureTileBucket();
|
||||||
|
|
||||||
const app = new Elysia()
|
export const app = new Elysia()
|
||||||
.get("/health", () => ({ status: "ok" }))
|
.get("/health", () => ({ status: "ok" }))
|
||||||
.get("/api/servers", async () => {
|
.get("/api/servers", async () => {
|
||||||
const rows = await db.select({ id: servers.id, name: servers.name }).from(servers);
|
const rows = await db.select({ id: servers.id, name: servers.name }).from(servers);
|
||||||
@@ -91,7 +91,12 @@ const app = new Elysia()
|
|||||||
open: wsGateway.open,
|
open: wsGateway.open,
|
||||||
message: wsGateway.message,
|
message: wsGateway.message,
|
||||||
close: wsGateway.close,
|
close: wsGateway.close,
|
||||||
})
|
});
|
||||||
.listen(Number(process.env.PORT ?? 3000));
|
|
||||||
|
|
||||||
|
// Only bind a real port when run directly (`bun run src/index.ts`) — tests import `app` and
|
||||||
|
// drive it via `.handle()` instead, so they don't race for a port with a real running instance
|
||||||
|
// (see MCMapper's memory notes on stale dev-server processes double-binding a port on Windows).
|
||||||
|
if (import.meta.main) {
|
||||||
|
app.listen(Number(process.env.PORT ?? 3000));
|
||||||
console.log(`[api] listening on :${app.server?.port}`);
|
console.log(`[api] listening on :${app.server?.port}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "./db/client";
|
||||||
|
import { servers } from "./db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests need real Postgres/Redis/MinIO — see README's "Running tests" section for
|
||||||
|
* how to start them. Each test file creates its own server row with a random token so runs
|
||||||
|
* never collide with each other or with dev data, and tears it down (which cascades to any
|
||||||
|
* chunk_columns/chunk_sections/tile_pointers/mesh_pointers rows via their FK's ON DELETE CASCADE).
|
||||||
|
*/
|
||||||
|
export async function createTestServer(namePrefix: string) {
|
||||||
|
const token = `test-${namePrefix}-${crypto.randomUUID()}`;
|
||||||
|
const [row] = await db.insert(servers).values({ name: `test-${namePrefix}`, token }).returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTestServer(serverId: string) {
|
||||||
|
await db.delete(servers).where(eq(servers.id, serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal stand-in for Elysia's WS connection object — enough for wsGateway.message()/close(). */
|
||||||
|
export class FakeSocket {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
sent: string[] = [];
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
send(data: string) {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMessage(): any {
|
||||||
|
return JSON.parse(this.sent[this.sent.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "./db/client";
|
||||||
|
import { chunkColumns, chunkSections } from "./db/schema";
|
||||||
|
import { redis, DIRTY_CHUNK_STREAM } from "./redis";
|
||||||
|
import { wsGateway, chunkOf } from "./ws-gateway";
|
||||||
|
import { createTestServer, deleteTestServer, FakeSocket } from "./test-helpers";
|
||||||
|
|
||||||
|
describe("chunkOf", () => {
|
||||||
|
test("floors toward negative infinity, matching Math.floor(coord/16)", () => {
|
||||||
|
expect(chunkOf(0)).toBe(0);
|
||||||
|
expect(chunkOf(15)).toBe(0);
|
||||||
|
expect(chunkOf(16)).toBe(1);
|
||||||
|
expect(chunkOf(-1)).toBe(-1);
|
||||||
|
expect(chunkOf(-16)).toBe(-1);
|
||||||
|
expect(chunkOf(-17)).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wsGateway.message", () => {
|
||||||
|
let server: { id: string; token: string };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = await createTestServer("ws-gateway");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteTestServer(server.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hello with a valid token acknowledges with the server id", async () => {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
|
||||||
|
expect(socket.closed).toBe(false);
|
||||||
|
expect(socket.lastMessage()).toEqual({ type: "hello_ack", ok: true, serverId: server.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hello with an invalid token is rejected and the connection closed", async () => {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: "not-a-real-token" }));
|
||||||
|
expect(socket.closed).toBe(true);
|
||||||
|
expect(socket.lastMessage()).toEqual({ type: "hello_ack", ok: false, error: "invalid_token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a message before hello is rejected as not_authenticated", async () => {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
await wsGateway.message(socket, JSON.stringify({ type: "columns", dimension: 0, columns: [] }));
|
||||||
|
expect(socket.closed).toBe(true);
|
||||||
|
expect(socket.lastMessage()).toEqual({ type: "error", error: "not_authenticated" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid JSON gets an error reply, not a thrown exception", async () => {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
await wsGateway.message(socket, "{not json");
|
||||||
|
expect(socket.lastMessage()).toEqual({ type: "error", error: "invalid_json" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("columns upserts chunk_columns and marks exactly the touched chunks dirty", async () => {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
|
||||||
|
|
||||||
|
const streamLenBefore = await redis.xlen(DIRTY_CHUNK_STREAM);
|
||||||
|
|
||||||
|
// Two columns in chunk (0,0), one in chunk (1,0) — should dedupe to exactly 2 dirty events.
|
||||||
|
await wsGateway.message(
|
||||||
|
socket,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "columns",
|
||||||
|
dimension: 0,
|
||||||
|
columns: [
|
||||||
|
{ x: 1, z: 1, height: 64, blockId: 2, blockMeta: 0 },
|
||||||
|
{ x: 2, z: 2, height: 65, blockId: 3, blockMeta: 0 },
|
||||||
|
{ x: 16, z: 1, height: 70, blockId: 1, blockMeta: 0 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(chunkColumns)
|
||||||
|
.where(and(eq(chunkColumns.serverId, server.id), eq(chunkColumns.dimension, 0)));
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
const byX = Object.fromEntries(rows.map((r) => [r.x, r]));
|
||||||
|
expect(byX[1]!.blockId).toBe(2);
|
||||||
|
expect(byX[1]!.height).toBe(64);
|
||||||
|
expect(byX[16]!.blockId).toBe(1);
|
||||||
|
|
||||||
|
const streamLenAfter = await redis.xlen(DIRTY_CHUNK_STREAM);
|
||||||
|
expect(streamLenAfter - streamLenBefore).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a repeated column overwrites rather than duplicating", async () => {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
|
||||||
|
|
||||||
|
const send = (x: number, blockId: number) =>
|
||||||
|
wsGateway.message(
|
||||||
|
socket,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "columns",
|
||||||
|
dimension: 0,
|
||||||
|
columns: [{ x, z: 100, height: 64, blockId, blockMeta: 0 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await send(50, 2);
|
||||||
|
await send(50, 3);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(chunkColumns)
|
||||||
|
.where(and(eq(chunkColumns.serverId, server.id), eq(chunkColumns.x, 50), eq(chunkColumns.z, 100)));
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]!.blockId).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sections upserts chunk_sections and marks the chunk dirty", async () => {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
|
||||||
|
|
||||||
|
const blocks = Buffer.alloc(8192).toString("base64"); // all-air section is fine for this test
|
||||||
|
const streamLenBefore = await redis.xlen(DIRTY_CHUNK_STREAM);
|
||||||
|
|
||||||
|
await wsGateway.message(
|
||||||
|
socket,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sections",
|
||||||
|
dimension: 0,
|
||||||
|
chunkX: 9,
|
||||||
|
chunkZ: -3,
|
||||||
|
sections: [{ sectionY: 4, blocks }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(chunkSections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chunkSections.serverId, server.id),
|
||||||
|
eq(chunkSections.x, 9),
|
||||||
|
eq(chunkSections.z, -3),
|
||||||
|
eq(chunkSections.sectionY, 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]!.blocks).toBe(blocks);
|
||||||
|
|
||||||
|
const streamLenAfter = await redis.xlen(DIRTY_CHUNK_STREAM);
|
||||||
|
expect(streamLenAfter - streamLenBefore).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("close() forgets the connection's authentication state", async () => {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
|
||||||
|
wsGateway.close(socket);
|
||||||
|
|
||||||
|
const socket2 = new FakeSocket();
|
||||||
|
socket2.id = socket.id; // simulate the same connection id being reused post-close
|
||||||
|
await wsGateway.message(socket2, JSON.stringify({ type: "columns", dimension: 0, columns: [] }));
|
||||||
|
expect(socket2.lastMessage()).toEqual({ type: "error", error: "not_authenticated" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,7 +45,7 @@ interface ConnState {
|
|||||||
|
|
||||||
const connections = new Map<string | number, ConnState>();
|
const connections = new Map<string | number, ConnState>();
|
||||||
|
|
||||||
function chunkOf(coord: number): number {
|
export function chunkOf(coord: number): number {
|
||||||
return Math.floor(coord / 16);
|
return Math.floor(coord / 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts",
|
"dev": "bun run --watch src/index.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"build:css": "tailwindcss -i src/public/css/input.css -o src/public/css/tailwind.css"
|
"build:css": "tailwindcss -i src/public/css/input.css -o src/public/css/tailwind.css",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"elysia": "^1.1.0",
|
"elysia": "^1.1.0",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const app = new Elysia()
|
|||||||
.get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css")))
|
.get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css")))
|
||||||
.get("/js/map.js", () => Bun.file(join(import.meta.dir, "public/js/map.js")))
|
.get("/js/map.js", () => Bun.file(join(import.meta.dir, "public/js/map.js")))
|
||||||
.get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js")))
|
.get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js")))
|
||||||
|
.get("/js/mesh-format.js", () => Bun.file(join(import.meta.dir, "public/js/mesh-format.js")))
|
||||||
.listen(Number(process.env.PORT ?? 3001));
|
.listen(Number(process.env.PORT ?? 3001));
|
||||||
|
|
||||||
console.log(`[frontend] listening on :${app.server?.port}`);
|
console.log(`[frontend] listening on :${app.server?.port}`);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Binary mesh format written by worker/src/mesh.rs's MeshBuffers::encode():
|
||||||
|
// u32 vertexCount, u32 indexCount,
|
||||||
|
// f32[vertexCount*3] positions, f32[vertexCount*3] normals, f32[vertexCount*3] colors,
|
||||||
|
// u32[indexCount] indices — all little-endian.
|
||||||
|
//
|
||||||
|
// Pulled into its own module (rather than living inline in mesh.js) so it can be unit tested
|
||||||
|
// without a browser/Babylon — see mesh-format.test.ts.
|
||||||
|
export function parseMeshBuffer(buf) {
|
||||||
|
const view = new DataView(buf);
|
||||||
|
const vertexCount = view.getUint32(0, true);
|
||||||
|
const indexCount = view.getUint32(4, true);
|
||||||
|
let offset = 8;
|
||||||
|
|
||||||
|
const positions = new Float32Array(buf, offset, vertexCount * 3);
|
||||||
|
offset += vertexCount * 3 * 4;
|
||||||
|
const normals = new Float32Array(buf, offset, vertexCount * 3);
|
||||||
|
offset += vertexCount * 3 * 4;
|
||||||
|
const rgb = new Float32Array(buf, offset, vertexCount * 3);
|
||||||
|
offset += vertexCount * 3 * 4;
|
||||||
|
const indices = new Uint32Array(buf, offset, indexCount);
|
||||||
|
|
||||||
|
// Babylon's VertexData.colors wants RGBA.
|
||||||
|
const colors = new Float32Array(vertexCount * 4);
|
||||||
|
for (let i = 0; i < vertexCount; i++) {
|
||||||
|
colors[i * 4] = rgb[i * 3];
|
||||||
|
colors[i * 4 + 1] = rgb[i * 3 + 1];
|
||||||
|
colors[i * 4 + 2] = rgb[i * 3 + 2];
|
||||||
|
colors[i * 4 + 3] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positions, normals, colors, indices };
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { parseMeshBuffer } from "./mesh-format";
|
||||||
|
|
||||||
|
// Builds a buffer matching worker/src/mesh.rs's MeshBuffers::encode() layout, independent of
|
||||||
|
// parseMeshBuffer itself, so these tests catch a mismatch in either direction (Rust producer
|
||||||
|
// drifting from JS consumer, or vice versa) rather than just testing the parser against its own
|
||||||
|
// assumptions.
|
||||||
|
function buildMeshBuffer(positions: number[][], normals: number[][], colors: number[][], indices: number[]): ArrayBuffer {
|
||||||
|
const vertexCount = positions.length;
|
||||||
|
const indexCount = indices.length;
|
||||||
|
const buf = new ArrayBuffer(8 + vertexCount * 36 + indexCount * 4);
|
||||||
|
const view = new DataView(buf);
|
||||||
|
view.setUint32(0, vertexCount, true);
|
||||||
|
view.setUint32(4, indexCount, true);
|
||||||
|
|
||||||
|
let offset = 8;
|
||||||
|
for (const [x, y, z] of positions) {
|
||||||
|
view.setFloat32(offset, x, true);
|
||||||
|
view.setFloat32(offset + 4, y, true);
|
||||||
|
view.setFloat32(offset + 8, z, true);
|
||||||
|
offset += 12;
|
||||||
|
}
|
||||||
|
for (const [x, y, z] of normals) {
|
||||||
|
view.setFloat32(offset, x, true);
|
||||||
|
view.setFloat32(offset + 4, y, true);
|
||||||
|
view.setFloat32(offset + 8, z, true);
|
||||||
|
offset += 12;
|
||||||
|
}
|
||||||
|
for (const [r, g, b] of colors) {
|
||||||
|
view.setFloat32(offset, r, true);
|
||||||
|
view.setFloat32(offset + 4, g, true);
|
||||||
|
view.setFloat32(offset + 8, b, true);
|
||||||
|
offset += 12;
|
||||||
|
}
|
||||||
|
for (const i of indices) {
|
||||||
|
view.setUint32(offset, i, true);
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseMeshBuffer", () => {
|
||||||
|
test("parses an empty mesh", () => {
|
||||||
|
const buf = buildMeshBuffer([], [], [], []);
|
||||||
|
const { positions, normals, colors, indices } = parseMeshBuffer(buf);
|
||||||
|
expect(positions.length).toBe(0);
|
||||||
|
expect(normals.length).toBe(0);
|
||||||
|
expect(colors.length).toBe(0);
|
||||||
|
expect(indices.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses positions, normals, and indices unchanged", () => {
|
||||||
|
const buf = buildMeshBuffer(
|
||||||
|
[
|
||||||
|
[0, 0, 0],
|
||||||
|
[16, 0, 0],
|
||||||
|
[16, 16, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, 1, 0],
|
||||||
|
[0, 1, 0],
|
||||||
|
[0, 1, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[1, 0, 0],
|
||||||
|
[1, 0, 0],
|
||||||
|
[1, 0, 0],
|
||||||
|
],
|
||||||
|
[0, 1, 2],
|
||||||
|
);
|
||||||
|
const { positions, normals, indices } = parseMeshBuffer(buf);
|
||||||
|
expect(Array.from(positions)).toEqual([0, 0, 0, 16, 0, 0, 16, 16, 0]);
|
||||||
|
expect(Array.from(normals)).toEqual([0, 1, 0, 0, 1, 0, 0, 1, 0]);
|
||||||
|
expect(Array.from(indices)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expands RGB colors to RGBA with alpha 1", () => {
|
||||||
|
const buf = buildMeshBuffer([[0, 0, 0]], [[0, 1, 0]], [[0.5, 0.25, 0.75]], [0]);
|
||||||
|
const { colors } = parseMeshBuffer(buf);
|
||||||
|
expect(Array.from(colors)).toEqual([0.5, 0.25, 0.75, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("colors array length is 4x vertex count, not 3x", () => {
|
||||||
|
const buf = buildMeshBuffer(
|
||||||
|
[
|
||||||
|
[0, 0, 0],
|
||||||
|
[1, 1, 1],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, 1, 0],
|
||||||
|
[0, 1, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[1, 0, 0],
|
||||||
|
[0, 1, 0],
|
||||||
|
],
|
||||||
|
[0, 1],
|
||||||
|
);
|
||||||
|
const { colors, positions } = parseMeshBuffer(buf);
|
||||||
|
expect(colors.length).toBe((positions.length / 3) * 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,39 +2,11 @@
|
|||||||
// at startup — no camera-based dynamic streaming/culling yet, that's a natural follow-up once
|
// at startup — no camera-based dynamic streaming/culling yet, that's a natural follow-up once
|
||||||
// there's a reason to care about performance at scale. Dimension is hardcoded to 0 (overworld),
|
// there's a reason to care about performance at scale. Dimension is hardcoded to 0 (overworld),
|
||||||
// matching the 2D map's assumption (see public/js/map.js).
|
// matching the 2D map's assumption (see public/js/map.js).
|
||||||
|
import { parseMeshBuffer } from "./mesh-format.js";
|
||||||
|
|
||||||
const DIMENSION = 0;
|
const DIMENSION = 0;
|
||||||
const CHUNK_RADIUS = 2; // (2*2+1)^2 = 25 chunks
|
const CHUNK_RADIUS = 2; // (2*2+1)^2 = 25 chunks
|
||||||
|
|
||||||
// Binary mesh format written by worker/src/mesh.rs's MeshBuffers::encode():
|
|
||||||
// u32 vertexCount, u32 indexCount,
|
|
||||||
// f32[vertexCount*3] positions, f32[vertexCount*3] normals, f32[vertexCount*3] colors,
|
|
||||||
// u32[indexCount] indices — all little-endian.
|
|
||||||
function parseMeshBuffer(buf) {
|
|
||||||
const view = new DataView(buf);
|
|
||||||
const vertexCount = view.getUint32(0, true);
|
|
||||||
const indexCount = view.getUint32(4, true);
|
|
||||||
let offset = 8;
|
|
||||||
|
|
||||||
const positions = new Float32Array(buf, offset, vertexCount * 3);
|
|
||||||
offset += vertexCount * 3 * 4;
|
|
||||||
const normals = new Float32Array(buf, offset, vertexCount * 3);
|
|
||||||
offset += vertexCount * 3 * 4;
|
|
||||||
const rgb = new Float32Array(buf, offset, vertexCount * 3);
|
|
||||||
offset += vertexCount * 3 * 4;
|
|
||||||
const indices = new Uint32Array(buf, offset, indexCount);
|
|
||||||
|
|
||||||
// Babylon's VertexData.colors wants RGBA.
|
|
||||||
const colors = new Float32Array(vertexCount * 4);
|
|
||||||
for (let i = 0; i < vertexCount; i++) {
|
|
||||||
colors[i * 4] = rgb[i * 3];
|
|
||||||
colors[i * 4 + 1] = rgb[i * 3 + 1];
|
|
||||||
colors[i * 4 + 2] = rgb[i * 3 + 2];
|
|
||||||
colors[i * 4 + 3] = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { positions, normals, colors, indices };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSectionMesh(scene, serverId, chunkX, chunkZ, sectionY) {
|
async function loadSectionMesh(scene, serverId, chunkX, chunkZ, sectionY) {
|
||||||
const res = await fetch(`/api/meshes/${serverId}/${DIMENSION}/${chunkX}/${chunkZ}/${sectionY}.bin`);
|
const res = await fetch(`/api/meshes/${serverId}/${DIMENSION}/${chunkX}/${chunkZ}/${sectionY}.bin`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ html(lang="en")
|
|||||||
a.text-sm.text-neutral-400.underline(href="/") back to 2D map
|
a.text-sm.text-neutral-400.underline(href="/") back to 2D map
|
||||||
span#status.text-sm.text-neutral-500 loading…
|
span#status.text-sm.text-neutral-500 loading…
|
||||||
canvas#renderCanvas.flex-1
|
canvas#renderCanvas.flex-1
|
||||||
script(src="/js/mesh.js")
|
script(type="module" src="/js/mesh.js")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"allowJs": true,
|
||||||
"types": ["bun-types", "pug"]
|
"types": ["bun-types", "pug"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,3 +78,58 @@ fn wool_color(meta: u8) -> [u8; 3] {
|
|||||||
_ => UNKNOWN_COLOR,
|
_ => UNKNOWN_COLOR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn air_is_the_dedicated_air_color() {
|
||||||
|
assert_eq!(color_for(0, 0), AIR_COLOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_block_ignores_meta_unless_the_block_uses_it() {
|
||||||
|
// Stone (id 1) has no meta variants — any meta should map to the same color.
|
||||||
|
assert_eq!(color_for(1, 0), color_for(1, 15));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oak_log_and_planks_differ() {
|
||||||
|
assert_ne!(color_for(5, 0), color_for(17, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn planks_vary_by_meta() {
|
||||||
|
let oak = color_for(5, 0);
|
||||||
|
let spruce = color_for(5, 1);
|
||||||
|
let birch = color_for(5, 2);
|
||||||
|
assert_ne!(oak, spruce);
|
||||||
|
assert_ne!(oak, birch);
|
||||||
|
assert_ne!(spruce, birch);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wool_all_sixteen_colors_are_distinct() {
|
||||||
|
let colors: Vec<[u8; 3]> = (0..16).map(|meta| color_for(35, meta)).collect();
|
||||||
|
for i in 0..colors.len() {
|
||||||
|
for j in (i + 1)..colors.len() {
|
||||||
|
assert_ne!(colors[i], colors[j], "wool meta {i} and {j} share a color");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unmapped_block_id_falls_back_to_unknown_color() {
|
||||||
|
assert_eq!(color_for(9999, 0), UNKNOWN_COLOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_color_is_distinct_from_every_real_terrain_color() {
|
||||||
|
// The whole point of UNKNOWN_COLOR is to be visually obvious — assert it doesn't
|
||||||
|
// silently collide with a real block's color and blend in.
|
||||||
|
for id in [1u16, 2, 3, 4, 7, 8, 12, 17, 24, 41, 49, 87, 121] {
|
||||||
|
assert_ne!(color_for(id, 0), UNKNOWN_COLOR, "block {id} accidentally matches UNKNOWN_COLOR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,3 +56,64 @@ impl RenderBackend for CpuRenderBackend {
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::palette::color_for;
|
||||||
|
|
||||||
|
fn decode(bytes: &[u8]) -> RgbImage {
|
||||||
|
image::load_from_memory(bytes).expect("worker must always produce a decodable PNG").to_rgb8()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_columns_produce_a_full_background_tile() {
|
||||||
|
let backend = CpuRenderBackend::new();
|
||||||
|
let bytes = backend.rasterize_tile(&[]).unwrap();
|
||||||
|
let img = decode(&bytes);
|
||||||
|
assert_eq!(img.dimensions(), (256, 256));
|
||||||
|
for pixel in img.pixels() {
|
||||||
|
assert_eq!(*pixel, Rgb([30, 30, 40]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_column_paints_exactly_its_upscaled_16x16_block() {
|
||||||
|
let backend = CpuRenderBackend::new();
|
||||||
|
let stone = color_for(1, 0);
|
||||||
|
let columns = vec![ColumnPixel { local_x: 0, local_z: 0, block_id: 1, block_meta: 0 }];
|
||||||
|
let img = decode(&backend.rasterize_tile(&columns).unwrap());
|
||||||
|
|
||||||
|
// Inside the upscaled region for local (0,0): pixels 0..16 on both axes.
|
||||||
|
assert_eq!(*img.get_pixel(0, 0), Rgb(stone));
|
||||||
|
assert_eq!(*img.get_pixel(15, 15), Rgb(stone));
|
||||||
|
// Just outside that region must still be background — proves UPSCALE didn't bleed.
|
||||||
|
assert_eq!(*img.get_pixel(16, 0), Rgb([30, 30, 40]));
|
||||||
|
assert_eq!(*img.get_pixel(0, 16), Rgb([30, 30, 40]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_grid_paints_every_upscaled_pixel() {
|
||||||
|
let backend = CpuRenderBackend::new();
|
||||||
|
let grass = color_for(2, 0);
|
||||||
|
let mut columns = Vec::with_capacity(256);
|
||||||
|
for x in 0..16u8 {
|
||||||
|
for z in 0..16u8 {
|
||||||
|
columns.push(ColumnPixel { local_x: x, local_z: z, block_id: 2, block_meta: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let img = decode(&backend.rasterize_tile(&columns).unwrap());
|
||||||
|
assert_eq!(img.dimensions(), (256, 256));
|
||||||
|
for pixel in img.pixels() {
|
||||||
|
assert_eq!(*pixel, Rgb(grass));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_bounds_columns_are_ignored_rather_than_panicking() {
|
||||||
|
let backend = CpuRenderBackend::new();
|
||||||
|
let columns = vec![ColumnPixel { local_x: 200, local_z: 0, block_id: 1, block_meta: 0 }];
|
||||||
|
let result = backend.rasterize_tile(&columns);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user