Phase 11: real block textures for 2D tiles
Layers texture-averaged colors on top of palette.rs's hand-picked table as a fallback (color_for_textured), not a hard replacement — biome-tinted/animated blocks (grass top, leaves, water, lava) deliberately keep the hand-picked color since averaging their raw jar textures would be wrong, not just imprecise. Vanilla: worker/src/textures.rs downloads Mojang's official client jar directly from launchermeta/piston-meta/piston-data (same endpoints the real launcher uses, gated behind ACCEPT_MINECRAFT_EULA=true, off by default, mirrors BlueMap's accept-download) and averages every block texture, cached to disk so it's not re-downloaded every restart. Modded: ingests block_registry/block_textures messages from the mod (new Postgres tables + MinIO storage in api/src/textures.ts) — sent once per connection over the existing WS gateway. texturepacks/<name>/ (Dynmap-style flat PNGs) lets an operator override the vanilla defaults worker-wide via TEXTURE_PACK. A per-server texturePack admin column/API exists for the same purpose, but render-time per-server resolution (of both the admin selection and the ingested modded textures) is explicitly deferred — the worker still applies one process-wide palette; true per-server resolution needs the render pipeline to thread a server-scoped palette through the batch/GPU-dispatch path, judged too big a change for this phase. Documented in README. Docker was unavailable in this dev environment for the usual integration-test verification; bunx tsc --noEmit was used as a fallback static check instead (clean except 2 pre-existing unrelated errors in markers.test.ts).
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "texture_pack" text;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS "block_registry" (
|
||||
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
|
||||
"block_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
PRIMARY KEY ("server_id", "block_id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "block_textures" (
|
||||
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
|
||||
"name" text NOT NULL,
|
||||
"storage_key" text NOT NULL,
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY ("server_id", "name")
|
||||
);
|
||||
@@ -115,6 +115,36 @@ describe("updateServerSettings", () => {
|
||||
expect(result).toEqual({ ok: false, error: "invalid_waypoint_format" });
|
||||
});
|
||||
|
||||
test("texturePack defaults to null (use the downloaded defaults) on a freshly registered server", async () => {
|
||||
const created = await registerServer("admin-test-texturepack-default", "offline");
|
||||
if (!created.ok) throw new Error("setup failed");
|
||||
createdId = created.server.id;
|
||||
expect(created.server.texturePack).toBeNull();
|
||||
});
|
||||
|
||||
test("accepts a valid texturePack name and can clear it back to null", async () => {
|
||||
const created = await registerServer("admin-test-texturepack", "offline");
|
||||
if (!created.ok) throw new Error("setup failed");
|
||||
createdId = created.server.id;
|
||||
|
||||
const set = await updateServerSettings(createdId, { texturePack: "my-resource-pack" });
|
||||
expect(set.ok).toBe(true);
|
||||
if (set.ok) expect(set.server.texturePack).toBe("my-resource-pack");
|
||||
|
||||
const cleared = await updateServerSettings(createdId, { texturePack: null });
|
||||
expect(cleared.ok).toBe(true);
|
||||
if (cleared.ok) expect(cleared.server.texturePack).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects a texturePack name shaped like a path traversal attempt", async () => {
|
||||
const created = await registerServer("admin-test-bad-texturepack", "offline");
|
||||
if (!created.ok) throw new Error("setup failed");
|
||||
createdId = created.server.id;
|
||||
|
||||
const result = await updateServerSettings(createdId, { texturePack: "../../etc/passwd" });
|
||||
expect(result).toEqual({ ok: false, error: "invalid_texture_pack" });
|
||||
});
|
||||
|
||||
test("returns not_found for a nonexistent server", async () => {
|
||||
const result = await updateServerSettings(crypto.randomUUID(), { name: "x" });
|
||||
expect(result).toEqual({ ok: false, error: "not_found" });
|
||||
|
||||
@@ -4,6 +4,10 @@ import { servers } from "./db/schema";
|
||||
|
||||
const VALID_AUTH_MODES = ["online", "offline"];
|
||||
const VALID_WAYPOINT_FORMATS = ["journeymap", "xaero", "off"];
|
||||
// Matches a bare directory name only (no `/`, `..`, or other path-traversal-shaped input) — this
|
||||
// is used to build a `texturepacks/<name>/` path on the worker's filesystem (see textures.rs's
|
||||
// `average_directory`), so it's validated here even though nothing reads it yet at render time.
|
||||
const TEXTURE_PACK_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/**
|
||||
* Admin routes are gated on a single shared secret (`MCMAPPER_ADMIN_TOKEN`), not a per-account
|
||||
@@ -46,6 +50,7 @@ export type UpdateServerSettingsInput = Partial<{
|
||||
anonymousChatAllowed: boolean;
|
||||
waypointFormat: string;
|
||||
playerPositionsVisible: boolean;
|
||||
texturePack: string | null;
|
||||
}>;
|
||||
|
||||
export type UpdateServerSettingsResult =
|
||||
@@ -62,6 +67,13 @@ export async function updateServerSettings(
|
||||
if (input.waypointFormat !== undefined && !VALID_WAYPOINT_FORMATS.includes(input.waypointFormat)) {
|
||||
return { ok: false, error: "invalid_waypoint_format" };
|
||||
}
|
||||
if (
|
||||
input.texturePack !== undefined &&
|
||||
input.texturePack !== null &&
|
||||
!TEXTURE_PACK_NAME.test(input.texturePack)
|
||||
) {
|
||||
return { ok: false, error: "invalid_texture_pack" };
|
||||
}
|
||||
|
||||
const [server] = await db.update(servers).set(input).where(eq(servers.id, id)).returning();
|
||||
if (!server) return { ok: false, error: "not_found" };
|
||||
|
||||
@@ -18,6 +18,12 @@ export const servers = pgTable("servers", {
|
||||
// viewers. Independent of the mod-local `playerTrackingEnabled` config (see MCMapperMod.java's
|
||||
// doc comment) — this is the backend-side "should we show it" toggle, admin-configurable.
|
||||
playerPositionsVisible: boolean("player_positions_visible").notNull().default(true),
|
||||
// Phase 11: name of a `texturepacks/<name>/` directory on the worker (Dynmap-style operator
|
||||
// override, layered on top of the downloaded vanilla-texture-averaged defaults) — null means
|
||||
// "use the defaults". Recorded here so the choice is admin-configurable/persisted per server;
|
||||
// see worker/README's Phase 11 note for the current limitation on how this is actually applied
|
||||
// at render time (worker-wide via `TEXTURE_PACK`, not yet resolved per-job from this column).
|
||||
texturePack: text("texture_pack"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -151,6 +157,43 @@ export const meshPointers = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// Phase 11: the mod's numeric-blockId -> registry-name dump (e.g. `4000 -> "botania:manapool"`),
|
||||
// sent once per connection since it's stable for a world's lifetime (see BackendConnection.java's
|
||||
// wire-protocol comment). Lets the worker eventually resolve `chunkColumns.blockId` (a numeric id,
|
||||
// meaningless without the mod list that assigned it) to a texture — see `blockTextures` below.
|
||||
// Currently write-only from the api's perspective: nothing reads this table yet (the worker's
|
||||
// texture-averaged rendering is still one process-wide default palette, not per-server-resolved —
|
||||
// see worker/README's Phase 11 note), stored now so the data isn't lost once that wiring lands.
|
||||
export const blockRegistry = pgTable(
|
||||
"block_registry",
|
||||
{
|
||||
serverId: uuid("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
blockId: integer("block_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.serverId, table.blockId] })],
|
||||
);
|
||||
|
||||
// Phase 11: metadata pointer to one modded block's texture PNG (extracted by the mod off its own
|
||||
// classloader — Forge doesn't split client/server jars, so these assets are already present on a
|
||||
// dedicated server's classpath, just unused server-side) in the shared MinIO bucket. Mirrors
|
||||
// `tilePointers`/`meshPointers`'s "binary in MinIO, pointer in Postgres" shape, just populated
|
||||
// from the api side (this data arrives over the mod's WS connection) rather than by the worker.
|
||||
export const blockTextures = pgTable(
|
||||
"block_textures",
|
||||
{
|
||||
serverId: uuid("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
storageKey: text("storage_key").notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.serverId, table.name] })],
|
||||
);
|
||||
|
||||
// Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres.
|
||||
// One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
|
||||
// multi-resolution tiles.
|
||||
|
||||
+10
-7
@@ -260,19 +260,22 @@ export const app = new Elysia()
|
||||
set.status = 401;
|
||||
return { error: "unauthenticated" };
|
||||
}
|
||||
const { name, authMode, anonymousChatAllowed, waypointFormat, playerPositionsVisible } = body as {
|
||||
name?: string;
|
||||
authMode?: string;
|
||||
anonymousChatAllowed?: boolean;
|
||||
waypointFormat?: string;
|
||||
playerPositionsVisible?: boolean;
|
||||
};
|
||||
const { name, authMode, anonymousChatAllowed, waypointFormat, playerPositionsVisible, texturePack } =
|
||||
body as {
|
||||
name?: string;
|
||||
authMode?: string;
|
||||
anonymousChatAllowed?: boolean;
|
||||
waypointFormat?: string;
|
||||
playerPositionsVisible?: boolean;
|
||||
texturePack?: string | null;
|
||||
};
|
||||
const result = await updateServerSettings(params.id, {
|
||||
name,
|
||||
authMode,
|
||||
anonymousChatAllowed,
|
||||
waypointFormat,
|
||||
playerPositionsVisible,
|
||||
texturePack,
|
||||
});
|
||||
if (!result.ok) set.status = result.error === "not_found" ? 404 : 400;
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "./db/client";
|
||||
import { blockRegistry, blockTextures } from "./db/schema";
|
||||
import { minio, TILE_BUCKET } from "./minio";
|
||||
import { storeBlockRegistry, storeBlockTextures, textureStorageKey } from "./textures";
|
||||
import { createTestServer, deleteTestServer } from "./test-helpers";
|
||||
|
||||
describe("textureStorageKey", () => {
|
||||
test("sanitizes registry-name characters not safe in a flat object key", () => {
|
||||
expect(textureStorageKey("server-1", "botania:manaPool")).toBe("server-1/textures/botania_manaPool.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeBlockRegistry", () => {
|
||||
let server: { id: string };
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createTestServer("block-registry");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteTestServer(server.id);
|
||||
});
|
||||
|
||||
test("upserts entries, keyed by (serverId, blockId)", async () => {
|
||||
await storeBlockRegistry(server.id, [
|
||||
{ id: 4000, name: "botania:manapool" },
|
||||
{ id: 4001, name: "botania:altar" },
|
||||
]);
|
||||
const rows = await db.select().from(blockRegistry).where(eq(blockRegistry.serverId, server.id));
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows.find((r) => r.blockId === 4000)?.name).toBe("botania:manapool");
|
||||
});
|
||||
|
||||
test("re-sending the same id with a new name overwrites, not duplicates", async () => {
|
||||
await storeBlockRegistry(server.id, [{ id: 4000, name: "botania:mana_pool_renamed" }]);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(blockRegistry)
|
||||
.where(and(eq(blockRegistry.serverId, server.id), eq(blockRegistry.blockId, 4000)));
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0]!.name).toBe("botania:mana_pool_renamed");
|
||||
});
|
||||
|
||||
test("an empty list is a no-op, not an error", async () => {
|
||||
await expect(storeBlockRegistry(server.id, [])).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeBlockTextures", () => {
|
||||
let server: { id: string };
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createTestServer("block-textures");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteTestServer(server.id);
|
||||
await minio.removeObject(TILE_BUCKET, textureStorageKey(server.id, "botania:manapool")).catch(() => {});
|
||||
});
|
||||
|
||||
test("stores the raw PNG bytes in MinIO and a pointer row in Postgres", async () => {
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // not a real PNG — just distinguishable bytes
|
||||
await storeBlockTextures(server.id, [{ name: "botania:manapool", dataBase64: pngBytes.toString("base64") }]);
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(blockTextures)
|
||||
.where(and(eq(blockTextures.serverId, server.id), eq(blockTextures.name, "botania:manapool")));
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.storageKey).toBe(textureStorageKey(server.id, "botania:manapool"));
|
||||
|
||||
const stream = await minio.getObject(TILE_BUCKET, row!.storageKey);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
||||
expect(Buffer.concat(chunks).equals(pngBytes)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "./db/client";
|
||||
import { blockRegistry, blockTextures } from "./db/schema";
|
||||
import { minio, TILE_BUCKET } from "./minio";
|
||||
|
||||
export interface BlockRegistryEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BlockTextureEntry {
|
||||
name: string;
|
||||
dataBase64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts the mod's numeric-blockId -> registry-name dump for one server (Phase 11) — sent once
|
||||
* per connection since a world's id assignments are stable for its lifetime (see
|
||||
* BackendConnection.java's wire-protocol comment). Nothing reads this table yet; it's stored now
|
||||
* so the mapping isn't lost before the worker's per-server texture resolution (still a single
|
||||
* process-wide default palette — see worker/README's Phase 11 note) is wired up to use it.
|
||||
*/
|
||||
export async function storeBlockRegistry(serverId: string, entries: BlockRegistryEntry[]) {
|
||||
if (entries.length === 0) return;
|
||||
await db
|
||||
.insert(blockRegistry)
|
||||
.values(entries.map((e) => ({ serverId, blockId: e.id, name: e.name })))
|
||||
.onConflictDoUpdate({
|
||||
target: [blockRegistry.serverId, blockRegistry.blockId],
|
||||
set: { name: sql.raw("excluded.name") },
|
||||
});
|
||||
}
|
||||
|
||||
/** Registry names are "modid:path" — sanitize to a flat, safe MinIO object key. */
|
||||
export function textureStorageKey(serverId: string, name: string): string {
|
||||
const safe = name.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
||||
return `${serverId}/textures/${safe}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores raw texture PNGs the mod extracted off its own classloader (Phase 11 — Forge doesn't
|
||||
* split client/server jars, so a dedicated server already has every loaded mod's texture assets
|
||||
* on its classpath, just unused server-side) into the shared MinIO bucket, one object per
|
||||
* registry name, with a metadata pointer row — mirrors the tile/mesh pointer "binary in MinIO,
|
||||
* pointer in Postgres" pattern (worker/src/storage.rs), just populated from the api side since
|
||||
* this data arrives over the mod's WS connection rather than through the render pipeline.
|
||||
*/
|
||||
export async function storeBlockTextures(serverId: string, textures: BlockTextureEntry[]) {
|
||||
for (const t of textures) {
|
||||
const bytes = Buffer.from(t.dataBase64, "base64");
|
||||
const storageKey = textureStorageKey(serverId, t.name);
|
||||
await minio.putObject(TILE_BUCKET, storageKey, bytes);
|
||||
await db
|
||||
.insert(blockTextures)
|
||||
.values({ serverId, name: t.name, storageKey })
|
||||
.onConflictDoUpdate({
|
||||
target: [blockTextures.serverId, blockTextures.name],
|
||||
set: { storageKey, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { markChunkDirty } from "./redis";
|
||||
import { storeLinkCode } from "./link";
|
||||
import { recordAndPublishChat } from "./chat";
|
||||
import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition } from "./players";
|
||||
import { storeBlockRegistry, storeBlockTextures, type BlockRegistryEntry, type BlockTextureEntry } from "./textures";
|
||||
|
||||
// Wire protocol (mod <-> api), one JSON object per WS text frame:
|
||||
//
|
||||
@@ -58,6 +59,18 @@ import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition
|
||||
// players.ts's publishPlayerPositions to whatever browsers are subscribed on /ws/players/:serverId
|
||||
// (players-gateway.ts), gated on the per-server `playerPositionsVisible` admin toggle (independent
|
||||
// of the mod-local `playerTrackingEnabled` config that decides whether this message is sent at all).
|
||||
//
|
||||
// mod -> api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
|
||||
// mod -> api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
|
||||
//
|
||||
// Phase 11: sent once after `hello_ack` (a world's numeric block-id assignments and mod-jar
|
||||
// contents are both stable for the server's lifetime, so there's no need to resend on a timer).
|
||||
// `block_registry` is the numeric-id -> registry-name mapping needed to make sense of
|
||||
// `chunkColumns.blockId`/`blockMeta` for modded blocks (see MCMapperMod.java's connect-time
|
||||
// registry dump); `block_textures` is the mod's best-effort classloader extraction of each
|
||||
// block's texture PNG (see textures.ts's doc comment for storage). Both are stored now but not
|
||||
// yet read by the render pipeline — see worker/README's Phase 11 note on the deferred per-server
|
||||
// palette-resolution work this unlocks.
|
||||
|
||||
interface Column {
|
||||
x: number;
|
||||
@@ -219,6 +232,18 @@ export const wsGateway = {
|
||||
await publishPlayerPositions(state.serverId, dimension, players);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "block_registry") {
|
||||
const entries: BlockRegistryEntry[] = msg.entries ?? [];
|
||||
await storeBlockRegistry(state.serverId, entries);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "block_textures") {
|
||||
const textures: BlockTextureEntry[] = msg.textures ?? [];
|
||||
await storeBlockTextures(state.serverId, textures);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: any) {
|
||||
|
||||
Reference in New Issue
Block a user