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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
15
playwright.config.js
Normal 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 },
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
160
tests/e2e/combat-loop.e2e.js
Normal file
160
tests/e2e/combat-loop.e2e.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
102
tests/e2e/combat-loop.spec.js
Normal file
102
tests/e2e/combat-loop.spec.js
Normal 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 });
|
||||
});
|
||||
432
tests/e2e/milestone-2-combat-loop.spec.js
Normal file
432
tests/e2e/milestone-2-combat-loop.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user