Initial REM planning editor

This commit is contained in:
FlatHack
2026-06-18 10:27:16 +02:00
commit a5f686510f
9 changed files with 4663 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
FROM node:24-alpine
WORKDIR /app
COPY package.json ./
COPY server.mjs ./
COPY star-map.html ./
ENV HOST=0.0.0.0
ENV PORT=3000
ENV DATA_DIR=/data
VOLUME ["/data"]
EXPOSE 3000
CMD ["node", "server.mjs"]
+257
View File
@@ -0,0 +1,257 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const projectDir = dirname(fileURLToPath(import.meta.url));
const systemNames = {
li01: "New York",
li02: "California",
li03: "Colorado",
li04: "Texas",
li05: "Alaska",
li06: "New Nevada",
li07: "Puerto Rico",
br01: "New London",
br02: "Cambridge",
br03: "Manchester",
br04: "Leeds",
br05: "Edinburgh",
br06: "Dublin",
ku01: "New Tokyo",
ku02: "Shikoku",
ku03: "Kyushu",
ku04: "Honshu",
ku05: "Hokkaido",
ku06: "Chugoku",
ku07: "Tohoku",
rh01: "New Berlin",
rh02: "Frankfurt",
rh03: "Stuttgart",
rh04: "Hamburg",
rh05: "Dresden",
iw01: "Bering",
iw02: "Hudson",
iw03: "Magellan",
iw04: "Cortez",
iw05: "Kepler",
iw06: "Galileo",
bw01: "Omega-3",
bw02: "Omega-5",
bw03: "Omega-7",
bw04: "Omega-11",
bw05: "Omega-41",
bw06: "Sigma-13",
bw07: "Sigma-17",
bw08: "Sigma-19",
bw09: "Tau-31",
bw10: "Tau-29",
bw11: "Sigma 2",
ew01: "Tau-37",
ew02: "Omicron Alpha",
ew03: "Omicron Gamma",
ew04: "Omicron Theta",
ew05: "Unknown Gamma",
ew06: "Unknown Delta",
hi01: "Unknown Alpha",
hi02: "Omicron Beta",
st01: "Omicron Minor",
st02: "Omicron Beta",
st02c: "Unknown Beta",
st03: "Omicron Major",
st03b: "Omicron Major",
st04: "Omicron Lambda",
st05: "Omicron Prime",
fp7_system: "Omicron Major"
};
function stripComment(line) {
return line.replace(/;.*/, "").trim();
}
function parseIniBlocks(text) {
const blocks = [];
let current = null;
for (const rawLine of text.split(/\r?\n/)) {
const line = stripComment(rawLine);
if (!line) {
continue;
}
const header = line.match(/^\[([^\]]+)]$/);
if (header) {
current = { section: header[1].toLowerCase(), values: {} };
blocks.push(current);
continue;
}
if (!current) {
continue;
}
const pair = line.match(/^([^=]+?)\s*=\s*(.*)$/);
if (!pair) {
continue;
}
const key = pair[1].trim().toLowerCase();
const value = pair[2].trim();
if (!current.values[key]) {
current.values[key] = [];
}
current.values[key].push(value);
}
return blocks;
}
function firstValue(block, key) {
return block.values[key]?.[0];
}
function parseGridPosition(value) {
const parts = value.split(",").map((part) => Number(part.trim()));
if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) {
throw new Error(`Invalid system pos: ${value}`);
}
return { gridX: parts[0], gridY: parts[1] };
}
function makeConnectionId(from, to) {
return `c-${from}-${to}`;
}
function normalizePair(from, to) {
return [from, to].sort((a, b) => a.localeCompare(b)).join("|");
}
async function readDirectConnections(universeDir, systemsById) {
const connections = [];
const seen = new Set();
for (const system of systemsById.values()) {
const systemFile = resolve(universeDir, system.file.replaceAll("\\", "/"));
let text;
try {
text = await readFile(systemFile, "utf8");
} catch {
continue;
}
for (const block of parseIniBlocks(text)) {
if (block.section !== "object") {
continue;
}
const gotoValues = block.values.goto || [];
const archetype = (firstValue(block, "archetype") || "").toLowerCase();
const type = archetype.includes("hole") ? "neutral" : "standard";
for (const gotoValue of gotoValues) {
const targetId = gotoValue.split(",")[0]?.trim().toLowerCase();
if (!targetId || !systemsById.has(targetId) || targetId === system.id) {
continue;
}
const key = normalizePair(system.id, targetId);
if (seen.has(key)) {
continue;
}
seen.add(key);
const [from, to] = key.split("|");
connections.push({
id: makeConnectionId(from, to),
from,
to,
type
});
}
}
}
return connections.sort((a, b) => a.id.localeCompare(b.id));
}
export async function buildUniverseMap(options = {}) {
const universeDir = options.universeDir || "C:/PROJECTS/REM/REM-Mod/DATA/UNIVERSE";
const universePath = join(universeDir, "universe.ini");
const text = await readFile(universePath, "utf8");
const systemBlocks = parseIniBlocks(text).filter((block) => block.section === "system");
const parsedSystems = systemBlocks.map((block) => {
const nickname = firstValue(block, "nickname");
const file = firstValue(block, "file");
const pos = firstValue(block, "pos");
if (!nickname || !file || !pos) {
throw new Error("System block is missing nickname/file/pos");
}
const id = nickname.toLowerCase();
const { gridX, gridY } = parseGridPosition(pos);
return {
id,
nickname,
name: systemNames[id] || nickname,
file,
gridX,
gridY,
stridName: Number(firstValue(block, "strid_name") || 0),
navMapScale: Number(firstValue(block, "navmapscale") || 1),
visit: Number(firstValue(block, "visit") || 0),
note: ""
};
});
const minCol = Math.min(...parsedSystems.map((system) => system.gridX));
const minRow = Math.min(...parsedSystems.map((system) => system.gridY));
const maxCol = Math.max(...parsedSystems.map((system) => system.gridX));
const maxRow = Math.max(...parsedSystems.map((system) => system.gridY));
const cellSize = 72;
const margin = 72;
const grid = { minCol, minRow, maxCol, maxRow, cellSize, margin };
const systems = parsedSystems.map((system) => ({
...system,
x: margin + (system.gridX - minCol) * cellSize,
y: margin + (system.gridY - minRow) * cellSize
})).sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
const systemsById = new Map(systems.map((system) => [system.id, system]));
const connections = await readDirectConnections(universeDir, systemsById);
return {
source: "universe.ini",
background: "#05080f",
grid,
viewBox: {
x: 0,
y: 0,
width: margin * 2 + (maxCol - minCol) * cellSize,
height: margin * 2 + (maxRow - minRow) * cellSize
},
systems,
connections
};
}
async function updateProjectFiles() {
const mapData = await buildUniverseMap();
const htmlPath = join(projectDir, "star-map.html");
const dataPath = join(projectDir, "data", "map.json");
const html = await readFile(htmlPath, "utf8");
const json = JSON.stringify(mapData, null, 2);
const nextHtml = html.replace(
/<script type="application\/json" id="initial-map-data">[\s\S]*?<\/script>/,
`<script type="application/json" id="initial-map-data">\n${json}\n </script>`
);
if (nextHtml === html) {
throw new Error("Could not replace initial-map-data in star-map.html");
}
await writeFile(htmlPath, nextHtml, "utf8");
await mkdir(dirname(dataPath), { recursive: true });
await writeFile(dataPath, `${json}\n`, "utf8");
return mapData;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const mapData = await updateProjectFiles();
console.log(`Imported ${mapData.systems.length} systems and ${mapData.connections.length} connections from universe.ini`);
}
+1434
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "editable-star-map",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"import:universe": "node build-universe-map.mjs",
"start": "node server.mjs",
"test": "node --test star-map.test.mjs server.test.mjs"
},
"engines": {
"node": ">=20"
}
}
+145
View File
@@ -0,0 +1,145 @@
import { createServer as createHttpServer } from "node:http";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const projectDir = dirname(fileURLToPath(import.meta.url));
function sendJson(response, statusCode, value) {
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store"
});
response.end(JSON.stringify(value));
}
function sendText(response, statusCode, text, contentType = "text/plain; charset=utf-8") {
response.writeHead(statusCode, {
"content-type": contentType,
"cache-control": "no-store"
});
response.end(text);
}
async function readRequestBody(request, limitBytes = 5 * 1024 * 1024) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > limitBytes) {
throw new Error("Payload zu gross");
}
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
function validateMapData(value) {
if (!value || typeof value !== "object") {
throw new Error("Karte muss ein Objekt sein");
}
if (!Array.isArray(value.systems) || !Array.isArray(value.connections)) {
throw new Error("systems und connections muessen Arrays sein");
}
const ids = new Set();
for (const system of value.systems) {
if (!system || typeof system !== "object") {
throw new Error("Ungueltiges System");
}
if (typeof system.id !== "string" || typeof system.name !== "string") {
throw new Error("System braucht id und name");
}
if (!Number.isFinite(system.x) || !Number.isFinite(system.y)) {
throw new Error("System braucht x/y Koordinaten");
}
ids.add(system.id);
}
for (const connection of value.connections) {
if (!connection || typeof connection !== "object") {
throw new Error("Ungueltige Verbindung");
}
if (typeof connection.id !== "string" || typeof connection.from !== "string" || typeof connection.to !== "string") {
throw new Error("Verbindung braucht id/from/to");
}
if (!ids.has(connection.from) || !ids.has(connection.to)) {
throw new Error("Verbindung verweist auf unbekanntes System");
}
}
return {
source: typeof value.source === "string" ? value.source : undefined,
background: typeof value.background === "string" ? value.background : "#05080f",
grid: value.grid && typeof value.grid === "object" ? value.grid : undefined,
viewBox: value.viewBox && typeof value.viewBox === "object" ? value.viewBox : undefined,
systems: value.systems,
connections: value.connections
};
}
async function readInitialMap(htmlPath) {
const html = await readFile(htmlPath, "utf8");
const match = html.match(/<script type="application\/json" id="initial-map-data">([\s\S]*?)<\/script>/);
if (!match) {
throw new Error("initial-map-data nicht gefunden");
}
return validateMapData(JSON.parse(match[1]));
}
export function createMapServer(options = {}) {
const htmlPath = options.htmlPath || join(projectDir, "star-map.html");
const dataDir = options.dataDir || process.env.DATA_DIR || join(projectDir, "data");
const dataPath = join(dataDir, "map.json");
return createHttpServer(async (request, response) => {
try {
const url = new URL(request.url, "http://localhost");
if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/star-map.html")) {
const html = await readFile(htmlPath, "utf8");
sendText(response, 200, html, "text/html; charset=utf-8");
return;
}
if (url.pathname === "/api/map" && request.method === "GET") {
if (existsSync(dataPath)) {
sendJson(response, 200, validateMapData(JSON.parse(await readFile(dataPath, "utf8"))));
return;
}
sendJson(response, 200, await readInitialMap(htmlPath));
return;
}
if (url.pathname === "/api/map" && request.method === "PUT") {
const body = await readRequestBody(request);
const mapData = validateMapData(JSON.parse(body));
await mkdir(dataDir, { recursive: true });
const tempPath = `${dataPath}.tmp`;
await writeFile(tempPath, `${JSON.stringify(mapData, null, 2)}\n`, "utf8");
await rename(tempPath, dataPath);
sendJson(response, 200, { ok: true });
return;
}
sendJson(response, 404, { error: "Nicht gefunden" });
} catch (error) {
const status = error instanceof SyntaxError || /ungueltig|braucht|muss|verweist|Payload|systems/i.test(error.message)
? 400
: 500;
sendJson(response, status, { error: error.message });
}
});
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const port = Number(process.env.PORT || 3000);
const host = process.env.HOST || "127.0.0.1";
const server = createMapServer();
server.listen(port, host, () => {
console.log(`Star Map Editor: http://${host}:${port}`);
});
}
+60
View File
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createMapServer } from "./server.mjs";
test("server stores and returns the editable map JSON", async () => {
const dataDir = await mkdtemp(join(tmpdir(), "star-map-"));
const server = createMapServer({ dataDir });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
const baseUrl = `http://127.0.0.1:${port}`;
try {
const payload = {
background: "#101010",
systems: [{ id: "alpha", name: "Alpha", x: 10, y: 20, note: "saved" }],
connections: []
};
const saveResponse = await fetch(`${baseUrl}/api/map`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
assert.equal(saveResponse.status, 200);
assert.deepEqual(await saveResponse.json(), { ok: true });
const loadResponse = await fetch(`${baseUrl}/api/map`);
assert.equal(loadResponse.status, 200);
assert.deepEqual(await loadResponse.json(), payload);
} finally {
await new Promise((resolve) => server.close(resolve));
await rm(dataDir, { recursive: true, force: true });
}
});
test("server rejects invalid map payloads", async () => {
const dataDir = await mkdtemp(join(tmpdir(), "star-map-"));
const server = createMapServer({ dataDir });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
try {
const response = await fetch(`http://127.0.0.1:${port}/api/map`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ systems: "bad", connections: [] })
});
assert.equal(response.status, 400);
} finally {
await new Promise((resolve) => server.close(resolve));
await rm(dataDir, { recursive: true, force: true });
}
});
+2620
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildUniverseMap } from "./build-universe-map.mjs";
const html = readFileSync(new URL("./star-map.html", import.meta.url), "utf8");
const startScript = readFileSync(new URL("./start-webserver.cmd", import.meta.url), "utf8");
function extractJsonScript(id) {
const pattern = new RegExp(`<script type="application/json" id="${id}">([\\s\\S]*?)</script>`);
const match = html.match(pattern);
assert.ok(match, `missing JSON script #${id}`);
return JSON.parse(match[1]);
}
test("universe import builds a grid map from universe.ini", async () => {
const data = await buildUniverseMap({
universeDir: "C:/PROJECTS/REM/REM-Mod/DATA/UNIVERSE"
});
assert.equal(data.source, "universe.ini");
assert.equal(data.systems.length, 58);
assert.ok(data.connections.length >= 80, "expected direct jump connections from system INIs");
assert.deepEqual(data.grid, {
minCol: 0,
minRow: 0,
maxCol: 16,
maxRow: 16,
cellSize: 72,
margin: 72
});
const newYork = data.systems.find((system) => system.id === "li01");
assert.ok(newYork, "missing Li01");
assert.equal(newYork.name, "New York");
assert.equal(newYork.gridX, 7);
assert.equal(newYork.gridY, 8);
assert.equal(newYork.x, 576);
assert.equal(newYork.y, 648);
assert.ok(data.connections.some((connection) => connection.from === "li01" && connection.to === "li02"));
});
test("template contains the systems and connections from universe.ini", () => {
const data = extractJsonScript("initial-map-data");
assert.equal(data.source, "universe.ini");
assert.equal(data.systems.length, 58, "expected active systems from universe.ini");
assert.ok(data.connections.length >= 80, "expected direct route set from system INIs");
assert.ok(data.grid, "expected grid metadata");
for (const name of ["Tau-37", "Leeds", "New Tokyo", "Sigma-19", "New Berlin", "Omicron Prime"]) {
assert.ok(data.systems.some((system) => system.name === name), `missing ${name}`);
}
for (const connection of data.connections) {
assert.ok(data.systems.some((system) => system.id === connection.from), `missing source ${connection.from}`);
assert.ok(data.systems.some((system) => system.id === connection.to), `missing target ${connection.to}`);
}
});
test("editor UI exposes creation, deletion, linking, notes, and background controls", () => {
assert.match(html, /data-mode="add"/, "missing add-system mode");
assert.match(html, /data-mode="connect"/, "missing connection mode");
assert.match(html, /id="deleteSelected"/, "missing delete action");
assert.match(html, /id="systemNote"/, "missing per-system note field");
assert.match(html, /id="backgroundColor"/, "missing configurable background");
assert.match(html, /id="saveRemote"/, "missing persistent save action");
assert.match(html, /id="loadRemote"/, "missing persistent load action");
assert.doesNotMatch(html, /id="snapToGrid"/, "grid snapping must not be optional");
assert.match(html, /function snapPointToGrid/, "missing grid snapping behavior");
assert.doesNotMatch(html, /snapToGrid\.checked/, "grid snapping must always be active");
assert.match(html, /function createSystem/, "missing system creation behavior");
assert.match(html, /function createConnection/, "missing connection creation behavior");
assert.match(html, /async function saveRemoteMap/, "missing server save behavior");
assert.match(html, /async function loadRemoteMap/, "missing server load behavior");
});
test("system dragging has a forgiving hit target and stable pointer handlers", () => {
assert.match(html, /class", "system-hit"/, "missing large system drag hit target");
assert.match(html, /function handleDragMove/, "missing shared drag move handler");
assert.match(html, /window\.addEventListener\("pointermove", handleDragMove\)/, "missing window pointermove handler");
assert.match(html, /window\.addEventListener\("pointerup", handleDragEnd\)/, "missing window pointerup handler");
});
test("start script launches the local server and opens the browser", () => {
assert.match(startScript, /node.*server\.mjs/i, "start script must launch server.mjs with node");
assert.match(startScript, /127\.0\.0\.1:3000/i, "start script must open the local app URL");
assert.match(startScript, /Start-Process/i, "start script must open the browser");
});
+27
View File
@@ -0,0 +1,27 @@
@echo off
setlocal
set "APP_DIR=%~dp0"
set "PORT=3000"
set "APP_URL=http://127.0.0.1:3000/"
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$ErrorActionPreference = 'Stop';" ^
"$appDir = $env:APP_DIR;" ^
"$port = [int]$env:PORT;" ^
"$url = $env:APP_URL;" ^
"$listener = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue;" ^
"if (-not $listener) {" ^
" Start-Process -FilePath 'node' -ArgumentList 'server.mjs' -WorkingDirectory $appDir -WindowStyle Hidden;" ^
" Start-Sleep -Milliseconds 900;" ^
"}" ^
"Start-Process $url;"
if errorlevel 1 (
echo Fehler: Server konnte nicht gestartet oder Browser nicht geoeffnet werden.
echo Pruefe, ob Node.js installiert ist und Port %PORT% frei ist.
pause
exit /b 1
)
endlocal