- Unit.js: add health/armor to entity data
- CombatSystem.js: integration hooks for M2 combat loop
- EntityStateMachine.js: orchestrator.registerEntity refactor
- playwright.config.js: M2 e2e config
- tests/e2e/combat-loop.{spec,e2e}.js: M2 combat loop verification
- tests/e2e/milestone-2-combat-loop.spec.js: M2 acceptance test
- .gitignore: exclude debug scripts + screenshots from commits
161 lines
6.5 KiB
JavaScript
161 lines
6.5 KiB
JavaScript
const { chromium } = require('playwright');
|
|
const assert = require('assert');
|
|
|
|
const SITE = process.env.RESTITUTION_URL || 'https://restitution.damascusfront.net';
|
|
|
|
(async () => {
|
|
let browser;
|
|
try {
|
|
browser = await chromium.launch({
|
|
executablePath: '/usr/lib/chromium/chromium',
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'],
|
|
});
|
|
const page = await browser.newPage();
|
|
|
|
const logs = [];
|
|
page.on('console', msg => logs.push({ type: msg.type(), text: msg.text() }));
|
|
page.on('pageerror', err => logs.push({ type: 'pageerror', text: err.message }));
|
|
|
|
// 1. Load game and navigate lobby
|
|
await page.goto(SITE, { waitUntil: 'networkidle' });
|
|
await page.click('button:has-text("Create Game")');
|
|
await page.waitForSelector('button:has-text("Start Game")', { timeout: 15000 });
|
|
await page.click('button:has-text("Start Game")');
|
|
|
|
// Wait for canvas + game boot
|
|
let hasCanvas = false;
|
|
let gameExists = false;
|
|
for (let i = 0; i < 60; i++) {
|
|
hasCanvas = await page.evaluate(() => !!document.querySelector('canvas'));
|
|
gameExists = await page.evaluate(() => !!window.game);
|
|
if (hasCanvas && gameExists) break;
|
|
await page.waitForTimeout(500);
|
|
}
|
|
assert.strictEqual(hasCanvas, true, 'Canvas not found after lobby');
|
|
assert.strictEqual(gameExists, true, 'window.game not exposed after lobby');
|
|
|
|
// Let scene.create() finish (map + units spawn)
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Verify scene booted
|
|
const sceneReady = await page.evaluate(() => {
|
|
const scene = window.game.scene?.getScene('Map_Player');
|
|
return scene?.sys?.settings?.active === true;
|
|
});
|
|
assert.strictEqual(sceneReady, true, 'Map_Player scene not active');
|
|
|
|
// 2. Spawn player unit via F key at camera center
|
|
await page.keyboard.press('KeyF');
|
|
await page.waitForTimeout(500);
|
|
|
|
// 3. Spawn enemy unit adjacent to first friendly (using groundLayer.worldToTileXY)
|
|
const spawnResult = await page.evaluate(() => {
|
|
try {
|
|
const scene = window.game.scene.getScene('Map_Player');
|
|
const tm = scene.teamManager;
|
|
const all = tm.getAllUnits();
|
|
const friendly = all.find(u => tm.getEntityTeam(u) === 'team-A');
|
|
if (!friendly) return { error: 'no friendly' };
|
|
// Use groundLayer to convert world->tile (accounts for layer offset)
|
|
const raw = scene.groundLayer.worldToTileXY(friendly.x, friendly.y);
|
|
const ft = { x: Math.floor(raw.x), y: Math.floor(raw.y) };
|
|
const enemyTile = { x: ft.x + 1, y: ft.y };
|
|
const enemy = scene.unitFactory.spawnInfantry(enemyTile, 'team-B');
|
|
// Set low health for fast death
|
|
if (enemy) {
|
|
enemy.setData('health', 15);
|
|
enemy.setData('armor', 0);
|
|
if (enemy.components?.health) {
|
|
enemy.components.health.current = 15;
|
|
enemy.components.health.armor = 0;
|
|
}
|
|
}
|
|
return { friendlyTile: ft, enemyTile, enemySpawned: !!enemy, enemyWorld: { x: enemy?.x, y: enemy?.y } };
|
|
} catch (e) {
|
|
return { error: e.message, stack: e.stack };
|
|
}
|
|
});
|
|
console.log('spawnResult:', spawnResult);
|
|
assert.ok(!spawnResult.error, 'spawn enemy failed: ' + (spawnResult.error + ' ' + (spawnResult.stack || '')));
|
|
assert.strictEqual(spawnResult.enemySpawned, true, 'enemy unit not spawned');
|
|
await page.waitForTimeout(1000);
|
|
|
|
// 4. Verify both factions exist and are alive
|
|
const bothSides = await page.evaluate(() => {
|
|
const scene = window.game.scene.getScene('Map_Player');
|
|
const tm = scene.teamManager;
|
|
const grouped = tm.getAllUnitsGrouped();
|
|
const teamA = Array.from(grouped.get('team-A') || []).filter(u => !u.dead && !(u.isDead && u.isDead()));
|
|
const teamB = Array.from(grouped.get('team-B') || []).filter(u => !u.dead && !(u.isDead && u.isDead()));
|
|
return { teamA: teamA.length, teamB: teamB.length };
|
|
});
|
|
assert.ok(bothSides.teamA > 0, 'team-A has no alive units');
|
|
assert.ok(bothSides.teamB > 0, 'team-B has no alive units');
|
|
|
|
// Screenshot: after auto-engage setup
|
|
await page.screenshot({ path: 'test-results/e2e-01-spawned.png', fullPage: false });
|
|
|
|
// 5. Let combat tick and verify projectiles exist
|
|
let projectilesFired = false;
|
|
for (let tick = 0; tick < 40; tick++) {
|
|
await page.waitForTimeout(250);
|
|
const projCount = await page.evaluate(() => {
|
|
const scene = window.game.scene.getScene('Map_Player');
|
|
const cs = scene.orchestrator.systems.combat;
|
|
return cs.projectiles.countActive();
|
|
});
|
|
if (projCount > 0) {
|
|
projectilesFired = true;
|
|
break;
|
|
}
|
|
}
|
|
assert.strictEqual(projectilesFired, true, 'No projectiles fired within 10s');
|
|
|
|
// Screenshot: mid-combat
|
|
await page.screenshot({ path: 'test-results/e2e-02-midcombat.png', fullPage: false });
|
|
|
|
// 6. Verify health bars exist
|
|
const healthBarsExist = await page.evaluate(() => {
|
|
const scene = window.game.scene.getScene('Map_Player');
|
|
const hb = scene.healthBars;
|
|
return hb && Object.keys(hb.bars || {}).length > 0;
|
|
});
|
|
assert.strictEqual(healthBarsExist, true, 'Health bars not found');
|
|
|
|
// 7. Let combat run until someone dies
|
|
let deathObserved = false;
|
|
let deathInfo = null;
|
|
for (let tick = 0; tick < 60; tick++) {
|
|
await page.waitForTimeout(500);
|
|
const state = await page.evaluate(() => {
|
|
const scene = window.game.scene.getScene('Map_Player');
|
|
const tm = scene.teamManager;
|
|
const grouped = tm.getAllUnitsGrouped();
|
|
const teamA = Array.from(grouped.get('team-A') || []);
|
|
const teamB = Array.from(grouped.get('team-B') || []);
|
|
const deadA = teamA.filter(u => u.dead || (u.isDead && u.isDead())).length;
|
|
const deadB = teamB.filter(u => u.dead || (u.isDead && u.isDead())).length;
|
|
return { deadA, deadB, totalA: teamA.length, totalB: teamB.length };
|
|
});
|
|
if (state.deadA > 0 || state.deadB > 0) {
|
|
deathObserved = true;
|
|
deathInfo = state;
|
|
break;
|
|
}
|
|
}
|
|
assert.strictEqual(deathObserved, true, 'No unit died within 30s of combat');
|
|
console.log('Death observed:', deathInfo);
|
|
|
|
// Screenshot: post-death
|
|
await page.screenshot({ path: 'test-results/e2e-03-postdeath.png', fullPage: false });
|
|
|
|
console.log('E2E PASSED: spawn units, auto-engage, projectiles, health bars, death');
|
|
process.exitCode = 0;
|
|
} catch (err) {
|
|
console.error('E2E FAILED:', err.message);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
if (browser) await browser.close();
|
|
}
|
|
})();
|