/* ========================================================= * Balance harness — a scripted auto-player plays FULL games * at every difficulty and reports survival statistics. * node test/balance.js * ========================================================= */ 'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const ctx = { console, performance: { now: () => Date.now() }, setTimeout, clearTimeout, setInterval, clearInterval }; ctx.window = ctx; ctx.globalThis = ctx; vm.createContext(ctx); for (const f of ['config', 'utils', 'audio', 'world', 'entities', 'sim']) vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8'), ctx, { filename: f + '.js' }); const RTS = ctx.RTS; // ---------- scripted strategy ---------- function autoPlay(diff, seed, maxDays = 18) { const st = RTS.sim.newGame(diff, seed); const hq = RTS.sim.hq(); const rng = RTS.util.makeRng(seed ^ 0xabcdef); const W = RTS.CONFIG.WORLD.W, H = RTS.CONFIG.WORLD.H; // find the densest deposit cluster (what a player sees and walks toward) function denseAnchor(kind) { let best = null, bestN = -1; for (let y = 4; y < H - 4; y += 2) { for (let x = 4; x < W - 4; x += 2) { const i = y * W + x; const tl = st.world.tiles; if (tl.terrain[i] === 3 || st.bgrid[i]) continue; const dhq = Math.hypot(x - hq.x, y - hq.y); if (dhq < 6) continue; let n = 0; for (let dy = -4; dy <= 4; dy += 2) for (let dx = -4; dx <= 4; dx += 2) { const j = (y + dy) * W + (x + dx); if (j >= 0 && j < W * H && tl[kind][j] && tl.terrain[j] !== 3) n++; } n -= dhq * 0.35; // mild preference for closer clusters if (n > bestN) { bestN = n; best = { x, y }; } } } return best; } const putAt = (defId, ax, ay, r0, r1) => { for (let i = 0; i < 200; i++) { const a = rng() * Math.PI * 2, r = r0 + rng() * (r1 - r0); const x = Math.round(ax + Math.cos(a) * r), y = Math.round(ay + Math.sin(a) * r); if (RTS.sim.canPlace(defId, x, y).ok && RTS.sim.state().res.gold >= (RTS.CONFIG.BUILDINGS[defId].cost.gold || 0)) { const res = RTS.sim.place(defId, x, y); if (res.ok) return res.b; } } return null; }; const put = (defId, r0, r1) => putAt(defId, hq.x, hq.y, r0, r1); // k-th tower sits on an even compass bearing so the ring has no gaps let towersPlaced = 0; const putTower = () => { const k = towersPlaced++; const bearing = k * Math.PI / 4 + rng() * 0.5; for (let i = 0; i < 40; i++) { const a = bearing + rng.range(-0.25, 0.25); const r = 4.5 + rng() * 1.1; const x = Math.round(hq.x + Math.cos(a) * r), y = Math.round(hq.y + Math.sin(a) * r); if (RTS.sim.canPlace('watchtower', x, y).ok) { const res = RTS.sim.place('watchtower', x, y); if (res.ok) return res.b; } } return null; }; const treeA = denseAnchor('tree'); const rockA = denseAnchor('rock'); const dt = 1 / 30; const step = (sec) => { for (let i = 0; i < sec * 30; i++) RTS.sim.tick(dt); }; let t = 0, upg = 0; while (!st.over && st.day < maxDays) { step(1); t += 1; const count = (id) => st.buildings.filter(b => !b.dead && b.defId === id).length; // OPENING BOOK — must fit START_RES (normal: 280w/190s) with power in mind: // tower -> farm -> GENERATOR (before demand exceeds HQ's 10) -> tower -> income if (t <= 18) { const book = [ [2, () => putTower()], [4, () => put('farm', 2.5, 4)], [6, () => put('generator', 3.5, 6)], [8, () => putTower()], [10, () => treeA && putAt('forester', treeA.x, treeA.y, 0.5, 3)], [12, () => treeA && count('forester') < 2 && putAt('forester', treeA.x, treeA.y, 0.5, 3)], [14, () => put('house', 2, 3.8)], [16, () => rockA && putAt('quarry', rockA.x, rockA.y, 0.5, 3)], ]; for (const [at, fn] of book) if (t >= at && !book['d' + at]) { book['d' + at] = true; if (fn()) break; } } else { // DYNAMIC PHASE: keep income flowing, power ahead of demand, then defense. if (treeA && count('forester') < 2) putAt('forester', treeA.x, treeA.y, 0.5, 3); if (rockA && count('quarry') < 2) putAt('quarry', rockA.x, rockA.y, 0.5, 3); if (st.energyCap - st.energyUse < 8) put('generator', 3.5, 6); if (count('farm') < Math.max(1, 1 + Math.floor(st.day / 3))) put('farm', 2.5, 4); if (st.popCap < Math.min(24, 6 + st.day * 2)) put('house', 2, 3.8); const twWant = Math.max(count('watchtower'), Math.min(9, 1 + Math.floor(st.day * 1.2))); if (count('watchtower') < twWant) putTower(); if (st.day >= 2 && count('barracks') < 1) put('barracks', 2.5, 4); } // wall ring once stone flows: cheap HP that buys tower time const RING = 6.25; if (st.day >= 4 && count('wall') + count('gate') < 44 && st.res.stone > 140) { const a0 = rng() * Math.PI * 2; for (let k = 0; k < 48; k++) { const a = a0 + (k / 48) * Math.PI * 2; const x = Math.round(hq.x + Math.cos(a) * RING); const y = Math.round(hq.y + Math.sin(a) * RING); const id = (k % 12 === 0) ? 'gate' : 'wall'; if (!RTS.sim.canPlace(id, x, y).ok) continue; if (RTS.sim.place(id, x, y).ok) break; // one segment per decision tick } } // military: rangers continuously, one pen + tamers from day 5 const bar = st.buildings.find(b => !b.dead && b.done && b.defId === 'barracks'); if (bar && bar.trainQ.length < 3 && st.res.gold > 90) { RTS.sim.trainUnit(bar, 'ranger'); RTS.sim.setRally(bar.id, hq.x + rng() * 3 - 1.5, hq.y + rng() * 3 - 1.5); // fight from inside the walls } if (st.day >= 5 && count('primalpen') < 1) put('primalpen', 3, 5.5); const pen = st.buildings.find(b => !b.dead && b.done && b.defId === 'primalpen'); if (pen && pen.trainQ.length < 1 && st.res.gold > 160) RTS.sim.trainUnit(pen, 'tamer'); if (st.res.gold > 550) RTS.sim.buyUpgrade((upg++ % 2) ? 'range' : 'weapon'); } return { diff, seed, victory: st.victory, over: st.over, dayReached: st.day, kills: st.stats.kills, lost: st.stats.lost, }; } const RUNS = parseInt(process.argv[2] || '5', 10); const summary = {}; for (const diff of ['easy', 'normal', 'hard']) { summary[diff] = []; for (let k = 0; k < RUNS; k++) { const r = autoPlay(diff, 1000 + k * 7919); summary[diff].push(r); console.log(r.diff.padEnd(7), 'seed', String(r.seed).padEnd(5), r.victory ? '🏆 WIN ' : (r.over ? '💀 LOST' : '⏳ DNF '), 'day', String(r.dayReached).padStart(2), 'kills', String(r.kills).padStart(3), 'lost', r.lost); } } console.log('\n===== SUMMARY (' + RUNS + ' runs each) ====='); let prevRate = Infinity; for (const diff of ['easy', 'normal', 'hard']) { const rs = summary[diff]; const wins = rs.filter(r => r.victory).length; const medDay = rs.map(r => r.dayReached).sort((a, b) => a - b)[Math.floor(rs.length / 2)]; console.log(diff.padEnd(7), 'win rate', Math.round(wins / rs.length * 100) + '%', '| median day', medDay, '| avg kills', Math.round(rs.reduce((n, r) => n + r.kills, 0) / rs.length)); } console.log('\n(auto-player is deliberately simple — treat these as relative signals)');