diff --git a/.gitignore b/.gitignore index 6704566..a1f3edb 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..3c5cc41 --- /dev/null +++ b/playwright.config.js @@ -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 }, + }, +}); diff --git a/src/entities/Unit.js b/src/entities/Unit.js index 6158bb2..bf82186 100644 --- a/src/entities/Unit.js +++ b/src/entities/Unit.js @@ -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, }); } } diff --git a/src/systems/CombatSystem.js b/src/systems/CombatSystem.js index c24a9a7..138d9e7 100644 --- a/src/systems/CombatSystem.js +++ b/src/systems/CombatSystem.js @@ -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; diff --git a/src/systems/EntityStateMachine.js b/src/systems/EntityStateMachine.js index 9b607b6..2f4ecd3 100644 --- a/src/systems/EntityStateMachine.js +++ b/src/systems/EntityStateMachine.js @@ -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); + } } } } diff --git a/tests/e2e/combat-loop.e2e.js b/tests/e2e/combat-loop.e2e.js new file mode 100644 index 0000000..9f847f1 --- /dev/null +++ b/tests/e2e/combat-loop.e2e.js @@ -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(); + } +})(); diff --git a/tests/e2e/combat-loop.spec.js b/tests/e2e/combat-loop.spec.js new file mode 100644 index 0000000..db60b0b --- /dev/null +++ b/tests/e2e/combat-loop.spec.js @@ -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 }); +}); diff --git a/tests/e2e/milestone-2-combat-loop.spec.js b/tests/e2e/milestone-2-combat-loop.spec.js new file mode 100644 index 0000000..115fbda --- /dev/null +++ b/tests/e2e/milestone-2-combat-loop.spec.js @@ -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); + }); +});