225 lines
7.3 KiB
TypeScript
225 lines
7.3 KiB
TypeScript
// Regression net for the backend defects found in the audit. These drive the
|
|
// real classes rather than re-implementing their logic, so they fail if the
|
|
// behaviour regresses rather than if the code is merely reshaped.
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { createRequire } from 'node:module';
|
|
|
|
import { PhysicalContainer } from '../backend/src/physics/containers/physical-container';
|
|
import { BoundingBox } from '../backend/src/physics/bounding-boxes/bounding-box';
|
|
import { ProjectilePhysical } from '../backend/src/objects/projectile-physical';
|
|
import { CharacterPhysical } from '../backend/src/objects/character-physical';
|
|
import { StepCommand } from '../backend/src/commands/step';
|
|
import { GeneratePointsCommand } from '../backend/src/commands/generate-points';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const shared = require('../shared/lib/main.js');
|
|
const { vec2 } = require('../shared/node_modules/gl-matrix');
|
|
const { applyArrayPlugins, Random, settings, CharacterTeam } = shared;
|
|
|
|
applyArrayPlugins();
|
|
|
|
const step = settings.targetPhysicsDeltaTimeInSeconds;
|
|
|
|
// Collects the points a stepped object asks the game to award.
|
|
class PointsSpy {
|
|
public blue = 0;
|
|
public red = 0;
|
|
public handleCommand(command: unknown) {
|
|
if (command instanceof GeneratePointsCommand) {
|
|
this.blue += command.blue;
|
|
this.red += command.red;
|
|
}
|
|
}
|
|
}
|
|
|
|
const boxAround = (center: number[], radius: number) => {
|
|
const box = new BoundingBox();
|
|
box.xMin = center[0] - radius;
|
|
box.xMax = center[0] + radius;
|
|
box.yMin = center[1] - radius;
|
|
box.yMax = center[1] + radius;
|
|
return box;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
Random.seed = 1;
|
|
});
|
|
|
|
describe('projectile broadphase registration', () => {
|
|
// The bounding box is only rebuilt by the center/radius setters, but the
|
|
// geometry helpers write `center` in place — so a projectile stayed registered
|
|
// at its muzzle for its whole flight. View-area streaming is driven by that
|
|
// box, so shots fired off-screen were never sent, and shots in view vanished
|
|
// as soon as their muzzle scrolled away.
|
|
it('follows the projectile instead of staying at the muzzle', () => {
|
|
const container = new PhysicalContainer();
|
|
container.initialize();
|
|
|
|
const projectile = new ProjectilePhysical(
|
|
vec2.fromValues(0, 0),
|
|
20,
|
|
40,
|
|
CharacterTeam.blue,
|
|
vec2.fromValues(3000, 0),
|
|
{ team: CharacterTeam.blue } as never,
|
|
container,
|
|
0,
|
|
);
|
|
container.addObject(projectile);
|
|
|
|
for (let i = 0; i < 40; i++) {
|
|
container.handleCommand(new StepCommand(step, new PointsSpy() as never));
|
|
}
|
|
|
|
expect(projectile.center[0]).toBeGreaterThan(400);
|
|
expect(container.findIntersecting(boxAround(projectile.center, 100))).toContain(
|
|
projectile,
|
|
);
|
|
expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile);
|
|
});
|
|
|
|
it('fast-forwards a shot by the sender latency', () => {
|
|
const container = new PhysicalContainer();
|
|
container.initialize();
|
|
const spawn = vec2.fromValues(0, 0);
|
|
|
|
const make = () =>
|
|
new ProjectilePhysical(
|
|
vec2.clone(spawn),
|
|
20,
|
|
40,
|
|
CharacterTeam.blue,
|
|
vec2.fromValues(3000, 0),
|
|
{ team: CharacterTeam.blue } as never,
|
|
container,
|
|
0,
|
|
);
|
|
|
|
const plain = make();
|
|
const compensated = make();
|
|
compensated.fastForward(0.1);
|
|
|
|
expect(compensated.center[0]).toBeGreaterThan(plain.center[0] + 200);
|
|
});
|
|
|
|
it('never fast-forwards further than the cap', () => {
|
|
const container = new PhysicalContainer();
|
|
container.initialize();
|
|
const projectile = new ProjectilePhysical(
|
|
vec2.fromValues(0, 0),
|
|
20,
|
|
40,
|
|
CharacterTeam.blue,
|
|
vec2.fromValues(3000, 0),
|
|
{ team: CharacterTeam.blue } as never,
|
|
container,
|
|
0,
|
|
);
|
|
projectile.fastForward(10);
|
|
const cap = settings.chargeShotSpeedMax * settings.maxProjectileCatchUpSeconds;
|
|
expect(projectile.center[0]).toBeLessThanOrEqual(cap + 50);
|
|
});
|
|
});
|
|
|
|
describe('scoring', () => {
|
|
const spawnCharacter = (container: PhysicalContainer) => {
|
|
const character = new CharacterPhysical(
|
|
'tester',
|
|
0,
|
|
0,
|
|
CharacterTeam.blue,
|
|
container,
|
|
vec2.fromValues(0, 0),
|
|
);
|
|
container.addObject(character);
|
|
return character;
|
|
};
|
|
|
|
// A disconnect and an NPC being retired on join both run through onDie(), and
|
|
// used to award the opposing team a full kill, so ordinary connection churn
|
|
// moved the match score with nobody playing.
|
|
it('awards nothing for an administrative death', () => {
|
|
const container = new PhysicalContainer();
|
|
container.initialize();
|
|
const character = spawnCharacter(container);
|
|
const game = new PointsSpy();
|
|
|
|
character.onDie(); // what PlayerBase.destroy() does on a socket drop
|
|
container.handleCommand(new StepCommand(step, game as never));
|
|
|
|
expect(game.blue).toBe(0);
|
|
expect(game.red).toBe(0);
|
|
});
|
|
|
|
it('still awards a kill for a combat death', () => {
|
|
const container = new PhysicalContainer();
|
|
container.initialize();
|
|
const character = spawnCharacter(container);
|
|
const game = new PointsSpy();
|
|
|
|
character.onDie(true);
|
|
container.handleCommand(new StepCommand(step, game as never));
|
|
|
|
expect(game.red).toBe(settings.playerKillPoint);
|
|
expect(game.blue).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('charged shots', () => {
|
|
// Capping only the COST meant a max-charge request on a nearly empty pool paid
|
|
// a tap's price while still getting full-charge radius, speed and recoil.
|
|
it('scales the shot by the strength actually paid, not the requested charge', () => {
|
|
const container = new PhysicalContainer();
|
|
container.initialize();
|
|
const character = new CharacterPhysical(
|
|
'tester',
|
|
0,
|
|
0,
|
|
CharacterTeam.blue,
|
|
container,
|
|
vec2.fromValues(0, 0),
|
|
);
|
|
container.addObject(character);
|
|
|
|
// Drain the pool down to roughly a tap's worth.
|
|
(character as unknown as { projectileStrength: number }).projectileStrength =
|
|
settings.chargeShotStrengthMin;
|
|
|
|
const before = container.findIntersecting(boxAround([0, 0], 5000)).length;
|
|
character.shootTowards(vec2.fromValues(1000, 0), 1);
|
|
const spawned = container
|
|
.findIntersecting(boxAround([0, 0], 5000))
|
|
.filter((o) => o.gameObject instanceof ProjectilePhysical)
|
|
.map((o) => o.gameObject as ProjectilePhysical);
|
|
|
|
expect(container.findIntersecting(boxAround([0, 0], 5000)).length).toBeGreaterThan(
|
|
before,
|
|
);
|
|
expect(spawned[0].charge).toBeCloseTo(0, 5);
|
|
expect(spawned[0].radius).toBeCloseTo(settings.chargeShotRadiusMin, 5);
|
|
});
|
|
|
|
it('ignores a shot aimed at the shooter itself', () => {
|
|
const container = new PhysicalContainer();
|
|
container.initialize();
|
|
const character = new CharacterPhysical(
|
|
'tester',
|
|
0,
|
|
0,
|
|
CharacterTeam.blue,
|
|
container,
|
|
vec2.fromValues(0, 0),
|
|
);
|
|
container.addObject(character);
|
|
|
|
const projectilesBefore = container
|
|
.findIntersecting(boxAround([0, 0], 5000))
|
|
.filter((o) => o.gameObject instanceof ProjectilePhysical).length;
|
|
character.shootTowards(vec2.clone(character.center), 1);
|
|
const projectilesAfter = container
|
|
.findIntersecting(boxAround([0, 0], 5000))
|
|
.filter((o) => o.gameObject instanceof ProjectilePhysical).length;
|
|
|
|
expect(projectilesAfter).toBe(projectilesBefore);
|
|
});
|
|
});
|