Files
deepseek 8fbe70d0b0 Repterra Web — full game: base building, power grid, taming & breeding, aquatic raiders, day/night, save/load
- Isometric canvas RTS vs dinosaur waves (fan demake of Repterra)
- Economy: houses/taxes, farms, foresters, quarries; colonist staffing
- Power grid: generators extend build range; brownout + recovery
- Defense: walls/gates, watchtowers (AA), cannon towers (ground-only)
- Taming: Primal Pen + Tamers collar weakened dinos; pets obey commands
- Breeding: tamed pairs incubate eggs at the pen; hatchlings grow up
- 7 dino species incl. flying Pteranodons and lake-raiding Suchomimus
- Telegraphed waves with direction arrows; day-15 final horde; 3 difficulties
- Day/night cycle, fog of war, minimap, synth audio, 1x-3x speeds
- Save/Load/Continue + dawn autosave (full JSON state snapshots)
- Tests: 80-assertion headless suite, browser boot + E2E, balance harness
2026-08-23 07:00:23 +00:00

175 lines
7.2 KiB
JavaScript

/* =========================================================
* 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)');