From ec52e57deeb43149b48b6b2ccd09e545b574fcb8 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 14 Sep 2026 21:55:37 +0100 Subject: [PATCH] . --- .../scripts/objects/game-object-container.ts | 42 ++- test/suspended-rendering.test.ts | 278 ++++++++++++++++++ 2 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 test/suspended-rendering.test.ts diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 805bdd4..a469ff1 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -1,6 +1,7 @@ import { Renderer } from 'sdf-2d'; import { Circle, + CharacterBase, CommandExecutors, CommandReceiver, CreateObjectsCommand, @@ -21,6 +22,10 @@ import { CharacterView } from './types/character-view'; import { PlanetView } from './types/planet-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 { public player?: CharacterView; public readonly camera: Camera; @@ -28,6 +33,7 @@ export class GameObjectContainer extends CommandReceiver { private objects: Map = new Map(); private wasLocalPlayerAlive = false; + private lastStepAtMs = performance.now(); // Creates and deletes take effect on the render timeline, not on arrival. private visibleFrom = new Map(); @@ -67,6 +73,7 @@ export class GameObjectContainer extends CommandReceiver { [PropertyUpdatesForObjects.name]: (c: PropertyUpdatesForObjects) => { serverTimeline.onSnapshot(c.timestamp); this.stampPending(c.timestamp); + this.pruneDeferred(c.timestamp - deferredHistorySeconds); c.updates.forEach((u) => { const object = this.objects.get(u.id); u.updates.forEach((au) => object?.updateProperty?.(au)); @@ -96,6 +103,7 @@ export class GameObjectContainer extends CommandReceiver { this.awaitingDeleteStamp = []; this.remoteCalls = []; this.wasLocalPlayerAlive = false; + this.lastStepAtMs = performance.now(); } public get localPlayer(): CharacterView | undefined { @@ -104,6 +112,12 @@ export class GameObjectContainer extends CommandReceiver { public step(deltaTimeInSeconds: number) { 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.remoteCalls = this.remoteCalls.filter(({ object, calls, at }) => { if (this.objects.get(object.id) !== object) { @@ -166,8 +180,25 @@ export class GameObjectContainer extends CommandReceiver { } } - private retireDueObjects() { - const renderTime = serverTimeline.renderTime; + private pruneDeferred(cutoff: number) { + 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) { if (renderTime >= at) { this.deleteObject(id); @@ -205,8 +236,13 @@ export class GameObjectContainer extends CommandReceiver { } private deleteObject(id: Id) { - this.objects.get(id)?.beforeDestroy?.(); + const object = this.objects.get(id); + object?.beforeDestroy?.(); 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.visibleFrom.delete(id); this.deleteAt.delete(id); diff --git a/test/suspended-rendering.test.ts b/test/suspended-rendering.test.ts new file mode 100644 index 0000000..a1ae8a5 --- /dev/null +++ b/test/suspended-rendering.test.ts @@ -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; + deleteAt: Map; + 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); + }); +});