ai
This commit is contained in:
parent
b6db7e8dc7
commit
d9b80b92ca
22 changed files with 563 additions and 62 deletions
3
test/__snapshots__/physics-determinism.test.mjs.snap
Normal file
3
test/__snapshots__/physics-determinism.test.mjs.snap
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
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]}"`;
|
||||
83
test/physics-determinism.test.mjs
Normal file
83
test/physics-determinism.test.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Determinism guard for the shared character simulation. The client predictor
|
||||
// and the authoritative server run the EXACT same stepCharacterMovement; if it
|
||||
// were non-deterministic (a stray Math.random / Date, or an order-dependent
|
||||
// reduce) prediction would rubber-band and could never reconcile. This is also
|
||||
// the regression net for the upcoming GC/scratch-pool perf rewrites: the pinned
|
||||
// hash must not change unless physics behaviour is intentionally changed.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
const { vec2 } = require('../shared/node_modules/gl-matrix');
|
||||
|
||||
const {
|
||||
stepCharacterMovement,
|
||||
resolveCircleMovement,
|
||||
headRadius,
|
||||
feetRadius,
|
||||
headOffset,
|
||||
leftFootOffset,
|
||||
rightFootOffset,
|
||||
} = shared;
|
||||
|
||||
const makeBody = (center, radius) => ({
|
||||
center: vec2.clone(center),
|
||||
radius,
|
||||
velocity: vec2.create(),
|
||||
lastNormal: vec2.fromValues(0, 1),
|
||||
restitution: 0,
|
||||
});
|
||||
|
||||
// A free-space world (no planets): the body is driven purely by the movement
|
||||
// input force, posture springs and momentum decay — enough to exercise the
|
||||
// deterministic core without coupling the test to planet SDF geometry.
|
||||
const emptyWorld = {
|
||||
groundsNear: () => [],
|
||||
stepBody: (body, dt) => {
|
||||
resolveCircleMovement(body, dt, []);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const stepSeconds = 1 / 200;
|
||||
|
||||
// Run a fixed, scripted input sequence through the shared sim and return a
|
||||
// stable string snapshot of the final pose + carried momentum.
|
||||
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(),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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])],
|
||||
});
|
||||
};
|
||||
|
||||
describe('shared character simulation determinism', () => {
|
||||
it('produces identical output across independent runs', () => {
|
||||
expect(runSimulation()).toBe(runSimulation());
|
||||
});
|
||||
|
||||
it('matches the pinned reference pose (changes only with intentional physics edits)', () => {
|
||||
// Regression pin — update deliberately when physics behaviour changes.
|
||||
expect(runSimulation()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
96
test/prediction-reconciliation.test.ts
Normal file
96
test/prediction-reconciliation.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Reconciliation guard for the REAL client predictor. It exercises
|
||||
// LocalCharacterPredictor end-to-end with an injected clock, verifying the two
|
||||
// properties reconciliation depends on:
|
||||
// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly
|
||||
// (no spurious drift on top of server truth), and
|
||||
// 2. un-acknowledged input is replayed forward deterministically.
|
||||
// This is the net for prediction-touching changes (the death-while-dead fix,
|
||||
// future netcode work) — a regression shows up as drift or non-determinism.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
LocalCharacterPredictor,
|
||||
setPredictorClockForTesting,
|
||||
} from '../frontend/src/scripts/helper/prediction/local-character-predictor';
|
||||
|
||||
const HEAD_RADIUS = 50;
|
||||
const FEET_RADIUS = 20;
|
||||
|
||||
// A Circle-shaped pose; the predictor only reads .center / .radius, so plain
|
||||
// objects with array centres are sufficient (and avoid importing gl-matrix).
|
||||
const poseAt = (cx: number, cy: number) => ({
|
||||
head: { center: [cx, cy + 37], radius: HEAD_RADIUS },
|
||||
leftFoot: { center: [cx - 33, cy - 18], radius: FEET_RADIUS },
|
||||
rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS },
|
||||
});
|
||||
|
||||
describe('local prediction reconciliation', () => {
|
||||
it('reproduces the authoritative pose exactly when all input is acknowledged', () => {
|
||||
let clock = 1000;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
const auth = poseAt(500, 500);
|
||||
|
||||
const t = predictor.recordInput([1, 0]);
|
||||
predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input
|
||||
predictor.setStrength(80);
|
||||
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
|
||||
|
||||
// No clock advance → zero replay window → predicted pose == authoritative.
|
||||
const used = predictor.update([], 1 / 60);
|
||||
|
||||
expect(used).toBe(true);
|
||||
expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5);
|
||||
expect(predictor.head.center[1]).toBeCloseTo(auth.head.center[1], 5);
|
||||
expect(predictor.leftFoot.center[0]).toBeCloseTo(auth.leftFoot.center[0], 5);
|
||||
expect(predictor.rightFoot.center[0]).toBeCloseTo(auth.rightFoot.center[0], 5);
|
||||
});
|
||||
|
||||
it('replays un-acknowledged input deterministically', () => {
|
||||
const drive = () => {
|
||||
let clock = 0;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
clock = 1000;
|
||||
predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below)
|
||||
predictor.setStrength(80);
|
||||
const auth = poseAt(0, 0);
|
||||
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
|
||||
predictor.recordInput([1, 0]); // unacked rightward input at t=1000
|
||||
|
||||
clock = 1100; // replay ~100 ms forward
|
||||
predictor.update([], 1 / 60);
|
||||
return [
|
||||
predictor.head.center[0],
|
||||
predictor.head.center[1],
|
||||
predictor.leftFoot.center[0],
|
||||
predictor.rightFoot.center[0],
|
||||
];
|
||||
};
|
||||
|
||||
const a = drive();
|
||||
const b = drive();
|
||||
expect(a).toEqual(b); // deterministic replay
|
||||
expect(a.every((n) => Number.isFinite(n))).toBe(true);
|
||||
expect(a[0]).toBeGreaterThan(0.5); // rightward input actually moved the body
|
||||
});
|
||||
|
||||
it('suppresses prediction while the local player is dead', () => {
|
||||
let clock = 5000;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
const auth = poseAt(200, 200);
|
||||
const t = predictor.recordInput([1, 0]);
|
||||
predictor.acknowledge(t, [0, 0], -Infinity);
|
||||
predictor.setStrength(80);
|
||||
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
|
||||
predictor.setAlive(false); // dead, awaiting respawn
|
||||
|
||||
clock = 5200; // input + elapsed time that would otherwise be replayed forward
|
||||
// Even with a valid authoritative pose and pending input, a dead body must
|
||||
// not be predicted/moved — that was the "move while dead" bug.
|
||||
expect(predictor.update([], 1 / 60)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -17,6 +17,11 @@ 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,
|
||||
|
|
@ -77,6 +82,67 @@ describe('serialization round-trip (built shared lib)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('networked entity round-trips (toArray ↔ constructor contract)', () => {
|
||||
it('round-trips a ProjectileBase', () => {
|
||||
const out = deserialize(serialize(new ProjectileBase(7, [10, 20], 30, 'blue', 40)));
|
||||
expect(out).toBeInstanceOf(ProjectileBase);
|
||||
expect(out.id).toBe(7);
|
||||
expect(out.center[0]).toBeCloseTo(10, 5);
|
||||
expect(out.center[1]).toBeCloseTo(20, 5);
|
||||
expect(out.radius).toBe(30);
|
||||
expect(out.team).toBe('blue');
|
||||
expect(out.strength).toBe(40);
|
||||
});
|
||||
|
||||
it('round-trips a CharacterBase with its nested body Circles', () => {
|
||||
const out = deserialize(
|
||||
serialize(
|
||||
new CharacterBase(
|
||||
8,
|
||||
'Bob',
|
||||
2,
|
||||
1,
|
||||
'red',
|
||||
100,
|
||||
new Circle([1, 2], 50),
|
||||
new Circle([3, 4], 20),
|
||||
new Circle([5, 6], 20),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(out).toBeInstanceOf(CharacterBase);
|
||||
expect(out.id).toBe(8);
|
||||
expect(out.name).toBe('Bob');
|
||||
expect(out.killCount).toBe(2);
|
||||
expect(out.deathCount).toBe(1);
|
||||
expect(out.team).toBe('red');
|
||||
expect(out.health).toBe(100);
|
||||
expect(out.head).toBeInstanceOf(Circle);
|
||||
expect(out.head.center[0]).toBeCloseTo(1, 5);
|
||||
expect(out.head.radius).toBe(50);
|
||||
});
|
||||
|
||||
it('round-trips a LampBase', () => {
|
||||
const out = deserialize(serialize(new LampBase(9, [7, 8], [1, 0.5, 0.2], 0.8)));
|
||||
expect(out).toBeInstanceOf(LampBase);
|
||||
expect(out.center[1]).toBeCloseTo(8, 5);
|
||||
expect(out.color[2]).toBeCloseTo(0.2, 5);
|
||||
expect(out.lightness).toBeCloseTo(0.8, 5);
|
||||
});
|
||||
|
||||
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)),
|
||||
);
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared character geometry — single source of truth', () => {
|
||||
it('matches the known body layout', () => {
|
||||
expect(headRadius).toBe(50);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue