Initial REM planning editor
This commit is contained in:
+16
@@ -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"]
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
});
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user