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).
This commit is contained in:
2026-08-17 21:46:42 +02:00
commit 460d136d21
28 changed files with 2494 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.env
.env.*
*.env
node_modules/
dist/
.DS_Store
+7
View File
@@ -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"]
+15
View File
@@ -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"
}
}
+58
View File
@@ -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();
+41
View File
@@ -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();
+64
View File
@@ -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();
+127
View File
@@ -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<string> {
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();
+75
View File
@@ -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();
+29
View File
@@ -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(/<option[^>]*>([\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();
+940
View File
@@ -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<string, SystemMapping> = {
"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<string> {
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<any[]> {
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<string> {
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<any[]> {
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<any[]> {
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<any[]>((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<string> {
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<void>((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();
+34
View File
@@ -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();
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html>
<head>
<!-- Page Metadata -->
<title>Critical Failure!</title>
<meta name="description" content="Foundry Virtual Tabletop - A Self-Hosted &amp; Modern Role-playing Platform">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="icons/vtt.png">
<!-- Included Stylesheets -->
<link href="css/foundry2.css" rel="stylesheet" type="text/css" media="all">
<style>
@import "fonts/fontawesome/css/all.min.css" layer(variables);
</style>
<!-- Included Scripts -->
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<script src="" defer></script>
<!-- Inline Scripts -->
<script>
const SIGNED_EULA=true;
const ROUTE_PREFIX="";
const MESSAGES=null;
</script>
<!-- Layer System & Module Styles -->
<style>
</style>
<!-- Inline Styles -->
</head>
<body class="auth error flexcol theme-dark">
<div id="main-background"></div>
<!-- Page Header -->
<header id="main-header" class="flexcol">
<h1>Critical Failure!</h1>
</header>
<!-- Body Content -->
<article id="error" class="application framed standard-form">
<h2 class="border">Foundry Virtual Tabletop</h2>
<div class="error-details">
<p>There is currently no active game session. Please wait for the host to configure the world and then refresh this page.</p>
</div>
<footer class="form-footer">
<a class="button" href="/setup" target="_self">
<i class="fa-solid fa-backward" inert></i> Go Back
</a>
</footer>
</article>
<!-- Footer Watermark -->
<footer id="watermark" class="flexcol">
<p id="software-version">Version 14 Build 363</p>
</footer>
<!-- Global Tooltip Element -->
<aside id="tooltip" role="tooltip" popover="manual"></aside>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
+4
View File
@@ -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"]
+190
View File
@@ -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 <stdio.h>
#include <curl/curl.h>
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
```
+26
View File
@@ -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.
+28
View File
@@ -0,0 +1,28 @@
#include <stdio.h>
#include <curl/curl.h>
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;
}
+4
View File
@@ -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
+29
View File
@@ -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:
+7
View File
@@ -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"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" class="dark bg-slate-950 text-slate-100">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Foundry VTT Admin Panel</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+20
View File
@@ -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"
}
}
+130
View File
@@ -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") &#9788;
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")
+501
View File
@@ -0,0 +1,501 @@
import './style.css';
import template from './index.pug';
document.querySelector<HTMLDivElement>('#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 = '<option value="" disabled>No worlds installed</option>';
} 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 = `
<div class="col-span-full py-16 text-center text-slate-500 text-sm bg-slate-900/10 border border-dashed border-slate-800 rounded-2xl">
No actors synced for this world yet. Ensure the world is active and proxy is connected.
</div>
`;
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 = `
<div>
<div class="flex justify-between items-start mb-4">
<span class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${
actor.type === 'character' ? 'bg-indigo-500/15 text-indigo-400' : 'bg-fuchsia-500/15 text-fuchsia-400'
}">${actor.type}</span>
<span class="text-xs font-bold text-slate-400 bg-slate-950 px-2 py-0.5 rounded border border-slate-850">Lvl ${level}</span>
</div>
<h4 class="text-lg font-bold text-slate-100 group-hover:text-violet-400 transition mb-2">${actor.name}</h4>
<!-- HP Bar -->
<div class="mt-4">
<div class="flex justify-between text-xs font-medium text-slate-400 mb-1">
<span>HP</span>
<span>${hpVal} / ${hpMax}</span>
</div>
<div class="w-full bg-slate-950 rounded-full h-1.5 overflow-hidden border border-slate-850">
<div class="h-full rounded-full ${barColor}" style="width: ${hpPct}%"></div>
</div>
</div>
</div>
<button class="btn-edit mt-6 w-full py-2 bg-slate-950 hover:bg-violet-900/20 hover:text-violet-400 border border-slate-800 hover:border-violet-800/40 rounded-xl text-xs font-semibold text-slate-300 transition" data-id="${actor._id}">
Edit Sheet
</button>
`;
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();
})();
+4
View File
@@ -0,0 +1,4 @@
declare module '*.pug' {
const content: string;
export default content;
}
+7
View File
@@ -0,0 +1,7 @@
@import "tailwindcss";
@layer base {
body {
@apply antialiased selection:bg-violet-500/30;
}
}
+19
View File
@@ -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"]
}
+25
View File
@@ -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
}
});