.
Some checks failed
Build & deploy / Build & publish server image (pull_request) Has been skipped
Build & deploy / Build & deploy website (pull_request) Failing after 16s

This commit is contained in:
Andras Schmelczer 2026-09-14 21:55:37 +01:00
commit ec52e57dee
2 changed files with 317 additions and 3 deletions

View file

@ -1,6 +1,7 @@
import { Renderer } from 'sdf-2d'; import { Renderer } from 'sdf-2d';
import { import {
Circle, Circle,
CharacterBase,
CommandExecutors, CommandExecutors,
CommandReceiver, CommandReceiver,
CreateObjectsCommand, CreateObjectsCommand,
@ -21,6 +22,10 @@ import { CharacterView } from './types/character-view';
import { PlanetView } from './types/planet-view'; import { PlanetView } from './types/planet-view';
import { View } from './view'; import { View } from './view';
// Keep enough history for interpolation, without retaining a background tab's entire session.
const deferredHistorySeconds = 0.5;
const persistentRemoteCalls = new Set(['setKillCount', 'setLight', 'setContested']);
export class GameObjectContainer extends CommandReceiver { export class GameObjectContainer extends CommandReceiver {
public player?: CharacterView; public player?: CharacterView;
public readonly camera: Camera; public readonly camera: Camera;
@ -28,6 +33,7 @@ export class GameObjectContainer extends CommandReceiver {
private objects: Map<Id, View> = new Map(); private objects: Map<Id, View> = new Map();
private wasLocalPlayerAlive = false; private wasLocalPlayerAlive = false;
private lastStepAtMs = performance.now();
// Creates and deletes take effect on the render timeline, not on arrival. // Creates and deletes take effect on the render timeline, not on arrival.
private visibleFrom = new Map<Id, number>(); private visibleFrom = new Map<Id, number>();
@ -67,6 +73,7 @@ export class GameObjectContainer extends CommandReceiver {
[PropertyUpdatesForObjects.name]: (c: PropertyUpdatesForObjects) => { [PropertyUpdatesForObjects.name]: (c: PropertyUpdatesForObjects) => {
serverTimeline.onSnapshot(c.timestamp); serverTimeline.onSnapshot(c.timestamp);
this.stampPending(c.timestamp); this.stampPending(c.timestamp);
this.pruneDeferred(c.timestamp - deferredHistorySeconds);
c.updates.forEach((u) => { c.updates.forEach((u) => {
const object = this.objects.get(u.id); const object = this.objects.get(u.id);
u.updates.forEach((au) => object?.updateProperty?.(au)); u.updates.forEach((au) => object?.updateProperty?.(au));
@ -96,6 +103,7 @@ export class GameObjectContainer extends CommandReceiver {
this.awaitingDeleteStamp = []; this.awaitingDeleteStamp = [];
this.remoteCalls = []; this.remoteCalls = [];
this.wasLocalPlayerAlive = false; this.wasLocalPlayerAlive = false;
this.lastStepAtMs = performance.now();
} }
public get localPlayer(): CharacterView | undefined { public get localPlayer(): CharacterView | undefined {
@ -104,6 +112,12 @@ export class GameObjectContainer extends CommandReceiver {
public step(deltaTimeInSeconds: number) { public step(deltaTimeInSeconds: number) {
this.stampPending(serverTimeline.snapshotTime); this.stampPending(serverTimeline.snapshotTime);
const nowMs = performance.now();
const resumed = nowMs - this.lastStepAtMs > deferredHistorySeconds * 1000;
this.lastStepAtMs = nowMs;
this.pruneDeferred(
serverTimeline.renderTime - (resumed ? 0 : deferredHistorySeconds),
);
this.retireDueObjects(); this.retireDueObjects();
this.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => { this.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => {
if (this.objects.get(object.id) !== object) { if (this.objects.get(object.id) !== object) {
@ -166,8 +180,25 @@ export class GameObjectContainer extends CommandReceiver {
} }
} }
private retireDueObjects() { private pruneDeferred(cutoff: number) {
const renderTime = serverTimeline.renderTime; this.retireDueObjects(cutoff);
this.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => {
if (at === undefined || at > cutoff) {
return true;
}
for (const call of calls) {
if (call.functionName === 'setHealth' && object instanceof CharacterBase) {
// The view's health setter also plays hit feedback; expired damage is state only.
object.health = call.args[0];
} else if (persistentRemoteCalls.has(call.functionName)) {
object.processRemoteCalls([call]);
}
}
return false;
});
}
private retireDueObjects(renderTime = serverTimeline.renderTime) {
for (const [id, at] of this.deleteAt) { for (const [id, at] of this.deleteAt) {
if (renderTime >= at) { if (renderTime >= at) {
this.deleteObject(id); this.deleteObject(id);
@ -205,8 +236,13 @@ export class GameObjectContainer extends CommandReceiver {
} }
private deleteObject(id: Id) { private deleteObject(id: Id) {
this.objects.get(id)?.beforeDestroy?.(); const object = this.objects.get(id);
object?.beforeDestroy?.();
this.objects.delete(id); this.objects.delete(id);
this.remoteCalls = this.remoteCalls.filter((call) => call.object !== object);
if (this.player === object) {
this.player = undefined;
}
this.planets = this.planets.filter((p) => p.id !== id); this.planets = this.planets.filter((p) => p.id !== id);
this.visibleFrom.delete(id); this.visibleFrom.delete(id);
this.deleteAt.delete(id); this.deleteAt.delete(id);

View file

@ -0,0 +1,278 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CharacterBase,
CharacterTeam,
Circle,
CreateObjectsCommand,
DeleteObjectsCommand,
GameObject,
PropertyUpdatesForObjects,
RemoteCall,
RemoteCallsForObject,
RemoteCallsForObjects,
} from '../frontend/node_modules/shared';
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';
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/sound-handler', () => ({
SoundHandler: { play: vi.fn() },
Sounds: {},
}));
vi.mock('../frontend/src/scripts/feedback-hud', () => ({ FeedbackHud: {} }));
let nowMs = 0;
beforeEach(() => {
nowMs = 0;
vi.spyOn(performance, 'now').mockImplementation(() => nowMs);
serverTimeline.reset();
});
afterEach(() => vi.restoreAllMocks());
function character(id = 1) {
const pose = new Circle([0, 0], 10);
const object = new CharacterBase(
id,
'Remote player',
0,
0,
CharacterTeam.red,
100,
pose,
pose,
pose,
);
return Object.assign(object, {
step: vi.fn(),
render: vi.fn(),
beforeDestroy: vi.fn(),
// A health setter also represents damage sound/flash feedback in CharacterView.
setHealth: vi.fn((health: number) => (object.health = health)),
onShoot: vi.fn(),
onDie: vi.fn(),
});
}
function calls(
container: GameObjectContainer,
id: number,
...entries: Array<[string, ...unknown[]]>
) {
container.handleCommand(
new RemoteCallsForObjects([
new RemoteCallsForObject(
id,
entries.map(([name, ...args]) => new RemoteCall(name, args)),
),
]),
);
}
function snapshot(container: GameObjectContainer, timestamp: number) {
nowMs = (timestamp - 5) * 1000;
container.handleCommand(new PropertyUpdatesForObjects([], timestamp));
}
function retained(container: GameObjectContainer) {
return container as unknown as {
objects: Map<number, unknown>;
deleteAt: Map<number, number>;
remoteCalls: Array<{ object: unknown }>;
};
}
describe('suspended rendering', () => {
it('bounds deleted views and effect batches as snapshots arrive without frames', () => {
const container = new GameObjectContainer({} as never);
const survivor = character();
container.handleCommand(new CreateObjectsCommand([survivor]));
const deleted: PlanetView[] = [];
for (let i = 0; i < 1000; i++) {
const object = new PlanetView(i + 100, []);
deleted.push(object);
container.handleCommand(new CreateObjectsCommand([object]));
calls(container, survivor.id, ['onShoot', 40]);
container.handleCommand(new DeleteObjectsCommand([object.id]));
snapshot(container, 5 + i * 0.04);
expect(container.planets.length).toBeLessThanOrEqual(15);
expect(retained(container).objects.size).toBeLessThanOrEqual(16);
expect(retained(container).deleteAt.size).toBeLessThanOrEqual(15);
expect(retained(container).remoteCalls.length).toBeLessThanOrEqual(15);
}
expect(deleted[0].beforeDestroy).toHaveBeenCalledOnce();
expect(deleted[984].beforeDestroy).toHaveBeenCalledOnce();
expect(survivor.onShoot).not.toHaveBeenCalled();
serverTimeline.step(0);
container.step(0);
expect(survivor.onShoot).not.toHaveBeenCalled();
expect(retained(container).remoteCalls.length).toBeLessThanOrEqual(3);
// With no additional effects, subsequent snapshots retire the entire old tail.
snapshot(container, 46);
expect(container.planets).toHaveLength(0);
expect(retained(container).objects.size).toBe(1);
expect(retained(container).deleteAt.size).toBe(0);
expect(retained(container).remoteCalls).toHaveLength(0);
serverTimeline.step(0);
container.step(0);
expect(survivor.onShoot).not.toHaveBeenCalled();
});
it('preserves expired state silently and applies fresh health feedback in order', () => {
const container = new GameObjectContainer({} as never);
const object = character();
container.handleCommand(new CreateObjectsCommand([object]));
calls(container, object.id, ['setHealth', 80], ['setKillCount', 1], ['onShoot', 40]);
snapshot(container, 5);
calls(container, object.id, ['setHealth', 60], ['setKillCount', 2], ['onDie']);
snapshot(container, 5.04);
snapshot(container, 5.6);
expect(object.health).toBe(60);
expect(object.killCount).toBe(2);
expect(object.setHealth).not.toHaveBeenCalled();
expect(object.onShoot).not.toHaveBeenCalled();
expect(object.onDie).not.toHaveBeenCalled();
calls(container, object.id, ['setHealth', 40], ['setKillCount', 3], ['onShoot', 50]);
snapshot(container, 5.7);
serverTimeline.step(0);
container.step(0);
expect(object.health).toBe(60);
expect(object.setHealth).not.toHaveBeenCalled();
nowMs += 110;
serverTimeline.step(0.11);
container.step(0);
expect(object.health).toBe(40);
expect(object.killCount).toBe(3);
expect(object.setHealth).toHaveBeenCalledExactlyOnceWith(40);
expect(object.onShoot).toHaveBeenCalledExactlyOnceWith(50);
expect(object.onDie).not.toHaveBeenCalled();
});
it('preserves final light and contested state while discarding transient planet effects', () => {
const container = new GameObjectContainer({} as never);
const object = Object.assign(new (class extends GameObject {})(1), {
contested: false,
lightness: 0,
color: [0, 0, 0],
step() {},
render() {},
setContested(value: boolean) {
this.contested = value;
},
setLight(color: number[], lightness: number) {
this.color = color;
this.lightness = lightness;
},
onFlipped: vi.fn(),
generatedPoints: vi.fn(),
});
container.handleCommand(new CreateObjectsCommand([object]));
calls(
container,
object.id,
['setContested', true],
['setLight', [1, 0, 0], 2],
['onFlipped', CharacterTeam.red],
['generatedPoints', 10],
);
snapshot(container, 5);
calls(
container,
object.id,
['setContested', false],
['setLight', [0, 0, 1], 3],
['onFlipped', CharacterTeam.blue],
['generatedPoints', 20],
);
snapshot(container, 5.04);
snapshot(container, 6);
expect(object.contested).toBe(false);
expect(object.color).toEqual([0, 0, 1]);
expect(object.lightness).toBe(3);
expect(object.onFlipped).not.toHaveBeenCalled();
expect(object.generatedPoints).not.toHaveBeenCalled();
expect(retained(container).remoteCalls).toHaveLength(0);
});
it('releases effect references immediately when a paused view is replaced', () => {
const container = new GameObjectContainer({} as never);
let object = character();
container.handleCommand(new CreateObjectsCommand([object]));
snapshot(container, 5);
for (let i = 0; i < 100; i++) {
calls(container, object.id, ['onShoot', 40]);
container.handleCommand(new DeleteObjectsCommand([object.id]));
const replacement = character(object.id);
container.handleCommand(new CreateObjectsCommand([replacement]));
expect(object.beforeDestroy).toHaveBeenCalledOnce();
expect(retained(container).remoteCalls).toHaveLength(0);
expect(retained(container).objects.get(object.id)).toBe(replacement);
object = replacement;
}
snapshot(container, 5.04);
nowMs += 200;
serverTimeline.step(0.2);
container.step(0);
expect(object.beforeDestroy).not.toHaveBeenCalled();
});
it('expires the last queued effects on resume even when snapshots also stopped', () => {
const container = new GameObjectContainer({} as never);
const object = character();
container.handleCommand(new CreateObjectsCommand([object]));
calls(container, object.id, ['setHealth', 20], ['setKillCount', 4], ['onShoot', 40]);
snapshot(container, 5);
nowMs += 10_000;
serverTimeline.step(10);
container.step(10);
expect(object.health).toBe(20);
expect(object.killCount).toBe(4);
expect(object.setHealth).not.toHaveBeenCalled();
expect(object.onShoot).not.toHaveBeenCalled();
expect(retained(container).remoteCalls).toHaveLength(0);
});
it('releases a deleted local player reference without waiting for a frame', () => {
const container = new GameObjectContainer({} as never);
const object = character();
container.handleCommand(new CreateObjectsCommand([object]));
container.player = object as never;
snapshot(container, 5);
container.handleCommand(new DeleteObjectsCommand([object.id]));
snapshot(container, 5.04);
expect(container.localPlayer).toBe(object);
snapshot(container, 6);
expect(object.beforeDestroy).toHaveBeenCalledOnce();
expect(container.player).toBeUndefined();
expect(container.localPlayer).toBeUndefined();
expect(retained(container).objects.size).toBe(0);
});
});