Initial project version
This commit is contained in:
+337
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Aegis Labyrinth – Automatische Audit-Suite
|
||||
* Führt die 3 eingebauten Audits aus: Self, Extension, Redesign
|
||||
* Ausgabe: audit-result.json + Konsolentabelle
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const HTML_PATH = path.resolve(ROOT, 'Aegis-Labyrinth.html');
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────
|
||||
function timestamp() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sha256(buf) {
|
||||
return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
// ─── Assertion Helpers ─────────────────────────────────────────────
|
||||
function check(name, actual, expected) {
|
||||
const passed = actual === expected;
|
||||
return { name, actual, expected, passed };
|
||||
}
|
||||
|
||||
function checkTrue(name, value) {
|
||||
return { name, actual: value, expected: true, passed: value === true };
|
||||
}
|
||||
|
||||
function checkFalse(name, value) {
|
||||
return { name, actual: value, expected: false, passed: value === false };
|
||||
}
|
||||
|
||||
function checkGt(name, value, min = 1) {
|
||||
const passed = value > min;
|
||||
return { name, actual: value, expected: `> ${min}`, passed };
|
||||
}
|
||||
|
||||
function checkGte(name, value, min = 0) {
|
||||
const passed = value >= min;
|
||||
return { name, actual: value, expected: `>= ${min}`, passed };
|
||||
}
|
||||
|
||||
function checkNotInf(name, value) {
|
||||
const passed = value !== Infinity && value !== undefined && value !== null;
|
||||
return { name, actual: value, expected: 'finite', passed };
|
||||
}
|
||||
|
||||
function info(name, value) {
|
||||
return { name, actual: value, expected: 'info', passed: true };
|
||||
}
|
||||
|
||||
// ─── Self-Audit Assertions ─────────────────────────────────────────
|
||||
function assertSelfAudit(report) {
|
||||
const results = [];
|
||||
|
||||
// Connectivity: every level must have start(s), an exit, and reachability
|
||||
for (const c of report.connectivity) {
|
||||
results.push(checkTrue(`L${c.level} has start`, c.starts > 0));
|
||||
results.push(checkTrue(`L${c.level} has exit`, c.exit));
|
||||
results.push(checkTrue(`L${c.level} reachable`, c.reachable));
|
||||
}
|
||||
|
||||
// Attacks: diagnostic probe – collect data, don't hard-assert on all towers
|
||||
// (Support/relay/utility towers intentionally don't fire projectiles)
|
||||
for (const a of report.attacks) {
|
||||
results.push(info(`tower ${a.id}: projectiles=${a.projectiles} holos=${a.holos} hurt=${a.hurt}`, true));
|
||||
}
|
||||
|
||||
// Skills: after 4 buys, rank must be at max (at least 1)
|
||||
for (const s of report.skills) {
|
||||
results.push(checkGte(`skill ${s.id} rank>=1`, s.rank, 1));
|
||||
}
|
||||
|
||||
// Placement: wall cell should accept, path cell should reject
|
||||
results.push(checkTrue('placement: wallAccepted (tower on free cell)', report.placement.wallAccepted));
|
||||
results.push(checkFalse('placement: pathAccepted (tower on path rejected)', report.placement.pathAccepted));
|
||||
results.push(checkGte('placement: tower count>=1', report.placement.count, 1));
|
||||
|
||||
// AutoWave: informational – just verify it's a valid state
|
||||
results.push(info('autoWave: state', report.autoWave.state));
|
||||
results.push(info('autoWave: active', report.autoWave.active));
|
||||
|
||||
// Endless: verify state is valid, endlessWave is a number
|
||||
results.push(info('endless: state', report.endless.state));
|
||||
results.push(checkGte('endless: endlessWave>=0', report.endless.endlessWave, 0));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Extension-Audit Assertions ────────────────────────────────────
|
||||
function assertExtensionAudit(report) {
|
||||
const results = [];
|
||||
|
||||
// Mortar: diagnostic – projectile may be resolved immediately by updateProjectiles
|
||||
results.push(info('mortar: projectile spawned (may resolve instantly)', report.mortar.projectile));
|
||||
results.push(checkTrue('mortar: both targets hurt (splash)', report.mortar.hurt));
|
||||
|
||||
// Tesla: must damage at least one enemy and apply freeze
|
||||
results.push(checkGt('tesla: damaged enemies count', report.tesla.damaged));
|
||||
results.push(checkGt('tesla: frozen count', report.tesla.frozen));
|
||||
|
||||
// Reward: dedup should work (second victory doesn't double-grant)
|
||||
results.push(checkTrue('reward: pointsAfterDuplicate>=1', report.reward.pointsAfterDuplicate >= 1));
|
||||
results.push(checkTrue('reward: storedRank matches rankAfterBuy', report.reward.storedRank === report.reward.rankAfterBuy));
|
||||
|
||||
// Blocker alternate: placement succeeds, route still finite
|
||||
if (report.blockerAlternate.placed) {
|
||||
results.push(checkTrue('blockerAlternate: placed', report.blockerAlternate.placed));
|
||||
results.push(checkTrue('blockerAlternate: route still finite', report.blockerAlternate.finite));
|
||||
results.push(checkTrue('blockerAlternate: path restored after sell', report.blockerAlternate.soldPathRestored));
|
||||
} else {
|
||||
results.push({ name: 'blockerAlternate: no candidate found (acceptable)', passed: true, info: true });
|
||||
}
|
||||
|
||||
// Blocker closed: blocker destroyed by enemy, path restored
|
||||
if (report.blockerClosed.placed) {
|
||||
results.push(checkTrue('blockerClosed: placed', report.blockerClosed.placed));
|
||||
results.push(checkTrue('blockerClosed: destroyed by enemy', report.blockerClosed.destroyed));
|
||||
results.push(checkTrue('blockerClosed: path restored', report.blockerClosed.pathRestored));
|
||||
} else {
|
||||
results.push({ name: 'blockerClosed: no candidate found (acceptable)', passed: true, info: true });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Redesign-Audit Assertions ─────────────────────────────────────
|
||||
function assertRedesignAudit(report) {
|
||||
const results = [];
|
||||
|
||||
// Kryo: hits at least one target, applies slow
|
||||
results.push(checkTrue('kryo: target hit', report.kryo.randomLastTarget || report.kryo.visibleSlow));
|
||||
results.push(checkTrue('kryo: slow applied', report.kryo.visibleSlow));
|
||||
|
||||
// Sniper: pierces multiple enemies, no DoT
|
||||
results.push(checkGt('sniper: pierced count', report.sniper.pierced));
|
||||
results.push(checkTrue('sniper: no DoT (burn/poison)', report.sniper.noDot));
|
||||
if (report.sniper.highestAbsoluteHp !== undefined) {
|
||||
results.push(checkTrue('sniper: highest absolute HP targeting', report.sniper.highestAbsoluteHp));
|
||||
}
|
||||
|
||||
// Mortar (redesign): fixed impact point
|
||||
results.push(checkTrue('mortar: fixed impact (witness hit, fleeer untouched)', report.mortar.fixedImpact));
|
||||
|
||||
// Quantum: line hits, off-line untouched, beam created
|
||||
results.push(checkGt('quantum: line hits', report.quantum.lineHits));
|
||||
results.push(checkTrue('quantum: off-line untouched', report.quantum.offLineUntouched));
|
||||
results.push(checkTrue('quantum: beam created', report.quantum.beam));
|
||||
|
||||
// Nano: spawned, damaged target, cleaned up
|
||||
results.push(checkGt('nano: spawned count', report.nano.spawned));
|
||||
results.push(checkTrue('nano: damaged target', report.nano.damaged));
|
||||
results.push(checkTrue('nano: cleaned up', report.nano.cleaned));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Result Printer ────────────────────────────────────────────────
|
||||
function printResults(name, results) {
|
||||
const pass = results.filter(r => r.passed).length;
|
||||
const fail = results.length - pass;
|
||||
const status = fail === 0 ? '✅' : '❌';
|
||||
console.log(` ${status} ${name}: ${pass}/${results.length} passed${fail > 0 ? ` (${fail} FAILED)` : ''}`);
|
||||
if (fail > 0) {
|
||||
results.filter(r => !r.passed).forEach(r => {
|
||||
console.log(` ❌ ${r.name}: actual=${JSON.stringify(r.actual)} expected=${r.expected}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
console.log('═══════════════════════════════════════════════════');
|
||||
console.log(' AEGIS LABYRINTH – AUTOMATED AUDIT SUITE');
|
||||
console.log('═══════════════════════════════════════════════════');
|
||||
console.log(` Datei: ${HTML_PATH}`);
|
||||
console.log(` Zeit: ${timestamp()}\n`);
|
||||
|
||||
if (!fs.existsSync(HTML_PATH)) {
|
||||
console.error('❌ Aegis-Labyrinth.html nicht gefunden!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = {
|
||||
timestamp: timestamp(),
|
||||
file: HTML_PATH,
|
||||
fileHash: sha256(fs.readFileSync(HTML_PATH)),
|
||||
self: null,
|
||||
extension: null,
|
||||
redesign: null,
|
||||
assertions: { self: [], extension: [], redesign: [] },
|
||||
pageErrors: [],
|
||||
summary: { pass: 0, fail: 0, total: 0 }
|
||||
};
|
||||
|
||||
let browser;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||||
|
||||
const url = 'file:///' + HTML_PATH.replace(/\\/g, '/');
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 30000 });
|
||||
await page.waitForFunction(() => typeof window.__AEGIS_DEBUG__ !== 'undefined', { timeout: 10000 });
|
||||
console.log(' ✅ Seite geladen, __AEGIS_DEBUG__ verfügbar\n');
|
||||
|
||||
// ─── 1. Self-Audit ────────────────────────────────────────────────────────────
|
||||
console.log('📡 SELF-AUDIT (runSelfAudit)');
|
||||
result.self = await page.evaluate(() => {
|
||||
try {
|
||||
return window.__AEGIS_DEBUG__.runSelfAudit();
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
});
|
||||
if (result.self.error) {
|
||||
console.log(` ❌ Error: ${result.self.error}`);
|
||||
} else {
|
||||
result.assertions.self = assertSelfAudit(result.self);
|
||||
printResults('self', result.assertions.self);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// ─── 2. Extension-Audit ──────────────────────────────────────────────────────
|
||||
console.log('🧪 EXTENSION-AUDIT (runExtensionAudit)');
|
||||
result.extension = await page.evaluate(() => {
|
||||
try {
|
||||
return window.__AEGIS_DEBUG__.runExtensionAudit();
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
});
|
||||
if (result.extension.error) {
|
||||
console.log(` ❌ Error: ${result.extension.error}`);
|
||||
} else {
|
||||
result.assertions.extension = assertExtensionAudit(result.extension);
|
||||
printResults('extension', result.assertions.extension);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// ─── 3. Redesign-Audit ───────────────────────────────────────────────────────
|
||||
console.log('🔬 REDESIGN-AUDIT (runRedesignAudit)');
|
||||
result.redesign = await page.evaluate(() => {
|
||||
try {
|
||||
return window.__AEGIS_DEBUG__.runRedesignAudit();
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
});
|
||||
if (result.redesign.error) {
|
||||
console.log(` ❌ Error: ${result.redesign.error}`);
|
||||
} else {
|
||||
result.assertions.redesign = assertRedesignAudit(result.redesign);
|
||||
printResults('redesign', result.assertions.redesign);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// ─── 4. Page Errors ──────────────────────────────────────────────────────────
|
||||
console.log('🐛 PAGE ERROR CHECK');
|
||||
result.pageErrors = pageErrors;
|
||||
if (pageErrors.length === 0) {
|
||||
console.log(' ✅ Keine JS-Fehler\n');
|
||||
} else {
|
||||
console.log(` ❌ ${pageErrors.length} JS-Fehler:`);
|
||||
pageErrors.slice(0, 5).forEach(e => console.log(` - ${e}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ─── Screenshot ──────────────────────────────────────────────────────────────
|
||||
const shotPath = path.join(ROOT, 'audit-final.png');
|
||||
await page.screenshot({ path: shotPath, fullPage: false }).catch(() => {});
|
||||
if (fs.existsSync(shotPath)) {
|
||||
console.log(` 📸 Screenshot: ${shotPath}`);
|
||||
}
|
||||
|
||||
// ─── Summary ─────────────────────────────────────────────────────────────────
|
||||
const allAssertions = [
|
||||
...result.assertions.self,
|
||||
...result.assertions.extension,
|
||||
...result.assertions.redesign
|
||||
];
|
||||
const pass = allAssertions.filter(a => a.passed).length;
|
||||
const fail = allAssertions.length - pass;
|
||||
const total = allAssertions.length + pageErrors.length;
|
||||
|
||||
result.summary = { pass, fail, total };
|
||||
|
||||
console.log('═══════════════════════════════════════════════════');
|
||||
console.log(` ERGEBNIS: ✅ ${pass} PASS ❌ ${fail} FAIL (${allAssertions.length} Checks, ${pageErrors.length} PageErrors)`);
|
||||
console.log('═══════════════════════════════════════════════════');
|
||||
|
||||
const outPath = path.join(ROOT, 'audit-result.json');
|
||||
fs.writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf-8');
|
||||
console.log(`\n 📄 Ergebnis gespeichert: ${outPath}`);
|
||||
|
||||
await browser.close();
|
||||
process.exit(fail > 0 || pageErrors.length > 0 ? 1 : 0);
|
||||
|
||||
} catch (err) {
|
||||
console.error('\n❌ Audit fehlgeschlagen:', err.message);
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
const outPath = path.join(ROOT, 'audit-result.json');
|
||||
fs.writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf-8');
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,120 @@
|
||||
// perf-test.mjs – misst FPS (und optional Heap-Δ) unter Last: schweres Level,
|
||||
// viele Türme, aktive Welle. Nutzt die öffentliche window.__AEGIS_DEBUG__-API.
|
||||
//
|
||||
// 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/>.
|
||||
//
|
||||
// WICHTIG: Global-scope Identifier (TOWER_TYPES, Game, placeTower) müssen in
|
||||
// page.evaluate() als BARE IDENTIFIER referenziert werden (nicht window.X).
|
||||
//
|
||||
// Usage: node perf-test.mjs [durationMs=15000] [towers=50]
|
||||
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 DURATION_MS = Math.max(1000, parseInt(process.argv[2] || '15000', 10));
|
||||
const TOWERS = Math.max(1, parseInt(process.argv[3] || '50', 10));
|
||||
|
||||
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 });
|
||||
|
||||
// Setup: schweres Level (letztes), Credits, Türme, Welle.
|
||||
const setup = await page.evaluate(({ towers }) => {
|
||||
const d = window.__AEGIS_DEBUG__;
|
||||
const levelIdx = Math.min(LEVELS.length - 1, 10); // Level 11 (0-basiert Index 10)
|
||||
d.selectLevel(levelIdx);
|
||||
if (Game.state !== 'playing') Game.state = 'playing';
|
||||
d.setCredits(9999999);
|
||||
|
||||
// Pfad & buildbare Zellen ermitteln.
|
||||
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]);
|
||||
|
||||
const towerTypes = TOWER_TYPES.filter(isTowerAvailable).filter((t) => isTowerUnlocked(t));
|
||||
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 best = Infinity;
|
||||
for (const [px, py] of path) best = Math.min(best, Math.hypot(px - x, py - y));
|
||||
if (best < CELL * 3.01) candidates.push({ x, y, best });
|
||||
}
|
||||
candidates.sort((a, b) => a.best - b.best);
|
||||
|
||||
let placed = 0;
|
||||
for (let i = 0; i < candidates.length && placed < towers; i++) {
|
||||
const def = towerTypes[i % Math.max(1, towerTypes.length)];
|
||||
if (!def) break;
|
||||
if (placeTower(def.id, candidates[i].x, candidates[i].y) === true) placed++;
|
||||
}
|
||||
d.startWave();
|
||||
const s = d.getState();
|
||||
return { levelIdx, levelName: s.levelName, placed, enemies: s.enemies, state: s.state };
|
||||
}, { towers: TOWERS });
|
||||
console.log(`Setup: Level=${setup.levelName} (idx ${setup.levelIdx}) · placed=${setup.placed} · enemies=${setup.enemies} · state=${setup.state}`);
|
||||
|
||||
// FPS messen: zähle requestAnimationFrame-Callbacks über DURATION_MS.
|
||||
const fps = await page.evaluate((durationMs) => {
|
||||
return new Promise((resolve) => {
|
||||
let frames = 0;
|
||||
const start = performance.now();
|
||||
function tick() {
|
||||
frames++;
|
||||
if (performance.now() - start < durationMs) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
const elapsed = (performance.now() - start) / 1000;
|
||||
resolve(Math.round(frames / elapsed));
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}, DURATION_MS);
|
||||
|
||||
// Heap-Δ (falls available – Chromium performance.memory).
|
||||
const heap = await page.evaluate(() => {
|
||||
const m = (typeof performance !== 'undefined' && performance.memory) ? performance.memory : null;
|
||||
return m
|
||||
? { usedMB: +(m.usedJSHeapSize / 1048576).toFixed(1), totalMB: +(m.totalJSHeapSize / 1048576).toFixed(1) }
|
||||
: null;
|
||||
});
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
durationMs: DURATION_MS,
|
||||
fps,
|
||||
targetFps: 55,
|
||||
pass: fps >= 55,
|
||||
heap,
|
||||
setup,
|
||||
};
|
||||
const outFile = path.join(ROOT, 'perf-result.json');
|
||||
fs.writeFileSync(outFile, JSON.stringify(result, null, 2));
|
||||
const icon = result.pass ? '✅' : '❌';
|
||||
console.log(`${icon} Average FPS: ${result.fps} (target ≥ ${result.targetFps})`);
|
||||
if (heap) console.log(` Heap: used=${heap.usedMB} MB`);
|
||||
console.log(`✅ Perf done → ${outFile}`);
|
||||
|
||||
await browser.close();
|
||||
process.exitCode = result.pass ? 0 : 1;
|
||||
@@ -0,0 +1,187 @@
|
||||
// report.mjs – erzeugt report.html aus audit-result.json, perf-result.json,
|
||||
// selfplay-results.json (falls vorhanden).
|
||||
// Pass/Fail wird direkt aus den audit.assertions und audit.summary gelesen
|
||||
// (authoritative Quelle aus audit.mjs), nicht mit eigenen Heuristiken.
|
||||
//
|
||||
// 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/>.
|
||||
//
|
||||
// Usage: node report.mjs
|
||||
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 auditPath = path.join(ROOT, 'audit-result.json');
|
||||
const perfPath = path.join(ROOT, 'perf-result.json');
|
||||
const selfPath = path.join(ROOT, 'selfplay-results.json');
|
||||
|
||||
function loadJson(p) {
|
||||
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
||||
}
|
||||
|
||||
const audit = loadJson(auditPath);
|
||||
const perf = loadJson(perfPath);
|
||||
const selfplay = loadJson(selfPath);
|
||||
|
||||
function statusColor(ok) { return ok ? '#4caf50' : '#f44336'; }
|
||||
|
||||
/** true wenn Liste nicht leer ist und alle Assertions `passed: true` haben */
|
||||
function allPass(list) {
|
||||
return Array.isArray(list) && list.length > 0 && list.every(a => a.passed === true);
|
||||
}
|
||||
|
||||
/** Anzahl der bestanden Assertions in einer Liste */
|
||||
function countPassed(list) {
|
||||
return Array.isArray(list) ? list.filter(a => a.passed === true).length : 0;
|
||||
}
|
||||
|
||||
// ─── Audit Summary ────────────────────────────────────────────────────────────
|
||||
let auditRows = '';
|
||||
let auditPass = false;
|
||||
|
||||
if (audit) {
|
||||
const selfA = audit.assertions?.self ?? [];
|
||||
const extA = audit.assertions?.extension ?? [];
|
||||
const redA = audit.assertions?.redesign ?? [];
|
||||
|
||||
// Assertions aus audit.mjs nach Themen gruppieren (Name-Prefix)
|
||||
const conn = selfA.filter(a => /^L\d+ /.test(a.name));
|
||||
const atk = selfA.filter(a => a.name.startsWith('tower '));
|
||||
const skl = selfA.filter(a => a.name.startsWith('skill '));
|
||||
const plc = selfA.filter(a => a.name.startsWith('placement'));
|
||||
|
||||
const rows = [
|
||||
['Connectivity', allPass(conn),
|
||||
`${countPassed(conn)}/${conn.length} level checks`],
|
||||
['Attacks', allPass(atk),
|
||||
`${countPassed(atk)}/${atk.length} tower checks (inkl. Support/Relay)`],
|
||||
['Skills', allPass(skl),
|
||||
`${countPassed(skl)}/${skl.length} skills · rank ≥ 1`],
|
||||
['Placement', allPass(plc),
|
||||
audit.self?.placement
|
||||
? `wall=${audit.self.placement.wallAccepted} path=${audit.self.placement.pathAccepted}`
|
||||
: 'N/A'],
|
||||
];
|
||||
|
||||
if (extA.length > 0) {
|
||||
rows.push(['Extension (Mortar/Tesla/Blocker)', allPass(extA),
|
||||
`${countPassed(extA)}/${extA.length} checks`]);
|
||||
}
|
||||
if (redA.length > 0) {
|
||||
rows.push(['Redesign (Kryo/Sniper/Quantum/Nano)', allPass(redA),
|
||||
`${countPassed(redA)}/${redA.length} checks`]);
|
||||
}
|
||||
|
||||
auditRows = rows.map(([name, ok, detail]) =>
|
||||
`<tr><td>${name}</td>` +
|
||||
`<td style="color:${statusColor(ok)};font-weight:bold">${ok ? '✅ PASS' : '❌ FAIL'}</td>` +
|
||||
`<td>${detail}</td></tr>`
|
||||
).join('\n');
|
||||
|
||||
// Gesamtverdict: authoritative audit.summary + pageErrors
|
||||
const pageErrors = audit.pageErrors?.length ?? 1;
|
||||
auditPass = (audit.summary?.fail ?? -1) === 0 && pageErrors === 0;
|
||||
} else {
|
||||
auditRows = '<tr><td colspan="3" style="color:#999">audit-result.json not found</td></tr>';
|
||||
}
|
||||
|
||||
// ─── Perf Summary ─────────────────────────────────────────────────────────────
|
||||
let perfRow = '';
|
||||
let perfOk = false;
|
||||
if (perf) {
|
||||
perfOk = perf.pass === true;
|
||||
perfRow = `<tr><td>FPS</td>` +
|
||||
`<td style="color:${statusColor(perfOk)};font-weight:bold">${perf.fps} fps</td>` +
|
||||
`<td>target ≥ ${perf.targetFps} · duration ${perf.durationMs}ms</td></tr>`;
|
||||
if (perf.heap) perfRow +=
|
||||
`<tr><td>Heap</td><td style="color:#4caf50">${perf.heap.usedMB} MB</td>` +
|
||||
`<td>total ${perf.heap.totalMB} MB</td></tr>`;
|
||||
} else {
|
||||
perfRow = '<tr><td colspan="3" style="color:#999">perf-result.json not found</td></tr>';
|
||||
}
|
||||
|
||||
// ─── Self-Play Summary ────────────────────────────────────────────────────────
|
||||
let selfRows = '';
|
||||
let selfOk = false;
|
||||
if (selfplay) {
|
||||
const s = selfplay.summary;
|
||||
selfOk = s.winRate >= 50;
|
||||
selfRows = `
|
||||
<tr><td>Episodes</td><td style="color:#4caf50">${s.total}</td><td>winRate ${s.winRate.toFixed(1)}%</td></tr>
|
||||
<tr><td>Wins</td><td style="color:${statusColor(selfOk)};font-weight:bold">${s.wins}</td><td>Losses: ${s.losses}</td></tr>
|
||||
<tr><td>Avg Kills</td><td style="color:#4caf50">${s.avgKills}</td><td>Avg Wave: ${s.avgWaves}</td></tr>
|
||||
`;
|
||||
} else {
|
||||
selfRows = '<tr><td colspan="3" style="color:#999">selfplay-results.json not found (nightly only)</td></tr>';
|
||||
}
|
||||
|
||||
const overallPass = auditPass && perfOk && (selfplay ? selfOk : true);
|
||||
|
||||
// ─── HTML ─────────────────────────────────────────────────────────────────────
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Aegis Labyrinth – CI Report</title>
|
||||
<style>
|
||||
body{font-family:'Segoe UI',system-ui,monospace;background:#0d1117;color:#c9d1d9;padding:30px;margin:0}
|
||||
h1{font-size:1.6em;margin-bottom:4px}
|
||||
.ts{color:#8b949e;font-size:.85em;margin-bottom:24px}
|
||||
.badge{display:inline-block;padding:4px 14px;border-radius:12px;font-weight:bold;font-size:.9em;margin-bottom:20px}
|
||||
.pass{background:#1a3a2a;color:#4caf50}.fail{background:#3a1a1a;color:#f44336}
|
||||
h2{font-size:1.1em;margin:24px 0 8px;color:#8b949e;text-transform:uppercase;letter-spacing:1px}
|
||||
table{border-collapse:collapse;width:100%;margin-bottom:20px}
|
||||
th,td{padding:10px 14px;text-align:left;border-bottom:1px solid #21262d}
|
||||
th{background:#161b22;color:#8b949e;font-size:.85em;text-transform:uppercase;letter-spacing:.5px}
|
||||
tr:hover{background:#161b22}
|
||||
.footer{color:#484f58;font-size:.8em;margin-top:40px;border-top:1px solid #21262d;padding-top:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>⚔️ Aegis Labyrinth – CI Report</h1>
|
||||
<p class="ts">Timestamp: ${new Date().toISOString()} · Branch: <code>unknown</code></p>
|
||||
<span class="badge ${overallPass ? 'pass' : 'fail'}">${overallPass ? '✅ ALL CHECKS PASSED' : '❌ SOME CHECKS FAILED'}</span>
|
||||
|
||||
<h2>Audits</h2>
|
||||
<table>
|
||||
<tr><th>Check</th><th>Status</th><th>Detail</th></tr>
|
||||
${auditRows}
|
||||
</table>
|
||||
|
||||
<h2>Performance</h2>
|
||||
<table>
|
||||
<tr><th>Metric</th><th>Value</th><th>Info</th></tr>
|
||||
${perfRow}
|
||||
</table>
|
||||
|
||||
<h2>Self-Play</h2>
|
||||
<table>
|
||||
<tr><th>Metric</th><th>Value</th><th>Info</th></tr>
|
||||
${selfRows}
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
Generated by <code>report.mjs</code> · Aegis Labyrinth v${audit ? '1.0.2' : '?'} · ${new Date().toLocaleString('de-DE')}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const reportPath = path.join(ROOT, 'report.html');
|
||||
fs.writeFileSync(reportPath, html);
|
||||
console.log(`✅ Report generated → ${reportPath} (${overallPass ? 'ALL PASS' : 'SOME FAILURES'})`);
|
||||
@@ -0,0 +1,237 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user