Update Healer Man server

This commit is contained in:
phenom committed 2026-09-27 21:48:00 -04:00
1 parent 15a5254380
commit 9d7e0e2f9e
71 files changed
+3490 -81

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import { writeFileSync } from "node:fs";
import { DUNGEON_DEFINITIONS, AVAILABLE_RANDOMIZED_CREATURE_CATALOG, BITE_SIZED_RANDOMIZED_CREATURE_CATALOG } from "../src/game/dungeonRegistry";
import { dungeonContentSourceId, isBiteSizedDungeonId, isRandomizedBiteSizedDungeonId } from "../src/game/dungeonContentSource";
import { sourceBossCandidates } from "../src/game/dungeons/sourceBossCandidates";
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "../src/game/dungeons/biteSizedStockades";
const layouts = DUNGEON_DEFINITIONS.filter((d) => isBiteSizedDungeonId(d.id) && !isRandomizedBiteSizedDungeonId(d.id));
const sources = DUNGEON_DEFINITIONS.filter((d) => !isBiteSizedDungeonId(d.id));
const defaults = layouts.map((d) => {
const sourceDungeonId = dungeonContentSourceId(d.id);
const bossIds = new Set(d.bosses.map((b) => b.id));
const boss = [...d.staticSpawns, ...(d.encounterSpawnGroups ?? []).flatMap((g) => g.spawns)].find((s) => bossIds.has(s.id))!;
return { id: `${d.id}:default`, dungeonId: d.id, sourceDungeonId, name: d.title.split(": ").slice(1).join(": ") || d.title,
revision: 0, legacyLayout: true, partySpawn: { footPosition: d.entrance.footPosition, yaw: d.entrance.yaw },
mobs: d.staticSpawns.filter((s) => !bossIds.has(s.id)).map((s) => ({ ...s, sourceDungeonId })),
bossSpawn: { footPosition: boss.position, yaw: boss.yaw ?? 0 }, bossActivation: d.bosses[0].eventOnly ? "after-mobs" : "immediate" };
});
const metadata = layouts.map((d) => {
const source = sources.find((s) => s.id === dungeonContentSourceId(d.id))!;
const candidates = sourceBossCandidates(source);
return { id: d.id, sourceDungeonId: source.id, title: source.title, expansion: source.expansion,
bounds: source.bounds, difficultyIds: source.difficultyVariants.map((v) => v.id),
legacyBlockers: d.serverGameObjects.filter((object) => !object.interaction && object.states.find((state) => state.id === object.initialState)?.blocksMovement)
.map((object) => ({ position: object.position, yaw: object.yaw ?? 0, halfExtents: object.halfExtents })),
bossCandidates: (source.id === "stormwind-stockade" ? BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((b) => candidates.find((c) => c.objective.id === b.sourceSpawnId)!) : candidates)
.map((b) => ({ sourceDungeonId: source.id, entityId: b.spawn.entityId, objectiveId: b.objective.id, name: b.objective.name })) };
});
const creatures = sources.flatMap((d) => Object.values(d.entities).map((e) => ({ sourceDungeonId: d.id, entityId: e.id, kind: e.kind,
name: e.name, dungeonTitle: d.title, modelUrl: e.visual.model?.url ?? null })));
const pool = (entries: typeof AVAILABLE_RANDOMIZED_CREATURE_CATALOG) => entries.map((e) => ({ sourceDungeonId: e.sourceDungeonId, entityId: e.entityId,
objectiveId: `randomized:${e.key}`, key: e.key, name: e.name, kind: e.kind }));
writeFileSync(new URL("../shared/bite-sized-defaults.json", import.meta.url), `${JSON.stringify({ schemaVersion: 1, spots: defaults }, null, 2)}\n`);
writeFileSync(new URL("../shared/bite-sized-catalog.json", import.meta.url), `${JSON.stringify({ dungeons: metadata, creatures,
pools: { "randomized-bite-sized-stormwind-stockade": pool(AVAILABLE_RANDOMIZED_CREATURE_CATALOG),
"randomized-bite-sized-stormwind-stockade-available": pool(BITE_SIZED_RANDOMIZED_CREATURE_CATALOG) } })}\n`);
+63
View File
@@ -0,0 +1,63 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { build } from "esbuild";
import { Group, Mesh } from "three";
import { DUNGEON_REGISTRY } from "../src/game/dungeonRegistry";
import { BITE_SIZED_LAYOUT_CATALOG } from "../shared/bite-sized-layouts.mjs";
import { preparePartyNavigationGraph } from "../src/game/partyNavigationAssetCache";
import { buildPartyNavigationTriangleNodes } from "../src/game/partyNavigation";
import { biteSizedRouteSegmentClear } from "../src/game/biteSizedSpotNavigation";
import { readDungeonGeometry } from "./dungeon-pipeline/wow-stair-geometry";
// Ship the exact gameplay navigation validator and graphs with the API server.
// The server does not need the downloadable rendering assets to validate a draft.
mkdirSync("server/generated", { recursive: true });
const graphs: Record<string, unknown> = {};
for (const entry of BITE_SIZED_LAYOUT_CATALOG.dungeons) {
const source = DUNGEON_REGISTRY[entry.sourceDungeonId];
const asset = source.assets.navigation!;
const file = asset.url?.startsWith("/") ? `public${asset.url}` : `src/assets/game/dungeons/${source.id}/${asset.fileName}`;
const geometries = await readDungeonGeometry(file);
const scene = new Group();
geometries.forEach((g) => scene.add(new Mesh(g)));
const graph = preparePartyNavigationGraph(source.id, scene, source.navigationLinks, source.traversalRamps, 1_000_000, true);
const triangleNodes = graph.triangleNodes ?? buildPartyNavigationTriangleNodes(graph.triangles!, graph.nodes.map((n) => n.position));
const regions = new Int32Array(triangleNodes.length).fill(graph.triangleNodes ? -1 : 0);
let region = 0;
for (let i = 0; i < triangleNodes.length; i++) {
if (regions[i] !== -1) continue;
const stack = [i]; regions[i] = region;
while (stack.length) {
const current = stack.pop()!;
for (const edge of triangleNodes[current].neighbors) if (regions[edge.to] === -1) { regions[edge.to] = region; stack.push(edge.to); }
}
region++;
}
let legacyRegions: number[] | undefined;
if (entry.legacyBlockers.length) {
const filtered = new Int32Array(triangleNodes.length).fill(-1);
let component = 0;
for (let i = 0; i < triangleNodes.length; i++) {
if (filtered[i] !== -1) continue;
const stack = [i]; filtered[i] = component;
while (stack.length) {
const current = stack.pop()!;
for (const edge of triangleNodes[current].neighbors) {
if (filtered[edge.to] !== -1 || !biteSizedRouteSegmentClear(entry.legacyBlockers, triangleNodes[current].centroid, triangleNodes[edge.to].centroid)) continue;
filtered[edge.to] = component; stack.push(edge.to);
}
}
component++;
}
legacyRegions = Array.from(filtered);
}
// Publication needs a surface and reachability components, not A* costs and
// portals for every triangle. Keep four-decimal coordinate precision.
graphs[source.id] = { nodes: graph.nodes.map((n) => ({ id: n.id, position: n.position.map((v) => Math.round(v * 10_000) / 10_000), neighbors: [] })),
triangles: graph.triangles, regions: Array.from(regions), legacyRegions };
geometries.forEach((g) => g.dispose());
console.log(`Navigation: ${source.id}`);
}
writeFileSync("server/generated/bite-sized-navigation.json.gz", gzipSync(JSON.stringify(graphs), { level: 9 }));
await build({ entryPoints: ["src/game/biteSizedSpotNavigation.ts"], bundle: true, platform: "node", format: "esm",
outfile: "server/generated/bite-sized-navigation.mjs", banner: { js: "// Generated by scripts/generate-bite-sized-navigation.ts; edit the shared TypeScript source." } });
+188
View File
@@ -0,0 +1,188 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const out = path.resolve('playtest-artifacts/bite-sized-editor');
fs.mkdirSync(out, { recursive: true });
const observations = [];
async function state(page) {
return page.evaluate(async () => {
const { useGameStore } = await window.playtestImport('/src/game/store.ts');
const { useBiteSizedLayoutsStore } = await window.playtestImport('/src/game/biteSizedLayoutsStore.ts');
const { useDungeonRunStore } = await window.playtestImport('/src/game/dungeonRunStore.ts');
const game = useGameStore.getState(), layouts = useBiteSizedLayoutsStore.getState();
return { yaw: game.cameraYaw, position: game.playerPosition, grounded: game.playerGrounded, assetStatus: game.assetStatus,
mode: layouts.editorMode, point: layouts.editingPointId, focused: layouts.panelFocused, dirty: layouts.dirty,
run: useDungeonRunStore.getState().progress, pointerLock: document.pointerLockElement !== null };
});
}
async function ready(page) {
await page.locator('.game-shell').waitFor({ timeout: 120000 });
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await page.waitForFunction(async () => {
const { useGameStore } = await window.playtestImport('/src/game/store.ts');
return useGameStore.getState().assetStatus === 'ready';
}, null, { timeout: 120000 });
await page.waitForTimeout(1200);
console.log(`Ready: ${(await state(page)).mode || 'run'}`);
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-editor-playtest-'));
const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir });
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
const auth = await (await fetch(`${origin}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'codex_dev_gm', password: 'local-playtest-password' }) })).json();
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.addInitScript(() => {
// Vite app imports may carry an HMR timestamp even in a fresh browser.
// Reuse that exact URL so inspection never creates a second Zustand store.
window.playtestImport = (modulePath) => {
const url = performance.getEntriesByType('resource').find((entry) => new URL(entry.name).pathname === modulePath)?.name;
return import(url || modulePath);
};
});
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/api/**', async (route) => {
const request = route.request();
const response = await route.fetch({ url: `${origin}${new URL(request.url()).pathname}${new URL(request.url()).search}` });
await route.fulfill({ response });
});
try {
await page.goto(process.env.BITE_SIZED_EDITOR_URL || 'http://127.0.0.1:5173', { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.getByLabel('Username', { exact: true }).fill('codex_dev_gm');
await page.getByLabel('Password', { exact: true }).fill('local-playtest-password');
await page.getByRole('button', { name: /Enter world/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('SpotTester');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue|Enter world/ }).first().click({ force: true });
await page.getByRole('button', { name: 'Continue', exact: true }).click({ force: true });
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /GM Menu/ }).click();
await page.getByRole('button', { name: /Bite-Sized Editor/ }).click();
const panel = page.locator('.bite-sized-editor');
await panel.getByLabel('Dungeon', { exact: true }).selectOption('bite-sized-stormwind-stockade');
await panel.getByRole('button', { name: 'Go to spot', exact: true }).click();
await ready(page);
assert.equal((await state(page)).mode, 'edit');
assert.equal((await state(page)).grounded, true);
await page.screenshot({ path: path.join(out, 'wow-editor.png') });
await panel.getByRole('button', { name: 'New spot', exact: true }).click();
await ready(page);
await panel.getByLabel('Spot name', { exact: true }).fill('Playtest new spot');
await panel.getByLabel('Other dungeons', { exact: true }).check();
await panel.getByLabel('Search mobs', { exact: true }).fill('Forsaken');
await panel.getByRole('button', { name: 'Add mob here', exact: true }).click();
await panel.getByRole('button', { name: 'Duplicate mob point', exact: true }).waitFor();
await panel.getByRole('button', { name: 'Duplicate mob point', exact: true }).click();
await panel.getByRole('button', { name: 'Remove mob point', exact: true }).click();
await panel.getByRole('button', { name: 'Random boss spawn', exact: true }).click();
await panel.getByRole('button', { name: 'Set here', exact: true }).click();
await page.screenshot({ path: path.join(out, 'cross-dungeon-preview.png') });
await panel.getByRole('button', { name: 'Save & publish', exact: true }).click();
await page.waitForFunction(async () => {
const { useBiteSizedLayoutsStore } = await window.playtestImport('/src/game/biteSizedLayoutsStore.ts');
return !useBiteSizedLayoutsStore.getState().saving && !useBiteSizedLayoutsStore.getState().dirty;
}, null, { timeout: 120000 });
observations.push({ name: 'publish new spot with imported mob', state: await state(page) });
await panel.getByRole('button', { name: 'Test run', exact: true }).click();
await ready(page);
assert.equal((await state(page)).run.timed, false);
assert.equal((await state(page)).mode, 'test');
assert.equal(await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
return Object.values(useCombatStore.getState().mobs).some((m) => m.boss && !m.dead);
}), false, 'The after-mobs boss must be absent before the trash dies');
await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
const combat = useCombatStore.getState();
Object.values(combat.mobs).filter((m) => !m.boss).forEach((m) => combat.damageMob(m.id, 1e9));
});
await page.waitForTimeout(1200);
await page.screenshot({ path: path.join(out, 'boss-after-mobs.png') });
assert.equal(await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
return Object.values(useCombatStore.getState().mobs).some((m) => m.boss && !m.dead);
}), true, 'The boss must activate after mobs die');
await panel.getByRole('button', { name: 'Return to editing', exact: true }).click();
await ready(page);
await panel.getByLabel('Boss arrival', { exact: true }).selectOption('immediate');
await panel.getByRole('button', { name: 'Test run', exact: true }).click();
await ready(page);
await page.screenshot({ path: path.join(out, 'boss-present.png') });
assert.equal(await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
return Object.values(useCombatStore.getState().mobs).some((m) => m.boss && !m.dead);
}), true, 'The immediate boss must be present');
await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
const combat = useCombatStore.getState(); Object.values(combat.mobs).forEach((m) => combat.damageMob(m.id, 1e9));
});
await panel.getByText(/Test complete/).waitFor({ timeout: 15000 });
observations.push({ name: 'both boss modes unranked test', state: await state(page) });
await panel.getByRole('button', { name: 'Return to editing', exact: true }).click();
await ready(page);
await panel.getByLabel('Spot name', { exact: true }).focus();
const before = await state(page);
await page.keyboard.down('w'); await page.waitForTimeout(350); await page.keyboard.up('w');
assert.ok(Math.hypot(...(await state(page)).position.map((v, i) => v - before.position[i])) < .1, 'Panel focus must gate movement');
await panel.getByRole('button', { name: 'Collapse editor' }).click();
await page.mouse.move(550, 380); await page.mouse.down({ button: 'right' });
const yaw = (await state(page)).yaw;
await page.mouse.move(650, 395, { steps: 8 });
await page.waitForTimeout(300);
assert.notEqual((await state(page)).yaw, yaw);
assert.equal((await state(page)).pointerLock, false);
await page.mouse.up({ button: 'right' }); await page.waitForTimeout(100);
const released = (await state(page)).yaw;
await page.mouse.move(700, 420, { steps: 4 }); await page.waitForTimeout(300);
assert.equal((await state(page)).yaw, released);
observations.push({ name: 'panel input gating and right drag, no pointer lock', state: await state(page) });
await panel.getByRole('button', { name: 'Spot browser', exact: true }).click();
await panel.getByLabel('Dungeon', { exact: true }).selectOption('bite-sized-forsaken-abbey-ancalon');
await panel.getByRole('button', { name: 'Go to spot', exact: true }).click();
await ready(page);
await page.screenshot({ path: path.join(out, 'rom-editor.png') });
observations.push({ name: 'RoM editing', state: await state(page) });
await panel.getByRole('button', { name: 'Spot browser', exact: true }).click();
await panel.getByRole('button', { name: 'Main menu', exact: true }).click();
for (const source of ['stormwind-stockade', 'forsaken-abbey']) {
await page.evaluate(async (source) => {
const { selectBiteSizedRun } = await window.playtestImport('/shared/bite-sized-layouts.mjs');
const { useBiteSizedLayoutsStore } = await window.playtestImport('/src/game/biteSizedLayoutsStore.ts');
const { prepareBiteSizedLayoutContent } = await window.playtestImport('/src/app/biteSizedLayoutContent.ts');
const { useShellStore } = await window.playtestImport('/src/app/shellStore.ts');
const { installDungeonShellRuntime } = await window.playtestImport('/src/app/dungeonShellRuntime.ts');
const id = 'randomized-bite-sized-stormwind-stockade-available', seed = `playtest-${source}`;
const parent = useBiteSizedLayoutsStore.getState().config.spots.find((s) => s.sourceDungeonId === source).dungeonId;
const selection = selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, id, seed, { eligibleDungeonIds: [parent] });
await prepareBiteSizedLayoutContent(id, seed, selection); installDungeonShellRuntime();
useShellStore.getState().enterDungeon(id, 'healer', { freshRun: true, runId: seed, biteSizedSelection: selection });
}, source);
await ready(page);
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 30000 });
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(out, `randomized-${source}.png`) });
observations.push({ name: `randomized ${source}`, state: await state(page) });
await page.evaluate(async () => { const { useShellStore } = await window.playtestImport('/src/app/shellStore.ts'); useShellStore.getState().returnToMainMenu(); });
await page.locator('.main-menu-screen').waitFor();
}
assert.deepEqual(errors, []);
fs.writeFileSync(path.join(out, 'observations.json'), `${JSON.stringify({ observations, errors }, null, 2)}\n`);
console.log(`Passed ${observations.length} live checks. Screenshots: ${out}`);
} catch (error) {
await page.screenshot({ path: path.join(out, 'failure.png') });
console.error((await page.locator('body').innerText()).slice(-2400));
console.error(errors.slice(-8)); throw error;
} finally {
await browser.close(); await new Promise((resolve) => runtime.server.close(resolve));
fs.rmSync(dataDir, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });