import { VoxelGrid } from "./src/grid/VoxelGrid.js"; import { CrystalSpiresGenerator } from "./src/generation/CrystalSpiresGenerator.js"; // 1. Get Seed from Args const args = process.argv.slice(2); const seed = args.length > 0 ? parseInt(args[0]) : 727852; console.log(`Debug Run: Seed ${seed}`); // Mock OffscreenCanvas for Node.js environment global.OffscreenCanvas = class { constructor(width, height) { this.width = width; this.height = height; } getContext() { return { fillStyle: "", fillRect: () => {}, beginPath: () => {}, moveTo: () => {}, lineTo: () => {}, stroke: () => {}, fill: () => {}, arc: () => {}, save: () => {}, restore: () => {}, translate: () => {}, rotate: () => {}, scale: () => {}, createLinearGradient: () => ({ addColorStop: () => {} }), createRadialGradient: () => ({ addColorStop: () => {} }), createImageData: (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4), }), putImageData: () => {}, }; } }; const grid = new VoxelGrid(20, 20, 20); const generator = new CrystalSpiresGenerator(grid, seed); generator.generate(4, 3); // 2. Validate Bridges const bridges = generator.generatedAssets.bridges || []; const spires = generator.generatedAssets.spires || []; console.log(`\n=== Bridge Validity Check ===`); console.log(`Total Spires: ${spires.length}`); console.log(`Total Bridges: ${bridges.length}`); // Helper: Collision Check function checkCylinderCollision(x1, z1, x2, z2, cx, cz, r) { const dx = x2 - x1; const dz = z2 - z1; const fx = x1 - cx; const fz = z1 - cz; const a = dx * dx + dz * dz; const b = 2 * (fx * dx + fz * dz); const c = fx * fx + fz * fz - r * r; let discrim = b * b - 4 * a * c; if (discrim < 0) return false; discrim = Math.sqrt(discrim); const t1 = (-b - discrim) / (2 * a); const t2 = (-b + discrim) / (2 * a); if ((t1 > 0.05 && t1 < 0.95) || (t2 > 0.05 && t2 < 0.95)) return true; return false; } // Helper: Check if a position is valid to stand on function isValidStance(grid, x, y, z) { if ( x < 0 || x >= grid.size.x || z < 0 || z >= grid.size.z || y < 0 || y >= grid.size.y ) return false; const ground = grid.getCell(x, y - 1, z); const feet = grid.getCell(x, y, z); const head = grid.getCell(x, y + 1, z); if (ground === 0 || feet !== 0 || head !== 0) { // console.log(`Stance Fail @ ${x},${y},${z}: G=${ground} F=${feet} H=${head}`); return false; } return true; } // Helper: BFS Pathfinding // Helper: Game-Accurate Walkability Check (Matches MovementSystem.js) function checkWalkability(grid, start, end, spireCenter = null) { const q = []; const visited = new Set(); const cameFrom = new Map(); // Track path for reconstruction // Find valid start Y (Unit stands ON TOP of the block) // Generator `start` is the Bridge Block (Floor). // So Unit Y = start.y + 1. const sx = Math.round(start.x); const sz = Math.round(start.z); // Find Walkable Y logic from MovementSystem // Checks: Cell(y) != 0 is FAIL. Cell(y-1) == 0 is FAIL. Cell(y+1) != 0 is FAIL. const findWalkableY = (x, z, refY) => { const levels = [refY, refY + 1, refY - 1, refY - 2]; for (const y of levels) { if ( grid.getCell(x, y, z) === 0 && // Feet Clear grid.getCell(x, y - 1, z) !== 0 && // Floor Solid grid.getCell(x, y + 1, z) === 0 ) { // Head Clear return y; } } return null; }; const sy = findWalkableY(sx, sz, Math.round(start.y) + 1); if (sy === null) { return { success: false, reason: `Invalid Start Stance near ${sx},${start.y},${sz}`, }; } const ex = Math.round(end.x); const ez = Math.round(end.z); // Target Y is flexible (logic finds it) q.push({ x: sx, y: sy, z: sz, dist: 0 }); const startKey = `${sx},${sy},${sz}`; visited.add(startKey); cameFrom.set(startKey, null); while (q.length > 0) { const curr = q.shift(); // Target Reached? (Approximate X/Z matches) // Note: We check if we are 'standing' on or near the target platform rim if (Math.abs(curr.x - ex) <= 2 && Math.abs(curr.z - ez) <= 2) { // Also check Y proximity? if (Math.abs(curr.y - (end.y + 1)) <= 3) { // Perform Outward Check if needed if (spireCenter) { // Reconstruct Path let path = []; let k = `${curr.x},${curr.y},${curr.z}`; while (k) { const [px, py, pz] = k.split(",").map(Number); path.push({ x: px, y: py, z: pz }); k = cameFrom.get(k); } path.reverse(); // Check first 3 steps if (path.length > 1) { const p0 = path[0]; // Check a bit further steps to be sure const checkIdx = Math.min(path.length - 1, 3); const pCheck = path[checkIdx]; const d0 = Math.sqrt( Math.pow(p0.x - spireCenter.x, 2) + Math.pow(p0.z - spireCenter.z, 2) ); const dN = Math.sqrt( Math.pow(pCheck.x - spireCenter.x, 2) + Math.pow(pCheck.z - spireCenter.z, 2) ); if (dN < d0) { // Check if we are walking on a BRIDGE (ID 20) or PLATFORM (ID != 20) // If on Platform, Inward is valid (walking across). // If on Bridge, Inward is invalid (curling back). // `pCheck` is at feet level? No, pCheck has .y stance. // Grid cell at y-1 is floor. const floorID = grid.getCell( Math.round(pCheck.x), Math.round(pCheck.y - 1), Math.round(pCheck.z) ); if (floorID === 20) { console.log( `[DEBUG] Inward Fail (Bridge Voxel): Center(${spireCenter.x}, ${spireCenter.z})` ); console.log( `[DEBUG] Path[0]: (${p0.x}, ${p0.y}, ${p0.z}) D=${d0.toFixed( 2 )}` ); console.log( `[DEBUG] Path[${checkIdx}]: (${pCheck.x}, ${pCheck.y}, ${ pCheck.z }) D=${dN.toFixed(2)} ID=${floorID}` ); return { success: false, reason: "Path goes INWARD towards spire center (Bridge Recurve)", }; } else { // console.log(`[DEBUG] Inward Move Allowed on Platform (ID ${floorID})`); } } } } return { success: true }; } } if (curr.dist > 300) continue; // Spec CoA Implementation // 1. Neighbors (Horizontal Only) const dirs = [ { x: 1, z: 0 }, { x: -1, z: 0 }, { x: 0, z: 1 }, { x: 0, z: -1 }, ]; for (const d of dirs) { const nx = curr.x + d.x; const nz = curr.z + d.z; if (!grid.isValidBounds(nx, 0, nz)) continue; // CoA 1: Gradient <= 1 // Check Step Up (y+1), Level (y), Step Down (y-1) const candidates = [curr.y, curr.y + 1, curr.y - 1]; for (const ny of candidates) { if (!grid.isValidBounds(nx, ny, nz)) continue; // CoA 3 & 4: Headroom Validation // Floor(ny-1) must be solid. // Feet(ny) can be Air, Bridge(20), Teleporter(22). // Head1(ny+1) MUST be Air. // Head2(ny+2) MUST be Air. const floor = grid.getCell(nx, ny - 1, nz); const feet = grid.getCell(nx, ny, nz); const h1 = grid.getCell(nx, ny + 1, nz); const h2 = grid.getCell(nx, ny + 2, nz); const isFloorSolid = floor !== 0; const isFeetPassable = feet === 0 || feet === 20 || feet === 22; const isHeadClear = h1 === 0 && h2 === 0; if (isFloorSolid && isFeetPassable && isHeadClear) { const key = `${nx},${ny},${nz}`; if (!visited.has(key)) { visited.add(key); cameFrom.set(key, `${curr.x},${curr.y},${curr.z}`); q.push({ x: nx, y: ny, z: nz, dist: curr.dist + 1 }); } } } } } return { success: false, reason: "No Path Found (Gap or Blockage)" }; } let validBridges = 0; bridges.forEach((b, idx) => { let isValid = true; let issues = []; // Check 1: Pillar Collision for (let i = 0; i < spires.length; i++) { if (i === b.fromSpire || i === b.toSpire) continue; const s = spires[i]; if ( checkCylinderCollision( b.start.x, b.start.z, b.end.x, b.end.z, s.x, s.z, s.radius + 1.0 ) ) { isValid = false; issues.push(`Clips Spire ${i}`); } } // Check 2: Slope const yDiff = Math.abs(b.end.y - b.start.y); const rimDist = b.dist; const reqYdMax = Math.max(rimDist, 3) * 3.0; if (yDiff > 12) { isValid = false; issues.push(`Too steep (Y=${yDiff} > 12)`); } if (yDiff > reqYdMax) { isValid = false; issues.push(`Slope limit exceeded`); } // Bridge Validity Logic (CoA Compliance) // 1. Walkability (Forward) const walkA = checkWalkability(grid, b.start, b.end, { x: 0, y: 0, z: 0 }); // Centroid irrelevant now // 2. Walkability (Reverse) const walkB = checkWalkability(grid, b.end, b.start, { x: 0, y: 0, z: 0 }); if (!walkA.success) { isValid = false; issues.push(`Forward Unwalkable: ${walkA.reason}`); } if (isValid && !walkB.success) { isValid = false; issues.push(`Reverse Unwalkable: ${walkB.reason}`); } // Note: We removed strict "Inward" and "Tortuosity" checks as Bezier curves are organic. // The primary constraints are now Gradient (in checkWalkability) and Headroom. if (isValid) { console.log( `Bridge #${idx} [Spire ${b.fromSpire} -> ${b.toSpire}]: VALID (OK)` ); validBridges++; } else { console.log( `Bridge #${idx} [Spire ${b.fromSpire} -> ${ b.toSpire }]: INVALID (${issues.join(", ")})` ); } }); console.log(`\n=== Orphan Platform Check ===`); let orphans = 0; let totalPlatforms = 0; spires.forEach((s, sIdx) => { s.platforms.forEach((p, pIdx) => { totalPlatforms++; // Check if this platform has any connections recorded in its object // Note: connections array stores points, so strictly length > 0 if (!p.connections || p.connections.length === 0) { orphans++; console.log( `Spire ${sIdx} Platform ${pIdx} (Y=${p.y}) is ORPHANED (0 connections)` ); } }); }); console.log(`Total Platforms: ${totalPlatforms}`); console.log(`Orphan Platforms: ${orphans}`); console.log(`\n=== Graph Connectivity Check ===`); // 1. Gather all platforms and assign IDs let allPlats = []; spires.forEach((s, sIdx) => { s.platforms.forEach((p, pIdx) => { p.id = `${sIdx}:${pIdx}`; allPlats.push(p); }); }); allPlats.sort((a, b) => a.y - b.y); // 2. Build Adjacency Graph from Bridges // Bridge stores: { fromSpire, toSpire, start, end } // We need to map start/end points back to platforms. // Helper: Find platform containing point function findPlat(pt, sIdx) { const s = spires[sIdx]; // Find plat with closest Y and matching X/Z within radius let best = null; let minD = Infinity; for (const p of s.platforms) { const dy = Math.abs(p.y - pt.y); if (dy < 2.0) return p; // Direct height match usually enough } return null; } const adj = new Map(); // ID -> Set allPlats.forEach((p) => adj.set(p.id, new Set())); bridges.forEach((b) => { // Note: b.fromSpire/b.toSpire are indices // b.start is on fromSpire, b.end is on toSpire const pA = findPlat(b.start, b.fromSpire); const pB = findPlat(b.end, b.toSpire); if (pA && pB) { adj.get(pA.id).add(pB.id); adj.get(pB.id).add(pA.id); // console.log(`[Graph] Linked ${pA.id} <--> ${pB.id} via Bridge ${bridges.indexOf(b)}`); } else { console.log( `[Graph] Failed to map bridge ${bridges.indexOf(b)} to platforms!` ); console.log( ` Bridge: Spire ${b.fromSpire} -> ${b.toSpire}, Y: ${b.start.y.toFixed( 1 )} -> ${b.end.y.toFixed(1)}` ); console.log(` pA: ${pA ? pA.id : "NULL"}, pB: ${pB ? pB.id : "NULL"}`); } }); // 3. BFS from Lowest Platform const startNode = allPlats[0]; const targetNode = allPlats[allPlats.length - 1]; // Highest const qGraph = [startNode.id]; const visitedGraph = new Set([startNode.id]); while (qGraph.length > 0) { const curr = qGraph.shift(); const neighbors = adj.get(curr); if (neighbors) { for (const n of neighbors) { if (!visitedGraph.has(n)) { visitedGraph.add(n); qGraph.push(n); } } } } // 4. Report const fullyConnected = visitedGraph.size === allPlats.length; const topReachable = visitedGraph.has(targetNode.id); console.log( `Nodes Reachable from Bottom (Y=${startNode.y}): ${visitedGraph.size} / ${allPlats.length}` ); console.log( `Top Platform (Y=${targetNode.y}) Reachable: ${topReachable ? "YES" : "NO"}` ); console.log(`Full Graph Interconnected: ${fullyConnected ? "YES" : "NO"}`); // CoA 5: Global Reachability via Grid Flood Fill (True Walkability) // Graph connectivity assumes bridges work. Flood Fill PROVES it. console.log("\n=== Global Flood Fill (Spawn -> All) ==="); // DEBUG: Check specific coordinate reported by Generator logic const checkVal = grid.getCell(12, 11, 5); console.log(`DEBUG CHECK: Cell(12,11,5) = ${checkVal}`); // 0. Find all Teleporters (ID 22) const teleporters = []; for (let x = 0; x < grid.width; x++) { for (let z = 0; z < grid.depth; z++) { for (let y = 0; y < grid.height; y++) { if (grid.getCell(x, y, z) === 22) { teleporters.push({ x, y, z }); } } } } console.log(`Found ${teleporters.length} Teleporter Nodes.`); const spawnPlat = allPlats[0]; const reachableSet = new Set(); // Start slightly above spawn platform to ensure standing const floodQ = [ { x: Math.round(spawnPlat.x), y: Math.round(spawnPlat.y + 1), z: Math.round(spawnPlat.z), }, ]; const visitedFlood = new Set([`${floodQ[0].x},${floodQ[0].y},${floodQ[0].z}`]); // Optimization: Limit flood fill iterations let iterations = 0; const MAX_ITER = 50000; while (floodQ.length > 0 && iterations < MAX_ITER) { iterations++; const curr = floodQ.shift(); // Check Teleport Jump const floorID = grid.getCell(curr.x, curr.y - 1, curr.z); const feetID = grid.getCell(curr.x, curr.y, curr.z); if (floorID === 22 || feetID === 22) { for (const t of teleporters) { const destKey = `${t.x},${t.y + 1},${t.z}`; if (!visitedFlood.has(destKey)) { visitedFlood.add(destKey); floodQ.push({ x: t.x, y: t.y + 1, z: t.z }); } } } // Neighbors (Same logic as checkWalkability) const dirs = [ { x: 1, z: 0 }, { x: -1, z: 0 }, { x: 0, z: 1 }, { x: 0, z: -1 }, ]; for (const d of dirs) { const nx = curr.x + d.x; const nz = curr.z + d.z; if (!grid.isValidBounds(nx, 0, nz)) continue; const candidates = [curr.y, curr.y + 1, curr.y - 1]; for (const ny of candidates) { if (!grid.isValidBounds(nx, ny, nz)) continue; const floor = grid.getCell(nx, ny - 1, nz); const feet = grid.getCell(nx, ny, nz); const h1 = grid.getCell(nx, ny + 1, nz); const h2 = grid.getCell(nx, ny + 2, nz); const isFloorSolid = floor !== 0; // Allow ID 22 (Teleporter Node) as passable const isPassable = feet === 0 || feet === 20 || feet === 22; if (isFloorSolid && isPassable && h1 === 0 && h2 === 0) { const key = `${nx},${ny},${nz}`; if (!visitedFlood.has(key)) { visitedFlood.add(key); floodQ.push({ x: nx, y: ny, z: nz }); } } } } } // Check Coverage let floodReachableCount = 0; allPlats.forEach((p, idx) => { let hit = false; const searchR = p.radius - 1; for (let x = Math.round(p.x - searchR); x <= Math.round(p.x + searchR); x++) { for ( let z = Math.round(p.z - searchR); z <= Math.round(p.z + searchR); z++ ) { // Check surface (y) or just above (y+1) if ( visitedFlood.has(`${x},${Math.round(p.y + 1)},${z}`) || visitedFlood.has(`${x},${Math.round(p.y)},${z}`) ) { hit = true; break; } } if (hit) break; } if (hit) floodReachableCount++; else console.log(`[FloodFail] Platform ${p.globalId} unreachable from Spawn.`); }); console.log( `Flood Fill Reachable Platforms: ${floodReachableCount} / ${allPlats.length}` ); console.log( `Global Reachability: ${ floodReachableCount === allPlats.length ? "PASS" : "FAIL" }` ); console.log(`\nGeneration Complete.`);