even more
This commit is contained in:
parent
82e092ba55
commit
8f50ebdb97
8 changed files with 1842 additions and 3095 deletions
|
|
@ -1,185 +1,150 @@
|
|||
// Regression net for backend defects — drives the real classes.
|
||||
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 { PhysicalContainer } from '../backend/src/physics/physical-container';
|
||||
import { BoundingBox } from '../backend/src/physics/bounding-box';
|
||||
import { ProjectilePhysical } from '../backend/src/objects/projectile-physical';
|
||||
import { CharacterPhysical } from '../backend/src/objects/character-physical';
|
||||
import { StepCommand, GeneratePointsCommand } from '../backend/src/commands/commands';
|
||||
|
||||
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 { Random, settings, CharacterTeam } = shared;
|
||||
|
||||
const step = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
|
||||
class PointsSpy {
|
||||
public blue = 0;
|
||||
public red = 0;
|
||||
public handleCommand(command: unknown) {
|
||||
if (command instanceof GeneratePointsCommand) {
|
||||
this.blue += command.blue;
|
||||
this.red += command.red;
|
||||
}
|
||||
public addPoints(blue: number, red: number) {
|
||||
this.blue += blue;
|
||||
this.red += red;
|
||||
}
|
||||
public announce() {}
|
||||
}
|
||||
|
||||
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;
|
||||
const makeWorld = () => {
|
||||
const game = new PointsSpy();
|
||||
return { game, container: new PhysicalContainer(game) };
|
||||
};
|
||||
|
||||
const spawnProjectile = (container: PhysicalContainer) =>
|
||||
new ProjectilePhysical(
|
||||
vec2.fromValues(0, 0),
|
||||
20,
|
||||
40,
|
||||
CharacterTeam.blue,
|
||||
vec2.fromValues(3000, 0),
|
||||
{ team: CharacterTeam.blue } as never,
|
||||
container,
|
||||
0,
|
||||
);
|
||||
|
||||
const spawnCharacter = (container: PhysicalContainer) => {
|
||||
const character = new CharacterPhysical(
|
||||
'tester',
|
||||
0,
|
||||
0,
|
||||
CharacterTeam.blue,
|
||||
container,
|
||||
vec2.fromValues(0, 0),
|
||||
);
|
||||
container.addObject(character);
|
||||
return character;
|
||||
};
|
||||
|
||||
const projectilesIn = (container: PhysicalContainer) =>
|
||||
container
|
||||
.findIntersecting(BoundingBox.ofCircle([0, 0], 5000))
|
||||
.filter((o) => o.gameObject instanceof ProjectilePhysical)
|
||||
.map((o) => o.gameObject as ProjectilePhysical);
|
||||
|
||||
beforeEach(() => {
|
||||
Random.seed = 1;
|
||||
});
|
||||
|
||||
describe('projectile broadphase registration', () => {
|
||||
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,
|
||||
);
|
||||
const { container } = makeWorld();
|
||||
const projectile = spawnProjectile(container);
|
||||
container.addObject(projectile);
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
container.handleCommand(new StepCommand(step, new PointsSpy() as never));
|
||||
container.step(step);
|
||||
}
|
||||
|
||||
expect(projectile.center[0]).toBeGreaterThan(400);
|
||||
expect(container.findIntersecting(boxAround(projectile.center, 100))).toContain(
|
||||
expect(
|
||||
container.findIntersecting(BoundingBox.ofCircle(projectile.center, 100)),
|
||||
).toContain(projectile);
|
||||
expect(container.findIntersecting(BoundingBox.ofCircle([0, 0], 100))).not.toContain(
|
||||
projectile,
|
||||
);
|
||||
expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile);
|
||||
});
|
||||
|
||||
it('spawns a shot just clear of the shooter, not a jump downrange', () => {
|
||||
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,
|
||||
);
|
||||
const { container } = makeWorld();
|
||||
const projectile = spawnProjectile(container);
|
||||
|
||||
expect(projectile.center[0]).toBeLessThanOrEqual(15);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
it('awards nothing for an administrative death', () => {
|
||||
const container = new PhysicalContainer();
|
||||
container.initialize();
|
||||
const { game, container } = makeWorld();
|
||||
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));
|
||||
character.onDie();
|
||||
container.step(step);
|
||||
|
||||
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 { game, container } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
const game = new PointsSpy();
|
||||
|
||||
character.onDie(true);
|
||||
container.handleCommand(new StepCommand(step, game as never));
|
||||
container.step(step);
|
||||
|
||||
expect(game.red).toBe(settings.playerKillPoint);
|
||||
expect(game.blue).toBe(0);
|
||||
});
|
||||
|
||||
it('awards a combat death only once', () => {
|
||||
const { game, container } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
|
||||
character.onDie(true);
|
||||
character.onDie(true);
|
||||
|
||||
expect(game.red).toBe(settings.playerKillPoint);
|
||||
});
|
||||
});
|
||||
|
||||
describe('charged shots', () => {
|
||||
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);
|
||||
|
||||
const { container } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
(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);
|
||||
const [spawned] = projectilesIn(container);
|
||||
|
||||
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);
|
||||
expect(spawned).toBeDefined();
|
||||
expect(spawned.charge).toBeCloseTo(0, 5);
|
||||
expect(spawned.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 { container } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
|
||||
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);
|
||||
expect(projectilesIn(container)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
// Slow-client backpressure: what a Player sheds when its socket stops draining.
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { PhysicalContainer } from '../backend/src/physics/containers/physical-container';
|
||||
import { PhysicalContainer } from '../backend/src/physics/physical-container';
|
||||
import { PlayerContainer } from '../backend/src/players/player-container';
|
||||
import { createWorld } from '../backend/src/create-world';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
const {
|
||||
applyArrayPlugins,
|
||||
Random,
|
||||
settings,
|
||||
TransportEvents,
|
||||
|
|
@ -21,9 +19,6 @@ const {
|
|||
ServerAnnouncement,
|
||||
} = shared;
|
||||
|
||||
applyArrayPlugins();
|
||||
|
||||
// Shaped like engine.io: the byte count lives on the ws socket under the transport.
|
||||
class FakeSocket {
|
||||
public readonly sent: Array<{ event: string; payload: unknown }> = [];
|
||||
public readonly conn = { transport: { socket: { bufferedAmount: 0 } } };
|
||||
|
|
@ -48,9 +43,8 @@ class FakeSocket {
|
|||
|
||||
const makePlayer = () => {
|
||||
Random.seed = 3;
|
||||
const objects = new PhysicalContainer();
|
||||
const objects = new PhysicalContainer({ addPoints() {}, announce() {} });
|
||||
createWorld(objects);
|
||||
objects.initialize();
|
||||
const players = new PlayerContainer(objects, 4, 0);
|
||||
const socket = new FakeSocket();
|
||||
const player = players.createPlayer({ name: 'player' }, socket as never);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Client outbound send cadence vs. server inbound allowance.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
|
|
@ -8,9 +7,7 @@ import { setFrameTimeMs } from '../frontend/src/scripts/helper/prediction/local-
|
|||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
const { vec2 } = require('../shared/node_modules/gl-matrix');
|
||||
const { applyArrayPlugins, settings, MoveActionCommand, ClientHeartbeatCommand } = shared;
|
||||
|
||||
applyArrayPlugins();
|
||||
const { settings, MoveActionCommand, ClientHeartbeatCommand } = shared;
|
||||
|
||||
const drive = (
|
||||
frameRate: number,
|
||||
|
|
@ -57,7 +54,7 @@ describe('client send cadence', () => {
|
|||
let sentOn = -1;
|
||||
const batches = drive(144, 0.1, (commandSocket) => {
|
||||
if (sentOn === -1) {
|
||||
commandSocket.handleCommand(new MoveActionCommand(vec2.fromValues(1, 0), 0));
|
||||
commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0));
|
||||
sentOn = 0;
|
||||
}
|
||||
});
|
||||
|
|
@ -73,15 +70,12 @@ describe('client send cadence', () => {
|
|||
expect(batches.every((b) => b.includes(ClientHeartbeatCommand.name))).toBe(true);
|
||||
});
|
||||
|
||||
// socket.io-client buffers emits made while disconnected in an uncapped
|
||||
// sendBuffer and flushes them on reconnect, ahead of the re-join — so every
|
||||
// one of them is both a leak and wasted bandwidth.
|
||||
it('sends nothing at all while the transport is down', () => {
|
||||
const batches = drive(
|
||||
60,
|
||||
30,
|
||||
(commandSocket) =>
|
||||
commandSocket.handleCommand(new MoveActionCommand(vec2.fromValues(1, 0), 0)),
|
||||
commandSocket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 0)),
|
||||
false,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Connection lifecycle: join guard, refusal reasons, reconnect — driven through a fake socket.io.
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
|
|
@ -7,10 +6,7 @@ import { defaultOptions } from '../backend/src/options';
|
|||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
const { applyArrayPlugins, Random, TransportEvents, JoinRejectionReason, settings } =
|
||||
shared;
|
||||
|
||||
applyArrayPlugins();
|
||||
const { Random, TransportEvents, JoinRejectionReason, settings } = shared;
|
||||
|
||||
class FakeSocket {
|
||||
public readonly sent: Array<{ event: string; payload: unknown }> = [];
|
||||
|
|
@ -40,7 +36,6 @@ class FakeSocket {
|
|||
this.disconnected = true;
|
||||
}
|
||||
|
||||
/** Number of listeners the server has attached for an event. */
|
||||
public listenerCount(event: string): number {
|
||||
return (this.handlers.get(event) ?? []).length;
|
||||
}
|
||||
|
|
@ -142,9 +137,6 @@ describe('hostile payloads', () => {
|
|||
expect(server.serverInfo.playerCount).toBe(4);
|
||||
});
|
||||
|
||||
// socket.io dispatches listeners with no try/catch of its own, so anything
|
||||
// thrown out of a connection-scoped handler is an uncaught exception: the
|
||||
// process exits and every player in the match is dropped with it.
|
||||
it.each([
|
||||
['no payload at all', undefined],
|
||||
['null', null],
|
||||
|
|
@ -190,7 +182,6 @@ describe('reconnect', () => {
|
|||
expect(server.serverInfo.playerCount).toBe(1);
|
||||
expect(second.disconnected).toBe(false);
|
||||
expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined();
|
||||
// Fresh token: the old one cannot be replayed.
|
||||
expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).not.toBe(token);
|
||||
});
|
||||
|
||||
|
|
@ -251,9 +242,6 @@ describe('inbound rate limiting', () => {
|
|||
});
|
||||
|
||||
describe('pre-join rate limiting', () => {
|
||||
// These listeners exist before any join, so the limiter inside
|
||||
// onPlayerToServer never sees them: unmetered, each one is a reply or an
|
||||
// allocation an unauthenticated client can ask for at line rate.
|
||||
it('clamps a flood of joins on an already-joined connection', () => {
|
||||
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
|
||||
const socket = attach();
|
||||
|
|
@ -312,7 +300,6 @@ describe('reconnect tokens', () => {
|
|||
});
|
||||
|
||||
describe('reconnect while the dropped socket is still live', () => {
|
||||
// engine.io needs ~45 s to notice a transport drop; the client is back in ~1 s.
|
||||
it('retires the ghost so the returning player keeps its slot, team and score', () => {
|
||||
const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 });
|
||||
|
||||
|
|
@ -321,7 +308,6 @@ describe('reconnect while the dropped socket is still live', () => {
|
|||
const token = first.lastPayloadFor(TransportEvents.PlayerJoined) as string;
|
||||
expect(server.serverInfo.playerCount).toBe(1);
|
||||
|
||||
// No 'disconnect' fired: the server still holds the dead socket's player.
|
||||
const second = attach();
|
||||
second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
// Determinism guard: client predictor and server run the same stepCharacterMovement.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// Numerics match production because test/setup.mjs has already applied
|
||||
// glMatrix.setMatrixArrayType(Array) — without it this would pin f32.
|
||||
const shared = require('../shared/lib/main.js');
|
||||
const { vec2 } = require('../shared/node_modules/gl-matrix');
|
||||
|
||||
|
|
@ -12,8 +9,6 @@ const {
|
|||
stepCharacterMovement,
|
||||
tickPlanetDetachment,
|
||||
resolveCircleMovement,
|
||||
planetDistance,
|
||||
planetGravity,
|
||||
PlanetBase,
|
||||
Random,
|
||||
headRadius,
|
||||
|
|
@ -23,6 +18,8 @@ const {
|
|||
rightFootOffset,
|
||||
} = shared;
|
||||
|
||||
const stepSeconds = 1 / 200;
|
||||
|
||||
const makeBody = (center, radius) => ({
|
||||
center: vec2.clone(center),
|
||||
radius,
|
||||
|
|
@ -31,113 +28,73 @@ const makeBody = (center, radius) => ({
|
|||
restitution: 0,
|
||||
});
|
||||
|
||||
// Free-space world (no planets): exercises the deterministic core without planet SDF geometry.
|
||||
const emptyWorld = {
|
||||
groundsNear: () => [],
|
||||
const makeState = (start) => ({
|
||||
head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius),
|
||||
leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius),
|
||||
rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius),
|
||||
direction: 0,
|
||||
currentPlanet: undefined,
|
||||
secondsSinceOnSurface: 1,
|
||||
bodyVelocity: vec2.create(),
|
||||
});
|
||||
|
||||
const makeWorld = (grounds) => ({
|
||||
groundsNear: () => grounds,
|
||||
stepBody: (body, dt) => {
|
||||
resolveCircleMovement(body, dt, []);
|
||||
return undefined;
|
||||
const hit = resolveCircleMovement(body, dt, grounds);
|
||||
return hit && hit.isGround ? hit : undefined;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const stepSeconds = 1 / 200;
|
||||
|
||||
// A spinning planet, built the way create-world builds one, so the SDF/gravity/on-surface branches have a pin too.
|
||||
const makeSpinningPlanetWorld = () => {
|
||||
Random.seed = 20;
|
||||
const vertices = PlanetBase.createPlanetVertices(
|
||||
vec2.fromValues(0, -900),
|
||||
1700,
|
||||
1900,
|
||||
70,
|
||||
);
|
||||
const base = new PlanetBase(1, vertices);
|
||||
let rotation = 0;
|
||||
const surface = {
|
||||
canCollide: true,
|
||||
isGround: true,
|
||||
center: base.center,
|
||||
angularVelocity: 0.09,
|
||||
advance: (dt) => (rotation += surface.angularVelocity * dt),
|
||||
distance: (target) =>
|
||||
planetDistance(
|
||||
target,
|
||||
vertices,
|
||||
base.center,
|
||||
Math.cos(rotation),
|
||||
Math.sin(rotation),
|
||||
),
|
||||
gravityAt: (target) => planetGravity(base.center, base.radius, target),
|
||||
};
|
||||
const grounds = [surface];
|
||||
return {
|
||||
surface,
|
||||
world: {
|
||||
groundsNear: () => grounds,
|
||||
stepBody: (body, dt) => {
|
||||
const { hitObject } = resolveCircleMovement(body, dt, grounds);
|
||||
return hitObject && hitObject.isGround ? hitObject : undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const runPlanetSimulation = () => {
|
||||
const { surface, world } = makeSpinningPlanetWorld();
|
||||
const start = vec2.fromValues(0, 250);
|
||||
const state = {
|
||||
head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius),
|
||||
leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius),
|
||||
rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius),
|
||||
direction: 0,
|
||||
currentPlanet: undefined,
|
||||
secondsSinceOnSurface: 1,
|
||||
bodyVelocity: vec2.create(),
|
||||
};
|
||||
|
||||
for (let i = 0; i < 900; i++) {
|
||||
// Fall in, then walk right, then left.
|
||||
const input = i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0);
|
||||
tickPlanetDetachment(state, stepSeconds);
|
||||
stepCharacterMovement(state, world, input, stepSeconds);
|
||||
surface.advance(stepSeconds);
|
||||
}
|
||||
|
||||
const round = (v) => Math.round(v * 1000) / 1000;
|
||||
return JSON.stringify({
|
||||
head: [round(state.head.center[0]), round(state.head.center[1])],
|
||||
leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])],
|
||||
rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])],
|
||||
direction: round(state.direction),
|
||||
onSurface: !!state.currentPlanet,
|
||||
secondsSinceOnSurface: round(state.secondsSinceOnSurface),
|
||||
});
|
||||
};
|
||||
const round = (v) => Math.round(v * 1000) / 1000;
|
||||
const roundVec = (v) => [round(v[0]), round(v[1])];
|
||||
|
||||
const runSimulation = () => {
|
||||
const start = vec2.fromValues(100, 100);
|
||||
const state = {
|
||||
head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius),
|
||||
leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius),
|
||||
rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius),
|
||||
direction: 0,
|
||||
currentPlanet: undefined,
|
||||
secondsSinceOnSurface: 1,
|
||||
bodyVelocity: vec2.create(),
|
||||
};
|
||||
const state = makeState(vec2.fromValues(100, 100));
|
||||
const world = makeWorld([]);
|
||||
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const angle = i * 0.1;
|
||||
const input = vec2.fromValues(Math.cos(angle), Math.sin(angle));
|
||||
stepCharacterMovement(state, emptyWorld, input, stepSeconds);
|
||||
stepCharacterMovement(
|
||||
state,
|
||||
world,
|
||||
vec2.fromValues(Math.cos(angle), Math.sin(angle)),
|
||||
stepSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
const round = (v) => Math.round(v * 1000) / 1000;
|
||||
return JSON.stringify({
|
||||
head: [round(state.head.center[0]), round(state.head.center[1])],
|
||||
leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])],
|
||||
rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])],
|
||||
bodyVelocity: [round(state.bodyVelocity[0]), round(state.bodyVelocity[1])],
|
||||
head: roundVec(state.head.center),
|
||||
leftFoot: roundVec(state.leftFoot.center),
|
||||
rightFoot: roundVec(state.rightFoot.center),
|
||||
bodyVelocity: roundVec(state.bodyVelocity),
|
||||
});
|
||||
};
|
||||
|
||||
const runPlanetSimulation = () => {
|
||||
Random.seed = 20;
|
||||
const planet = new PlanetBase(
|
||||
1,
|
||||
PlanetBase.createPlanetVertices(vec2.fromValues(0, -900), 1700, 1900, 70),
|
||||
);
|
||||
planet.angularVelocity = 0.09;
|
||||
const world = makeWorld([planet]);
|
||||
const state = makeState(vec2.fromValues(0, 250));
|
||||
|
||||
for (let i = 0; i < 900; i++) {
|
||||
const input = i < 200 ? vec2.create() : vec2.fromValues(i < 600 ? 1 : -1, 0);
|
||||
tickPlanetDetachment(state, stepSeconds);
|
||||
stepCharacterMovement(state, world, input, stepSeconds);
|
||||
planet.advanceRotation(stepSeconds);
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
head: roundVec(state.head.center),
|
||||
leftFoot: roundVec(state.leftFoot.center),
|
||||
rightFoot: roundVec(state.rightFoot.center),
|
||||
direction: round(state.direction),
|
||||
onSurface: !!state.currentPlanet,
|
||||
secondsSinceOnSurface: round(state.secondsSinceOnSurface),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -147,7 +104,6 @@ describe('shared character simulation determinism', () => {
|
|||
});
|
||||
|
||||
it('matches the pinned reference pose (changes only with intentional physics edits)', () => {
|
||||
// Regression pin — update deliberately when physics behaviour changes.
|
||||
expect(runSimulation()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Reconciliation guard for the client predictor: acknowledged pose, deterministic replay, replay-window behaviour.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
LocalCharacterPredictor,
|
||||
|
|
@ -95,7 +94,6 @@ describe('local prediction reconciliation', () => {
|
|||
predictor.setAlive(false);
|
||||
|
||||
setFrameTimeMs(5200);
|
||||
// Regression: a dead body must not be predicted/moved.
|
||||
expect(predictor.update([], 1 / 60)).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -157,7 +155,6 @@ describe('local prediction reconciliation', () => {
|
|||
});
|
||||
|
||||
it('replays the streamed facing direction, not one guessed from the pose', () => {
|
||||
// Head trails 35 units behind: atan2 gives ~0.57 rad, server direction is 0.
|
||||
const walkingPose = () => ({
|
||||
head: { center: [-35, 37], radius: HEAD_RADIUS },
|
||||
leftFoot: { center: [-33, -18], radius: FEET_RADIUS },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Round-trip tests for the wire serializer, exercising the BUILT shared bundle.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
|
|
@ -13,12 +12,10 @@ const {
|
|||
MoveActionCommand,
|
||||
UpdatePropertyCommand,
|
||||
PropertyUpdatesForObject,
|
||||
// networked entity bases — their toArray() must mirror their constructor order
|
||||
CharacterBase,
|
||||
PlanetBase,
|
||||
ProjectileBase,
|
||||
LampBase,
|
||||
// geometry — single source of truth shared by server & client prediction
|
||||
headRadius,
|
||||
feetRadius,
|
||||
boundRadius,
|
||||
|
|
@ -37,7 +34,6 @@ describe('serialization round-trip (built shared lib)', () => {
|
|||
it('round-trips a Circle and rounds floats to 3 decimals', () => {
|
||||
const out = deserialize(serialize(new Circle([12.34567, -7.1], 5.43219)));
|
||||
expect(out).toBeInstanceOf(Circle);
|
||||
// serialize.ts rounds every float via toFixed(3)
|
||||
expect(out.center[0]).toBeCloseTo(12.346, 6);
|
||||
expect(out.center[1]).toBeCloseTo(-7.1, 6);
|
||||
expect(out.radius).toBeCloseTo(5.432, 6);
|
||||
|
|
@ -64,7 +60,6 @@ describe('serialization round-trip (built shared lib)', () => {
|
|||
});
|
||||
|
||||
it('preserves per-item class identity across a mixed batch', () => {
|
||||
// A clobbered name→constructor mapping surfaces as the wrong class.
|
||||
const batch = [
|
||||
new ServerAnnouncement('a'),
|
||||
new MoveActionCommand([0, 1], 5),
|
||||
|
|
@ -127,14 +122,25 @@ describe('networked entity round-trips (toArray ↔ constructor contract)', () =
|
|||
|
||||
it('round-trips a PlanetBase (derived centre/radius recomputed from vertices)', () => {
|
||||
const out = deserialize(
|
||||
serialize(new PlanetBase(10, [[0, 0], [100, 0], [50, 100]], 0.7, true)),
|
||||
serialize(
|
||||
new PlanetBase(
|
||||
10,
|
||||
[
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[50, 100],
|
||||
],
|
||||
0.7,
|
||||
true,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(out).toBeInstanceOf(PlanetBase);
|
||||
expect(out.id).toBe(10);
|
||||
expect(out.vertices).toHaveLength(3);
|
||||
expect(out.ownership).toBeCloseTo(0.7, 5);
|
||||
expect(out.isKeystone).toBe(true);
|
||||
expect(out.center[0]).toBeCloseTo(50, 5); // recomputed by the constructor
|
||||
expect(out.center[0]).toBeCloseTo(50, 5);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -142,7 +148,7 @@ describe('shared character geometry — single source of truth', () => {
|
|||
it('matches the known body layout', () => {
|
||||
expect(headRadius).toBe(50);
|
||||
expect(feetRadius).toBe(20);
|
||||
expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2); // 180
|
||||
expect(boundRadius).toBe((headRadius + feetRadius * 2) * 2);
|
||||
});
|
||||
|
||||
it('posture offsets are measured from the centre of mass (they sum to ~0)', () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
// Production calls glMatrix.setMatrixArrayType(Array) before anything else
|
||||
// (backend/src/main.ts, frontend/src/index.ts), which makes every vector an f64
|
||||
// Array instead of the default Float32Array. Without the same call here the
|
||||
// suite pins the numerics of a build nobody ships — including module-level
|
||||
// scratch vectors, which are allocated the moment a module is imported.
|
||||
//
|
||||
// One call per installed gl-matrix: the prebuilt shared bundle keeps gl-matrix
|
||||
// external and resolves shared/node_modules, while the backend and frontend
|
||||
// sources the tests import directly resolve their own copies.
|
||||
// Production makes every vector an f64 Array before any module-level vector is allocated.
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
|
|
|||
Loading…
Reference in a new issue