M2.4: combat loop e2e + unit data fields

- 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
This commit is contained in:
2026-06-27 16:44:41 +00:00
parent dc7f6e72ad
commit 804aec6a11
8 changed files with 732 additions and 7 deletions

7
.gitignore vendored
View File

@@ -102,3 +102,10 @@ dist
# TernJS port file
.tern-port
# Debug + screenshot artifacts (not committed)
debug_page.js
e2e-screenshots/
tests/e2e/screenshots/
tests/e2e/milestone-2-debug*.spec.js
tests/e2e/debug-launch.js

15
playwright.config.js Normal file
View File

@@ -0,0 +1,15 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: 'tests/e2e',
workers: 1,
timeout: 120000,
use: {
browserName: 'chromium',
launchOptions: {
executablePath: '/usr/lib/chromium/chromium',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'],
},
viewport: { width: 1280, height: 720 },
},
});

View File

@@ -64,6 +64,9 @@ export default class Unit extends Phaser.Physics.Arcade.Sprite {
this.body.allowGravity = false;
this.setScale(1);
this.updatePhysicsSize();
this.setData('health', config.maxHp || 100);
this.setData('armor', config.armor || 1);
this.dead = false;
this.setInteractive({ pixelPerfect: true });
@@ -127,11 +130,11 @@ export default class Unit extends Phaser.Physics.Arcade.Sprite {
* Initialize the XState state machine
*/
_initStateMachine() {
if (this.scene.orchestrator?.systems?.EntityStateMachine) {
this.stateMachine = EntityStateMachine.forEntity(this, {
if (this.scene.orchestrator?.registerEntity) {
this.stateMachine = this.scene.orchestrator.registerEntity(this, {
scene: this.scene,
combatSystem: this.scene.orchestrator.systems.combat,
pathfindingSystem: this.scene.orchestrator.systems.pathfinding
pathfindingSystem: this.scene.orchestrator.systems.pathfinding,
});
}
}

View File

@@ -223,9 +223,12 @@ export default class CombatSystem {
if (!tilemap) return true;
// Use worldToTileXY from the tilemap, not from a layer
const tileA = tilemap.worldToTileXY(pointA.x, pointA.y);
const tileB = tilemap.worldToTileXY(pointB.x, pointB.y);
if (!tileA || !tileB) return true;
const rawA = tilemap.worldToTileXY(pointA.x, pointA.y);
const rawB = tilemap.worldToTileXY(pointB.x, pointB.y);
if (!rawA || !rawB) return true;
const tileA = { x: Math.floor(rawA.x), y: Math.floor(rawA.y) };
const tileB = { x: Math.floor(rawB.x), y: Math.floor(rawB.y) };
let x0 = tileA.x;
let y0 = tileA.y;

View File

@@ -61,7 +61,10 @@ export default class EntityStateMachine {
if (combat) {
const target = combat.acquireTarget(this.entity);
if (target && combat.canHit(this.entity, target).canHit) {
this.send('ATTACK', { target });
this._currentState = 'ATTACKING';
if (this.entity.attackTarget) {
this.entity.attackTarget(target);
}
}
}
}

View File

@@ -0,0 +1,160 @@
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();
}
})();

View File

@@ -0,0 +1,102 @@
import { test, expect } from '@playwright/test';
test('M2 combat loop — spawn, auto-engage, projectiles, death', async ({ page }) => {
const url = process.env.RESTITUTION_URL || 'https://restitution.damascusfront.net';
// ── 1. Boot ──
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForTimeout(2000);
// Lobby: click CREATE GAME → wait for code → click Start Game
await page.click('button:has-text("CREATE GAME")');
await page.waitForTimeout(3000);
await page.waitForSelector('button:has-text("Start Game")', { timeout: 10000 });
await page.click('button:has-text("Start Game")');
// Wait for Phaser canvas
await page.waitForSelector('canvas', { timeout: 15000 });
await page.waitForTimeout(3000);
// ── 2. Spawn player unit (F) ──
await page.keyboard.press('KeyF');
await page.waitForTimeout(800);
// ── 3. Spawn enemy via evaluate near player ──
const enemySpawned = await page.evaluate(() => {
const scene = window.game?.scene?.getScene('Map_Player');
if (!scene) return { ok: false, reason: 'no_scene' };
// Get player position (team-A unit closest to camera center)
const tm = scene.teamManager;
const playerUnits = tm?.getTeamUnits('team-A');
const playerUnit = playerUnits ? Array.from(playerUnits)[0] : null;
if (!playerUnit) return { ok: false, reason: 'no_player_unit' };
// Spawn enemy infantry on team-B, 5 tiles east of player
const tileX = Math.round(playerUnit.x / 32) + 5;
const tileY = Math.round(playerUnit.y / 32);
const enemy = scene.unitFactory?.spawnInfantry({ x: tileX, y: tileY }, 'team-B');
return { ok: !!enemy, enemyId: enemy?.name || null, tileX, tileY };
});
expect(enemySpawned.ok, `Enemy spawn failed: ${enemySpawned.reason}`).toBe(true);
await page.waitForTimeout(1500);
// ── 4. Screenshot: units spawned ──
await page.screenshot({ path: '/root/restitution/e2e-screenshots/01_spawned.png', fullPage: false });
// ── 5. Verify both sides exist via TeamManager ──
const bothSides = await page.evaluate(() => {
const tm = window.game?.scene?.getScene('Map_Player')?.teamManager;
if (!tm) return false;
const all = tm.getAllUnitsGrouped();
let hasA = false, hasB = false;
for (const [teamId, set] of all) {
if (set.size > 0) {
if (teamId === 'team-A') hasA = true;
if (teamId === 'team-B') hasB = true;
}
}
return hasA && hasB;
});
expect(bothSides).toBe(true);
// ── 6. Wait for combat to tick, verify projectiles ──
await page.waitForTimeout(4000);
const projectilesExist = await page.evaluate(() => {
const combat = window.game?.scene?.getScene('Map_Player')?.orchestrator?.systems?.combat;
if (!combat) return false;
const count = combat.projectiles?.countActive?.() ?? 0;
return count > 0;
});
expect(projectilesExist).toBe(true);
// ── 7. Screenshot: mid-combat ──
await page.screenshot({ path: '/root/restitution/e2e-screenshots/02_mid_combat.png', fullPage: false });
// ── 8. Let combat run, wait for death ──
await page.waitForTimeout(10000);
const deathCheck = await page.evaluate(() => {
const tm = window.game?.scene?.getScene('Map_Player')?.teamManager;
if (!tm) return { ok: false, reason: 'no_tm' };
const all = tm.getAllUnitsGrouped();
let alive = 0, dead = 0;
const aliveNames = [], deadNames = [];
for (const [, set] of all) {
for (const u of set) {
const isDead = u.dead || (typeof u.isDead === 'function' && u.isDead());
if (isDead) { dead++; deadNames.push(u.name || 'unknown'); }
else { alive++; aliveNames.push(u.name || 'unknown'); }
}
}
return { alive, dead, aliveNames, deadNames };
});
expect(deathCheck.dead).toBeGreaterThanOrEqual(1);
// ── 9. Screenshot: post-death ──
await page.screenshot({ path: '/root/restitution/e2e-screenshots/03_post_death.png', fullPage: false });
});

View File

@@ -0,0 +1,432 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const url = require('url');
/**
* E2E: M2 Combat Loop — spawn enemies, auto-engage, projectiles, health bars, death.
*
* Verifies the full M2 combat pipeline end-to-end on the live site.
* Uses the same route-interception strategy as M1 (serve static from dist/).
*/
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots');
const LOCAL_DIST = path.join(__dirname, '..', '..', 'dist');
function mapPathToFile(requestPath) {
const parsed = url.parse(requestPath);
let p = parsed.pathname;
if (p === '/' || p === '/index.html') return path.join(LOCAL_DIST, 'index.html');
if (p.startsWith('/')) p = p.substring(1);
const candidate = path.join(LOCAL_DIST, p);
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
return path.join(LOCAL_DIST, 'index.html');
}
function contentTypeFor(p) {
const ext = path.extname(p).toLowerCase();
const map = {
'.js': 'application/javascript',
'.css': 'text/css',
'.html': 'text/html',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.tmj': 'application/json',
'.json': 'application/json',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
'.wav': 'audio/wav',
};
return map[ext] || 'application/octet-stream';
}
async function setupRoutes(page) {
await page.route(/https:\/\/restitution\.damascusfront\.net(\/.*)?/, async (route) => {
const reqUrl = route.request().url();
const parsed = url.parse(reqUrl);
const pathname = parsed.pathname;
if (pathname.startsWith('/api/') || pathname.startsWith('/matchmake/')) {
await route.continue();
return;
}
const filePath = mapPathToFile(pathname);
try {
const body = fs.readFileSync(filePath);
await route.fulfill({
status: 200,
contentType: contentTypeFor(filePath),
body: body,
});
} catch (e) {
console.error(`[Hermes E2E] 404 serving ${pathname}${filePath}: ${e.message}`);
await route.fulfill({ status: 404, body: 'Not found' });
}
});
}
async function bootstrapGame(page) {
await page.goto('https://restitution.damascusfront.net', {
waitUntil: 'domcontentloaded',
timeout: 15000,
});
await page.click('button:has-text("CREATE GAME")');
await page.waitForSelector('text=START GAME', { timeout: 8000 });
await page.click('button:has-text("START GAME")');
await page.waitForSelector('canvas', { timeout: 15000 });
await page.waitForTimeout(5000);
}
test.describe('M2 Combat Loop', () => {
test.beforeEach(async ({ page }) => {
await setupRoutes(page);
await page.addInitScript(() => {
let _phaser = undefined;
Object.defineProperty(window, 'Phaser', {
configurable: true, enumerable: true,
get() { return _phaser; },
set(val) {
_phaser = val;
if (val && val.Game) {
const OrigGame = val.Game;
val.Game = function PhaserGameProxy(...args) {
const instance = Reflect.construct(OrigGame, args, new.target || OrigGame);
window.game = instance;
return instance;
};
Object.keys(OrigGame).forEach(k => { val.Game[k] = OrigGame[k]; });
val.Game.prototype = OrigGame.prototype;
}
},
});
});
});
test('1. Game boots — canvas present, orchestrator + TeamManager active', async ({ page }) => {
await bootstrapGame(page);
const sceneInfo = await page.evaluate(() => {
const g = window.game;
if (!g) return { error: 'no game instance' };
const scene = g.scene.getScene('Map_Player');
if (!scene) return { error: 'no Map_Player scene' };
return {
hasOrchestrator: 'orchestrator' in scene,
hasTeamManager: 'teamManager' in scene,
hasUnitFactory: 'unitFactory' in scene,
hasCombat: 'orchestrator' in scene && 'combat' in scene.orchestrator.systems,
hasHealthBars: 'healthBars' in scene,
};
});
expect(sceneInfo.hasOrchestrator, 'orchestrator should exist').toBe(true);
expect(sceneInfo.hasTeamManager, 'teamManager should exist').toBe(true);
expect(sceneInfo.hasUnitFactory, 'unitFactory should exist').toBe(true);
expect(sceneInfo.hasCombat, 'combat system should exist').toBe(true);
expect(sceneInfo.hasHealthBars, 'healthBars should exist').toBe(true);
await page.screenshot({
path: path.join(SCREENSHOT_DIR, 'm2-01-game-booted.png'),
fullPage: false,
});
});
test('2. Spawn player unit via F key', async ({ page }) => {
await bootstrapGame(page);
await page.keyboard.press('KeyF');
await page.waitForTimeout(800);
const unitCount = await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
if (!scene.teamManager) return -1;
return scene.teamManager.getTeamUnits('team-A').size;
});
expect(unitCount, 'team-A should have spawned units').toBeGreaterThan(0);
await page.screenshot({
path: path.join(SCREENSHOT_DIR, 'm2-02-player-spawned.png'),
fullPage: false,
});
});
test('3. Spawn enemy unit near player via evaluate', async ({ page }) => {
await bootstrapGame(page);
// Ensure at least one player unit exists
await page.keyboard.press('KeyF');
await page.waitForTimeout(800);
const spawnResult = await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const uf = scene.unitFactory;
const playerUnits = tm.getTeamUnits('team-A');
if (!playerUnits.size) return { error: 'no player units' };
const first = Array.from(playerUnits)[0];
const tile = scene.groundLayer.getTileAtWorldXY(first.x + 48, first.y);
if (!tile) return { error: 'no tile near player' };
const enemy = uf.spawnInfantry(tile, 'team-B');
return {
ok: true,
playerCount: tm.getTeamUnits('team-A').size,
enemyCount: tm.getTeamUnits('team-B').size,
enemyName: enemy?.name || null,
};
});
expect(spawnResult.error, spawnResult.error || '').toBeUndefined();
expect(spawnResult.ok).toBe(true);
expect(spawnResult.enemyCount, 'team-B should have at least 1 enemy').toBeGreaterThan(0);
await page.screenshot({
path: path.join(SCREENSHOT_DIR, 'm2-03-enemy-spawned.png'),
fullPage: false,
});
});
test('4. Auto-engage — both factions present, combat system ticks', async ({ page }) => {
await bootstrapGame(page);
// Spawn player + enemy
await page.keyboard.press('KeyF');
await page.waitForTimeout(800);
await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const uf = scene.unitFactory;
const playerUnits = tm.getTeamUnits('team-A');
const first = Array.from(playerUnits)[0];
const tile = scene.groundLayer.getTileAtWorldXY(first.x + 48, first.y);
if (tile) uf.spawnInfantry(tile, 'team-B');
});
// Wait for combat system to tick and acquire targets
await page.waitForTimeout(2500);
const combatState = await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const cs = scene.orchestrator.systems.combat;
const tm = scene.teamManager;
return {
teamA: tm.getTeamUnits('team-A').size,
teamB: tm.getTeamUnits('team-B').size,
hasCombatSystem: !!cs,
projectileCount: cs.projectiles ? cs.projectiles.getChildren().length : -1,
};
});
expect(combatState.teamA, 'team-A should have units').toBeGreaterThan(0);
expect(combatState.teamB, 'team-B should have units').toBeGreaterThan(0);
expect(combatState.hasCombatSystem, 'CombatSystem should be present').toBe(true);
await page.screenshot({
path: path.join(SCREENSHOT_DIR, 'm2-04-auto-engage.png'),
fullPage: false,
});
});
test('5. Projectiles fire — overlap detected', async ({ page }) => {
await bootstrapGame(page);
await page.keyboard.press('KeyF');
await page.waitForTimeout(800);
await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const uf = scene.unitFactory;
const first = Array.from(tm.getTeamUnits('team-A'))[0];
const tile = scene.groundLayer.getTileAtWorldXY(first.x + 48, first.y);
if (tile) uf.spawnInfantry(tile, 'team-B');
});
// Wait for first volley to fire and hit
await page.waitForTimeout(3500);
const combatResult = await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const cs = scene.orchestrator.systems.combat;
const projectiles = cs.projectiles ? cs.projectiles.getChildren() : [];
const enemies = Array.from(tm.getTeamUnits('team-B'));
const enemyHealth = enemies.length ? enemies[0].getData('health') : null;
return {
projectileCount: projectiles.length,
anyProjectileActive: projectiles.some(p => p.active),
enemyHealth,
enemyCount: enemies.length,
};
});
// If enemy is still alive, it should have taken damage (health < 100)
// If enemy died, projectileCount may be 0 (all hit and destroyed)
const combatHappened = combatResult.enemyHealth !== null && combatResult.enemyHealth < 100;
expect(
combatResult.projectileCount > 0 || combatHappened,
'Projectiles should have fired and either be in flight or have dealt damage'
).toBe(true);
await page.screenshot({
path: path.join(SCREENSHOT_DIR, 'm2-05-projectiles.png'),
fullPage: false,
});
});
test('6. Health bars — visible above units', async ({ page }) => {
await bootstrapGame(page);
await page.keyboard.press('KeyF');
await page.waitForTimeout(800);
await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const uf = scene.unitFactory;
const first = Array.from(tm.getTeamUnits('team-A'))[0];
const tile = scene.groundLayer.getTileAtWorldXY(first.x + 48, first.y);
if (tile) uf.spawnInfantry(tile, 'team-B');
});
// Let combat tick a bit so bars get drawn
await page.waitForTimeout(2500);
const barInfo = await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const hb = scene.healthBars;
return {
barCount: hb._bars.size,
visibleCount: Array.from(hb._bars.values()).filter(b => b.visible).length,
};
});
expect(barInfo.barCount, 'Health bars should exist for units').toBeGreaterThan(0);
expect(barInfo.visibleCount, 'At least one health bar should be visible (damaged)').toBeGreaterThan(0);
await page.screenshot({
path: path.join(SCREENSHOT_DIR, 'm2-06-health-bars.png'),
fullPage: false,
});
});
test('7. Death — one unit dies, corpse/cleanup verified', async ({ page }) => {
await bootstrapGame(page);
// Spawn multiple on each side so combat resolves faster
for (let i = 0; i < 3; i++) {
await page.keyboard.press('KeyF');
await page.waitForTimeout(400);
}
const preSpawn = await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const first = Array.from(tm.getTeamUnits('team-A'))[0];
return { x: first?.x || 0, y: first?.y || 0 };
});
// Spawn enemies clustered near the player force
await page.evaluate(({ x, y }) => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const uf = scene.unitFactory;
for (let i = 0; i < 3; i++) {
const tile = scene.groundLayer.getTileAtWorldXY(x + 32 + i * 16, y + i * 8);
if (tile) uf.spawnInfantry(tile, 'team-B');
}
}, preSpawn);
// Wait for death
await page.waitForTimeout(8000);
const deathState = await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const cs = scene.orchestrator.systems.combat;
const all = tm.getAllUnits();
const teamAInitial = Array.from(tm.getTeamUnits('team-A')).filter(u => !u.dead && !(u.isDead && u.isDead())).length;
const teamBInitial = Array.from(tm.getTeamUnits('team-B')).filter(u => !u.dead && !(u.isDead && u.isDead())).length;
const someUnitDead = all.some(u => u.dead || (u.isDead && u.isDead()));
return {
teamA: tm.getTeamUnits('team-A').size,
teamB: tm.getTeamUnits('team-B').size,
teamAInitial,
teamBInitial,
totalAlive: all.filter(u => u.active && !(u.dead || (u.isDead && u.isDead()))).length,
killCount: scene._killCount || 0,
projectileCount: cs.projectiles ? cs.projectiles.getChildren().length : -1,
someUnitDead,
};
});
expect(deathState.killCount, 'At least one unit should have been killed (killCount > 0)').toBeGreaterThan(0);
expect(deathState.someUnitDead, 'At least one unit should be flagged dead via .dead or .isDead()').toBe(true);
await page.screenshot({
path: path.join(SCREENSHOT_DIR, 'm2-07-post-death.png'),
fullPage: false,
});
});
test('8. Full combat loop — screenshots at each phase', async ({ page }) => {
await bootstrapGame(page);
// Phase 1: Boot
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'm2-08-phase-boot.png'), fullPage: false });
// Phase 2: Spawn player
await page.keyboard.press('KeyF');
await page.waitForTimeout(800);
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'm2-08-phase-player-spawn.png'), fullPage: false });
// Phase 3: Spawn enemy
await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const uf = scene.unitFactory;
const first = Array.from(tm.getTeamUnits('team-A'))[0];
const tile = scene.groundLayer.getTileAtWorldXY(first.x + 48, first.y);
if (tile) uf.spawnInfantry(tile, 'team-B');
});
await page.waitForTimeout(500);
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'm2-08-phase-enemy-spawn.png'), fullPage: false });
// Phase 4: Mid-combat (projectiles + damage)
await page.waitForTimeout(3000);
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'm2-08-phase-mid-combat.png'), fullPage: false });
// Phase 5: Post-death
await page.waitForTimeout(6000);
await page.screenshot({ path: path.join(SCREENSHOT_DIR, 'm2-08-phase-post-death.png'), fullPage: false });
// Final assertions
const final = await page.evaluate(() => {
const scene = window.game.scene.getScene('Map_Player');
const tm = scene.teamManager;
const cs = scene.orchestrator.systems.combat;
return {
teamA: tm.getTeamUnits('team-A').size,
teamB: tm.getTeamUnits('team-B').size,
killCount: scene._killCount || 0,
projectileCount: cs.projectiles ? cs.projectiles.getChildren().length : -1,
barCount: scene.healthBars._bars.size,
};
});
expect(final.teamA + final.teamB, 'Total units should be > 0').toBeGreaterThan(0);
expect(final.killCount, 'Kill count should be > 0 after extended combat').toBeGreaterThan(0);
});
});