astra
This commit is contained in:
parent
c074bc2645
commit
244620105a
12 changed files with 2080 additions and 518 deletions
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`;
|
||||
|
||||
exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[320.826,-34],"leftFoot":[248.634,-50.688],"rightFoot":[281.09,-72.183],"direction":-0.314,"onSurface":true,"secondsSinceOnSurface":0}"`;
|
||||
exports[`shared character simulation determinism > matches the pinned reference pose on a spinning planet 1`] = `"{"head":[321.49,-34.424],"leftFoot":[249.254,-51.093],"rightFoot":[281.714,-72.595],"direction":-0.315,"onSurface":true,"secondsSinceOnSurface":0}"`;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,32 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
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 { PlanetPhysical } from '../backend/src/objects/planet-physical';
|
||||
import { PlayerContainer } from '../backend/src/players/player-container';
|
||||
import { defaultOptions } from '../backend/src/options';
|
||||
import { GameServer } from '../backend/src/game-server';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
const { vec2 } = require('../shared/node_modules/gl-matrix');
|
||||
const { Random, settings, CharacterTeam } = shared;
|
||||
const {
|
||||
Random,
|
||||
settings,
|
||||
CharacterTeam,
|
||||
SetAspectRatioActionCommand,
|
||||
TransportEvents,
|
||||
deserialize,
|
||||
CreateObjectsCommand,
|
||||
PropertyUpdatesForObjects,
|
||||
InputAcknowledgement,
|
||||
LeapActionCommand,
|
||||
calculateViewArea,
|
||||
} = shared;
|
||||
|
||||
const step = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
|
||||
|
|
@ -63,6 +80,196 @@ beforeEach(() => {
|
|||
Random.seed = 1;
|
||||
});
|
||||
|
||||
it('credits posthumous kills without making a corpse appear alive to clients', () => {
|
||||
const { container } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
character.setHealth(0);
|
||||
character.onDie();
|
||||
character.addKill('late projectile victim');
|
||||
|
||||
expect(character.killCount).toBe(1);
|
||||
expect(character.health).toBe(0);
|
||||
expect(
|
||||
character.getRemoteCalls().some((call) => call.functionName === 'setHealth'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const controlledServer = () => {
|
||||
let nowMs = 1000;
|
||||
let nextTick: () => void = () => {};
|
||||
vi.spyOn(process.hrtime, 'bigint').mockImplementation(() => BigInt(nowMs * 1e6));
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => nowMs);
|
||||
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
nextTick = callback;
|
||||
return 0;
|
||||
}) as never);
|
||||
const server = new GameServer(
|
||||
{
|
||||
on() {},
|
||||
to() {
|
||||
return { emit() {} };
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
...defaultOptions,
|
||||
npcCount: 0,
|
||||
},
|
||||
);
|
||||
const internals = server as unknown as {
|
||||
players: PlayerContainer;
|
||||
objects: PhysicalContainer;
|
||||
isInEndGame: boolean;
|
||||
};
|
||||
server.start();
|
||||
return {
|
||||
players: internals.players,
|
||||
objects: internals.objects,
|
||||
server,
|
||||
tick(milliseconds: number) {
|
||||
nowMs += milliseconds;
|
||||
nextTick();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('server snapshot cadence', () => {
|
||||
it('waits for physics before acknowledging input or clearing remote calls', () => {
|
||||
const { players, objects, tick } = controlledServer();
|
||||
const communicate = vi.spyOn(players, 'stepCommunication');
|
||||
const character = spawnCharacter(objects);
|
||||
character.shootTowards(vec2.fromValues(1000, 0));
|
||||
|
||||
tick(1);
|
||||
|
||||
expect(communicate).not.toHaveBeenCalled();
|
||||
expect(
|
||||
character.getRemoteCalls().some((call) => call.functionName === 'onShoot'),
|
||||
).toBe(true);
|
||||
|
||||
tick(4);
|
||||
|
||||
expect(communicate).toHaveBeenCalledOnce();
|
||||
expect(character.getRemoteCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'sends 25 snapshots per simulated second (endgame: %s)',
|
||||
(endGame) => {
|
||||
const { players, server, tick } = controlledServer();
|
||||
(server as unknown as { isInEndGame: boolean }).isInEndGame = endGame;
|
||||
const batches: unknown[] = [];
|
||||
const player = players.createPlayer({ name: 'player' }, {
|
||||
on() {},
|
||||
emit(event: string, batch: unknown) {
|
||||
if (event === TransportEvents.ServerToPlayer) {
|
||||
batches.push(batch);
|
||||
}
|
||||
},
|
||||
} as never);
|
||||
|
||||
for (let i = 0; i < 200; i++) {
|
||||
tick(5);
|
||||
}
|
||||
|
||||
expect(batches).toHaveLength(25);
|
||||
const snapshot = deserialize(batches[24] as string).find(
|
||||
(command: object) => command instanceof PropertyUpdatesForObjects,
|
||||
);
|
||||
const character = snapshot.updates.find(
|
||||
(object: { id: number }) => object.id === player.character!.id,
|
||||
);
|
||||
const strength = character.updates.find(
|
||||
(update: { propertyKey: string }) => update.propertyKey === 'strength',
|
||||
);
|
||||
expect(strength.rateOfChange).toBeCloseTo(
|
||||
settings.playerStrengthRegenerationPerSeconds * (endGame ? 0.5 : 1),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('slow-motion extrapolation', () => {
|
||||
const wallDelta = step;
|
||||
const timeScale = 0.25;
|
||||
|
||||
it('coasts projectiles at the same speed as the slowed server without changing momentum', () => {
|
||||
const { container } = makeWorld();
|
||||
const projectile = spawnProjectile(container);
|
||||
const before = vec2.clone(projectile.center);
|
||||
projectile.step(wallDelta * timeScale);
|
||||
const update = projectile.getPropertyUpdates(timeScale).updates[0];
|
||||
|
||||
expect(
|
||||
vec2.scaleAndAdd(vec2.create(), before, update.rateOfChange, wallDelta),
|
||||
).toEqual(projectile.center);
|
||||
expect(projectile.object.velocity).toEqual([3000, 0]);
|
||||
const strength = projectile
|
||||
.getPropertyUpdates(timeScale)
|
||||
.updates.find((property) => property.propertyKey === 'strength')!;
|
||||
expect(40 + strength.rateOfChange * wallDelta).toBeCloseTo(projectile.strength);
|
||||
expect(strength.propertyValue).toBe(projectile.strength);
|
||||
});
|
||||
|
||||
it('coasts planet rotation using wall-clock seconds', () => {
|
||||
const { game } = makeWorld();
|
||||
const planet = new PlanetPhysical(
|
||||
[
|
||||
[-100, -100],
|
||||
[-100, 100],
|
||||
[100, 100],
|
||||
[100, -100],
|
||||
],
|
||||
false,
|
||||
game,
|
||||
);
|
||||
const angularVelocity = planet.angularVelocity;
|
||||
planet.step(wallDelta * timeScale);
|
||||
const update = planet
|
||||
.getPropertyUpdates(timeScale)
|
||||
.updates.find((property) => property.propertyKey === 'rotation')!;
|
||||
|
||||
expect(update.rateOfChange * wallDelta).toBeCloseTo(planet.rotation);
|
||||
expect(planet.angularVelocity).toBe(angularVelocity);
|
||||
});
|
||||
|
||||
it('scales character pose derivatives without mutating the stored simulation rates', () => {
|
||||
const { container } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
character.step(settings.spawnDespawnTime + step);
|
||||
character.setMoveDirection(vec2.fromValues(1, 0));
|
||||
vec2.set(character.bodyVelocity, 1000, 0);
|
||||
const parts = [character.head, character.leftFoot, character.rightFoot];
|
||||
const before = parts.map((part) => vec2.clone(part.center));
|
||||
character.step(wallDelta * timeScale);
|
||||
const originalRates = character.getPropertyUpdates();
|
||||
const slowedRates = character.getPropertyUpdates(timeScale);
|
||||
|
||||
parts.forEach((part, i) => {
|
||||
const rate = slowedRates.updates[i].rateOfChange;
|
||||
expect(vec2.scaleAndAdd(vec2.create(), before[i], rate.center, wallDelta)).toEqual(
|
||||
part.center,
|
||||
);
|
||||
});
|
||||
expect(character.getPropertyUpdates()).toEqual(originalRates);
|
||||
});
|
||||
|
||||
it('coasts spawn growth using the slowed radius derivative', () => {
|
||||
const { container } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
character.step(0.1);
|
||||
const before = character.head.radius;
|
||||
character.step(wallDelta * timeScale);
|
||||
const radiusSpeed =
|
||||
character.getPropertyUpdates(timeScale).updates[0].rateOfChange.radius;
|
||||
|
||||
expect(before + radiusSpeed * wallDelta).toBeCloseTo(character.head.radius);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectile broadphase registration', () => {
|
||||
it('follows the projectile instead of staying at the muzzle', () => {
|
||||
const { container } = makeWorld();
|
||||
|
|
@ -88,6 +295,168 @@ describe('projectile broadphase registration', () => {
|
|||
|
||||
expect(projectile.center[0]).toBeLessThanOrEqual(15);
|
||||
});
|
||||
|
||||
it('does not advance a projectile destroyed earlier in the same world step', () => {
|
||||
const { container } = makeWorld();
|
||||
const projectile = spawnProjectile(container);
|
||||
const before = vec2.clone(projectile.center);
|
||||
const character = spawnCharacter(container);
|
||||
character.step = () => projectile.destroy();
|
||||
container.addObject(projectile);
|
||||
|
||||
container.step(step);
|
||||
|
||||
expect(projectile.center).toEqual(before);
|
||||
expect(projectile.strength).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authoritative held movement', () => {
|
||||
it('distinguishes no leap from an attempted leap timestamped at zero', () => {
|
||||
const { container } = makeWorld();
|
||||
const players = new PlayerContainer(container, 1, 0);
|
||||
const batches: string[] = [];
|
||||
const player = players.createPlayer({ name: 'player' }, {
|
||||
on() {},
|
||||
emit(event: string, payload: string) {
|
||||
if (event === TransportEvents.ServerToPlayer) {
|
||||
batches.push(payload);
|
||||
}
|
||||
},
|
||||
} as never);
|
||||
const sendSnapshot = () =>
|
||||
player.stepCommunications(settings.updateMessageInterval, 1000, (object) =>
|
||||
object.getPropertyUpdates(),
|
||||
);
|
||||
const leapAck = () =>
|
||||
deserialize(batches[batches.length - 1]).find(
|
||||
(command: object) => command instanceof InputAcknowledgement,
|
||||
).lastLeapClientTimeMs;
|
||||
|
||||
sendSnapshot();
|
||||
expect(leapAck()).toBe(-1);
|
||||
|
||||
player.handleCommand(new LeapActionCommand(0));
|
||||
sendSnapshot();
|
||||
expect(leapAck()).toBe(0);
|
||||
});
|
||||
|
||||
it('applies the latest direction when press and release arrive in the same tick', () => {
|
||||
const { container } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
character.step(settings.spawnDespawnTime + step);
|
||||
const before = vec2.clone(character.center);
|
||||
|
||||
character.setMoveDirection(vec2.fromValues(1, 0));
|
||||
character.setMoveDirection(vec2.fromValues(0, 0));
|
||||
character.step(step);
|
||||
|
||||
expect(character.center).toEqual(before);
|
||||
});
|
||||
|
||||
it('uses the latest held direction for a leap received in the same batch', () => {
|
||||
const { container } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
character.step(settings.spawnDespawnTime + step);
|
||||
character.currentPlanet = {
|
||||
center: vec2.fromValues(0, -100),
|
||||
angularVelocity: 0,
|
||||
} as never;
|
||||
|
||||
character.setMoveDirection(vec2.fromValues(1, 0));
|
||||
character.leap();
|
||||
|
||||
expect(character.bodyVelocity[0]).toBeGreaterThan(0);
|
||||
expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo(
|
||||
settings.leapCooldownSeconds,
|
||||
);
|
||||
character.step(step);
|
||||
expect(character.movementSnapshot.leapCooldownRemaining).toBeCloseTo(
|
||||
settings.leapCooldownSeconds - step,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planets', () => {
|
||||
it('does not capture using a character killed after registering its presence', () => {
|
||||
const { container, game } = makeWorld();
|
||||
const character = spawnCharacter(container);
|
||||
const planet = new PlanetPhysical(
|
||||
[
|
||||
[-100, -100],
|
||||
[-100, 100],
|
||||
[100, 100],
|
||||
[100, -100],
|
||||
],
|
||||
false,
|
||||
game,
|
||||
);
|
||||
planet.registerPresence(character);
|
||||
character.onDie();
|
||||
|
||||
planet.step(step);
|
||||
|
||||
expect(planet.ownership).toBe(0.5);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ aspect: 8, center: [0, 750] },
|
||||
{ aspect: 0.2, center: [750, 0] },
|
||||
])('sends offscreen gravity sources at aspect $aspect', ({ aspect, center }) => {
|
||||
const { container, game } = makeWorld();
|
||||
const batches: string[] = [];
|
||||
const socket = {
|
||||
on() {},
|
||||
emit(event: string, payload: string) {
|
||||
if (event === TransportEvents.ServerToPlayer) {
|
||||
batches.push(payload);
|
||||
}
|
||||
},
|
||||
};
|
||||
const players = new PlayerContainer(container, 1, 0);
|
||||
const player = players.createPlayer({ name: 'player' }, socket as never);
|
||||
const [x, y] = center;
|
||||
const planet = new PlanetPhysical(
|
||||
[
|
||||
[x - 20, y - 20],
|
||||
[x - 20, y + 20],
|
||||
[x + 20, y + 20],
|
||||
[x + 20, y - 20],
|
||||
],
|
||||
false,
|
||||
game,
|
||||
);
|
||||
container.addObject(planet);
|
||||
player.handleCommand(new SetAspectRatioActionCommand(aspect));
|
||||
const { topLeft, size } = calculateViewArea(player.center, aspect, 1.2);
|
||||
expect(
|
||||
planet.boundingBox.intersects(
|
||||
new BoundingBox(
|
||||
topLeft[0],
|
||||
topLeft[0] + size[0],
|
||||
topLeft[1] - size[1],
|
||||
topLeft[1],
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
player.stepCommunications(settings.updateMessageInterval + step, 1000, (object) =>
|
||||
object.getPropertyUpdates(),
|
||||
);
|
||||
|
||||
const created = deserialize(batches[0]).find(
|
||||
(command: object) => command instanceof CreateObjectsCommand,
|
||||
);
|
||||
expect(created.objects.map((object: { id: number }) => object.id)).toContain(
|
||||
planet.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses an integer default seed so separate server runs can generate different worlds', () => {
|
||||
expect(Number.isInteger(defaultOptions.seed)).toBe(true);
|
||||
expect(defaultOptions.seed).toBeGreaterThanOrEqual(0);
|
||||
expect(defaultOptions.seed).toBeLessThan(0x100000000);
|
||||
});
|
||||
|
||||
describe('scoring', () => {
|
||||
|
|
@ -124,6 +493,23 @@ describe('scoring', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('NPC population', () => {
|
||||
it('replaces a departing human without adding extra bots for humans still playing', () => {
|
||||
const { container } = makeWorld();
|
||||
const players = new PlayerContainer(container, 16, 4);
|
||||
const socket = { on() {} } as never;
|
||||
const first = players.createPlayer({ name: 'first' }, socket);
|
||||
players.createPlayer({ name: 'second' }, socket);
|
||||
expect(players.players).toHaveLength(4);
|
||||
|
||||
first.destroy();
|
||||
players.deletePlayer(first);
|
||||
|
||||
expect(players.count).toBe(1);
|
||||
expect(players.players).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('charged shots', () => {
|
||||
it('scales the shot by the strength actually paid, not the requested charge', () => {
|
||||
const { container } = makeWorld();
|
||||
|
|
|
|||
|
|
@ -15,13 +15,19 @@ const {
|
|||
CreateObjectsCommand,
|
||||
DeleteObjectsCommand,
|
||||
PropertyUpdatesForObjects,
|
||||
PropertyUpdatesForObject,
|
||||
UpdatePropertyCommand,
|
||||
InputAcknowledgement,
|
||||
UpdateMinimap,
|
||||
ServerAnnouncement,
|
||||
} = shared;
|
||||
|
||||
class FakeSocket {
|
||||
public readonly sent: Array<{ event: string; payload: unknown }> = [];
|
||||
public readonly conn = { transport: { socket: { bufferedAmount: 0 } } };
|
||||
public readonly conn = {
|
||||
transport: { socket: { bufferedAmount: 0 } },
|
||||
writeBuffer: [] as Array<{ data: unknown }>,
|
||||
};
|
||||
|
||||
public set bufferedAmount(bytes: number) {
|
||||
this.conn.transport.socket.bufferedAmount = bytes;
|
||||
|
|
@ -58,7 +64,19 @@ const queueOneSnapshot = (player: ReturnType<typeof makePlayer>['player']) => {
|
|||
player.queueCommandSend(new DeleteObjectsCommand([1234]));
|
||||
player.queueCommandSend(new ServerAnnouncement('team blue wins'));
|
||||
player.queueCommandSend(new UpdateMinimap([]));
|
||||
player.queueCommandSend(new PropertyUpdatesForObjects([], 1));
|
||||
player.queueCommandSend(
|
||||
new PropertyUpdatesForObjects(
|
||||
[
|
||||
new PropertyUpdatesForObject(1234, [
|
||||
new UpdatePropertyCommand('ownership', 1, 0),
|
||||
]),
|
||||
],
|
||||
1,
|
||||
),
|
||||
);
|
||||
player.queueCommandSend(
|
||||
new InputAcknowledgement(123, player.character!.movementSnapshot, 0, 0),
|
||||
);
|
||||
};
|
||||
|
||||
const typesIn = (batch: Array<{ constructor: { name: string } }>) =>
|
||||
|
|
@ -80,6 +98,7 @@ describe('slow-client backpressure', () => {
|
|||
ServerAnnouncement.name,
|
||||
UpdateMinimap.name,
|
||||
PropertyUpdatesForObjects.name,
|
||||
InputAcknowledgement.name,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -89,7 +108,8 @@ describe('slow-client backpressure', () => {
|
|||
queueOneSnapshot(player);
|
||||
player.sendQueuedCommandsToClient();
|
||||
|
||||
expect(typesIn(socket.lastBatch)).not.toContain(PropertyUpdatesForObjects.name);
|
||||
const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects);
|
||||
expect(marker).toMatchObject({ updates: [], timestamp: 1 });
|
||||
});
|
||||
|
||||
it('sheds only the state the next tick regenerates', () => {
|
||||
|
|
@ -99,7 +119,8 @@ describe('slow-client backpressure', () => {
|
|||
player.sendQueuedCommandsToClient();
|
||||
|
||||
const types = typesIn(socket.lastBatch);
|
||||
expect(types).not.toContain(PropertyUpdatesForObjects.name);
|
||||
expect(types).toContain(PropertyUpdatesForObjects.name);
|
||||
expect(types).not.toContain(InputAcknowledgement.name);
|
||||
expect(types).not.toContain(UpdateMinimap.name);
|
||||
expect(types).toContain(CreateObjectsCommand.name);
|
||||
expect(types).toContain(DeleteObjectsCommand.name);
|
||||
|
|
@ -115,4 +136,32 @@ describe('slow-client backpressure', () => {
|
|||
|
||||
expect(socket.sent.length).toBe(before);
|
||||
});
|
||||
|
||||
it('also measures packets waiting inside Engine.IO for the transport to drain', () => {
|
||||
const { player, socket } = makePlayer();
|
||||
socket.conn.writeBuffer.push({
|
||||
data: Buffer.alloc(settings.maxBufferedBytesPerClient + 1),
|
||||
});
|
||||
queueOneSnapshot(player);
|
||||
player.sendQueuedCommandsToClient();
|
||||
|
||||
const marker = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects);
|
||||
expect(marker).toMatchObject({ updates: [], timestamp: 1 });
|
||||
expect(typesIn(socket.lastBatch)).not.toContain(InputAcknowledgement.name);
|
||||
});
|
||||
|
||||
it('sends a fresh pose and acknowledgement after the transport drains', () => {
|
||||
const { player, socket } = makePlayer();
|
||||
socket.bufferedAmount = settings.maxBufferedBytesPerClient + 1;
|
||||
queueOneSnapshot(player);
|
||||
player.sendQueuedCommandsToClient();
|
||||
|
||||
socket.bufferedAmount = 0;
|
||||
queueOneSnapshot(player);
|
||||
player.sendQueuedCommandsToClient();
|
||||
|
||||
const snapshot = socket.lastBatch.find((c) => c instanceof PropertyUpdatesForObjects);
|
||||
expect(snapshot).toMatchObject({ updates: [expect.anything()], timestamp: 1 });
|
||||
expect(typesIn(socket.lastBatch)).toContain(InputAcknowledgement.name);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
206
test/client-object-lifecycle.test.ts
Normal file
206
test/client-object-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
const {
|
||||
CreateObjectsCommand,
|
||||
DeleteObjectsCommand,
|
||||
PropertyUpdatesForObjects,
|
||||
RemoteCallsForObjects,
|
||||
RemoteCallsForObject,
|
||||
RemoteCall,
|
||||
UpdatePropertyCommand,
|
||||
CharacterTeam,
|
||||
} = createRequire(import.meta.url)('../shared/lib/main.js');
|
||||
import { GameObjectContainer } from '../frontend/src/scripts/objects/game-object-container';
|
||||
import { PlanetView } from '../frontend/src/scripts/objects/types/planet-view';
|
||||
import { serverTimeline } from '../frontend/src/scripts/helper/server-timeline';
|
||||
import { LinearInterpolator } from '../frontend/src/scripts/helper/interpolators/linear-interpolator';
|
||||
import { ProjectileView } from '../frontend/src/scripts/objects/types/projectile-view';
|
||||
|
||||
vi.mock('../frontend/node_modules/sdf-2d', () => ({
|
||||
CircleLight: class {
|
||||
constructor(
|
||||
public center: number[],
|
||||
public color: number[],
|
||||
public intensity: number,
|
||||
) {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../frontend/src/scripts/objects/types/camera', () => ({
|
||||
Camera: class {
|
||||
draw() {}
|
||||
follow() {}
|
||||
},
|
||||
}));
|
||||
vi.mock('../frontend/src/scripts/objects/types/planet-view', () => ({
|
||||
PlanetView: class {
|
||||
beforeDestroy = vi.fn();
|
||||
render = vi.fn();
|
||||
processRemoteCalls = vi.fn();
|
||||
step() {}
|
||||
constructor(public id: number) {}
|
||||
},
|
||||
}));
|
||||
vi.mock('../frontend/src/scripts/feedback-hud', () => ({ FeedbackHud: {} }));
|
||||
|
||||
let nowMs = 0;
|
||||
beforeEach(() => {
|
||||
nowMs = 0;
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => nowMs);
|
||||
serverTimeline.reset();
|
||||
});
|
||||
|
||||
describe('remote effects', () => {
|
||||
it('plays remote effects on the render timeline and local feedback immediately', () => {
|
||||
const container = new GameObjectContainer({} as never);
|
||||
const remote = new PlanetView(1, []);
|
||||
const local = {
|
||||
id: 2,
|
||||
health: 100,
|
||||
position: [0, 0],
|
||||
snapshotStrength: 80,
|
||||
step() {},
|
||||
processRemoteCalls: vi.fn(),
|
||||
};
|
||||
container.handleCommand(new CreateObjectsCommand([remote, local]));
|
||||
container.player = local as never;
|
||||
const calls = [new RemoteCall('onShoot', [40])];
|
||||
container.handleCommand(
|
||||
new RemoteCallsForObjects([
|
||||
new RemoteCallsForObject(remote.id, calls),
|
||||
new RemoteCallsForObject(local.id, calls),
|
||||
]),
|
||||
);
|
||||
container.handleCommand(new PropertyUpdatesForObjects([], 5));
|
||||
expect(local.processRemoteCalls).toHaveBeenCalledWith(calls);
|
||||
step(0);
|
||||
container.step(0);
|
||||
expect(remote.processRemoteCalls).not.toHaveBeenCalled();
|
||||
step(0.11);
|
||||
container.step(0);
|
||||
expect(remote.processRemoteCalls).toHaveBeenCalledWith(calls);
|
||||
});
|
||||
|
||||
it('discards queued effects when a view is replaced or the game resets', () => {
|
||||
const container = new GameObjectContainer({} as never);
|
||||
const original = new PlanetView(1, []);
|
||||
container.handleCommand(new CreateObjectsCommand([original]));
|
||||
container.handleCommand(
|
||||
new RemoteCallsForObjects([
|
||||
new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['red'])]),
|
||||
]),
|
||||
);
|
||||
container.handleCommand(new PropertyUpdatesForObjects([], 5));
|
||||
const replacement = new PlanetView(1, []);
|
||||
container.handleCommand(new CreateObjectsCommand([replacement]));
|
||||
container.handleCommand(new PropertyUpdatesForObjects([], 5.04));
|
||||
step(0.3);
|
||||
container.step(0);
|
||||
expect(original.processRemoteCalls).not.toHaveBeenCalled();
|
||||
expect(replacement.processRemoteCalls).not.toHaveBeenCalled();
|
||||
|
||||
container.handleCommand(
|
||||
new RemoteCallsForObjects([
|
||||
new RemoteCallsForObject(1, [new RemoteCall('onFlipped', ['blue'])]),
|
||||
]),
|
||||
);
|
||||
container.reset();
|
||||
container.step(0);
|
||||
expect(replacement.processRemoteCalls).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const step = (deltaSeconds: number) => {
|
||||
nowMs += deltaSeconds * 1000;
|
||||
serverTimeline.step(deltaSeconds);
|
||||
};
|
||||
|
||||
describe('snapshot object lifetime', () => {
|
||||
it('keeps creation and deletion aligned with the delayed render timeline', () => {
|
||||
const container = new GameObjectContainer({} as never);
|
||||
const planet = new PlanetView(1, []);
|
||||
container.handleCommand(new CreateObjectsCommand([planet]));
|
||||
container.handleCommand(new PropertyUpdatesForObjects([], 5));
|
||||
step(0);
|
||||
container.render({} as never, {} as never, true);
|
||||
expect(planet.render).not.toHaveBeenCalled();
|
||||
|
||||
step(0.11);
|
||||
container.render({} as never, {} as never, true);
|
||||
expect(planet.render).toHaveBeenCalledOnce();
|
||||
|
||||
container.handleCommand(new DeleteObjectsCommand([1]));
|
||||
container.handleCommand(new PropertyUpdatesForObjects([], 5.2));
|
||||
container.step(0);
|
||||
expect(planet.beforeDestroy).not.toHaveBeenCalled();
|
||||
step(0.3);
|
||||
container.step(0);
|
||||
expect(planet.beforeDestroy).toHaveBeenCalledOnce();
|
||||
expect(container.planets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not delete a replacement when an object re-enters before delayed retirement', () => {
|
||||
const container = new GameObjectContainer({} as never);
|
||||
const original = new PlanetView(1, []);
|
||||
container.handleCommand(new CreateObjectsCommand([original]));
|
||||
container.handleCommand(new PropertyUpdatesForObjects([], 5));
|
||||
step(0);
|
||||
container.handleCommand(new DeleteObjectsCommand([1]));
|
||||
container.handleCommand(new PropertyUpdatesForObjects([], 5.04));
|
||||
|
||||
const replacement = new PlanetView(1, []);
|
||||
container.handleCommand(new CreateObjectsCommand([replacement]));
|
||||
container.handleCommand(new PropertyUpdatesForObjects([], 5.08));
|
||||
step(0.3);
|
||||
container.step(0);
|
||||
container.render({} as never, {} as never, true);
|
||||
|
||||
expect(original.beforeDestroy).toHaveBeenCalledOnce();
|
||||
expect(replacement.beforeDestroy).not.toHaveBeenCalled();
|
||||
expect(replacement.render).toHaveBeenCalledOnce();
|
||||
expect(container.planets).toEqual([replacement]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote render timeline', () => {
|
||||
it('samples projectile strength on the server timeline, including slowed fading', () => {
|
||||
const projectile = new ProjectileView(1, [0, 0] as never, 10, CharacterTeam.red, 100);
|
||||
serverTimeline.onSnapshot(5);
|
||||
projectile.updateProperty(new UpdatePropertyCommand('strength', 100, -10));
|
||||
serverTimeline.onSnapshot(5.2);
|
||||
projectile.updateProperty(new UpdatePropertyCommand('strength', 98, -10));
|
||||
step(0);
|
||||
projectile.step(2);
|
||||
expect(projectile.strength).toBeCloseTo(99, 6);
|
||||
expect([...projectile.center]).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('does not accumulate an extra frame of lead while chasing its target', () => {
|
||||
serverTimeline.onSnapshot(10);
|
||||
step(0);
|
||||
for (let i = 0; i < 600; i++) step(1 / 60);
|
||||
expect(serverTimeline.renderTime).toBeCloseTo(19.9, 8);
|
||||
});
|
||||
|
||||
it('interpolates positions and bounds extrapolation while snapshots are missing', () => {
|
||||
const position = new LinearInterpolator(0);
|
||||
serverTimeline.onSnapshot(10);
|
||||
position.addFrame(0, 100);
|
||||
serverTimeline.onSnapshot(10.1);
|
||||
position.addFrame(10, 100);
|
||||
step(0);
|
||||
step(0.05);
|
||||
expect(position.getValue(0.05)).toBeCloseTo(5, 6);
|
||||
step(0.4);
|
||||
expect(position.getValue(0.4)).toBeCloseTo(16, 6);
|
||||
});
|
||||
|
||||
it('ages a new snapshot from its arrival, not the beginning of the render frame', () => {
|
||||
nowMs = 15;
|
||||
serverTimeline.onSnapshot(10);
|
||||
nowMs = 16;
|
||||
serverTimeline.step(0.016);
|
||||
expect(serverTimeline.renderTime).toBeCloseTo(9.901, 6);
|
||||
});
|
||||
});
|
||||
|
|
@ -81,4 +81,21 @@ describe('client send cadence', () => {
|
|||
|
||||
expect(batches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('discards commands from the previous connection before the next join', () => {
|
||||
const batches: Array<string> = [];
|
||||
const socket = new CommandSocket({
|
||||
connected: true,
|
||||
emit: (_event: string, payload: string) => batches.push(payload),
|
||||
} as never);
|
||||
setFrameTimeMs(1000);
|
||||
socket.sendQueuedCommands();
|
||||
socket.queue(new MoveActionCommand(vec2.fromValues(1, 0), 1000));
|
||||
socket.reset();
|
||||
socket.sendQueuedCommands();
|
||||
|
||||
expect(batches).toHaveLength(2);
|
||||
expect(batches[1]).toContain(ClientHeartbeatCommand.name);
|
||||
expect(batches[1]).not.toContain(MoveActionCommand.name);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
302
test/frontend-regressions.test.ts
Normal file
302
test/frontend-regressions.test.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { KeyboardListener } from '../frontend/src/scripts/commands/keyboard-listener';
|
||||
import { TouchListener } from '../frontend/src/scripts/commands/touch-listener';
|
||||
import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler';
|
||||
import { initializeOptions, options } from '../frontend/src/scripts/options-handler';
|
||||
import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage';
|
||||
|
||||
const { connect } = vi.hoisted(() => ({ connect: vi.fn() }));
|
||||
vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({
|
||||
io: connect,
|
||||
}));
|
||||
vi.mock('../frontend/src/scripts/configuration', () => ({
|
||||
servers: ['https://first.test', 'https://second.test'],
|
||||
}));
|
||||
vi.mock('../frontend/src/scripts/sound-handler', () => ({
|
||||
Sounds: { click: 'click' },
|
||||
SoundHandler: { play: vi.fn(), playAmbient: vi.fn(), stopAmbient: vi.fn() },
|
||||
}));
|
||||
vi.mock('../frontend/src/scripts/charge-indicator', () => ({
|
||||
ChargeIndicator: { begin: vi.fn(), end: vi.fn() },
|
||||
}));
|
||||
|
||||
// Only the DOM operations exercised by the chooser and input listeners are needed.
|
||||
class Element extends EventTarget {
|
||||
public children: Element[] = [];
|
||||
public parentElement?: Element;
|
||||
public style: Record<string, string> = {};
|
||||
public className = '';
|
||||
public checked = false;
|
||||
public disabled = false;
|
||||
public onsubmit: ((event: Event) => void) | null = null;
|
||||
|
||||
public appendChild(child: Element) {
|
||||
child.remove();
|
||||
this.children.push(child);
|
||||
child.parentElement = this;
|
||||
}
|
||||
public append(...children: Element[]) {
|
||||
children.forEach((child) => this.appendChild(child));
|
||||
}
|
||||
public remove() {
|
||||
if (this.parentElement) {
|
||||
this.parentElement.children = this.parentElement.children.filter((c) => c !== this);
|
||||
this.parentElement = undefined;
|
||||
}
|
||||
}
|
||||
public querySelector() {
|
||||
return this.children[0];
|
||||
}
|
||||
public getBoundingClientRect() {
|
||||
return { left: 0, top: 0, width: 100, height: 100 };
|
||||
}
|
||||
}
|
||||
|
||||
class Socket extends EventEmitter {
|
||||
public io = new EventEmitter();
|
||||
public close = vi.fn();
|
||||
}
|
||||
|
||||
const dispatch = (target: EventTarget, type: string, fields = {}) => {
|
||||
const event = Object.assign(new Event(type, { cancelable: true }), fields);
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
const touch = (identifier: number, clientX = 50, clientY = 50) => ({
|
||||
identifier,
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
const touchEvent = (
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
changedTouches: unknown[],
|
||||
touches = changedTouches,
|
||||
) => dispatch(target, type, { changedTouches, touches });
|
||||
|
||||
let windowTarget: EventTarget;
|
||||
let sockets: Socket[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
windowTarget = new EventTarget();
|
||||
sockets = [];
|
||||
vi.stubGlobal('window', windowTarget);
|
||||
vi.stubGlobal('addEventListener', windowTarget.addEventListener.bind(windowTarget));
|
||||
vi.stubGlobal(
|
||||
'removeEventListener',
|
||||
windowTarget.removeEventListener.bind(windowTarget),
|
||||
);
|
||||
vi.stubGlobal('document', { createElement: () => new Element() });
|
||||
vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn() });
|
||||
connect.mockImplementation(() => {
|
||||
const socket = new Socket();
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const chooser = () => {
|
||||
const form = new Element();
|
||||
form.appendChild(new Element());
|
||||
const container = new Element();
|
||||
const handler = new JoinFormHandler(form as never, container as never);
|
||||
return { form, container, handler };
|
||||
};
|
||||
const serverInfo = {
|
||||
serverName: 'test',
|
||||
playerCount: 1,
|
||||
playerLimit: 8,
|
||||
gameStatePercent: 20,
|
||||
};
|
||||
|
||||
describe('server chooser lifecycle', () => {
|
||||
it('aborts discovery and ignores responses completing after the chooser closes', async () => {
|
||||
const signals: AbortSignal[] = [];
|
||||
let resolveBody!: (value: unknown) => void;
|
||||
const body = new Promise((resolve) => {
|
||||
resolveBody = resolve;
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url, { signal }) => {
|
||||
signals.push(signal);
|
||||
return Promise.resolve({ ok: true, json: () => body });
|
||||
}),
|
||||
);
|
||||
const { form, container, handler } = chooser();
|
||||
await Promise.resolve();
|
||||
handler.destroy();
|
||||
resolveBody(serverInfo);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
||||
expect(sockets).toHaveLength(0);
|
||||
expect(container.children).toHaveLength(0);
|
||||
expect(form.onsubmit).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('selects a remaining server when the selected connection fails and removes listeners on exit', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: true, json: async () => serverInfo })),
|
||||
);
|
||||
const { form, container, handler } = chooser();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(container.children).toHaveLength(2);
|
||||
expect(container.children[0].children[0].checked).toBe(true);
|
||||
expect(container.children[1].children[0].checked).toBe(false);
|
||||
|
||||
sockets[0].io.emit('reconnect_failed');
|
||||
expect(container.children).toHaveLength(1);
|
||||
expect(container.children[0].children[0].checked).toBe(true);
|
||||
expect(form.children[0].disabled).toBe(false);
|
||||
|
||||
handler.destroy();
|
||||
expect(
|
||||
sockets.every((socket) => socket.io.listenerCount('reconnect_failed') === 0),
|
||||
).toBe(true);
|
||||
expect(container.children).toHaveLength(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('movement input', () => {
|
||||
it('ignores unrelated keys, prevents browser movement defaults, and resends held keys after respawn', () => {
|
||||
const send = vi.fn();
|
||||
const listener = new KeyboardListener(send);
|
||||
dispatch(windowTarget, 'keydown', { code: 'KeyP' });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
const key = dispatch(windowTarget, 'keydown', { code: 'ArrowRight' });
|
||||
expect(key.defaultPrevented).toBe(true);
|
||||
expect(send.mock.lastCall?.[0].direction).toEqual([1, 0]);
|
||||
listener.resendMovement();
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
dispatch(windowTarget, 'blur');
|
||||
expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]);
|
||||
listener.resendMovement();
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
listener.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
const touchControls = () => {
|
||||
const canvas = new Element();
|
||||
const overlay = new Element();
|
||||
const send = vi.fn();
|
||||
const game = {
|
||||
displayToWorldCoordinates: (position: number[]) => position,
|
||||
gameObjects: {
|
||||
localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 },
|
||||
},
|
||||
};
|
||||
const listener = new TouchListener(
|
||||
canvas as never,
|
||||
overlay as never,
|
||||
game as never,
|
||||
send,
|
||||
);
|
||||
listener.update();
|
||||
return { canvas, overlay, send, listener, fire: overlay.children[0] };
|
||||
};
|
||||
|
||||
describe('touch ownership', () => {
|
||||
it('keeps the first canvas gesture when a second finger lands before dragging', () => {
|
||||
const { canvas, send, listener } = touchControls();
|
||||
touchEvent(canvas, 'touchstart', [touch(1, 10, 20)]);
|
||||
touchEvent(
|
||||
canvas,
|
||||
'touchstart',
|
||||
[touch(2, 80, 90)],
|
||||
[touch(1, 10, 20), touch(2, 80, 90)],
|
||||
);
|
||||
touchEvent(canvas, 'touchend', [touch(2, 80, 90)], [touch(1, 10, 20)]);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
touchEvent(canvas, 'touchend', [touch(1, 10, 20)], []);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.lastCall?.[0].constructor.name).toBe('PrimaryActionCommand');
|
||||
listener.destroy();
|
||||
});
|
||||
|
||||
it('does not fire when a charge is cancelled or another finger releases', () => {
|
||||
const { fire, send, listener } = touchControls();
|
||||
touchEvent(fire, 'touchstart', [touch(1)]);
|
||||
touchEvent(fire, 'touchstart', [touch(2)]);
|
||||
touchEvent(fire, 'touchend', [touch(2)], [touch(1)]);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
touchEvent(fire, 'touchcancel', [touch(1)], []);
|
||||
touchEvent(fire, 'touchend', [touch(1)], []);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
touchEvent(fire, 'touchstart', [touch(3)]);
|
||||
touchEvent(fire, 'touchend', [touch(3)], []);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
listener.destroy();
|
||||
});
|
||||
|
||||
it('resends a held joystick after respawn and stops movement when focus is lost', () => {
|
||||
const { canvas, overlay, send, listener } = touchControls();
|
||||
touchEvent(canvas, 'touchstart', [touch(1)]);
|
||||
touchEvent(canvas, 'touchmove', [touch(1, 100, 50)]);
|
||||
listener.resendMovement();
|
||||
expect(send.mock.lastCall?.[0].direction[0]).toBe(1);
|
||||
expect(send.mock.lastCall?.[0].direction[1]).toBeCloseTo(0);
|
||||
dispatch(windowTarget, 'blur');
|
||||
expect(send.mock.lastCall?.[0].direction).toEqual([0, 0]);
|
||||
expect(overlay.children).toHaveLength(2);
|
||||
touchEvent(canvas, 'touchstart', [touch(2)]);
|
||||
touchEvent(canvas, 'touchmove', [touch(2, 100, 50)]);
|
||||
listener.destroy();
|
||||
expect(overlay.children).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stored preferences', () => {
|
||||
it('ignores corrupt JSON and unavailable storage', () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue('{');
|
||||
expect(loadStoredValue('userName')).toBeUndefined();
|
||||
vi.mocked(localStorage.getItem).mockImplementation(() => {
|
||||
throw new Error('disabled');
|
||||
});
|
||||
vi.mocked(localStorage.setItem).mockImplementation(() => {
|
||||
throw new Error('disabled');
|
||||
});
|
||||
expect(loadStoredValue('options')).toBeUndefined();
|
||||
expect(() => saveStoredValue('userName', 'player')).not.toThrow();
|
||||
});
|
||||
|
||||
it('only loads boolean options that the game recognizes', () => {
|
||||
Object.assign(options, {
|
||||
soundsEnabled: true,
|
||||
musicEnabled: true,
|
||||
vibrationEnabled: true,
|
||||
});
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(
|
||||
JSON.stringify({
|
||||
soundsEnabled: 'false',
|
||||
musicEnabled: false,
|
||||
vibrationEnabled: null,
|
||||
extra: true,
|
||||
}),
|
||||
);
|
||||
initializeOptions({
|
||||
soundsEnabled: new Element(),
|
||||
musicEnabled: new Element(),
|
||||
vibrationEnabled: new Element(),
|
||||
} as never);
|
||||
expect(options).toEqual({
|
||||
soundsEnabled: true,
|
||||
musicEnabled: false,
|
||||
vibrationEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
|
|
@ -99,6 +100,41 @@ const runPlanetSimulation = () => {
|
|||
};
|
||||
|
||||
describe('shared character simulation determinism', () => {
|
||||
it('does not depend on configuring vector precision before imports', () => {
|
||||
const run = (configureBeforeImport) =>
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
const { glMatrix, vec2 } = require('./shared/node_modules/gl-matrix');
|
||||
if (${configureBeforeImport}) glMatrix.setMatrixArrayType(Array);
|
||||
const s = require('./shared/lib/main.js');
|
||||
glMatrix.setMatrixArrayType(Array);
|
||||
const body = (offset, radius) => ({
|
||||
center: vec2.clone(offset), radius, velocity: vec2.create(),
|
||||
lastNormal: [0, 1], restitution: 0,
|
||||
});
|
||||
const state = {
|
||||
head: body(s.headOffset, s.headRadius),
|
||||
leftFoot: body(s.leftFootOffset, s.feetRadius),
|
||||
rightFoot: body(s.rightFootOffset, s.feetRadius),
|
||||
direction: 0, currentPlanet: undefined,
|
||||
secondsSinceOnSurface: 1, bodyVelocity: [0, 0],
|
||||
};
|
||||
const world = {
|
||||
groundsNear: () => [],
|
||||
stepBody: (body, dt) => s.resolveCircleMovement(body, dt, []),
|
||||
};
|
||||
for (let i = 0; i < 100; i++) s.stepCharacterMovement(state, world, [1, 0], 0.005);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
expect(run(false)).toBe(run(true));
|
||||
});
|
||||
|
||||
it('produces identical output across independent runs', () => {
|
||||
expect(runSimulation()).toBe(runSimulation());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ describe('local prediction reconciliation', () => {
|
|||
auth.rightFoot as never,
|
||||
);
|
||||
|
||||
const used = predictor.update([], 1 / 60);
|
||||
const used = predictor.update([]);
|
||||
|
||||
expect(used).toBe(true);
|
||||
expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5);
|
||||
|
|
@ -62,7 +62,7 @@ describe('local prediction reconciliation', () => {
|
|||
predictor.recordInput([1, 0]);
|
||||
|
||||
setFrameTimeMs(1100);
|
||||
predictor.update([], 1 / 60);
|
||||
predictor.update([]);
|
||||
return [
|
||||
predictor.head.center[0],
|
||||
predictor.head.center[1],
|
||||
|
|
@ -94,7 +94,7 @@ describe('local prediction reconciliation', () => {
|
|||
predictor.setAlive(false);
|
||||
|
||||
setFrameTimeMs(5200);
|
||||
expect(predictor.update([], 1 / 60)).toBe(false);
|
||||
expect(predictor.update([])).toBe(false);
|
||||
});
|
||||
|
||||
it('replays the whole un-acknowledged span, so a longer trip predicts further', () => {
|
||||
|
|
@ -113,7 +113,7 @@ describe('local prediction reconciliation', () => {
|
|||
auth.rightFoot as never,
|
||||
);
|
||||
|
||||
predictor.update([], 1 / 60);
|
||||
predictor.update([]);
|
||||
return predictor.head.center[0];
|
||||
};
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ describe('local prediction reconciliation', () => {
|
|||
auth.rightFoot as never,
|
||||
);
|
||||
|
||||
predictor.update([], 1 / 60);
|
||||
predictor.update([]);
|
||||
return predictor.head.center[0];
|
||||
};
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ describe('local prediction reconciliation', () => {
|
|||
);
|
||||
|
||||
setFrameTimeMs(40);
|
||||
predictor.update([], 1 / 60);
|
||||
predictor.update([]);
|
||||
return [predictor.head.center[0], predictor.head.center[1]];
|
||||
};
|
||||
|
||||
|
|
@ -189,3 +189,127 @@ describe('local prediction reconciliation', () => {
|
|||
expect(apart).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
const settledPoseAt = (x: number) => ({
|
||||
head: { center: [x, 55 - 55 / 3], radius: HEAD_RADIUS },
|
||||
leftFoot: { center: [x - 20, -55 / 3], radius: FEET_RADIUS },
|
||||
rightFoot: { center: [x + 20, -55 / 3], radius: FEET_RADIUS },
|
||||
});
|
||||
|
||||
const install = (predictor: LocalCharacterPredictor, timeMs: number, x: number) => {
|
||||
const pose = settledPoseAt(x);
|
||||
predictor.acknowledge(timeMs, movementState() as never, -Infinity);
|
||||
predictor.setAuthoritative(
|
||||
pose.head as never,
|
||||
pose.leftFoot as never,
|
||||
pose.rightFoot as never,
|
||||
);
|
||||
};
|
||||
|
||||
describe('prediction corrections and interruptions', () => {
|
||||
it('blends an authoritative correction and converges without slowing ordinary motion', () => {
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
setFrameTimeMs(1000);
|
||||
install(predictor, 1000, 0);
|
||||
predictor.update([]);
|
||||
|
||||
setFrameTimeMs(1016);
|
||||
install(predictor, 1016, 100);
|
||||
predictor.update([]);
|
||||
expect(predictor.head.center[0]).toBeCloseTo(0, 5);
|
||||
|
||||
setFrameTimeMs(1032);
|
||||
predictor.update([]);
|
||||
expect(predictor.head.center[0]).toBeGreaterThan(0);
|
||||
expect(predictor.head.center[0]).toBeLessThan(100);
|
||||
|
||||
setFrameTimeMs(1516);
|
||||
predictor.update([]);
|
||||
expect(predictor.head.center[0]).toBeCloseTo(100, 0);
|
||||
});
|
||||
|
||||
it('freezes at the replay horizon during an outage even if held input changes', () => {
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
setFrameTimeMs(0);
|
||||
install(predictor, 0, 0);
|
||||
predictor.recordInput([1, 0] as never);
|
||||
setFrameTimeMs(400);
|
||||
predictor.update([]);
|
||||
const frozen = [...predictor.head.center];
|
||||
|
||||
setFrameTimeMs(450);
|
||||
predictor.recordInput([-1, 0] as never);
|
||||
setFrameTimeMs(600);
|
||||
predictor.update([]);
|
||||
expect([...predictor.head.center]).toEqual(frozen);
|
||||
|
||||
setFrameTimeMs(2000);
|
||||
predictor.recordInput([0, 0] as never);
|
||||
setFrameTimeMs(2500);
|
||||
predictor.update([]);
|
||||
expect([...predictor.head.center]).toEqual(frozen);
|
||||
});
|
||||
|
||||
it('clears visual correction on respawn and disables prediction for end-game snapshots', () => {
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
setFrameTimeMs(1000);
|
||||
install(predictor, 1000, 0);
|
||||
predictor.update([]);
|
||||
install(predictor, 1000, 100);
|
||||
predictor.update([]);
|
||||
predictor.reset();
|
||||
install(predictor, 1000, 1000);
|
||||
predictor.update([]);
|
||||
expect(predictor.head.center[0]).toBe(1000);
|
||||
predictor.enabled = false;
|
||||
expect(predictor.update([])).toBe(false);
|
||||
predictor.reset();
|
||||
install(predictor, 1000, 1000);
|
||||
expect(predictor.update([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const ground = {
|
||||
id: 1,
|
||||
vertices: Array.from({ length: 15 }, (_, i) => {
|
||||
const angle = (i / 15) * -Math.PI * 2;
|
||||
return [800 * Math.cos(angle), 800 * Math.sin(angle)];
|
||||
}),
|
||||
snapshotRotation: 0,
|
||||
snapshotRotationSpeed: 0,
|
||||
};
|
||||
|
||||
const leapHeight = (cooldown: number, leap: boolean) => {
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
setFrameTimeMs(1000);
|
||||
const pose = poseAt(0, 900);
|
||||
predictor.acknowledge(
|
||||
1000,
|
||||
{
|
||||
...movementState(),
|
||||
groundPlanetId: 1,
|
||||
secondsSinceOnSurface: 0,
|
||||
leapCooldownRemaining: cooldown,
|
||||
} as never,
|
||||
-Infinity,
|
||||
);
|
||||
predictor.setAuthoritative(
|
||||
pose.head as never,
|
||||
pose.leftFoot as never,
|
||||
pose.rightFoot as never,
|
||||
);
|
||||
if (leap) predictor.recordLeap();
|
||||
setFrameTimeMs(1002);
|
||||
predictor.update([ground] as never);
|
||||
return predictor.head.center[1];
|
||||
};
|
||||
|
||||
describe('leap prediction', () => {
|
||||
it('applies a newly pressed leap in the very next fractional render tick', () => {
|
||||
expect(leapHeight(0, true)).toBeGreaterThan(leapHeight(0, false) + 1);
|
||||
});
|
||||
|
||||
it('does not predict a leap the authoritative cooldown will reject', () => {
|
||||
expect(leapHeight(0.2, true)).toEqual(leapHeight(0.2, false));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
headRadius,
|
||||
feetRadius,
|
||||
} from '../shared/src/physics/character-movement';
|
||||
import { followVec2 } from '../shared/src/helper/ease';
|
||||
import { settings } from '../shared/src/settings';
|
||||
|
||||
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
|
|
@ -51,20 +50,23 @@ interface Conditions {
|
|||
dropRate: number;
|
||||
/** Server work that can delay a socket callback past the packet's arrival. */
|
||||
serverBusyMs: number;
|
||||
wireJitterMs?: number;
|
||||
emitJitterMs?: number;
|
||||
changeInput?: boolean;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
/** Peak-to-peak variation in apparent speed, over its mean. Zero means perfectly even motion. */
|
||||
speedSpread: number;
|
||||
/** Peak-to-peak wobble of the character's screen position about its own mean, in world units. */
|
||||
screenWobble: number;
|
||||
/** How far the rendered character trails the true server pose, in world units. */
|
||||
lagUnits: number;
|
||||
finalError: number;
|
||||
maximumSpeed: number;
|
||||
}
|
||||
|
||||
// Runs a real server tick loop, a real wire, and a real render loop against the predictor, and
|
||||
// reports how steady the character's *on-screen* travel is. On-screen, not in world space: the
|
||||
// camera follows the character, so what the player sees is the residual between the two.
|
||||
// Exercise shared movement with independent server, network, and render clocks.
|
||||
// Measure world travel: the camera tracks the local character exactly, so measuring
|
||||
// their relative position would always report zero even when the world visibly stutters.
|
||||
const measure = ({
|
||||
fps,
|
||||
wireMs,
|
||||
|
|
@ -72,6 +74,9 @@ const measure = ({
|
|||
frameJitter,
|
||||
dropRate,
|
||||
serverBusyMs,
|
||||
wireJitterMs = 0,
|
||||
emitJitterMs = 0,
|
||||
changeInput = false,
|
||||
}: Conditions): Result => {
|
||||
const world = new ClientCharacterWorld();
|
||||
world.sync([planetAt(0)]);
|
||||
|
|
@ -86,28 +91,30 @@ const measure = ({
|
|||
bodyVelocity: [0, 0],
|
||||
};
|
||||
|
||||
const input = [1, 0];
|
||||
let input = [1, 0];
|
||||
let serverInput = [1, 0];
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
predictor.setStrength(settings.playerMaxStrength);
|
||||
|
||||
// Deterministic frame-time noise: a real display never delivers a perfectly even cadence.
|
||||
let seed = 12345;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
|
||||
const random = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
|
||||
|
||||
const nominalFrameMs = 1000 / fps;
|
||||
const toServer: Array<{ atMs: number; clientStampMs: number }> = [];
|
||||
const toClient: Array<{ atMs: number; ackAgeMs: number; clientStampMs: number; pose: any }> = [];
|
||||
const toServer: Array<{ atMs: number; clientStampMs: number; direction?: number[] }> =
|
||||
[];
|
||||
const toClient: Array<{
|
||||
atMs: number;
|
||||
ackAgeMs: number;
|
||||
clientStampMs: number;
|
||||
pose: any;
|
||||
}> = [];
|
||||
const samples: Array<{
|
||||
screen: Array<number>;
|
||||
world: Array<number>;
|
||||
atMs: number;
|
||||
error: number;
|
||||
}> = [];
|
||||
|
||||
// The client's own view of the character, and the camera that chases it.
|
||||
const camera = [0, planetRadius + 90];
|
||||
const cameraPreviousTarget = [0, planetRadius + 90];
|
||||
|
||||
let simMs = 0;
|
||||
let nextSnapshotMs = 0;
|
||||
let nextFrameAtMs = 0;
|
||||
|
|
@ -115,27 +122,33 @@ const measure = ({
|
|||
let lastInputClientTimeMs = 0;
|
||||
let lastInputReceiptMs = 0;
|
||||
let hasPressed = false;
|
||||
|
||||
let lastServerArrivalMs = 0;
|
||||
let lastClientArrivalMs = 0;
|
||||
|
||||
while (simMs < 8000) {
|
||||
tickPlanetDetachment(server, stepSeconds);
|
||||
stepCharacterMovement(server, world, input.slice() as never, stepSeconds);
|
||||
stepCharacterMovement(server, world, serverInput.slice() as never, stepSeconds);
|
||||
simMs += stepSeconds * 1000;
|
||||
|
||||
while (toServer.length > 0 && toServer[0].atMs <= simMs) {
|
||||
const packet = toServer.shift()!;
|
||||
if (packet.direction) serverInput = packet.direction;
|
||||
if (packet.clientStampMs > lastInputClientTimeMs) {
|
||||
lastInputClientTimeMs = packet.clientStampMs;
|
||||
// Socket callbacks run between physics frames, so this is the true arrival instant,
|
||||
// not the frame boundary — plus however long the frame in progress still had to run.
|
||||
lastInputReceiptMs = packet.atMs + serverBusyMs;
|
||||
lastInputReceiptMs = packet.atMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (simMs >= nextSnapshotMs) {
|
||||
nextSnapshotMs += snapshotMs;
|
||||
lastClientArrivalMs = Math.max(
|
||||
lastClientArrivalMs,
|
||||
simMs + Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs),
|
||||
);
|
||||
toClient.push({
|
||||
atMs: simMs + wireMs,
|
||||
atMs: lastClientArrivalMs,
|
||||
clientStampMs: lastInputClientTimeMs,
|
||||
ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0,
|
||||
pose: {
|
||||
|
|
@ -175,8 +188,16 @@ const measure = ({
|
|||
|
||||
// Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once
|
||||
// when the key goes down, then held by both sides.
|
||||
if (!hasPressed) {
|
||||
const desiredInput =
|
||||
changeInput && nextFrameAtMs >= 4500
|
||||
? nextFrameAtMs >= 6000
|
||||
? [0, 0]
|
||||
: [-1, 0]
|
||||
: [1, 0];
|
||||
const inputChanged = !hasPressed || desiredInput[0] !== input[0];
|
||||
if (inputChanged) {
|
||||
hasPressed = true;
|
||||
input = desiredInput;
|
||||
predictor.recordInput(input as never);
|
||||
}
|
||||
|
||||
|
|
@ -185,30 +206,35 @@ const measure = ({
|
|||
const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1);
|
||||
|
||||
if (!dropped) {
|
||||
predictor.update([planetAt(0)], frameMs / 1000);
|
||||
predictor.update([planetAt(0)]);
|
||||
const head = predictor.head.center;
|
||||
followVec2(
|
||||
camera as never,
|
||||
head,
|
||||
cameraPreviousTarget as never,
|
||||
frameMs / 1000,
|
||||
0.08,
|
||||
1500,
|
||||
);
|
||||
samples.push({
|
||||
screen: [head[0] - camera[0], head[1] - camera[1]],
|
||||
world: [head[0], head[1]],
|
||||
atMs: frameClientMs,
|
||||
error: Math.hypot(head[0] - server.head.center[0], head[1] - server.head.center[1]),
|
||||
error: Math.hypot(
|
||||
head[0] - server.head.center[0],
|
||||
head[1] - server.head.center[1],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Stamped at the top of the frame, emitted at the bottom.
|
||||
if (frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) {
|
||||
if (
|
||||
inputChanged ||
|
||||
(!dropped && frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1)
|
||||
) {
|
||||
lastHeartbeatClientMs = frameClientMs;
|
||||
lastServerArrivalMs = Math.max(
|
||||
lastServerArrivalMs,
|
||||
nextFrameAtMs +
|
||||
serverBusyMs +
|
||||
Math.max(0, emitLagMs + (random() * 2 - 1) * emitJitterMs) +
|
||||
Math.max(0, wireMs + (random() * 2 - 1) * wireJitterMs),
|
||||
);
|
||||
toServer.push({
|
||||
atMs: nextFrameAtMs + emitLagMs + wireMs,
|
||||
atMs: lastServerArrivalMs,
|
||||
clientStampMs: frameClientMs,
|
||||
direction: inputChanged ? input.slice() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -229,60 +255,115 @@ const measure = ({
|
|||
);
|
||||
const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
|
||||
|
||||
// Screen position: the camera settles to a constant lag, so anything left is visible wobble.
|
||||
const centre = [0, 1].map((axis) => {
|
||||
const values = tail.map((s) => s.screen[axis]);
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
});
|
||||
const wobble = Math.max(
|
||||
...tail.map((s) => Math.hypot(s.screen[0] - centre[0], s.screen[1] - centre[1])),
|
||||
);
|
||||
|
||||
if ((globalThis as any).__dump) {
|
||||
console.log('--- apparent speed (u/ms) ---');
|
||||
speeds.slice(0, 40).forEach((v, i) => console.log(`${i} ${v.toFixed(4)}`));
|
||||
}
|
||||
return {
|
||||
speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed,
|
||||
screenWobble: wobble,
|
||||
lagUnits: tail.reduce((sum, s) => sum + s.error, 0) / tail.length,
|
||||
finalError: tail[tail.length - 1].error,
|
||||
maximumSpeed: Math.max(...speeds),
|
||||
};
|
||||
};
|
||||
|
||||
const CONDITIONS: Array<[string, Conditions]> = [
|
||||
['localhost, clean frames', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }],
|
||||
['localhost, 144 Hz', { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }],
|
||||
['localhost, frame jitter', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.15, dropRate: 0, serverBusyMs: 0 }],
|
||||
['localhost, 2% dropped', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.05, dropRate: 0.02, serverBusyMs: 0 }],
|
||||
['localhost, variable CPU', { fps: 60, wireMs: 0.2, emitLagMs: 8, frameJitter: 0.15, dropRate: 0.02, serverBusyMs: 0 }],
|
||||
['30 ms link', { fps: 60, wireMs: 15, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }],
|
||||
['120 ms link', { fps: 60, wireMs: 60, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }],
|
||||
[
|
||||
'localhost, clean frames',
|
||||
{ fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 },
|
||||
],
|
||||
[
|
||||
'localhost, 144 Hz',
|
||||
{ fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 },
|
||||
],
|
||||
[
|
||||
'localhost, frame jitter',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 0.2,
|
||||
emitLagMs: 2,
|
||||
frameJitter: 0.15,
|
||||
dropRate: 0,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
'localhost, 2% dropped',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 0.2,
|
||||
emitLagMs: 2,
|
||||
frameJitter: 0.05,
|
||||
dropRate: 0.02,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
'localhost, slower CPU',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 0.2,
|
||||
emitLagMs: 8,
|
||||
frameJitter: 0.15,
|
||||
dropRate: 0.02,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
'30 ms link',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 15,
|
||||
emitLagMs: 2,
|
||||
frameJitter: 0.05,
|
||||
dropRate: 0,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
'120 ms link',
|
||||
{
|
||||
fps: 60,
|
||||
wireMs: 60,
|
||||
emitLagMs: 2,
|
||||
frameJitter: 0.05,
|
||||
dropRate: 0,
|
||||
serverBusyMs: 0,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
describe('local character render smoothness', () => {
|
||||
it('keeps on-screen travel even under realistic clocks and frame pacing', () => {
|
||||
;(globalThis as any).__dump = true
|
||||
measure(CONDITIONS[0][1])
|
||||
;(globalThis as any).__dump = false
|
||||
const results: Array<[string, Result]> = CONDITIONS.map(([label, c]) => [label, measure(c)]);
|
||||
it.each(CONDITIONS)('keeps travel even with %s', (_label, conditions) => {
|
||||
const result = measure(conditions);
|
||||
expect(result.speedSpread).toBeLessThan(0.05);
|
||||
if (conditions.wireMs < 1) expect(result.lagUnits).toBeLessThan(5);
|
||||
});
|
||||
|
||||
for (const [label, r] of results) {
|
||||
console.log(
|
||||
`${label.padEnd(26)} speed=${(r.speedSpread * 100).toFixed(1).padStart(6)}% ` +
|
||||
`wobble=${r.screenWobble.toFixed(2).padStart(6)}u ` +
|
||||
`lag=${r.lagUnits.toFixed(1).padStart(5)}u `,
|
||||
);
|
||||
}
|
||||
it('absorbs changing network and client CPU delay without snapshot jumps', () => {
|
||||
const result = measure({
|
||||
fps: 144,
|
||||
wireMs: 30,
|
||||
emitLagMs: 5,
|
||||
wireJitterMs: 10,
|
||||
emitJitterMs: 5,
|
||||
frameJitter: 0.15,
|
||||
dropRate: 0.02,
|
||||
serverBusyMs: 2,
|
||||
});
|
||||
expect(result.speedSpread).toBeLessThan(0.4);
|
||||
expect(result.lagUnits).toBeLessThan(25);
|
||||
});
|
||||
|
||||
for (const [label, r] of results) {
|
||||
// Even travel: what the eye reads as smooth motion.
|
||||
expect(r.speedSpread, label).toBeLessThan(0.05);
|
||||
// No sub-pixel swimming against the camera, which follows the same character.
|
||||
expect(r.screenWobble, label).toBeLessThan(1);
|
||||
}
|
||||
|
||||
// Prediction exists to cancel latency; a follower that lags undoes that on every link.
|
||||
const localhost = results.find(([label]) => label === 'localhost, clean frames')![1];
|
||||
expect(localhost.lagUnits).toBeLessThan(5);
|
||||
it('reconciles direction changes and stopping across an irregular link', () => {
|
||||
const result = measure({
|
||||
fps: 144,
|
||||
wireMs: 30,
|
||||
emitLagMs: 5,
|
||||
wireJitterMs: 10,
|
||||
emitJitterMs: 5,
|
||||
frameJitter: 0.15,
|
||||
dropRate: 0.02,
|
||||
serverBusyMs: 2,
|
||||
changeInput: true,
|
||||
});
|
||||
expect(result.maximumSpeed).toBeLessThan(1);
|
||||
expect(result.finalError).toBeLessThan(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,9 +22,17 @@ const {
|
|||
headOffset,
|
||||
leftFootOffset,
|
||||
rightFootOffset,
|
||||
CharacterMovementSnapshot,
|
||||
} = shared;
|
||||
|
||||
describe('serialization round-trip (built shared lib)', () => {
|
||||
it('preserves the authoritative leap cooldown for prediction', () => {
|
||||
const snapshot = new CharacterMovementSnapshot(0, [1, 2], [0, 1], [0, 1], 5, 0, 0.25);
|
||||
const out = deserialize(serialize(snapshot));
|
||||
expect(out).toBeInstanceOf(CharacterMovementSnapshot);
|
||||
expect(out.leapCooldownRemaining).toBe(0.25);
|
||||
});
|
||||
|
||||
it('reconstructs a ServerAnnouncement as the right class with its payload', () => {
|
||||
const [out] = deserialize(serialize([new ServerAnnouncement('Hello <b>world</b>')]));
|
||||
expect(out).toBeInstanceOf(ServerAnnouncement);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Production makes every vector an f64 Array before any module-level vector is allocated.
|
||||
// Use production's f64 vector type in every package. The import-order regression separately
|
||||
// verifies that physics constants also retain full precision before startup configures it.
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
|
|
|||
111
test/shared-regressions.test.ts
Normal file
111
test/shared-regressions.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { marchCircle } from '../shared/src/physics/march-circle';
|
||||
import { resolveCircleMovement } from '../shared/src/physics/resolve-circle-movement';
|
||||
import { finiteVec2, sanitizeName } from '../shared/src/helper/finite';
|
||||
import { CommandReceiver } from '../shared/src/commands/command-receiver';
|
||||
import type { PhysicsBody, Sdf } from '../shared/src/physics/sdf';
|
||||
|
||||
const bodyAt = (x: number, y: number, vx: number, vy: number): PhysicsBody => ({
|
||||
center: [x, y],
|
||||
radius: 1,
|
||||
velocity: [vx, vy],
|
||||
lastNormal: [0, 1],
|
||||
restitution: 1,
|
||||
});
|
||||
|
||||
const rightWall: Sdf = { canCollide: true, distance: ([x]) => 10 - x };
|
||||
|
||||
describe('circle collision movement', () => {
|
||||
it('spends only the remaining step time after a bounce', () => {
|
||||
const body = bodyAt(0, 0, 100, 0);
|
||||
expect(resolveCircleMovement(body, 0.2, [rightWall])).toBe(rightWall);
|
||||
// 9 units to the wall, then 11 units back: 20 units of total travel.
|
||||
expect(body.center[0]).toBeCloseTo(-2, 6);
|
||||
expect(body.velocity[0]).toBeCloseTo(-100, 6);
|
||||
});
|
||||
|
||||
it('resolves velocity at the second contact instead of leaving it aimed into a wall', () => {
|
||||
const body = bodyAt(0, 0, 100, 0);
|
||||
const leftWall: Sdf = { canCollide: true, distance: ([x]) => x + 5 };
|
||||
resolveCircleMovement(body, 0.3, [rightWall, leftWall]);
|
||||
expect(body.center[0]).toBeCloseTo(-4, 6);
|
||||
expect(body.velocity[0]).toBeCloseTo(100, 6);
|
||||
});
|
||||
|
||||
it('allows slow tangential sliding along a surface', () => {
|
||||
const body = { ...bodyAt(9, 0, 100, 10), restitution: 0 };
|
||||
resolveCircleMovement(body, 0.1, [rightWall]);
|
||||
expect(body.center[0]).toBeCloseTo(9, 6);
|
||||
expect(body.center[1]).toBeCloseTo(1, 6);
|
||||
expect(body.velocity[0]).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('ignores disabled objects when identifying the contacted surface', () => {
|
||||
const inactive: Sdf = { canCollide: false, distance: () => -100 };
|
||||
const onHit = vi.fn();
|
||||
const body = bodyAt(0, 0, 100, 0);
|
||||
const result = marchCircle(body, [20, 0], [inactive, rightWall], onHit);
|
||||
expect(result.hitObject).toBe(rightWall);
|
||||
expect(onHit).toHaveBeenCalledExactlyOnceWith(rightWall);
|
||||
expect(result.normal).toEqual([-1, 0]);
|
||||
});
|
||||
|
||||
it('samples the collision normal before the callback removes the obstacle', () => {
|
||||
const wall = { ...rightWall };
|
||||
const body = bodyAt(0, 0, 100, 0);
|
||||
resolveCircleMovement(body, 0.2, [wall], () => {
|
||||
wall.canCollide = false;
|
||||
});
|
||||
expect(body.center.every(Number.isFinite)).toBe(true);
|
||||
expect(body.velocity[0]).toBeCloseTo(-100, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network input helpers', () => {
|
||||
it('clamps huge finite vectors before allocating a float vector', () => {
|
||||
const direction = finiteVec2([1e300, 1e300], 1)!;
|
||||
expect(direction.every(Number.isFinite)).toBe(true);
|
||||
expect(Math.hypot(...direction)).toBeCloseTo(1, 6);
|
||||
expect(direction[0]).toBeGreaterThan(0.7);
|
||||
});
|
||||
|
||||
it('never calls methods on an untrusted player name', () => {
|
||||
expect(sanitizeName({ toString: {} }, 10)).toBe('');
|
||||
expect(sanitizeName('abcdefghijk', 10)).toBe('abcdefghij');
|
||||
});
|
||||
|
||||
it('does not dispatch inherited object methods as commands', () => {
|
||||
class Receiver extends CommandReceiver {
|
||||
public fallback = vi.fn();
|
||||
protected defaultCommandExecutor(command: object) {
|
||||
this.fallback(command);
|
||||
}
|
||||
}
|
||||
const receiver = new Receiver();
|
||||
const command = { constructor: { name: 'constructor' } };
|
||||
receiver.handleCommand(command);
|
||||
expect(receiver.fallback).toHaveBeenCalledExactlyOnceWith(command);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('random world seeds', () => {
|
||||
it('uses the random seed bits instead of truncating every initial seed to zero', async () => {
|
||||
const random = vi.spyOn(Math, 'random');
|
||||
const firstValues: number[] = [];
|
||||
for (const seed of [0.1, 0.5, 0.9]) {
|
||||
random.mockReturnValue(seed);
|
||||
vi.resetModules();
|
||||
const { Random } = await import('../shared/src/helper/random');
|
||||
firstValues.push(Random.getRandom());
|
||||
Random.seed = 42;
|
||||
const expected = [Random.getRandom(), Random.getRandom()];
|
||||
Random.seed = 42;
|
||||
expect([Random.getRandom(), Random.getRandom()]).toEqual(expected);
|
||||
}
|
||||
expect(new Set(firstValues).size).toBe(3);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue