From 460d136d21de01803f9f29595d9c16acc1efb44d Mon Sep 17 00:00:00 2001 From: Octoturge Date: Mon, 17 Aug 2026 21:46:42 +0200 Subject: [PATCH] chore: initial commit of foundryvtt-admin Spun out of the hbm-books working folder into its own repo. A small backend+frontend+COBOL admin tool for managing Foundry VTT worlds (socket inspection, join/shutdown debugging). --- .gitignore | 6 + backend/Dockerfile | 7 + backend/package.json | 15 + backend/src/debug-join-socket-verbose.ts | 58 ++ backend/src/debug-join-socket.ts | 41 + backend/src/debug-shutdown.ts | 64 ++ backend/src/debug-socket-actors.ts | 127 +++ backend/src/debug-socket-worlds.ts | 75 ++ backend/src/fetch-join.ts | 29 + backend/src/index.ts | 940 +++++++++++++++++++++++ backend/src/inspect-sockets.ts | 34 + backend/src/join-output.html | 80 ++ backend/tsconfig.json | 12 + cobol/Dockerfile | 4 + cobol/README.md | 190 +++++ cobol/hbm-admin.cbl | 26 + cobol/http_client.c | 28 + cobol/run.sh | 4 + docker-compose.yml | 29 + frontend/Dockerfile | 7 + frontend/index.html | 12 + frontend/package.json | 20 + frontend/src/index.pug | 130 ++++ frontend/src/main.ts | 501 ++++++++++++ frontend/src/pug.d.ts | 4 + frontend/src/style.css | 7 + frontend/tsconfig.json | 19 + frontend/vite.config.ts | 25 + 28 files changed, 2494 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/debug-join-socket-verbose.ts create mode 100644 backend/src/debug-join-socket.ts create mode 100644 backend/src/debug-shutdown.ts create mode 100644 backend/src/debug-socket-actors.ts create mode 100644 backend/src/debug-socket-worlds.ts create mode 100644 backend/src/fetch-join.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/inspect-sockets.ts create mode 100644 backend/src/join-output.html create mode 100644 backend/tsconfig.json create mode 100644 cobol/Dockerfile create mode 100644 cobol/README.md create mode 100644 cobol/hbm-admin.cbl create mode 100644 cobol/http_client.c create mode 100644 cobol/run.sh create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/index.pug create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/pug.d.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c641f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.env.* +*.env +node_modules/ +dist/ +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e5c086b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM oven/bun:latest +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN bun install +COPY src ./src +EXPOSE 3000 +CMD ["bun", "run", "src/index.ts"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..706aa52 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,15 @@ +{ + "name": "foundryvtt-admin-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "bun run src/index.ts" + }, + "dependencies": { + "socket.io-client": "^4.7.5" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + } +} diff --git a/backend/src/debug-join-socket-verbose.ts b/backend/src/debug-join-socket-verbose.ts new file mode 100644 index 0000000..d87063d --- /dev/null +++ b/backend/src/debug-join-socket-verbose.ts @@ -0,0 +1,58 @@ +import { io } from "socket.io-client"; +import { fetch } from "bun"; + +const FOUNDRY_URL = process.env.FOUNDRY_URL || "http://192.168.0.3:30000/"; + +function getFoundryUrl(subpath: string): string { + const base = FOUNDRY_URL.endsWith("/") ? FOUNDRY_URL.slice(0, -1) : FOUNDRY_URL; + const normalizedPath = subpath.startsWith("/") ? subpath : `/${subpath}`; + return `${base}${normalizedPath}`; +} + +async function run() { + console.log("Fetching join page to get guest cookie..."); + const joinPageRes = await fetch(getFoundryUrl("/join")); + const cookies = joinPageRes.headers.getSetCookie(); + let guestCookie = ""; + for (const c of cookies) { + if (c.startsWith("session=")) { + guestCookie = c.split(";")[0]; + break; + } + } + console.log("Guest cookie:", guestCookie); + + console.log("Connecting via Socket.io to:", getFoundryUrl("/")); + const socket = io(getFoundryUrl("/"), { + extraHeaders: { + Cookie: guestCookie + }, + timeout: 5000, + transports: ["websocket", "polling"] // try both + }); + + socket.on("connect", () => { + console.log("Socket connected!"); + console.log("Emitting getJoinData..."); + socket.emit("getJoinData", (data: any) => { + console.log("Callback received!"); + console.log("Data keys:", data ? Object.keys(data) : "null"); + console.log("World:", data ? JSON.stringify(data.world, null, 2) : "null"); + socket.disconnect(); + }); + }); + + socket.on("connect_error", (err) => { + console.log("connect_error:", err.message, err); + }); + + socket.on("disconnect", (reason) => { + console.log("disconnect:", reason); + }); + + socket.on("error", (err) => { + console.log("error event:", err); + }); +} + +run(); diff --git a/backend/src/debug-join-socket.ts b/backend/src/debug-join-socket.ts new file mode 100644 index 0000000..dde781c --- /dev/null +++ b/backend/src/debug-join-socket.ts @@ -0,0 +1,41 @@ +import { io } from "socket.io-client"; + +const FOUNDRY_URL = process.env.FOUNDRY_URL; + +async function run() { + console.log(`Connecting via Socket.io to: ${FOUNDRY_URL}...`); + try { + const socket = io(FOUNDRY_URL); + + socket.on("connect", () => { + console.log("Socket connected! Emitting getJoinData..."); + socket.emit("getJoinData", (data: any) => { + console.log("Received getJoinData response!"); + console.log("Data keys:", Object.keys(data)); + + if (data.users) { + console.log("Users count:", data.users.length); + console.log("Users list:", data.users.map((u: any) => ({ + id: u.id, + name: u.name, + role: u.role, + active: u.active + }))); + } else { + console.log("No users in join data:", data); + } + + socket.disconnect(); + }); + }); + + socket.on("connect_error", (err) => { + console.error("Connection error:", err); + }); + + } catch (err: any) { + console.error("Error:", err); + } +} + +run(); diff --git a/backend/src/debug-shutdown.ts b/backend/src/debug-shutdown.ts new file mode 100644 index 0000000..adb7904 --- /dev/null +++ b/backend/src/debug-shutdown.ts @@ -0,0 +1,64 @@ +import { fetch } from "bun"; + +const FOUNDRY_URL = process.env.FOUNDRY_URL; +const FOUNDRY_ADMIN_KEY = process.env.FOUNDRY_ADMIN_KEY; + +async function run() { + try { + // 1. Authenticate with admin password to get session cookie + const payload = new URLSearchParams(); + payload.append("action", "adminPassword"); + payload.append("adminPassword", FOUNDRY_ADMIN_KEY); + + console.log("Authenticating..."); + const res = await fetch(`${FOUNDRY_URL}/setup`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: payload.toString(), + redirect: "manual", + }); + + console.log(`Auth Status: ${res.status}`); + const cookies = res.headers.getSetCookie(); + console.log("Cookies:", cookies); + + let sessionCookie = ""; + for (const cookie of cookies) { + if (cookie.startsWith("session=")) { + sessionCookie = cookie.split(";")[0]; + break; + } + } + + if (!sessionCookie) { + console.log("No session cookie returned."); + return; + } + + // 2. Post shutdown request + console.log("Sending shutdown request..."); + const shutdownRes = await fetch(`${FOUNDRY_URL}/setup`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: sessionCookie, + }, + body: JSON.stringify({ + shutdown: true + }), + redirect: "manual" + }); + + console.log(`Shutdown Res Status: ${shutdownRes.status}`); + const text = await shutdownRes.text(); + console.log(`Response length: ${text.length}`); + console.log(`Response headers:`, shutdownRes.headers); + + } catch (err: any) { + console.error("Error:", err); + } +} + +run(); diff --git a/backend/src/debug-socket-actors.ts b/backend/src/debug-socket-actors.ts new file mode 100644 index 0000000..0a62684 --- /dev/null +++ b/backend/src/debug-socket-actors.ts @@ -0,0 +1,127 @@ +import { fetch } from "bun"; +import { io } from "socket.io-client"; + +const FOUNDRY_URL = process.env.FOUNDRY_URL || "http://192.168.0.3:30000/"; +const FOUNDRY_USER = "Gamemaster"; +const FOUNDRY_PASSWORD = "Gamemaster123!"; +const FOUNDRY_WORLD = "a-story-yet-to-be-finished"; + +function getFoundryUrl(subpath: string): string { + const base = FOUNDRY_URL.endsWith("/") ? FOUNDRY_URL.slice(0, -1) : FOUNDRY_URL; + const normalizedPath = subpath.startsWith("/") ? subpath : `/${subpath}`; + return `${base}${normalizedPath}`; +} + +async function fetchUserId(cookie: string): Promise { + return new Promise((resolve, reject) => { + const tempSocket = io(getFoundryUrl("/"), { + forceNew: true, + multiplex: false, + transports: ["websocket", "polling"], + extraHeaders: { Cookie: cookie }, + timeout: 5000 + }); + tempSocket.on("connect", () => { + tempSocket.emit("getJoinData", (data: any) => { + tempSocket.disconnect(); + if (data && data.users) { + const user = data.users.find((u: any) => u.name.toLowerCase() === FOUNDRY_USER.toLowerCase()); + if (user) resolve(user._id || user.id); + else reject(new Error("User not found")); + } else { + reject(new Error("No users list")); + } + }); + }); + tempSocket.on("connect_error", reject); + }); +} + +async function run() { + try { + console.log("Fetching guest cookie..."); + const joinRes = await fetch(getFoundryUrl("/join")); + const cookies = joinRes.headers.getSetCookie(); + let guestCookie = ""; + for (const c of cookies) { + if (c.startsWith("session=")) { + guestCookie = c.split(";")[0]; + break; + } + } + console.log("Guest Cookie:", guestCookie); + const userId = await fetchUserId(guestCookie); + console.log("User ID:", userId); + + const loginPayload = new URLSearchParams(); + loginPayload.append("userid", userId); + loginPayload.append("password", FOUNDRY_PASSWORD); + loginPayload.append("world", FOUNDRY_WORLD); + loginPayload.append("step", "join"); + + const loginRes = await fetch(getFoundryUrl("/join"), { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Cookie: guestCookie + }, + body: loginPayload.toString(), + redirect: "manual" + }); + + const setCookieHeaders = loginRes.headers.getSetCookie(); + let sessionCookie = ""; + for (const cookie of setCookieHeaders) { + if (cookie.startsWith("session=")) { + sessionCookie = cookie.split(";")[0]; + break; + } + } + if (!sessionCookie && loginRes.status === 200) { + sessionCookie = guestCookie; + } + console.log("Session Cookie:", sessionCookie); + + console.log("Connecting websocket..."); + const socket = io(getFoundryUrl("/"), { + forceNew: true, + multiplex: false, + transports: ["websocket", "polling"], + extraHeaders: { Cookie: sessionCookie } + }); + + socket.on("connect", () => { + console.log("Connected! Emitting getDocuments..."); + + // Test 1: legacy getDocuments event + socket.emit("getDocuments", "Actor", { query: {} }, (res: any) => { + console.log("getDocuments response:", res ? Object.keys(res) : "null"); + }); + + // Test 2: new modifyDocument get event + const req = { + type: "Actor", + action: "get", + operation: { query: {} } + }; + socket.emit("modifyDocument", req, (res: any) => { + console.log("modifyDocument response keys:", res ? Object.keys(res) : "null"); + if (res && res.result) { + console.log(`Fetched ${res.result.length} actors! First actor:`, JSON.stringify(res.result[0])); + } else { + console.log("Response did not contain result array. Full response:", JSON.stringify(res)); + } + socket.disconnect(); + }); + }); + + socket.on("connect_error", (err) => { + console.log("Connect error:", err); + }); + + } catch (e: any) { + console.log("Error:", e.message); + } +} + +run(); diff --git a/backend/src/debug-socket-worlds.ts b/backend/src/debug-socket-worlds.ts new file mode 100644 index 0000000..de552d7 --- /dev/null +++ b/backend/src/debug-socket-worlds.ts @@ -0,0 +1,75 @@ +import { fetch } from "bun"; +import { io } from "socket.io-client"; + +const FOUNDRY_URL = process.env.FOUNDRY_URL || "http://192.168.0.3:30000/"; +const FOUNDRY_ADMIN_KEY = process.env.FOUNDRY_ADMIN_KEY || "3ldrakarXH0mebrew"; + +function getFoundryUrl(subpath: string): string { + const base = FOUNDRY_URL.endsWith("/") ? FOUNDRY_URL.slice(0, -1) : FOUNDRY_URL; + const normalizedPath = subpath.startsWith("/") ? subpath : `/${subpath}`; + return `${base}${normalizedPath}`; +} + +async function run() { + try { + console.log("Authenticating..."); + const res = await fetch(getFoundryUrl("/setup"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + action: "adminPassword", + adminPassword: FOUNDRY_ADMIN_KEY + }), + redirect: "manual", + }); + + console.log("Auth status:", res.status); + const cookies = res.headers.getSetCookie(); + console.log("Cookies:", cookies); + + let sessionCookie = ""; + for (const cookie of cookies) { + if (cookie.startsWith("session=")) { + sessionCookie = cookie.split(";")[0]; + break; + } + } + + if (!sessionCookie) { + console.log("No session cookie!"); + return; + } + + console.log("Connecting to setup socket..."); + const socketSetup = io(getFoundryUrl("/"), { + extraHeaders: { + Cookie: sessionCookie + }, + timeout: 5000 + }); + + socketSetup.on("connect", () => { + console.log("Connected to setup socket! Emitting getSetupData..."); + socketSetup.emit("getSetupData", (data: any) => { + console.log("Received data. Keys:", data ? Object.keys(data) : "null/undefined"); + if (data) { + console.log("isAdmin:", data.isAdmin); + console.log("worlds:", data.worlds ? data.worlds.length : "undefined"); + } + socketSetup.disconnect(); + }); + }); + + socketSetup.on("connect_error", (err) => { + console.log("Connect error:", err.message, err); + socketSetup.disconnect(); + }); + + } catch (e: any) { + console.log("Error:", e.message); + } +} + +run(); diff --git a/backend/src/fetch-join.ts b/backend/src/fetch-join.ts new file mode 100644 index 0000000..722d637 --- /dev/null +++ b/backend/src/fetch-join.ts @@ -0,0 +1,29 @@ +import { fetch } from "bun"; +import * as fs from "fs"; + +const FOUNDRY_URL = process.env.FOUNDRY_URL; + +async function run() { + console.log(`Fetching join page from: ${FOUNDRY_URL}/join`); + try { + const res = await fetch(`${FOUNDRY_URL}/join`); + console.log(`Status: ${res.status}`); + console.log(`Redirected URL: ${res.url}`); + + const html = await res.text(); + console.log(`HTML Length: ${html.length}`); + fs.writeFileSync("src/join-output.html", html, "utf8"); + console.log("Saved HTML to src/join-output.html"); + + // Check if there are any options + const optionMatches = html.match(/]*>([\s\S]*?)<\/option>/g); + console.log("Found options count:", optionMatches ? optionMatches.length : 0); + if (optionMatches) { + console.log("Options:", optionMatches.slice(0, 10)); + } + } catch (err: any) { + console.error("Error:", err); + } +} + +run(); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..cb46168 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,940 @@ +import { io } from "socket.io-client"; +import * as fs from "fs"; +import * as path from "path"; + +const PORT = process.env.PORT || 3000; +const FOUNDRY_URL = process.env.FOUNDRY_URL || "http://localhost:30000"; + +function getFoundryUrl(subpath: string): string { + const base = FOUNDRY_URL.endsWith("/") ? FOUNDRY_URL.slice(0, -1) : FOUNDRY_URL; + const normalizedPath = subpath.startsWith("/") ? subpath : `/${subpath}`; + return `${base}${normalizedPath}`; +} + +const SHARED_DIR = "/app/shared"; +const ACTORS_FILE = path.join(SHARED_DIR, "ACTORS.DAT"); +const UPDATES_FILE = path.join(SHARED_DIR, "UPDATES.DAT"); +const WORLDS_CACHE_FILE = path.join(SHARED_DIR, "WORLDS_CACHE.json"); + +const FOUNDRY_ADMIN_KEY = process.env.FOUNDRY_ADMIN_KEY || ""; +let currentWorld = process.env.FOUNDRY_WORLD || ""; +let currentWorldTitle = ""; +let currentSystem = ""; +let currentUser = process.env.FOUNDRY_USER || "Admin"; +let currentPassword = process.env.FOUNDRY_PASSWORD || ""; + +let socket: any = null; +let actorsList: any[] = []; + +interface SystemMapping { + getHP: (actor: any) => { value: number; max: number }; + setHP: (hp: { value: number; max: number }, actorSystem: any) => void; + getLevel: (actor: any) => number; + setLevel: (level: number, actorSystem: any) => void; +} + +const SYSTEM_MAPPINGS: Record = { + "hbm-rpg-v3": { + getHP: (actor) => { + const h = actor.system?.attributes?.health || actor.system?.attributes?.hp || {}; + return { value: h.value ?? 0, max: h.max ?? 0 }; + }, + setHP: (hp, actorSystem) => { + actorSystem.attributes = actorSystem.attributes || {}; + actorSystem.attributes.health = { value: hp.value, max: hp.max }; + actorSystem.attributes.hp = { value: hp.value, max: hp.max }; + }, + getLevel: (actor) => { + return actor.system?.details?.year ?? actor.system?.details?.level ?? 0; + }, + setLevel: (level, actorSystem) => { + actorSystem.details = actorSystem.details || {}; + actorSystem.details.year = level; + actorSystem.details.level = level; + } + }, + "dnd5e": { + getHP: (actor) => { + const hp = actor.system?.attributes?.hp || {}; + return { value: hp.value ?? 0, max: hp.max ?? 0 }; + }, + setHP: (hp, actorSystem) => { + actorSystem.attributes = actorSystem.attributes || {}; + actorSystem.attributes.hp = { value: hp.value, max: hp.max }; + }, + getLevel: (actor) => { + return actor.system?.details?.level ?? 0; + }, + setLevel: (level, actorSystem) => { + actorSystem.details = actorSystem.details || {}; + actorSystem.details.level = level; + } + }, + "pf2e": { + getHP: (actor) => { + const hp = actor.system?.attributes?.hp || {}; + return { value: hp.value ?? 0, max: hp.max ?? 0 }; + }, + setHP: (hp, actorSystem) => { + actorSystem.attributes = actorSystem.attributes || {}; + actorSystem.attributes.hp = { value: hp.value, max: hp.max }; + }, + getLevel: (actor) => { + return actor.system?.details?.level?.value ?? actor.system?.details?.level ?? 0; + }, + setLevel: (level, actorSystem) => { + actorSystem.details = actorSystem.details || {}; + actorSystem.details.level = actorSystem.details.level || {}; + if (typeof actorSystem.details.level === 'object') { + actorSystem.details.level.value = level; + } else { + actorSystem.details.level = level; + } + } + } +}; + +function getActiveMapping(): SystemMapping { + return SYSTEM_MAPPINGS[currentSystem] || SYSTEM_MAPPINGS["dnd5e"]; +} + +if (!fs.existsSync(SHARED_DIR)) { + fs.mkdirSync(SHARED_DIR, { recursive: true }); +} + +function disconnectFromFoundry() { + if (socket) { + console.log("Disconnecting existing WebSocket from Foundry VTT..."); + socket.disconnect(); + socket = null; + } + actorsList = []; + writeActorsToDat(); +} + +async function fetchUserId(cookie: string): Promise { + return new Promise((resolve, reject) => { + const tempSocket = io(getFoundryUrl("/"), { + forceNew: true, + multiplex: false, + transports: ["websocket", "polling"], + extraHeaders: { + Cookie: cookie + }, + timeout: 5000 + }); + + tempSocket.on("connect", () => { + tempSocket.emit("getJoinData", (data: any) => { + tempSocket.disconnect(); + console.log("getJoinData response:", JSON.stringify(data)); + if (data && data.users) { + if (data.world) { + currentWorldTitle = data.world.title || ""; + currentSystem = data.world.system || ""; + } + const user = data.users.find((u: any) => u.name.toLowerCase() === currentUser.toLowerCase()); + if (user) { + resolve(user._id || user.id); + } else { + reject(new Error(`User "${currentUser}" not found in world users list. Available: ${data.users.map((u: any) => u.name).join(", ")}`)); + } + } else { + reject(new Error(`Failed to retrieve users list. Data: ${data ? JSON.stringify(data) : "null"}`)); + } + }); + }); + + tempSocket.on("connect_error", (err) => { + tempSocket.disconnect(); + reject(err); + }); + }); +} + +async function connectToFoundry() { + disconnectFromFoundry(); + if (!currentWorld) { + console.log("No active world configured. Waiting for world selection... "); + return; + } + + console.log(`Connecting to Foundry VTT world "${currentWorld}" as "${currentUser}" at ${FOUNDRY_URL}...`); + try { + const joinPageRes = await fetch(getFoundryUrl("/join")); + if (joinPageRes.url.includes("/setup")) { + console.log(`World "${currentWorld}" is not running. Please launch it first.`); + return; + } + + const cookies = joinPageRes.headers.getSetCookie(); + console.log("Response cookies from join page:", cookies); + let guestCookie = ""; + for (const c of cookies) { + if (c.startsWith("session=")) { + guestCookie = c.split(";")[0]; + break; + } + } + console.log("Extracted guest cookie:", guestCookie); + + if (!guestCookie) { + throw new Error("Failed to obtain guest session cookie from join page."); + } + + console.log("Fetching User ID from join socket..."); + const userId = await fetchUserId(guestCookie); + console.log(`Found User ID for "${currentUser}": ${userId}`); + + const postData = { + userid: userId, + password: currentPassword, + action: "join" + }; + + console.log(`Sending POST /join with JSON payload...`); + const loginRes = await fetch(getFoundryUrl("/join"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: guestCookie + }, + body: JSON.stringify(postData), + redirect: "manual" + }); + + console.log(`Login response status: ${loginRes.status}`); + const setCookieHeaders = loginRes.headers.getSetCookie(); + console.log("Login response set-cookie headers:", setCookieHeaders); + + let sessionCookie = ""; + for (const cookie of setCookieHeaders) { + if (cookie.startsWith("session=")) { + sessionCookie = cookie.split(";")[0]; + break; + } + } + + if (!sessionCookie) { + if (loginRes.status === 200) { + console.log("No new session cookie returned, using guest cookie as session cookie."); + sessionCookie = guestCookie; + } else { + const htmlText = await loginRes.text(); + console.log(`Login failed. Response body length: ${htmlText.length}`); + // Log some body context if we can find error messages + if (htmlText.includes("Invalid password") || htmlText.includes("error")) { + console.log("Found error indicator in body. HTML preview:", htmlText.substring(0, 1000)); + } else { + console.log("HTML preview:", htmlText.substring(0, 500)); + } + throw new Error(`Failed to get session cookie from login (Status: ${loginRes.status}).`); + } + } + + console.log("Authenticated successfully! Connecting WebSocket..."); + socket = io(getFoundryUrl("/"), { + forceNew: true, + multiplex: false, + transports: ["websocket", "polling"], + extraHeaders: { + Cookie: sessionCookie + } + }); + socket.on("connect", () => { + console.log(`WebSocket connected to Foundry VTT world "${currentWorld}"!`); + fetchActors().catch(err => console.error("Error fetching actors:", err)); + }); + socket.on("disconnect", () => { + console.log("WebSocket disconnected."); + }); + socket.on("createActor", (actor: any) => { + console.log(`Actor created in Foundry: ${actor._id || actor.id}`); + fetchActors().catch(err => console.error("Error fetching actors on create:", err)); + }); + socket.on("updateActor", (actor: any) => { + console.log(`Actor updated in Foundry: ${actor._id || actor.id}`); + fetchActors().catch(err => console.error("Error fetching actors on update:", err)); + }); + socket.on("deleteActor", (actor: any) => { + console.log(`Actor deleted in Foundry: ${actor._id || actor.id}`); + fetchActors().catch(err => console.error("Error fetching actors on delete:", err)); + }); + } catch (err: any) { + console.error("Error connecting to Foundry:", err.message); + console.log("Retrying in 10s..."); + setTimeout(connectToFoundry, 10000); + } +} + +function fetchActors(): Promise { + return new Promise((resolve, reject) => { + if (!socket) { + reject(new Error("WebSocket offline")); + return; + } + const req = { + type: "Actor", + action: "get", + operation: { query: {} } + }; + socket.emit("modifyDocument", req, (response: any) => { + if (response && response.result) { + const mapping = getActiveMapping(); + actorsList = response.result.map((actor: any) => { + if (actor.system) { + const hp = mapping.getHP(actor); + const level = mapping.getLevel(actor); + actor.system.attributes = actor.system.attributes || {}; + actor.system.attributes.hp = hp; + actor.system.details = actor.system.details || {}; + actor.system.details.level = level; + } + return actor; + }); + console.log(`Fetched ${actorsList.length} actors.`); + writeActorsToDat(); + resolve(actorsList); + } else { + console.log("Failed to fetch actors. Response:", JSON.stringify(response)); + reject(new Error("Failed to fetch actors")); + } + }); + }); +} + +function writeActorsToDat() { + try { + const mapping = getActiveMapping(); + const lines = actorsList.map(actor => { + const id = (actor._id || "").padEnd(16).substring(0, 16); + const name = (actor.name || "").padEnd(20).substring(0, 20); + const type = (actor.type || "").padEnd(10).substring(0, 10); + + const hp = mapping.getHP(actor); + const hpCur = String(hp.value).padStart(3, "0").substring(0, 3); + const hpMax = String(hp.max).padStart(3, "0").substring(0, 3); + + const lvlVal = mapping.getLevel(actor); + const level = String(lvlVal).padStart(2, "0").substring(0, 2); + + const padding = "".padEnd(25); + return `${id}${name}${type}${hpCur}${hpMax}${level}${padding}`; + }); + + fs.writeFileSync(ACTORS_FILE, lines.join("\n") + "\n", "utf8"); + console.log(`Synced actors to ${ACTORS_FILE}`); + } catch (err) { + console.error("Error writing ACTORS.DAT:", err); + } +} + +function startWatchingCobolUpdates() { + console.log(`Watching ${UPDATES_FILE}...`); + if (!fs.existsSync(UPDATES_FILE)) { + fs.writeFileSync(UPDATES_FILE, "", "utf8"); + } + + let fsWait = false; + fs.watch(UPDATES_FILE, (eventType) => { + if (eventType === "change") { + if (fsWait) return; + fsWait = true; + setTimeout(() => { + fsWait = false; + processCobolUpdates(); + }, 200); + } + }); +} + +function processCobolUpdates() { + try { + if (!fs.existsSync(UPDATES_FILE)) return; + const content = fs.readFileSync(UPDATES_FILE, "utf8"); + if (!content.trim()) return; + + const lines = content.split("\n"); + const updatesToSend: any[] = []; + + for (const line of lines) { + if (line.length < 54) continue; + const id = line.substring(0, 16).trim(); + const name = line.substring(16, 36).trim(); + const type = line.substring(36, 46).trim(); + const hpCur = parseInt(line.substring(46, 49), 10); + const hpMax = parseInt(line.substring(49, 52), 10); + const level = parseInt(line.substring(52, 54), 10); + + if (!id) continue; + + const mapping = getActiveMapping(); + const actorSystem: any = {}; + mapping.setHP({ value: hpCur, max: hpMax }, actorSystem); + mapping.setLevel(level, actorSystem); + + const update: any = { + _id: id, + name: name, + system: actorSystem + }; + updatesToSend.push(update); + } + + if (updatesToSend.length > 0 && socket) { + console.log(`Sending updates from COBOL updates to Foundry...`); + const req = { + type: "Actor", + action: "update", + operation: { + updates: updatesToSend + } + }; + socket.emit("modifyDocument", req, async (res: any) => { + console.log("Foundry update response received."); + try { + await fetchActors(); + } catch (err) { + console.error("Error refetching actors after COBOL update:", err); + } + fs.writeFileSync(UPDATES_FILE, "", "utf8"); + }); + } + } catch (err) { + console.error("Error processing COBOL updates:", err); + } +} + +async function getAdminSessionCookie(): Promise { + if (!FOUNDRY_ADMIN_KEY) { + throw new Error("FOUNDRY_ADMIN_KEY is not configured in backend environment."); + } + + const res = await fetch(getFoundryUrl("/setup"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + action: "adminPassword", + adminPassword: FOUNDRY_ADMIN_KEY + }), + redirect: "manual", + }); + + if (res.status === 403) { + throw new Error("Invalid Administrator Access Key (check FOUNDRY_ADMIN_KEY in .env.backend)."); + } + + const cookies = res.headers.getSetCookie(); + let sessionCookie = ""; + for (const cookie of cookies) { + if (cookie.startsWith("session=")) { + sessionCookie = cookie.split(";")[0]; + break; + } + } + + if (!sessionCookie) { + throw new Error("Failed to get session cookie from setup login."); + } + + return sessionCookie; +} + +function readWorldsCache(): any[] { + try { + if (fs.existsSync(WORLDS_CACHE_FILE)) { + const content = fs.readFileSync(WORLDS_CACHE_FILE, "utf8"); + return JSON.parse(content); + } + } catch (err) { + console.error("Error reading worlds cache:", err); + } + return []; +} + +function writeWorldsCache(worlds: any[]) { + try { + fs.writeFileSync(WORLDS_CACHE_FILE, JSON.stringify(worlds, null, 2), "utf8"); + console.log("Worlds list cached successfully."); + } catch (err) { + console.error("Error writing worlds cache:", err); + } +} + +async function getActiveWorldFromJoin(): Promise<{ id: string; title: string } | null> { + try { + const joinPageRes = await fetch(getFoundryUrl("/join")); + if (joinPageRes.url.includes("/setup")) { + return null; + } + const cookies = joinPageRes.headers.getSetCookie(); + let guestCookie = ""; + for (const c of cookies) { + if (c.startsWith("session=")) { + guestCookie = c.split(";")[0]; + break; + } + } + if (!guestCookie) return null; + + return new Promise((resolve) => { + const tempSocket = io(getFoundryUrl("/"), { + forceNew: true, + multiplex: false, + transports: ["websocket", "polling"], + extraHeaders: { + Cookie: guestCookie + }, + timeout: 4000 + }); + + tempSocket.on("connect", () => { + tempSocket.emit("getJoinData", (data: any) => { + tempSocket.disconnect(); + if (data && data.world) { + resolve({ + id: data.world.id, + title: data.world.title, + system: data.world.system + }); + } else { + resolve(null); + } + }); + }); + + tempSocket.on("connect_error", () => { + tempSocket.disconnect(); + resolve(null); + }); + }); + } catch (err) { + console.error("Error in getActiveWorldFromJoin:", err); + return null; + } +} + +async function handleWorldsListFallback(originalError: any): Promise { + const activeWorld = await getActiveWorldFromJoin(); + if (activeWorld) { + console.log(`handleWorldsListFallback: Detected active world "${activeWorld.id}" from join page.`); + currentWorldTitle = activeWorld.title; + currentSystem = activeWorld.system || ""; + const cached = readWorldsCache(); + if (cached && cached.length > 0) { + if (!cached.find((w: any) => w.id === activeWorld.id)) { + cached.push({ id: activeWorld.id, title: activeWorld.title }); + writeWorldsCache(cached); + } + return cached.map((w: any) => ({ + id: w.id, + title: w.title, + active: w.id === activeWorld.id + })); + } else { + const singleWorldList = [{ id: activeWorld.id, title: activeWorld.title, active: true }]; + writeWorldsCache([{ id: activeWorld.id, title: activeWorld.title }]); + return singleWorldList; + } + } else { + const cached = readWorldsCache(); + if (cached && cached.length > 0) { + console.log("handleWorldsListFallback: No active world detected, returning cached worlds list with active: false."); + return cached.map((w: any) => ({ + id: w.id, + title: w.title, + active: false + })); + } + throw originalError || new Error("No worlds data returned from setup socket"); + } +} + +async function fetchWorldsList(): Promise { + console.log("fetchWorldsList: Authenticating and getting setup cookie..."); + let cookie: string; + try { + cookie = await getAdminSessionCookie(); + } catch (err: any) { + console.error("fetchWorldsList: Failed to get admin session cookie:", err.message); + return await handleWorldsListFallback(err); + } + + console.log("fetchWorldsList: Cookie retrieved successfully. Connecting setup socket..."); + + try { + const worlds = await new Promise((resolve, reject) => { + const socketSetup = io(getFoundryUrl("/"), { + forceNew: true, + multiplex: false, + transports: ["websocket", "polling"], + extraHeaders: { + Cookie: cookie + }, + timeout: 5000 + }); + + socketSetup.on("connect", () => { + console.log("fetchWorldsList: Setup socket connected. Emitting getSetupData..."); + socketSetup.emit("getSetupData", (data: any) => { + socketSetup.disconnect(); + if (data && data.worlds) { + const activeWorldId = data.options?.world || null; + console.log(`fetchWorldsList: Found ${data.worlds.length} worlds. Active world ID: ${activeWorldId}`); + const worldsList = data.worlds.map((w: any) => ({ + id: w.id, + title: w.title, + active: w.id === activeWorldId + })); + const activeWorld = worldsList.find(w => w.active); + if (activeWorld) { + currentWorldTitle = activeWorld.title; + } + writeWorldsCache(worldsList.map((w: any) => ({ id: w.id, title: w.title }))); + resolve(worldsList); + } else { + console.log("fetchWorldsList: No worlds data found in response. Full data keys:", data ? Object.keys(data) : "null"); + reject(new Error("No worlds data returned from setup socket")); + } + }); + }); + + socketSetup.on("connect_error", (err) => { + console.error("fetchWorldsList: Setup socket connection error:", err.message); + socketSetup.disconnect(); + reject(err); + }); + }); + return worlds; + } catch (err) { + console.log("fetchWorldsList: Setup socket failed or returned empty. Attempting fallback..."); + return await handleWorldsListFallback(err); + } +} + +async function getActiveGMCookie(): Promise { + const joinPageRes = await fetch(getFoundryUrl("/join")); + if (joinPageRes.url.includes("/setup")) { + return ""; + } + + const cookies = joinPageRes.headers.getSetCookie(); + let guestCookie = ""; + for (const c of cookies) { + if (c.startsWith("session=")) { + guestCookie = c.split(";")[0]; + break; + } + } + + if (!guestCookie) { + throw new Error("Failed to get guest session cookie for GM login."); + } + + const userId = await fetchUserId(guestCookie); + + const postData = { + userid: userId, + password: currentPassword, + action: "join" + }; + + const loginRes = await fetch(getFoundryUrl("/join"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: guestCookie + }, + body: JSON.stringify(postData), + redirect: "manual" + }); + + const setCookieHeaders = loginRes.headers.getSetCookie(); + let sessionCookie = ""; + for (const cookie of setCookieHeaders) { + if (cookie.startsWith("session=")) { + sessionCookie = cookie.split(";")[0]; + break; + } + } + + if (!sessionCookie) { + if (loginRes.status === 200) { + console.log("No new session cookie returned for GM, using guest cookie as session cookie."); + sessionCookie = guestCookie; + } else { + console.error(`GM login failed with status ${loginRes.status}`); + } + } + + return sessionCookie; +} + +async function launchWorld(worldId: string) { + const cookie = await getAdminSessionCookie(); + + const res = await fetch(getFoundryUrl("/setup"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookie, + }, + body: JSON.stringify({ + action: "launchWorld", + world: worldId + }), + }); + + if (!res.ok) { + throw new Error(`Failed to launch world ${worldId}`); + } +} + +async function shutdownWorld() { + console.log("Shutting down active world..."); + let cookie = ""; + try { + cookie = await getActiveGMCookie(); + } catch (err: any) { + console.log("Could not get GM cookie for shutdown (setup might be active):", err.message); + } + + if (!cookie) { + console.log("Attempting shutdown using Admin access..."); + cookie = await getAdminSessionCookie(); + } + + const res = await fetch(getFoundryUrl("/setup"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookie, + }, + body: JSON.stringify({ + shutdown: true + }), + }); + + if (!res.ok) { + throw new Error(`Failed to shut down active world. Status: ${res.status}`); + } + console.log("World shutdown successfully completed."); +} + +Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Content-Type": "application/json" + }; + + if (req.method === "OPTIONS") { + return new Response(null, { headers }); + } + + if (url.pathname === "/api/actors" && req.method === "GET") { + return new Response(JSON.stringify(actorsList), { headers }); + } + + if (url.pathname === "/api/actors/refetch" && req.method === "POST") { + try { + if (socket) { + const list = await fetchActors(); + return new Response(JSON.stringify(list), { headers }); + } else { + return new Response(JSON.stringify({ error: "WebSocket offline" }), { status: 503, headers }); + } + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers }); + } + } + + if (url.pathname === "/api/actors/update" && req.method === "POST") { + try { + const body: any = await req.json(); + const { id, updates } = body; + + if (!id || !updates) { + return new Response(JSON.stringify({ error: "Missing parameters" }), { status: 400, headers }); + } + + if (socket) { + if (updates.system) { + const mapping = getActiveMapping(); + const hpVal = updates.system.attributes?.hp?.value; + const hpMax = updates.system.attributes?.hp?.max; + const level = updates.system.details?.level; + + const actorSystem: any = {}; + if (hpVal !== undefined && hpMax !== undefined) { + mapping.setHP({ value: hpVal, max: hpMax }, actorSystem); + } + if (level !== undefined) { + mapping.setLevel(level, actorSystem); + } + updates.system = actorSystem; + } + + const reqObj = { + type: "Actor", + action: "update", + operation: { + updates: [{ _id: id, ...updates }] + } + }; + await new Promise((resolve, reject) => { + socket.emit("modifyDocument", reqObj, async (res: any) => { + console.log(`Updated Actor ${id} via REST API. Response:`, JSON.stringify(res)); + try { + await fetchActors(); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + return new Response(JSON.stringify({ success: true }), { headers }); + } else { + return new Response(JSON.stringify({ error: "WebSocket offline" }), { status: 503, headers }); + } + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers }); + } + } + + if (url.pathname === "/api/worlds" && req.method === "GET") { + try { + const worlds = await fetchWorldsList(); + return new Response(JSON.stringify(worlds), { headers }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers }); + } + } + + if (url.pathname === "/api/worlds/launch" && req.method === "POST") { + try { + const { worldId } = await req.json(); + if (!worldId) { + return new Response(JSON.stringify({ error: "Missing worldId" }), { status: 400, headers }); + } + + // Find if there's currently an active world + const worlds = await fetchWorldsList(); + const activeWorld = worlds.find(w => w.active); + if (activeWorld) { + console.log(`Shutting down active world ${activeWorld.id} first...`); + await shutdownWorld(); + await new Promise(r => setTimeout(r, 2000)); + } + + console.log(`Launching world ${worldId}...`); + await launchWorld(worldId); + await new Promise(r => setTimeout(r, 3000)); + + currentWorld = worldId; + connectToFoundry(); + + return new Response(JSON.stringify({ success: true }), { headers }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers }); + } + } + + if (url.pathname === "/api/worlds/shutdown" && req.method === "POST") { + try { + console.log("Shutting down active world..."); + await shutdownWorld(); + disconnectFromFoundry(); + currentWorld = ""; + return new Response(JSON.stringify({ success: true }), { headers }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers }); + } + } + + if (url.pathname === "/api/worlds/select" && req.method === "POST") { + try { + const body: any = await req.json(); + const { worldId, user, password } = body; + if (!worldId) { + return new Response(JSON.stringify({ error: "Missing worldId" }), { status: 400, headers }); + } + + console.log(`Selecting world targeting: ${worldId}`); + currentWorld = worldId; + if (user) currentUser = user; + if (password !== undefined) currentPassword = password; + + connectToFoundry(); + return new Response(JSON.stringify({ success: true, world: currentWorld, user: currentUser }), { headers }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers }); + } + } + + if (url.pathname === "/api/packages/update" && req.method === "POST") { + try { + const body: any = await req.json(); + const { manifest, type } = body; + if (!manifest) { + return new Response(JSON.stringify({ error: "Missing manifest URL" }), { status: 400, headers }); + } + + const activeWorld = await getActiveWorldFromJoin(); + if (activeWorld) { + return new Response(JSON.stringify({ error: `A world (${activeWorld.title}) is currently active. Please shut down the world before updating packages.` }), { status: 400, headers }); + } + + console.log(`Installing/updating package of type ${type || "system"} from manifest: ${manifest}`); + const cookie = await getAdminSessionCookie(); + + const res = await fetch(getFoundryUrl("/setup"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookie, + }, + body: JSON.stringify({ + action: "installPackage", + type: type || "system", + manifest: manifest + }), + }); + + if (!res.ok) { + const errMsg = await res.text(); + throw new Error(`Foundry Setup returned status ${res.status}: ${errMsg}`); + } + + const resData = await res.json(); + console.log("Package update response:", resData); + + return new Response(JSON.stringify({ success: true, details: resData }), { headers }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers }); + } + } + + if (url.pathname === "/api/worlds/active" && req.method === "GET") { + return new Response(JSON.stringify({ + world: currentWorld, + worldTitle: currentWorldTitle || currentWorld, + system: currentSystem, + user: currentUser, + connected: socket?.connected || false + }), { headers }); + } + + return new Response(JSON.stringify({ error: "Not Found" }), { status: 404, headers }); + } +}); + +console.log(`Proxy Backend listening on port ${PORT}`); +connectToFoundry(); +startWatchingCobolUpdates(); diff --git a/backend/src/inspect-sockets.ts b/backend/src/inspect-sockets.ts new file mode 100644 index 0000000..0b994cc --- /dev/null +++ b/backend/src/inspect-sockets.ts @@ -0,0 +1,34 @@ +import { fetch } from "bun"; + +async function run() { + const url = "https://foundry.octoturge.com/scripts/foundry.mjs"; + console.log("Fetching:", url); + const res = await fetch(url); + const text = await res.text(); + console.log("Length:", text.length); + + // Let's search for where socket connection is initialized or used during Setup + // Search for: io( or connect( or socket + const queries = [ + /connect\s*\(\s*socket/i, + /socket\s*=\s*io/i, + /io\s*\(/i, + /class Setup/i, + /setup\.json/i, + /setupData/i, + /getSetupData/i, + /setup-menu/i + ]; + + for (const q of queries) { + console.log(`=== Matches for ${q} ===`); + const regex = new RegExp(`.{0,100}${q.source}.{0,100}`, "gi"); + const matches = text.match(regex); + console.log(`Count: ${matches ? matches.length : 0}`); + if (matches) { + matches.slice(0, 5).forEach(m => console.log(m.trim())); + } + } +} + +run(); diff --git a/backend/src/join-output.html b/backend/src/join-output.html new file mode 100644 index 0000000..33d0110 --- /dev/null +++ b/backend/src/join-output.html @@ -0,0 +1,80 @@ + + + + + Critical Failure! + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+

Critical Failure!

+
+ + +
+

Foundry Virtual Tabletop

+ +
+

There is currently no active game session. Please wait for the host to configure the world and then refresh this page.

+
+ + +
+ + +
+

Version 14 Build 363

+
+ + + + + \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..7221464 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} diff --git a/cobol/Dockerfile b/cobol/Dockerfile new file mode 100644 index 0000000..05824c7 --- /dev/null +++ b/cobol/Dockerfile @@ -0,0 +1,4 @@ +FROM alpine:latest +RUN apk add --no-cache gnucobol build-base curl +WORKDIR /app +CMD ["sh", "-c", "echo 'COBOL Container Ready. Shared files are in /app/shared.' && sh"] diff --git a/cobol/README.md b/cobol/README.md new file mode 100644 index 0000000..bb99c1e --- /dev/null +++ b/cobol/README.md @@ -0,0 +1,190 @@ +# COBOL Admin Panel Bridge Integration + +Welcome to your COBOL learning project! This container is equipped with **GnuCOBOL** (cobc) and mounts the shared folder at /app/shared. + +## How the Sync Interface Works +The Node.js proxy server generates /app/shared/ACTORS.DAT containing actor records. +Each record is exactly **80 bytes** (including a trailing newline \n), with the following fixed-width layout: + +| Field | Size (PIC) | Type | Offset | Description | +| :--- | :--- | :--- | :--- | :--- | +| **ID** | PIC X(a6) | Alphanumeric | 0 - 15 | Foundry Actor Unique ID | +| **NAME** | PIC X(20) | Alphanumeric | 16 - 35 | Character Name | +| **TYPE** | PIC X(10) | Alphanumeric | 36 - 45 | "character" or "npc" | +| **HP-CUR** | PIC 9(3) | Numeric | 46 - 48 | Current HP (zero-padded, e.g. 045) | +| **HP-MAX** | PIC 9(3) | Numeric | 49 - 51 | Max HP (zero-padded, e.g. 100) | +| **LEVEL** | PIC 9(2) | Numeric | 52 - 53 | Character Level (zero-padded, e.g. 05) | +| **PADDING** | PIC X(25) | Alphanumeric | 54 - 78 | Empty padding spaces | +| **NEWLINK** | PIC X | Alphanumeric | 79 | Carriage return / line feed \n + +### To Submit Updates +When your COBOL program wants to edit a sheet, it writes the updated 80-byte record into /app/shared/UPDATES.DAT. +The Node.js/Bun proxy detects writes to this file, parses the record, pushes the edit to Foundry VTT via WebSocket, and then clears UPDATES.DAT. + +--- + +## COBOL Snippets for your Project + +### 1. Declaring the Files in your Program +````cobol + ENVIRONMENT DIVISION. + INPUT-OUTPUT SECTION. + FILE-CONTROL. + * The source database generated by Node.js + SELECT ACTORS-FILE ASSIGN TO "/app/shared/ACTORS.DAT" + ORGANIZATION IS LINE SEQUENTIAL. + + * The transaction file you write to send updates to Foundry + SELECT UPDATES-FILE ASSIGN TO "/app/shared/UPDATES.DAT" + ORGANIZATION IS LINE SEQUENTIAL. +``` + +### 2. Declaring the Data Record Structure +````cobol + DATA DIVISION. + FILE SECTION. + FD ACTORS-FILE. + 01 ACTOR-RECORD. + 05 ACTOR-ID PIC X(16). + 05 ACTOR-NAME PIC X(20). + 05 ACTOR-TYPE PIC X(10). + 05 ACTOR-HP-CUR PIC 9(3). + 05 ACTOR-HP-MAX PIC 9(3). + 05 ACTOR-LEVEL PIC 9(2). + 05 FILLER PIC X(25). *> Filler for padding +``` + +### 3. Reading the File Loop +````cobol + WORKING-STORAGE SECTION. + 01 WS-EOF-FLAG PIC X VALUE 'N'. + 88 EOF-REACHED VALUE 'Y'. + + PROCEDURE DIVISION. + READ-DATABASE. + OPEN INPUT ACTORS-FILE. + PERFORM UNTIL EOF-REACHED + READ ACTORS-FILE + AT END + SET EOF-REACHED TO TRUE + NOT AT END + DISPLAY "Found: " ACTOR-NAME " (HP: " ACTOR-HP-CUR ")" + END-READ + END-PERFORM. + CLOSE ACTORS-FILE. +``` + +### 4. Writing an Update (Transaction) +````cobol + PROCEDURE DIVISION. + SEND-UPDATE. + OPEN OUTPUT UPDATES-FILE. + * Fill the record fields (ensure numeric values are zero-padded) + MOVE "abc123xyz789" TO ACTOR-ID. + MOVE "Gimli" TO ACTOR-NAME. + MOVE "character" TO ACTOR-TYPE. + MOVE 45 TO ACTOR-HP-CUR. + MOVE 100 TO ACTOR-HP-MAX. + MOVE 5 TO ACTOR-LEVEL. + + WRITE ACTOR-RECORD. + CLOSE UPDATES-FILE. +``` + +### 5. Alternative: Calling curl Directly from COBOL (REST API Access) +If your want to bypass files and hit the Node.js REST API directly from your COBOL program, GnuCOBOL provides the SYSTEM command: +```cobol + WORKING-STORAGE SECTION. + 01 API-COMMAND PIC X(200). + + PROCEDURE DIVISION. + TRIGGER-API. + * Using curl inside the Docker network to fetch the JSON list + STRING "curl -s http://backend:3000/api/actors > /app/shared/actors.json" + DELIMITED BY SIZE INTO API-COMMAND. + + CALL "SYSTEM" USING API-COMMAND. +``` + +--- + +## Compiling & Running Inside Docker +Attach to the running COBOL container shell: +```bash +docker compose exec -it cobol sh +``` + +Create your file hbm-admin.cob, then compile it: +```bash +cobc -x -o hbm-admin hbm-admin.cob +``` +Run the compiled executable: +```bash +./hbm-admin +``` + +--- + +### 6. Native C Bindings (Interfacing with libcurl) +Since GnuCOBOL translates COBOL code to C, you can interface with C libraries directly using the `CALL` statement. This allows you to perform real HTTP requests natively without spawning an external shell process. + +1. **Write a C Helper (`http_client.c`)**: +```c +#include +#include + +int send_actor_update(const char* url, const char* json_data) { + CURL *curl; + CURLcode res; + int success = 1; + + curl = curl_easy_init(); + if(curl) { + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_data); + + res = curl_easy_perform(curl); + if(res != CURLE_OK) { + fprintf(stderr, "curl_easy_perform() failed: %s\n", curl_easy_strerror(res)); + success = 0; + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + } + return success; +} +``` + +2. **Call it from COBOL (`hbm-admin.cob`)**: +```cobol + WORKING-STORAGE SECTION. + 01 URL-STRING PIC X(100) VALUE "http://backend:3000/api/actors/update". + 01 JSON-PAYLOAD PIC X(500). + 01 RESULT-CODE PIC S9(9) BINARY. + + PROCEDURE DIVISION. + MOVE "{\"id\":\"abc\",\"updates\":{\"system\":{\"attributes\":{\"hp\":{\"value\":45}}}}}" + TO JSON-PAYLOAD. + + CALL "send_actor_update" USING BY REFERENCE URL-STRING + BY REFERENCE JSON-PAYLOAD + RETURNING RESULT-CODE. + + IF RESULT-CODE = 1 + DISPLAY "Update successful!" + ELSE + DISPLAY "Update failed." + END-IF. +``` + +3. **Compile and link together**: +```bash +gcc -c http_client.c -o http_client.o +cobc -x -o hbm-admin hbm-admin.cob http_client.o -lcurl +``` + diff --git a/cobol/hbm-admin.cbl b/cobol/hbm-admin.cbl new file mode 100644 index 0000000..a625851 --- /dev/null +++ b/cobol/hbm-admin.cbl @@ -0,0 +1,26 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. HBM-ADMIN. + * + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 URL-STRING PIC X(100) + VALUE "http://backend:3000/api/actors/update". + 01 JSON-PAYLOAD PIC X(200). + 01 RESULT-CODE PIC S9(9) BINARY. + * + PROCEDURE DIVISION. + MAIN-LOGIC. + MOVE "Gimli" TO JSON-PAYLOAD. + DISPLAY "Calling API for: " JSON-PAYLOAD. + + CALL "send_actor_update" USING BY REFERENCE URL-STRING + BY REFERENCE JSON-PAYLOAD + RETURNING RESULT-CODE. + + IF RESULT-CODE = 1 + DISPLAY "Update successful!" + ELSE + DISPLAY "Update failed." + END-IF. + + STOP RUN. diff --git a/cobol/http_client.c b/cobol/http_client.c new file mode 100644 index 0000000..692d2d9 --- /dev/null +++ b/cobol/http_client.c @@ -0,0 +1,28 @@ +#include +#include + +int send_actor_update(const char* url, const char* json_data) { + CURL *curl; + CURLcode res; + int success = 1; + + curl = curl_easy_init(); + if(curl) { + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_data); + + res = curl_easy_perform(curl); + if(res != CURLE_OK) { + fprintf(stderr, "curl_easy_perform() failed: %s\n", curl_easy_strerror(res)); + success = 0; + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + } + return success; +} diff --git a/cobol/run.sh b/cobol/run.sh new file mode 100644 index 0000000..dc03b5d --- /dev/null +++ b/cobol/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +gcc -c http_client.c -o http_client.o +cobc -x -o hbm-admin hbm-admin.cbl http_client.o -lcurl diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..06fca78 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + backend: + build: ./backend + ports: + - "30001:3000" + env_file: .env.backend + volumes: + - shared-data:/app/shared + extra_hosts: + - "host.docker.internal:host-gateway" + + frontend: + build: ./frontend + ports: + - "30002:5173" + environment: + - VITE_BACKEND_URL=http://localhost:30001 + + cobol: + build: ./cobol + tty: true + stdin_open: true + volumes: + - shared-data:/app/shared + depends_on: + - backend + +volumes: + shared-data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6417e36 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json tsconfig.json vite.config.ts index.html ./ +RUN npm install +COPY src ./src +EXPOSE 5173 +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c1037ed --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Foundry VTT Admin Panel + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..334c73b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "foundryvtt-admin-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "tailwindcss": "^4.0.0-alpha.25", + "@tailwindcss/vite": "^4.0.0-alpha.25" + }, + "devDependencies": { + "typescript": "^5.2.2", + "vite": "^5.0.0", + "pug": "^3.0.2" + } +} \ No newline at end of file diff --git a/frontend/src/index.pug b/frontend/src/index.pug new file mode 100644 index 0000000..d4b265f --- /dev/null +++ b/frontend/src/index.pug @@ -0,0 +1,130 @@ +div(class="min-h-screen bg-slate-950 text-slate-100 font-sans p-6") + div(class="max-w-7xl mx-auto space-y-6") + // Header + header(class="flex flex-col sm:flex-row justify-between items-start sm:items-center pb-6 border-b border-slate-800 gap-4") + div + h1(class="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-violet-400 via-indigo-400 to-fuchsia-400 bg-clip-text text-transparent") Foundry VTT Admin Bridge + p(class="text-sm text-slate-400") Manage character sheets & worlds remotely via Flat Files and REST APIs + div(class="flex gap-3") + button#btn-refresh(class="px-4 py-2 bg-slate-900 border border-slate-800 rounded-xl text-sm font-semibold hover:bg-slate-800 hover:border-slate-700 transition-all" title="Refresh local frontend data from backend cache") Refresh Cache + button#btn-sync-foundry(class="px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-500/10" title="Force backend to pull fresh data from Foundry VTT websocket") Sync from Foundry + + // World Management Control Panel (Glassmorphism card) + div(class="bg-slate-900/30 backdrop-blur-xl border border-slate-850 p-6 rounded-3xl shadow-2xl grid grid-cols-1 lg:grid-cols-3 gap-6") + div(class="space-y-3 col-span-1 lg:col-span-2") + h2(class="text-lg font-bold text-slate-200 flex items-center gap-2") + span(class="w-2.5 h-2.5 rounded-full bg-violet-500 animate-pulse") + | World Configuration & Setup + p(class="text-xs text-slate-400") Select a world, launch it from the setup menu, or switch active proxy targeting. + + div(class="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2") + div + label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Target World + select#world-selector(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition") + option(value="" disabled selected) Loading worlds... + div + label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Admin User + input#world-user(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition" type="text" value="Admin") + div + label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Password + input#world-pass(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition" type="password" placeholder="World password") + + div(class="flex flex-col justify-end gap-3 col-span-1") + div(class="flex gap-2") + button#btn-launch(class="flex-1 px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-xl text-xs font-bold transition shadow-lg shadow-indigo-500/10") Launch World + button#btn-shutdown(class="flex-1 px-4 py-2 bg-red-950/40 hover:bg-red-900/20 text-red-400 border border-red-900/30 rounded-xl text-xs font-bold transition") Shutdown World + button#btn-select(class="w-full px-4 py-2 bg-slate-900 border border-slate-800 hover:bg-slate-800 hover:border-slate-700 text-slate-200 rounded-xl text-xs font-bold transition") Connect & Sync + + // Package & System Management Control Panel (Glassmorphism card) + div(class="bg-slate-900/30 backdrop-blur-xl border border-slate-850 p-6 rounded-3xl shadow-2xl grid grid-cols-1 lg:grid-cols-3 gap-6") + div(class="space-y-3 col-span-1 lg:col-span-2") + h2(class="text-lg font-bold text-slate-200 flex items-center gap-2") + span(class="w-2.5 h-2.5 rounded-full bg-indigo-500") + | Package & System Management + p(class="text-xs text-slate-400") Install or update game systems and modules directly using their manifest JSON URLs. + + div(class="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2") + div(class="sm:col-span-2") + label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Manifest URL + input#package-manifest(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition" type="text" value="https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json" placeholder="https://domain.com/system.json") + div + label(class="block text-[10px] font-bold text-slate-400 uppercase mb-1.5") Package Type + select#package-type(class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500 transition") + option(value="system" selected) System + option(value="module") Module + + div(class="flex flex-col justify-end gap-3 col-span-1") + button#btn-update-package(class="w-full px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-xl text-xs font-bold transition shadow-lg shadow-indigo-500/10") Update Package + div#package-status(class="text-[11px] text-slate-400 mt-1 text-center min-h-[16px]") + + // Status Banner & Metrics + div(class="grid grid-cols-1 md:grid-cols-4 gap-6") + // Active World Status Banner + div(class="bg-gradient-to-br from-slate-900/50 to-slate-950/20 backdrop-blur-md border border-slate-850 p-6 rounded-2xl col-span-1 md:col-span-2 flex flex-col justify-between gap-3") + div + div(class="flex justify-between items-start") + span(class="text-[10px] font-bold text-violet-400 uppercase tracking-widest") Currently Connected + span#active-world-system(class="text-[10px] font-bold text-indigo-400 uppercase tracking-wider bg-indigo-500/10 px-2.5 py-0.5 rounded-full border border-indigo-500/15") System: - + h3#active-world-name(class="text-xl font-bold text-slate-100 mt-1.5") - + div(class="flex items-center gap-2 text-xs text-slate-400") + span#connection-dot(class="w-2.5 h-2.5 rounded-full bg-slate-500") + span#connection-status Offline + span#active-world-user(class="ml-auto bg-slate-900 px-2 py-0.5 rounded text-[10px]") - + + // Metrics + div(class="bg-slate-900/40 backdrop-blur-md border border-slate-800/80 p-6 rounded-2xl flex flex-col justify-between") + p(class="text-xs font-semibold tracking-wider text-slate-400 uppercase") Players + h3#metric-players(class="text-4xl font-bold text-indigo-400") - + div(class="bg-slate-900/40 backdrop-blur-md border border-slate-800/80 p-6 rounded-2xl flex flex-col justify-between") + p(class="text-xs font-semibold tracking-wider text-slate-400 uppercase") NPCs + h3#metric-npcs(class="text-4xl font-bold text-fuchsia-400") - + + // Search and Filters + div(class="flex flex-col sm:flex-row justify-between items-center gap-4") + div(class="relative w-full sm:max-w-xs") + input#search-input(class="w-full px-4 py-2 bg-slate-900 border border-slate-800 rounded-xl text-sm focus:outline-none focus:border-violet-500 transition" placeholder="Search actors...") + div(class="flex gap-2 w-full sm:w-auto") + button.btn-filter.flex-1(class="sm:flex-none px-4 py-1.5 text-xs font-bold rounded-full transition bg-violet-600 text-white" data-filter="all") All + button.btn-filter.flex-1(class="sm:flex-none px-4 py-1.5 text-xs font-bold rounded-full transition bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200" data-filter="character") Players + button.btn-filter.flex-1(class="sm:flex-none px-4 py-1.5 text-xs font-bold rounded-full transition bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200" data-filter="npc") NPCs + + // Actors Grid + div#actors-grid(class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6") + + // Edit Modal (Hidden by default) + div#edit-modal(class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4") + div#modal-container(class="bg-slate-900 border border-slate-800 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden flex flex-col md:flex-row transition-all duration-300") + // Left Panel: Edit Form + div(class="w-full p-6 flex flex-col justify-between") + div + div(class="flex justify-between items-center pb-4 mb-4 border-b border-slate-800") + h3(class="text-lg font-bold text-slate-100") Edit Actor Sheet + button#modal-close(class="text-slate-400 hover:text-slate-200 text-xl") ☼ + form#edit-form(class="space-y-4") + input(type="hidden" id="edit-id") + div + label(class="block text-xs font-semibold text-slate-400 uppercase mb-2") Name + input#edit-name(class="w-full px-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500") + div(class="grid grid-cols-2 gap-4") + div + label(class="block text-xs font-semibold text-slate-400 uppercase mb-2") Current HP + input#edit-hp-value(class="w-full px-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500" type="number") + div + label(class="block text-xs font-semibold text-slate-400 uppercase mb-2") Max HP + input#edit-hp-max(class="w-full px-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500" type="number") + div + label(class="block text-xs font-semibold text-slate-400 uppercase mb-2") Level + input#edit-level(class="w-full px-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-sm text-slate-200 focus:outline-none focus:border-violet-500" type="number") + div(class="flex justify-between items-center pt-4 border-t border-slate-800 gap-3") + button#btn-debug-json(class="px-3 py-2 bg-slate-800 hover:bg-slate-750 text-xs font-semibold text-violet-400 border border-slate-700/80 rounded-xl transition" type="button") Debug JSON + div(class="flex gap-2") + button#btn-cancel(class="px-4 py-2 bg-slate-800 hover:bg-slate-750 text-slate-300 rounded-xl text-xs font-semibold transition" type="button") Cancel + button#btn-save(class="px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-xl text-xs font-bold" type="submit") Save + + // Right Panel: Debug JSON + div#debug-panel(class="hidden w-full md:w-[500px] border-t md:border-t-0 md:border-l border-slate-800 flex flex-col bg-slate-950/40") + div(class="px-6 py-4 border-b border-slate-800 flex justify-between items-center bg-slate-900") + h4(class="text-sm font-bold text-slate-200") Character Sheet JSON Debugger + button#btn-copy-json(class="text-xs bg-slate-800 hover:bg-slate-700 text-slate-300 px-2.5 py-1 rounded border border-slate-700 transition") Copy JSON + div(class="p-4 flex-1 overflow-auto max-h-[300px] md:max-h-[400px]") + pre#debug-json-content(class="text-[10px] font-mono text-slate-300 whitespace-pre-wrap selection:bg-violet-900/50") diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..6f9a6f1 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,501 @@ +import './style.css'; +import template from './index.pug'; + +document.querySelector('#app')!.innerHTML = template; + +interface Actor { + _id: string; + name: string; + type: string; + system: { + attributes?: { + hp?: { value: number; max: number; }; + }; + details?: { + level?: number; + }; + level?: number; + }; +} + +interface World { + id: string; + title: string; + active: boolean; +} + +let actors: Actor[] = []; +let worlds: World[] = []; +let currentFilter = 'all'; +let searchQuery = ''; +let activeActor: Actor | null = null; + +const API_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:30001'; + +// DOM Selectors - Worlds Configuration +const worldSelector = document.getElementById('world-selector') as HTMLSelectElement; +const worldUser = document.getElementById('world-user') as HTMLInputElement; +const worldPass = document.getElementById('world-pass') as HTMLInputElement; +const btnLaunch = document.getElementById('btn-launch') as HTMLButtonElement; +const btnShutdown = document.getElementById('btn-shutdown') as HTMLButtonElement; +const btnSelect = document.getElementById('btn-select') as HTMLButtonElement; + +// DOM Selectors - Package Management +const packageManifest = document.getElementById('package-manifest') as HTMLInputElement; +const packageType = document.getElementById('package-type') as HTMLSelectElement; +const btnUpdatePackage = document.getElementById('btn-update-package') as HTMLButtonElement; +const packageStatus = document.getElementById('package-status')!; + +// DOM Selectors - Status Banner +const activeWorldName = document.getElementById('active-world-name')!; +const connectionDot = document.getElementById('connection-dot')!; +const connectionStatus = document.getElementById('connection-status')!; +const activeWorldUser = document.getElementById('active-world-user')!; +const activeWorldSystem = document.getElementById('active-world-system')!; + +// DOM Selectors - Actors +const grid = document.getElementById('actors-grid')!; +const searchInput = document.getElementById('search-input') as HTMLInputElement; +const btnRefresh = document.getElementById('btn-refresh')!; +const btnSyncFoundry = document.getElementById('btn-sync-foundry')!; +const filters = document.querySelectorAll('.btn-filter'); +const modal = document.getElementById('edit-modal')!; +const modalClose = document.getElementById('modal-close')!; +const editForm = document.getElementById('edit-form') as HTMLFormElement; +const btnCancel = document.getElementById('btn-cancel')!; +const modalContainer = document.getElementById('modal-container')!; +const btnDebugJson = document.getElementById('btn-debug-json')!; +const debugPanel = document.getElementById('debug-panel')!; +const debugJsonContent = document.getElementById('debug-json-content')!; +const btnCopyJson = document.getElementById('btn-copy-json')!; + +// Metrics Selectors +const metricPlayers = document.getElementById('metric-players')!; +const metricNPCs = document.getElementById('metric-npcs')!; + +// Fetch worlds and configuration +async function loadWorlds() { + try { + const resActive = await fetch(`${API_URL}/api/worlds/active`); + const activeInfo = await resActive.json(); + + const resWorlds = await fetch(`${API_URL}/api/worlds`); + if (!resWorlds.ok) { + const errorMsg = await resWorlds.json(); + throw new Error(errorMsg.error || 'Setup offline'); + } + worlds = await resWorlds.json(); + + // Update active banner + if (activeInfo.world) { + activeWorldName.textContent = activeInfo.worldTitle || activeInfo.world; + activeWorldSystem.textContent = `System: ${activeInfo.system || 'Unknown'}`; + activeWorldUser.textContent = `User: ${activeInfo.user}`; + connectionStatus.textContent = activeInfo.connected ? 'Connected' : 'Connecting...'; + connectionDot.className = `w-2.5 h-2.5 rounded-full ${activeInfo.connected ? 'bg-emerald-500 animate-pulse' : 'bg-emerald-500 animate-pulse'}`; + } else { + activeWorldName.textContent = 'None (Setup Menu)'; + activeWorldSystem.textContent = 'System: -'; + activeWorldUser.textContent = '-'; + connectionStatus.textContent = 'Offline'; + connectionDot.className = 'w-2.5 h-2.5 rounded-full bg-slate-500'; + } + + // Populate Selector Dropdown + worldSelector.innerHTML = ''; + if (worlds.length === 0) { + worldSelector.innerHTML = ''; + } else { + worlds.forEach(w => { + const option = document.createElement('option'); + option.value = w.id; + option.textContent = w.title + (w.active ? ' (Active)' : ''); + worldSelector.appendChild(option); + }); + } + + // Select active world in dropdown if any, otherwise select first + const active = worlds.find(w => w.active); + if (active) { + worldSelector.value = active.id; + } else if (activeInfo.world) { + worldSelector.value = activeInfo.world; + } + + updateWorldButtons(); + + } catch (err: any) { + console.error('Failed to load worlds:', err); + activeWorldName.textContent = 'Setup Locked / Offline'; + connectionStatus.textContent = err.message || 'Check Server'; + connectionDot.className = 'w-2.5 h-2.5 rounded-full bg-red-500'; + } +} + +function updateWorldButtons() { + const selectedId = worldSelector.value; + const selectedWorld = worlds.find(w => w.id === selectedId); + + if (!selectedWorld) { + btnLaunch.disabled = true; + btnShutdown.disabled = true; + return; + } + + if (selectedWorld.active) { + btnLaunch.disabled = true; + btnLaunch.classList.add('opacity-50'); + btnShutdown.disabled = false; + btnShutdown.classList.remove('opacity-50'); + } else { + btnLaunch.disabled = false; + btnLaunch.classList.remove('opacity-50'); + btnShutdown.disabled = true; + btnShutdown.classList.add('opacity-50'); + } +} + +// Fetch actors data +async function loadData() { + try { + const res = await fetch(`${API_URL}/api/actors`); + actors = await res.json(); + updateMetrics(); + renderGrid(); + } catch (err) { + console.error('Failed to load actors:', err); + } +} + +function updateMetrics() { + metricPlayers.textContent = String(actors.filter(a => a.type === 'character').length); + metricNPCs.textContent = String(actors.filter(a => a.type === 'npc').length); +} + +function renderGrid() { + grid.innerHTML = ''; + + const filtered = actors.filter(actor => { + const matchesFilter = currentFilter === 'all' || actor.type === currentFilter; + const matchesSearch = actor.name.toLowerCase().includes(searchQuery.toLowerCase()); + return matchesFilter && matchesSearch; + }); + + if (filtered.length === 0) { + grid.innerHTML = ` +
+ No actors synced for this world yet. Ensure the world is active and proxy is connected. +
+ `; + return; + } + + filtered.forEach(actor => { + const hpVal = actor.system?.attributes?.hp?.value ?? 0; + const hpMax = actor.system?.attributes?.hp?.max ?? 0; + const hpPct = hpMax > 0 ? Math.min(100, Math.max(0, (hpVal / hpMax) * 100)) : 0; + const level = actor.system?.details?.level ?? actor.system?.level ?? 0; + + let barColor = 'bg-violet-500'; + if (actor.type === 'npc') { + barColor = 'bg-fuchsia-500'; + } + + const card = document.createElement('div'); + card.className = 'bg-slate-900/30 backdrop-blur-md border border-slate-850 hover:border-slate-700 p-6 rounded-2xl flex flex-col justify-between transition group shadow-lg'; + card.innerHTML = ` +
+
+ ${actor.type} + Lvl ${level} +
+

${actor.name}

+ + +
+
+ HP + ${hpVal} / ${hpMax} +
+
+
+
+
+
+ + + `; + card.querySelector('.btn-edit')!.addEventListener('click', () => openEditModal(actor)); + grid.appendChild(card); + }); +} + +function openEditModal(actor: Actor) { + activeActor = actor; + const idInput = document.getElementById('edit-id') as HTMLInputElement; + const nameInput = document.getElementById('edit-name') as HTMLInputElement; + const hpValInput = document.getElementById('edit-hp-value') as HTMLInputElement; + const hpMaxInput = document.getElementById('edit-hp-max') as HTMLInputElement; + const levelInput = document.getElementById('edit-level') as HTMLInputElement; + + idInput.value = actor._id; + nameInput.value = actor.name; + hpValInput.value = String(actor.system?.attributes?.hp?.value ?? 0); + hpMaxInput.value = String(actor.system?.attributes?.hp?.max ?? 0); + levelInput.value = String(actor.system?.details?.level ?? actor.system?.level ?? 0); + + // If JSON debugger was open, update its content + if (!debugPanel.classList.contains('hidden') && activeActor) { + debugJsonContent.textContent = JSON.stringify(activeActor, null, 2); + } + + modal.classList.remove('hidden'); +} + +function closeEditModal() { + modal.classList.add('hidden'); + debugPanel.classList.add('hidden'); + modalContainer.classList.add('max-w-md'); + modalContainer.classList.remove('md:max-w-4xl'); + editForm.reset(); + activeActor = null; +} + +// World Management Actions +worldSelector.addEventListener('change', updateWorldButtons); + +btnLaunch.addEventListener('click', async () => { + const worldId = worldSelector.value; + if (!worldId) return; + + btnLaunch.disabled = true; + btnLaunch.textContent = 'Launching...'; + + try { + const res = await fetch(`${API_URL}/api/worlds/launch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ worldId }) + }); + if (res.ok) { + console.log(`World ${worldId} launched successfully.`); + } + } catch (err) { + console.error('Failed to launch world:', err); + } finally { + btnLaunch.textContent = 'Launch World'; + await loadWorlds(); + setTimeout(loadData, 2000); + } +}); + +btnShutdown.addEventListener('click', async () => { + btnShutdown.disabled = true; + btnShutdown.textContent = 'Stopping...'; + + try { + const res = await fetch(`${API_URL}/api/worlds/shutdown`, { + method: 'POST' + }); + if (res.ok) { + console.log('Active world stopped.'); + } + } catch (err) { + console.error('Failed to stop world:', err); + } finally { + btnShutdown.textContent = 'Shutdown World'; + await loadWorlds(); + actors = []; + renderGrid(); + updateMetrics(); + } +}); + +btnSelect.addEventListener('click', async () => { + const worldId = worldSelector.value; + if (!worldId) return; + + btnSelect.disabled = true; + btnSelect.textContent = 'Connecting...'; + + try { + const res = await fetch(`${API_URL}/api/worlds/select`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + worldId, + user: worldUser.value, + password: worldPass.value + }) + }); + if (res.ok) { + console.log(`Switched target world to ${worldId}`); + } + } catch (err) { + console.error('Failed to select world:', err); + } finally { + btnSelect.textContent = 'Connect & Sync'; + await loadWorlds(); + setTimeout(loadData, 2000); + } +}); + +// Refresh, Filters and Edit submits +btnRefresh.addEventListener('click', async () => { + await loadWorlds(); + await loadData(); +}); + +btnSyncFoundry.addEventListener('click', async () => { + btnSyncFoundry.disabled = true; + const originalText = btnSyncFoundry.textContent; + btnSyncFoundry.textContent = 'Syncing...'; + try { + const res = await fetch(`${API_URL}/api/actors/refetch`, { method: 'POST' }); + if (res.ok) { + actors = await res.json(); + updateMetrics(); + renderGrid(); + } else { + console.error('Failed to refetch actors from backend'); + } + } catch (err) { + console.error('Error on refetch:', err); + } finally { + btnSyncFoundry.disabled = false; + btnSyncFoundry.textContent = originalText; + } +}); +modalClose.addEventListener('click', closeEditModal); +btnCancel.addEventListener('click', closeEditModal); + +btnDebugJson.addEventListener('click', () => { + if (debugPanel.classList.contains('hidden')) { + debugPanel.classList.remove('hidden'); + modalContainer.classList.remove('max-w-md'); + modalContainer.classList.add('md:max-w-4xl'); + if (activeActor) { + debugJsonContent.textContent = JSON.stringify(activeActor, null, 2); + } + } else { + debugPanel.classList.add('hidden'); + modalContainer.classList.add('max-w-md'); + modalContainer.classList.remove('md:max-w-4xl'); + } +}); + +btnCopyJson.addEventListener('click', () => { + if (activeActor) { + navigator.clipboard.writeText(JSON.stringify(activeActor, null, 2)); + btnCopyJson.textContent = 'Copied!'; + setTimeout(() => { + btnCopyJson.textContent = 'Copy JSON'; + }, 2000); + } +}); + +searchInput.addEventListener('input', (e) => { + searchQuery = (e.target as HTMLInputElement).value; + renderGrid(); +}); + +filters.forEach(btn => { + btn.addEventListener('click', (e) => { + filters.forEach(b => { + b.classList.remove('bg-violet-600', 'text-white'); + b.classList.add('bg-slate-900', 'border-slate-800', 'text-slate-400'); + }); + const target = e.target as HTMLButtonElement; + target.classList.remove('bg-slate-900', 'border-slate-800', 'text-slate-400'); + target.classList.add('bg-violet-600', 'text-white'); + currentFilter = target.dataset.filter!; + renderGrid(); + }); +}); + +editForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const id = (document.getElementById('edit-id') as HTMLInputElement).value; + const name = (document.getElementById('edit-name') as HTMLInputElement).value; + const hpVal = parseInt((document.getElementById('edit-hp-value') as HTMLInputElement).value, 10); + const hpMax = parseInt((document.getElementById('edit-hp-max') as HTMLInputElement).value, 10); + const level = parseInt((document.getElementById('edit-level') as HTMLInputElement).value, 10); + + const actor = actors.find(a => a._id === id); + if (!actor) return; + + const updates: any = { + name: name, + system: { + attributes: { + hp: { value: hpVal, max: hpMax } + }, + details: { + level: level + } + } + }; + + try { + const res = await fetch(`${API_URL}/api/actors/update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, updates }) + }); + if (res.ok) { + closeEditModal(); + loadData(); + } + } catch (err) { + console.error('Updating failed', err); + } +}); + +btnUpdatePackage.addEventListener('click', async () => { + const manifest = packageManifest.value.trim(); + const type = packageType.value; + if (!manifest) { + packageStatus.textContent = 'Error: Manifest URL is required.'; + packageStatus.className = 'text-[11px] text-red-400 mt-1 text-center min-h-[16px]'; + return; + } + + btnUpdatePackage.disabled = true; + btnUpdatePackage.textContent = 'Updating...'; + packageStatus.textContent = 'Initiating update...'; + packageStatus.className = 'text-[11px] text-slate-400 mt-1 text-center min-h-[16px]'; + + try { + const res = await fetch(`${API_URL}/api/packages/update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ manifest, type }) + }); + const result = await res.json(); + if (res.ok && result.success) { + packageStatus.textContent = '✓ Package updated successfully!'; + packageStatus.className = 'text-[11px] text-emerald-400 mt-1 text-center min-h-[16px] font-bold'; + } else { + packageStatus.textContent = `Error: ${result.error || 'Update failed'}`; + packageStatus.className = 'text-[11px] text-red-400 mt-1 text-center min-h-[16px]'; + } + } catch (err: any) { + console.error('Package update failed:', err); + packageStatus.textContent = `Error: ${err.message || 'Connection error'}`; + packageStatus.className = 'text-[11px] text-red-400 mt-1 text-center min-h-[16px]'; + } finally { + btnUpdatePackage.disabled = false; + btnUpdatePackage.textContent = 'Update Package'; + await loadWorlds(); + } +}); + +// Initial startup +(async () => { + await loadWorlds(); + await loadData(); +})(); diff --git a/frontend/src/pug.d.ts b/frontend/src/pug.d.ts new file mode 100644 index 0000000..8ee3bf5 --- /dev/null +++ b/frontend/src/pug.d.ts @@ -0,0 +1,4 @@ +declare module '*.pug' { + const content: string; + export default content; +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..cb503ee --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,7 @@ +@import "tailwindcss"; + +@layer base { + body { + @apply antialiased selection:bg-violet-500/30; + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2506fda --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d3d57ad --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; +import pug from 'pug'; + +export default defineConfig({ + plugins: [ + tailwindcss(), + { + name: 'vite-plugin-pug', + transform(src, id) { + if (id.endsWith('.pug')) { + const html = pug.compile(src)(); + return { + code: `export default ${JSON.stringify(html)};`, + map: null + }; + } + } + } + ], + server: { + port: 5173, + host: true + } +});