const fs = require("fs"); const vm = require("vm"); const path = require("path"); const assert = require("assert"); const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8"); const context = { console, Math, Date, JSON, isFinite }; vm.createContext(context); vm.runInContext(source, context); function seedRandom(seed) { let value = seed >>> 0; return function () { value = (value * 1664525 + 1013904223) >>> 0; return value / 0x100000000; }; } function samplePath(route) { const samples = []; for (let i = 0; i < route.length - 1; i++) { const [ax, ay] = route[i], [bx, by] = route[i + 1]; const length = Math.hypot(bx - ax, by - ay); const steps = Math.max(1, Math.ceil(length / 40)); for (let step = 0; step < steps; step++) { const ratio = step / steps; samples.push([ax + (bx - ax) * ratio, ay + (by - ay) * ratio, length / steps]); } } return samples; } const pathSamples = [samplePath(context.PATHS[0]), samplePath(context.PATHS[1])]; const areaFactor = [1, 1, 1.65, 1.6, 1.9, 1.35, 1]; function nextWaveComposition(state, wave) { const schedule = context.buildWaveSchedule(state, wave, context.wavePopulation(state, wave)); const counts = Array(context.ENEMIES.length).fill(0); for (const entry of schedule) counts[entry.type] += 1; return { schedule, counts }; } function chooseTowerType(state, composition, fixedType) { if (fixedType !== null) return fixedType; let bestType = 1, bestScore = -1; for (let towerType = 0; towerType < context.TOWERS.length; towerType++) { const tower = context.TOWERS[towerType]; let score = 0; for (let enemyType = 0; enemyType < context.ENEMIES.length; enemyType++) { const count = composition.counts[enemyType]; if (!count) continue; const enemy = context.ENEMIES[enemyType]; if (tower.targetClass !== "both" && tower.targetClass !== enemy.category) continue; const damage = tower.damage * (tower.targetClass === "both" ? tower.bothScale || 1 : 1); const exposedDps = damage / tower.cooldown; const shieldChance = enemyType === 4 ? 1 : 0.25; const shieldMultiplier = towerType === 1 || towerType === 5 ? 1 : 1 - shieldChance * (1 - (enemy.shieldResistance || 0.28)); const bossWeight = composition.schedule.some(entry => entry.type === enemyType && entry.boss) ? 1.2 : 1; score += count * Math.sqrt(enemy.hp) * exposedDps * shieldMultiplier * areaFactor[towerType] * bossWeight; } score /= Math.max(1, tower.cost); if (score > bestScore) { bestScore = score; bestType = towerType; } } return bestType; } function choosePosition(state, owner, range) { const xMin = owner === 0 ? 120 : 1520; const xMax = owner === 0 ? 1440 : 2840; let best = null, bestScore = -1; for (let y = 120; y <= 680; y += 80) for (let x = xMin; x <= xMax; x += 80) { if (!context.validBuildPosition(state, owner, x, y)) continue; const rangeSq = range * range; let score = 0; for (const [px, py, weight] of pathSamples[owner]) { const covered = state.towers.some(tower => tower.owner === owner && context.distanceSquared(tower.x, tower.y, px, py) <= context.towerRange(state, tower) ** 2); if (!covered && context.distanceSquared(x, y, px, py) <= rangeSq) score += weight; } if (score > bestScore) { bestScore = score; best = [x, y]; } } return best; } function runMission(difficulty, fixedType = null) { let random = seedRandom(0xB45710 + difficulty * 7919 + (fixedType === null ? 0 : fixedType * 97)); context.Math = Object.create(Math); context.Math.random = () => random(); const state = context.initialState({ difficulty, mode: "balance_test" }); state.started = true; let wavesCompleted = 0, totalSpawned = 0, totalBreached = 0; const trace = []; const maxTicksPerWave = 24000; for (let wave = 1; wave <= 15 && !state.gameOver; wave++) { const composition = nextWaveComposition(state, wave); for (let owner = 0; owner < 2; owner++) { let guard = 32; while (guard-- > 0) { const towerType = chooseTowerType(state, composition, fixedType); const tower = context.TOWERS[towerType]; if (state.credits[owner] < tower.cost) break; const position = choosePosition(state, owner, tower.range); if (!position) break; state.credits[owner] -= tower.cost; state.towers.push({ id: state.nextTowerId++, type: towerType, x: position[0], y: position[1], owner, level: 1, cooldown: 0.15, angle: owner === 0 ? 0 : Math.PI }); } } if (!context.startWave(state)) break; const beforeSpawn = state.nextEnemyId; let tick = 0; while (state.waveActive && !state.gameOver && tick++ < maxTicksPerWave) { context.updateWave(state, 0.1, { broadcastMessage() {} }); context.updateEnemies(state, 0.1); context.updateTowers(state, 0.1); } totalSpawned += state.nextEnemyId - beforeSpawn; totalBreached += Math.max(0, 30 - state.reactorLives); trace.push({wave, lives: state.reactorLives, spawned: state.nextEnemyId - beforeSpawn, towers: state.towers.length, credits: [...state.credits], enemyLeft: state.enemies.length, towerTypes: state.towers.map(t => t.type)}); if (tick >= maxTicksPerWave) break; if (!state.gameOver) wavesCompleted++; } return { difficulty, fixedType, wavesCompleted, reactorLives: state.reactorLives, trace, towers: state.towers.length, spawned: totalSpawned, remainingEnemies: state.enemies.length, credits: state.credits.reduce((sum, value) => sum + value, 0) }; } const baseline = []; for (let difficulty = 0; difficulty < context.DIFFICULTIES.length; difficulty++) { baseline.push(runMission(difficulty)); } const towerRuns = []; for (let tower = 0; tower < context.TOWERS.length; tower++) { for (let difficulty = 0; difficulty < context.DIFFICULTIES.length; difficulty++) { towerRuns.push(runMission(difficulty, tower)); } } assert.strictEqual(baseline.length, 3); assert.ok(towerRuns.length === context.TOWERS.length * 3); assert.ok(baseline.every(result => result.spawned > 0), "mission simulations must use the real wave scheduler"); console.log("BALANCE MISSION RESULTS (Nakama wave/combat code, fixed-seed greedy build planner, level-1 towers)"); console.log("planner: " + baseline.map(r => `D${r.difficulty} ${r.wavesCompleted}/15 waves, reactor ${r.reactorLives}, ${r.towers} towers`).join(" | ")); for (let tower = 0; tower < context.TOWERS.length; tower++) { const values = towerRuns.filter(result => result.fixedType === tower) .map(r => `D${r.difficulty} ${r.wavesCompleted}/15, R${r.reactorLives}, ${r.towers}T`).join(" | "); console.log(`tower ${tower} (${context.TOWERS[tower].targetClass}): ${values}`); } console.log(`BALANCE_MISSION_TEST_OK scenarios=${baseline.length + towerRuns.length}`);