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.
346 lines
11 KiB
JavaScript
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);
|
|
});
|
|
});
|