Files
iron-requiem/tests/slice2_projectile.test.js
Kay Kayyali 4af6d119af shell-velocity: increase 5-7x — apcbc 750→900, apcr 930→1200, he 550→700, heat 600→800
RED→GREEN: updated test expectations first (4 failures on velocity), then
changed shells.js data, then fixed all downstream hardcoded velocity values
in ammo/projectile/integration tests. 142 tests pass across 4 suites;
integration suite failures are pre-existing Phaser mock issues.
2026-05-24 01:21:24 +00:00

346 lines
11 KiB
JavaScript

/**
* Slice 2 — Projectile entity tests
*
* Tests shell type behaviors, penetration resolution, splash damage,
* pool recycling, and visual properties per Gitea issue #42.
*/
import { Projectile } from '../src/game/entities/Projectile.js';
import { shells } from '../src/data/shells.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createTarget(armor = 100) {
return { armor, hp: 300, active: true, x: 0, y: 0 };
}
// ---------------------------------------------------------------------------
// Shell type behavior tests
// ---------------------------------------------------------------------------
describe('Projectile — APCBC', () => {
it('has standard velocity of 900 px/sec from shells.js', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
expect(p.velocity).toBe(900);
expect(p.shellType).toBe('apcbc');
});
it('has penetration value from shells.js', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
expect(p.penetration).toBe(100);
});
it('has gray color for visual placeholder', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
expect(p.color).toBe('#808080'); // gray
});
it('does not have splash damage', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
expect(p.splash).toBe(0);
});
it('is not limited supply', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
expect(p.limited).toBe(false);
});
});
describe('Projectile — APCR', () => {
it('has high velocity of 1200 px/sec from shells.js', () => {
const p = new Projectile(0, 0, 0, shells.apcr);
expect(p.velocity).toBe(1200);
expect(p.shellType).toBe('apcr');
});
it('has higher penetration than APCBC', () => {
const p = new Projectile(0, 0, 0, shells.apcr);
expect(p.penetration).toBe(140);
expect(p.penetration).toBeGreaterThan(shells.apcbc.penetration);
});
it('has cyan color for visual placeholder', () => {
const p = new Projectile(0, 0, 0, shells.apcr);
expect(p.color).toBe('#00ffff'); // cyan
});
it('has limited supply flag set', () => {
const p = new Projectile(0, 0, 0, shells.apcr);
expect(p.limited).toBe(true);
});
});
describe('Projectile — HE', () => {
it('has velocity of 700 px/sec from shells.js', () => {
const p = new Projectile(0, 0, 0, shells.he);
expect(p.velocity).toBe(700);
expect(p.shellType).toBe('he');
});
it('has lower penetration than AP shells', () => {
const p = new Projectile(0, 0, 0, shells.he);
expect(p.penetration).toBe(30);
expect(p.penetration).toBeLessThan(shells.apcbc.penetration);
});
it('has splash damage radius of 60px', () => {
const p = new Projectile(0, 0, 0, shells.he);
expect(p.splash).toBe(60);
});
it('has yellow color for visual placeholder', () => {
const p = new Projectile(0, 0, 0, shells.he);
expect(p.color).toBe('#ffff00'); // yellow
});
it('is not limited supply', () => {
const p = new Projectile(0, 0, 0, shells.he);
expect(p.limited).toBe(false);
});
});
describe('Projectile — HEAT', () => {
it('has velocity of 800 px/sec from shells.js', () => {
const p = new Projectile(0, 0, 0, shells.heat);
expect(p.velocity).toBe(800);
expect(p.shellType).toBe('heat');
});
it('has penetration value from shells.js', () => {
const p = new Projectile(0, 0, 0, shells.heat);
expect(p.penetration).toBe(90);
});
it('has orange color for visual placeholder', () => {
const p = new Projectile(0, 0, 0, shells.heat);
expect(p.color).toBe('#ff8800'); // orange
});
it('has small splash radius (shaped charge)', () => {
const p = new Projectile(0, 0, 0, shells.heat);
expect(p.splash).toBe(15);
});
});
// ---------------------------------------------------------------------------
// Penetration resolution — onHit(target)
// ---------------------------------------------------------------------------
describe('Projectile — onHit(target) penetration resolution', () => {
it('penetrates when penetration > target armor', () => {
const p = new Projectile(0, 0, 0, shells.apcbc); // penetration = 100
const target = createTarget(80); // armor = 80
const result = p.onHit(target);
expect(result.penetrated).toBe(true);
});
it('does not penetrate when penetration < target armor', () => {
const p = new Projectile(0, 0, 0, shells.apcbc); // penetration = 100
const target = createTarget(120); // armor = 120
const result = p.onHit(target);
expect(result.penetrated).toBe(false);
});
it('penetrates when penetration equals target armor', () => {
const p = new Projectile(0, 0, 0, shells.apcbc); // penetration = 100
const target = createTarget(100); // armor = 100
const result = p.onHit(target);
expect(result.penetrated).toBe(true);
});
it('APCR penetrates heavy armor that APCBC cannot', () => {
const apcr = new Projectile(0, 0, 0, shells.apcr); // penetration = 140
const apcbc = new Projectile(0, 0, 0, shells.apcbc); // penetration = 100
const target = createTarget(130);
expect(apcr.onHit(target).penetrated).toBe(true);
expect(apcbc.onHit(target).penetrated).toBe(false);
});
it('HE rarely penetrates high armor targets', () => {
const p = new Projectile(0, 0, 0, shells.he); // penetration = 30
const target = createTarget(120); // heavy armor
const result = p.onHit(target);
expect(result.penetrated).toBe(false);
});
it('returns damage dealt even on non-penetration', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
const target = createTarget(120);
const result = p.onHit(target);
expect(result.penetrated).toBe(false);
expect(typeof result.damage).toBe('number');
expect(result.damage).toBeGreaterThanOrEqual(0);
});
it('returns full penetration damage on successful penetration', () => {
const p = new Projectile(0, 0, 0, shells.apcbc); // pen = 100
const target = createTarget(80);
const result = p.onHit(target);
expect(result.penetrated).toBe(true);
expect(result.damage).toBeGreaterThan(0);
});
it('HEAT penetration is independent of range at close range', () => {
const p = new Projectile(0, 0, 0, shells.heat); // pen = 90
const target = createTarget(80);
const result = p.onHit(target);
expect(result.penetrated).toBe(true);
});
});
// ---------------------------------------------------------------------------
// HE splash damage behavior
// ---------------------------------------------------------------------------
describe('Projectile — HE splash damage', () => {
it('returns splash radius in onHit result', () => {
const p = new Projectile(0, 0, 0, shells.he);
const result = p.onHit(createTarget(40));
expect(result.splashRadius).toBe(60);
});
it('splash damage is lower than direct hit damage', () => {
const p = new Projectile(0, 0, 0, shells.he);
const direct = p.onHit(createTarget(40));
expect(direct.damage).toBeGreaterThan(0);
// splash damage is a fraction of direct
expect(direct.splashRadius).toBeGreaterThan(0);
});
it('APCBC has no splash radius', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
const result = p.onHit(createTarget(40));
expect(result.splashRadius).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Pool recycling
// ---------------------------------------------------------------------------
describe('Projectile — pool recycling', () => {
it('is marked inactive when off-screen (x < 0)', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
p.x = -10;
expect(p.isOffScreen(640, 360)).toBe(true);
});
it('is marked inactive when off-screen (x > width)', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
p.x = 650;
expect(p.isOffScreen(640, 360)).toBe(true);
});
it('is marked inactive when off-screen (y < 0)', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
p.y = -10;
expect(p.isOffScreen(640, 360)).toBe(true);
});
it('is marked inactive when off-screen (y > height)', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
p.y = 370;
expect(p.isOffScreen(640, 360)).toBe(true);
});
it('is active when on-screen', () => {
const p = new Projectile(320, 180, 0, shells.apcbc);
expect(p.isOffScreen(640, 360)).toBe(false);
});
it('recycle resets projectile state for reuse', () => {
const p = new Projectile(100, 200, 90, shells.apcr);
// Simulate some usage
p.x = 500;
p.y = 300;
p.alive = false;
p.recycle(320, 180, 45, shells.he);
expect(p.x).toBe(320);
expect(p.y).toBe(180);
expect(p.angle).toBe(45);
expect(p.alive).toBe(true);
expect(p.shellType).toBe('he');
expect(p.velocity).toBe(shells.he.velocity);
expect(p.penetration).toBe(shells.he.penetration);
expect(p.color).toBe('#ffff00');
});
it('recycle resets the hit flag', () => {
const p = new Projectile(100, 200, 90, shells.apcbc);
p.onHit(createTarget(50)); // mark as hit
p.alive = false;
p.recycle(320, 180, 45, shells.heat);
expect(p.alive).toBe(true);
// Ready for a new hit
const result = p.onHit(createTarget(50));
expect(result.penetrated).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Movement (position update)
// ---------------------------------------------------------------------------
describe('Projectile — movement', () => {
it('moves in the direction of its angle over time', () => {
// 0 degrees = right
const p = new Projectile(320, 180, 0, shells.apcbc); // angle = 0, velocity = 900
const startX = p.x;
p.update(0.016); // 16ms frame
expect(p.x).toBeGreaterThan(startX);
expect(p.y).toBeCloseTo(180, 5); // no vertical movement at 0 degrees
});
it('moves downward when angle is 90 degrees', () => {
const p = new Projectile(320, 180, 90, shells.apcbc); // angle = 90, velocity = 900
const startY = p.y;
p.update(0.016); // 16ms frame
expect(p.y).toBeGreaterThan(startY);
expect(p.x).toBeCloseTo(320, 5); // no horizontal movement at 90 degrees
});
it('scales movement by velocity (APCR travels farther per frame than HE)', () => {
const apcr = new Projectile(320, 180, 0, shells.apcr); // velocity = 1200
const he = new Projectile(320, 180, 0, shells.he); // velocity = 700
apcr.update(0.016);
he.update(0.016);
const apcrDist = apcr.x - 320;
const heDist = he.x - 320;
expect(apcrDist).toBeGreaterThan(heDist);
});
});
// ---------------------------------------------------------------------------
// Expired / lifetime
// ---------------------------------------------------------------------------
describe('Projectile — lifetime expiry', () => {
it('is alive when created', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
expect(p.alive).toBe(true);
});
it('becomes dead after exceeding max lifetime', () => {
const p = new Projectile(0, 0, 0, shells.apcbc);
// Advance time past max lifetime
p.update(10); // 10 seconds — way beyond any reasonable projectile lifetime
expect(p.alive).toBe(false);
});
it('APCR has same lifetime as other shells', () => {
const apcr = new Projectile(0, 0, 0, shells.apcr);
const apcbc = new Projectile(0, 0, 0, shells.apcbc);
expect(apcr.maxLifetime).toBe(apcbc.maxLifetime);
});
});