237 lines
8.7 KiB
JavaScript
237 lines
8.7 KiB
JavaScript
// selfplay.mjs – automatisiert Level 0 mit einem deterministischen Bot durchspielen.
|
||
// Nutzt die öffentliche window.__AEGIS_DEBUG__-API sowie global-scope Functions
|
||
// (TOWER_TYPES, Game, placeTower, buySkill, buyMastery, upgradeAura, simulate).
|
||
//
|
||
// Copyright (C) 2026 Lila-Kuh
|
||
//
|
||
// This program is free software: you can redistribute it and/or modify
|
||
// it under the terms of the GNU General Public License as published by
|
||
// the Free Software Foundation, either version 3 of the License, or
|
||
// (at your option) any later version.
|
||
//
|
||
// This program is distributed in the hope that it will be useful,
|
||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
// GNU General Public License for more details.
|
||
//
|
||
// You should have received a copy of the GNU General Public License
|
||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
//
|
||
// STRATEGIE:
|
||
// 1. ALLE Türme entsperren (Game.completedTowerLevels = 99)
|
||
// 2. Sniper (range 390) als Primär-DPS – deckt das gesamte Spielfeld ab
|
||
// 3. Kryo (Freeze) + Tesla (Chain) + Quantum (Pierce) für Utility
|
||
// 4. Support-Auras (Aegis/Chrono/Range) für +10%/Stufe auf nahe Türme
|
||
// 5. Zwischen Wellen: ALLE Skills auf max, Mastery, Aura-Level
|
||
//
|
||
// WICHTIG: In page.evaluate() müssen die global-scope Identifier (const/let)
|
||
// als BARE IDENTIFIER referenziert werden – sie liegen NICHT auf window.
|
||
//
|
||
// Usage: node selfplay.mjs [episodes=10]
|
||
import { chromium } from 'playwright';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
import url from 'url';
|
||
|
||
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
||
const ROOT = path.resolve(HERE, '..');
|
||
const HTML = path.resolve(ROOT, 'Aegis-Labyrinth.html').replace(/\\/g, '/');
|
||
const MAX_EPISODES = Math.max(1, parseInt(process.argv[2] || '10', 10));
|
||
const TICKS_PER_EPISODE = 7200; // ~120s simulated per episode
|
||
|
||
const browser = await chromium.launch({ headless: true });
|
||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||
page.on('pageerror', (e) => console.error('[PAGE ERROR]', e.message));
|
||
|
||
await page.goto('file:///' + HTML, { waitUntil: 'load' });
|
||
await page.waitForFunction(() => window.__AEGIS_DEBUG__ !== undefined, { timeout: 15000 });
|
||
console.log('✅ Aegis-Labyrinth geladen');
|
||
|
||
const results = [];
|
||
for (let ep = 0; ep < MAX_EPISODES; ep++) {
|
||
const result = await page.evaluate(({ maxTicks }) => {
|
||
const d = window.__AEGIS_DEBUG__;
|
||
const dt = 1 / 60;
|
||
|
||
const obs = () => {
|
||
const s = d.getState();
|
||
return {
|
||
state: s.state,
|
||
credits: s.credits,
|
||
lives: s.lives,
|
||
waveIndex: s.waveIndex,
|
||
totalWaves: s.totalWaves,
|
||
waveActive: s.waveActive,
|
||
enemies: s.enemies,
|
||
towers: s.towers,
|
||
kills: s.kills,
|
||
leaked: s.leaked,
|
||
};
|
||
};
|
||
|
||
// ─── TOWER PLACEMENT: Sniper-heavy, alle freien Zellen ───
|
||
const placeTowers = () => {
|
||
const towerTypes = TOWER_TYPES.filter(isTowerAvailable).filter((t) => isTowerUnlocked(t));
|
||
if (towerTypes.length === 0) return 0;
|
||
|
||
// Pfad-Zellen
|
||
const path = [];
|
||
for (let y = 0; y < MAP_H; y++)
|
||
for (let x = 0; x < MAP_W; x++) if (Game.grid[y][x] === 1) path.push({ x, y });
|
||
if (path.length === 0) return 0;
|
||
|
||
// Alle freien Zellen, sortiert nach Distanz zum Pfad
|
||
const candidates = [];
|
||
for (let y = 0; y < MAP_H; y++) {
|
||
for (let x = 0; x < MAP_W; x++) {
|
||
if (Game.grid[y][x] !== 0) continue;
|
||
let minDist = Infinity;
|
||
for (const p of path) {
|
||
const dist = Math.hypot(p.x - x, p.y - y);
|
||
if (dist < minDist) minDist = dist;
|
||
}
|
||
candidates.push({ x, y, dist: minDist });
|
||
}
|
||
}
|
||
candidates.sort((a, b) => a.dist - b.dist);
|
||
|
||
// Turm-Zuordnung: Sniper dominiert (range 390 = ganzer Map)
|
||
const sniper = towerTypes.find(t => t.id === 'sniper');
|
||
const quantum = towerTypes.find(t => t.id === 'quantum');
|
||
const tesla = towerTypes.find(t => t.id === 'tesla');
|
||
const kryo = towerTypes.find(t => t.id === 'kryo');
|
||
const nuclear = towerTypes.find(t => t.id === 'nuclear');
|
||
const supports = towerTypes.filter(t => t.support);
|
||
|
||
// Reihenfolge: Utility zuerst (nah am Pfad), dann Sniper (weit, deckt alles)
|
||
const queue = [];
|
||
if (kryo) for (let i = 0; i < 6; i++) queue.push(kryo);
|
||
if (tesla) for (let i = 0; i < 6; i++) queue.push(tesla);
|
||
if (quantum) for (let i = 0; i < 4; i++) queue.push(quantum);
|
||
if (nuclear) for (let i = 0; i < 4; i++) queue.push(nuclear);
|
||
if (supports.length > 0) for (let i = 0; i < 6; i++) queue.push(supports[i % supports.length]);
|
||
// Rest: Sniper (range 390!)
|
||
while (queue.length < candidates.length) {
|
||
queue.push(sniper || towerTypes[0]);
|
||
}
|
||
|
||
let placed = 0;
|
||
for (let i = 0; i < candidates.length; i++) {
|
||
const c = candidates[i];
|
||
const def = queue[i];
|
||
const ok = placeTower(def.id, c.x, c.y);
|
||
if (ok === true) placed++;
|
||
}
|
||
return placed;
|
||
};
|
||
|
||
// ─── UPGRADES: Alle Skills max, Mastery, Aura-Level ───
|
||
const upgradeAll = () => {
|
||
let count = 0;
|
||
for (const t of Game.towers) {
|
||
if (t.def.support) {
|
||
while (upgradeAura(t)) { count++; if (t.auraLevel >= 10) break; }
|
||
} else {
|
||
for (const sk of t.def.skills) {
|
||
const maxR = sk.maxLevel || 5;
|
||
while (t.rank(sk.skillId) < maxR && buySkill(t, sk.skillId)) { count++; }
|
||
}
|
||
while (canBuyMastery(t) && buyMastery(t)) { count++; }
|
||
}
|
||
}
|
||
return count;
|
||
};
|
||
|
||
// ─── SETUP ───
|
||
d.selectLevel(0);
|
||
if (Game.state !== 'playing') Game.state = 'playing';
|
||
Game.completedTowerLevels = 99; // *** KRITISCH: ALLE Türme entsperren ***
|
||
|
||
// 1. Türme platzieren
|
||
d.setCredits(9999999);
|
||
const placed = placeTowers();
|
||
|
||
// 2. Alle Upgrades
|
||
d.setCredits(9999999);
|
||
const ups1 = upgradeAll();
|
||
|
||
// 3. Welle 1 starten
|
||
d.startWave();
|
||
|
||
// ─── SIMULATION ───
|
||
let maxKills = 0;
|
||
let waveReached = 0;
|
||
let totalUps = ups1;
|
||
|
||
for (let tick = 0; tick < maxTicks; tick++) {
|
||
simulate(dt);
|
||
const s = obs();
|
||
maxKills = Math.max(maxKills, s.kills);
|
||
waveReached = Math.max(waveReached, s.waveIndex);
|
||
|
||
if (s.state === 'victory' || s.state === 'defeat') break;
|
||
|
||
// Zwischenwellen: upgraden + evtl. neue Türme + nächste Welle
|
||
if (!s.waveActive && s.enemies === 0 && Game.state === 'playing') {
|
||
d.setCredits(9999999);
|
||
totalUps += upgradeAll();
|
||
placeTowers();
|
||
d.setCredits(9999999);
|
||
totalUps += upgradeAll();
|
||
d.startWave();
|
||
}
|
||
}
|
||
|
||
const final = obs();
|
||
return {
|
||
placed,
|
||
towerCount: Game.towers.length,
|
||
maxKills,
|
||
waveReached,
|
||
totalUpgrades: totalUps,
|
||
finalState: final.state,
|
||
finalKills: final.kills,
|
||
finalLeaked: final.leaked,
|
||
finalLives: final.lives,
|
||
};
|
||
}, { maxTicks: TICKS_PER_EPISODE });
|
||
|
||
results.push(result);
|
||
const icon = result.finalState === 'victory' ? '✅' : result.finalState === 'defeat' ? '❌' : '⏸️';
|
||
console.log(
|
||
`${icon} EP ${String(ep + 1).padStart(2)}/${MAX_EPISODES}: state=${result.finalState} ` +
|
||
`wave=${result.waveReached} kills=${result.maxKills} ` +
|
||
`towers=${result.towerCount} ups=${result.totalUpgrades} lives=${result.finalLives} leaked=${result.finalLeaked}`
|
||
);
|
||
|
||
// Reset zwischen Episoden
|
||
await page.evaluate(() => {
|
||
const d = window.__AEGIS_DEBUG__;
|
||
d.selectLevel(0);
|
||
Game.state = 'playing';
|
||
Game.towers.length = 0;
|
||
Game.enemies.length = 0;
|
||
Game.projectiles.length = 0;
|
||
Game.particles.length = 0;
|
||
Game.beams.length = 0;
|
||
if (Game.nanites) Game.nanites.length = 0;
|
||
});
|
||
}
|
||
|
||
const wins = results.filter((r) => r.finalState === 'victory').length;
|
||
const losses = results.filter((r) => r.finalState === 'defeat').length;
|
||
const summary = {
|
||
total: results.length,
|
||
wins,
|
||
losses,
|
||
winRate: results.length ? (wins / results.length) * 100 : 0,
|
||
avgKills: results.length ? Math.round(results.reduce((a, r) => a + r.maxKills, 0) / results.length) : 0,
|
||
avgWaves: results.length ? Math.round((results.reduce((a, r) => a + r.waveReached, 0) / results.length) * 10) / 10 : 0,
|
||
};
|
||
|
||
const outFile = path.join(ROOT, 'selfplay-results.json');
|
||
fs.writeFileSync(outFile, JSON.stringify({ timestamp: new Date().toISOString(), episodes: results, summary }, null, 2));
|
||
console.log(`✅ Self-Play done → ${outFile}`);
|
||
console.log(` ${summary.wins}/${summary.total} wins (${summary.winRate.toFixed(1)}%) · avg kills=${summary.avgKills} · avg wave=${summary.avgWaves}`);
|
||
|
||
await browser.close(); |