Files
iron-requiem/tests/slice2_collision.test.js

429 lines
16 KiB
JavaScript

/**
* Slice 2 — Collision/Damage Loop unit tests
*
* Tests: player fire, projectile hit resolution, enemy projectile
* hitting tank, hatch hitbox, screen shake, tank HP tracking.
*
* Uses Jest globals only — no vitest imports.
* RED phase: MainGame does not yet implement these behaviors.
*/
// ─── Mock Phaser before any imports ──────────────────────────────────
jest.mock('phaser', () => {
const orig = jest.requireActual('phaser');
return {
...orig,
Scene: class MockScene {
constructor(config) {
this.scene = config || {};
this.scene.launch = jest.fn();
}
add = {
image: jest.fn(() => ({ setOrigin: jest.fn(() => ({})) })),
existing: jest.fn(),
rectangle: jest.fn(() => ({ setOrigin: jest.fn(() => ({})) })),
graphics: jest.fn(() => ({
lineStyle: jest.fn(() => ({})),
lineBetween: jest.fn(() => ({})),
setDepth: jest.fn(() => ({})),
})),
text: jest.fn(() => ({ setDepth: jest.fn(() => ({})) })),
};
cameras = {
main: {
startFollow: jest.fn(),
shake: jest.fn(),
},
};
textures = { exists: jest.fn(() => false) };
physics = {
add: {
group: jest.fn(() => ({
maxSize: 100,
getChildren: jest.fn(() => []),
getFirstDead: jest.fn(() => null),
create: jest.fn(() => null),
killAndHide: jest.fn(),
})),
existing: jest.fn(),
},
overlap: jest.fn(),
};
input = {
keyboard: {
addKeys: jest.fn(() => ({
W: { isDown: false },
A: { isDown: false },
S: { isDown: false },
D: { isDown: false },
E: { isDown: false },
ONE: { isDown: false },
TWO: { isDown: false },
THREE: { isDown: false },
FOUR: { isDown: false },
})),
},
on: jest.fn(),
};
game = { __saveManager: null };
},
Input: {
Keyboard: { KeyCodes: { W: 87, A: 65, S: 83, D: 68, E: 69, ONE: 49, TWO: 50, THREE: 51, FOUR: 52 } },
},
Physics: { Arcade: { Sprite: class {} } },
};
});
// ─── Imports ─────────────────────────────────────────────────────────
import { MainGame } from '../src/game/scenes/MainGame.js';
import { AmmoSystem } from '../src/game/systems/AmmoSystem.js';
import { shells } from '../src/data/shells.js';
import { enemies } from '../src/data/enemies.js';
import { Projectile } from '../src/game/entities/Projectile.js';
import { Enemy } from '../src/game/entities/Enemy.js';
import { CommanderHatch } from '../src/game/entities/CommanderHatch.js';
// ─── Helpers ─────────────────────────────────────────────────────────
function freshScene() {
const mg = new MainGame();
// Stub textures.exists to avoid missing texture errors
mg.textures.exists = jest.fn(() => true);
mg.create();
return mg;
}
function createEnemyTarget(typeKey = 'type59', x = 500, y = 300) {
return new Enemy({}, typeKey, x, y);
}
// ══════════════════════════════════════════════════════════════════════
// Tank HP tracking
// ══════════════════════════════════════════════════════════════════════
describe('MainGame — tank HP', () => {
it('initializes tankHP to 300 in create()', () => {
const mg = freshScene();
expect(mg.tankHP).toBeDefined();
expect(mg.tankHP).toBe(300);
});
it('decreases tankHP when _onEnemyProjHitTank is called', () => {
const mg = freshScene();
mg.tankHP = 300;
// Simulate enemy projectile hitting tank
const projSprite = { active: true, destroy: jest.fn() };
mg._onEnemyProjHitTank(mg.tank, projSprite);
expect(mg.tankHP).toBeLessThan(300);
expect(mg.tankHP).toBeGreaterThanOrEqual(0);
});
it('does not go below 0 HP', () => {
const mg = freshScene();
mg.tankHP = 5;
const projSprite = { active: true, destroy: jest.fn() };
mg._onEnemyProjHitTank(mg.tank, projSprite);
expect(mg.tankHP).toBeGreaterThanOrEqual(0);
});
});
// ══════════════════════════════════════════════════════════════════════
// Player fire — mouse click handler
// ══════════════════════════════════════════════════════════════════════
describe('MainGame — player fire (pointerdown)', () => {
it('registers pointerdown event handler in create()', () => {
const mg = freshScene();
// input.on was called with ('pointerdown', fn, context) where fn is _onFire
const pointerdownCalls = mg.input.on.mock.calls.filter(
c => c[0] === 'pointerdown'
);
expect(pointerdownCalls.length).toBe(1);
expect(pointerdownCalls[0][1]).toBeInstanceOf(Function);
});
it('_onFire() spawns a projectile in projectileGroup when ammo available', () => {
const mg = freshScene();
// Ensure ammoSystem has ammo
expect(mg.ammoSystem.getTotalRemaining()).toBeGreaterThan(0);
// Count projectiles before fire
const before = mg.projectileGroup.getChildren().length;
mg._onFire();
const after = mg.projectileGroup.getChildren().length;
// Projectile group should have one more active sprite
expect(after).toBeGreaterThanOrEqual(before);
});
it('_onFire() does NOT throw when ammo system is empty', () => {
const mg = freshScene();
// Drain all ammo
const types = Object.keys(shells);
for (const t of types) {
mg.ammoSystem.selectShell(t);
const count = mg.ammoSystem.getInventory()[t];
for (let i = 0; i < count; i++) mg.ammoSystem.fire();
}
expect(() => mg._onFire()).not.toThrow();
});
it('_onFire() creates a Projectile with correct shell config from ammoSystem', () => {
const mg = freshScene();
mg.turret.angle = 45; // fire at 45 degrees
// Spy on projectileGroup.create
const createSpy = jest.spyOn(mg.projectileGroup, 'create');
mg._onFire();
// projectGroup.create should have been called to spawn the projectile sprite
expect(createSpy).toHaveBeenCalled();
createSpy.mockRestore();
});
it('_onFire() respects fire rate — rapid calls do not overshoot', () => {
const mg = freshScene();
const initialAmmo = mg.ammoSystem.getTotalRemaining();
// Fire twice rapidly
mg._onFire();
mg._onFire();
// Ammo should decrease by at least 1 but not more than 2
const remaining = mg.ammoSystem.getTotalRemaining();
expect(remaining).toBeGreaterThanOrEqual(initialAmmo - 2);
expect(remaining).toBeLessThan(initialAmmo);
});
});
// ══════════════════════════════════════════════════════════════════════
// Player projectile → enemy hit resolution
// ══════════════════════════════════════════════════════════════════════
describe('MainGame — _checkPlayerProjectileHits', () => {
it('exists as a method on MainGame', () => {
const mg = freshScene();
expect(typeof mg._checkPlayerProjectileHits).toBe('function');
});
it('detects enemy within projectile hit radius and applies damage', () => {
const mg = freshScene();
// Place an enemy at a known position
const enemy = createEnemyTarget('type59', 320, 180);
mg.enemies = [enemy];
// Create a player projectile near the enemy
const proj = new Projectile(320, 180, 0, shells.apcbc);
proj.alive = true;
// Manually add a sprite to projectileGroup (simulate _onFire output)
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: jest.fn() };
// Override getChildren to return our sprite
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
// Before hit
const hpBefore = enemy.hp;
mg._checkPlayerProjectileHits(16);
// Enemy should have taken damage
expect(enemy.hp).toBeLessThan(hpBefore);
});
it('does NOT hit enemies outside projectile hit radius', () => {
const mg = freshScene();
// Enemy far away from projectile
const enemy = createEnemyTarget('type59', 2000, 2000);
mg.enemies = [enemy];
const proj = new Projectile(320, 180, 0, shells.apcbc);
proj.alive = true;
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: jest.fn() };
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
const hpBefore = enemy.hp;
mg._checkPlayerProjectileHits(16);
// Enemy should NOT have taken damage
expect(enemy.hp).toBe(hpBefore);
});
it('marks projectile as dead after hit', () => {
const mg = freshScene();
const enemy = createEnemyTarget('type59', 320, 180);
mg.enemies = [enemy];
const proj = new Projectile(320, 180, 0, shells.apcbc);
proj.alive = true;
const destroyFn = jest.fn();
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: destroyFn };
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
mg._checkPlayerProjectileHits(16);
// Projectile should be despawned
expect(proj.alive).toBe(false);
expect(destroyFn).toHaveBeenCalled();
});
it('APCBC penetration vs Type 59 armor: penetration=100, armor=120 → non-penetration, reduced damage', () => {
const mg = freshScene();
// Type 59 has armor=120, APCBC pen=100 → does not penetrate
const enemy = createEnemyTarget('type59', 320, 180); // armor=120
mg.enemies = [enemy];
const proj = new Projectile(320, 180, 0, shells.apcbc); // pen=100
proj.alive = true;
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: jest.fn() };
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
const hpBefore = enemy.hp;
mg._checkPlayerProjectileHits(16);
// Did take damage but reduced (100 * 0.25 = 25)
const damageDealt = hpBefore - enemy.hp;
expect(damageDealt).toBeGreaterThan(0);
expect(damageDealt).toBeLessThan(100); // not full penetration damage
});
it('APCR penetration vs Type 62 armor: penetration=140, armor=40 → full penetration', () => {
const mg = freshScene();
// Type 62 has armor=40, APCR pen=140 → full penetration
const enemy = createEnemyTarget('type62', 320, 180); // armor=40, hp=100
mg.enemies = [enemy];
const proj = new Projectile(320, 180, 0, shells.apcr); // pen=140
proj.alive = true;
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: jest.fn() };
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
mg._checkPlayerProjectileHits(16);
// Full damage — should kill the Type 62 (100 hp vs 140 pen)
expect(enemy.hp).toBe(0);
expect(enemy.active).toBe(false);
});
});
// ══════════════════════════════════════════════════════════════════════
// Enemy projectile → tank hit (physics overlap)
// ══════════════════════════════════════════════════════════════════════
describe('MainGame — physics.overlap for enemy projectiles', () => {
it('registers physics.overlap in create() for enemyProjectileGroup vs tank', () => {
const mg = freshScene();
expect(mg.physics.overlap).toHaveBeenCalledWith(
mg.enemyProjectileGroup,
mg.tank,
expect.any(Function),
null,
mg
);
});
it('_onEnemyProjHitTank destroys the enemy projectile sprite', () => {
const mg = freshScene();
const destroyFn = jest.fn();
const projSprite = { active: true, destroy: destroyFn };
mg._onEnemyProjHitTank(mg.tank, projSprite);
expect(destroyFn).toHaveBeenCalled();
});
});
// ══════════════════════════════════════════════════════════════════════
// Hatch hitbox — unbuttoned tank hit
// ══════════════════════════════════════════════════════════════════════
describe('MainGame — hatch hitbox on enemy projectile hit', () => {
it('commanderHatch.takeHit() is called when unbuttoned and enemy proj hits tank', () => {
const mg = freshScene();
// Unbutton the commander
mg.commanderHatch.toggle();
expect(mg.commanderHatch.isUnbuttoned).toBe(true);
const takeHitSpy = jest.spyOn(mg.commanderHatch, 'takeHit');
const projSprite = { active: true, destroy: jest.fn() };
mg._onEnemyProjHitTank(mg.tank, projSprite);
expect(takeHitSpy).toHaveBeenCalled();
takeHitSpy.mockRestore();
});
it('commanderHatch.takeHit() is NOT called when buttoned up', () => {
const mg = freshScene();
// Ensure buttoned up (default)
expect(mg.commanderHatch.isUnbuttoned).toBe(false);
const takeHitSpy = jest.spyOn(mg.commanderHatch, 'takeHit');
const projSprite = { active: true, destroy: jest.fn() };
mg._onEnemyProjHitTank(mg.tank, projSprite);
expect(takeHitSpy).not.toHaveBeenCalled();
takeHitSpy.mockRestore();
});
it('forces hatch closed after unbuttoned hit (suppression)', () => {
const mg = freshScene();
mg.commanderHatch.toggle(); // unbutton
expect(mg.commanderHatch.isUnbuttoned).toBe(true);
const projSprite = { active: true, destroy: jest.fn() };
mg._onEnemyProjHitTank(mg.tank, projSprite);
// Hatch should be forced closed by takeHit()
expect(mg.commanderHatch.isUnbuttoned).toBe(false);
});
});
// ══════════════════════════════════════════════════════════════════════
// Screen shake on tank hit
// ══════════════════════════════════════════════════════════════════════
describe('MainGame — screen shake on damage', () => {
it('triggers camera shake when enemy projectile hits tank', () => {
const mg = freshScene();
const projSprite = { active: true, destroy: jest.fn() };
mg._onEnemyProjHitTank(mg.tank, projSprite);
expect(mg.cameras.main.shake).toHaveBeenCalled();
});
it('shake duration and intensity match expected values', () => {
const mg = freshScene();
const projSprite = { active: true, destroy: jest.fn() };
mg._onEnemyProjHitTank(mg.tank, projSprite);
// shake(100, 0.01) per spec
expect(mg.cameras.main.shake).toHaveBeenCalledWith(100, 0.01);
});
});
// ══════════════════════════════════════════════════════════════════════
// Tank death on HP reaching 0
// ══════════════════════════════════════════════════════════════════════
describe('MainGame — tank death', () => {
it('sets tank dead flag when HP reaches 0 from enemy projectile hit', () => {
const mg = freshScene();
mg.tankHP = 1;
const projSprite = { active: true, destroy: jest.fn() };
mg._onEnemyProjHitTank(mg.tank, projSprite);
expect(mg.tankHP).toBe(0);
expect(mg.tankDead).toBe(true);
});
});