diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index f6a9c6e..d49cfb6 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -88,7 +88,7 @@ export default abstract class Server { }) channel.on('command', (data) => { - const bytes = Buffer.byteLength(data.toString()) + const bytes = Buffer.byteLength(JSON.stringify(data)) this.bandwidthTracker.recordReceived('server', bytes) this.bufferIncomingCommand(channel, data) @@ -174,7 +174,7 @@ export default abstract class Server { type: ServerCommand.SV_STATE, } - this.bandwidthTracker.recordSent('server', Buffer.byteLength(state.toString())) + this.bandwidthTracker.recordSent('server', Buffer.byteLength(JSON.stringify(state))) con.channel.emit(ServerCommand.SV_STATE, state) }) diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index 21c6563..cc3a8a4 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -3,13 +3,14 @@ import type ServerWorld from '../../src/Networking/Server/World' import { beforeEach, describe, expect, it, vi } from 'vitest' import winston from 'winston' import Player from '../../src/Networking/Entities/Player' +import { ServerCommand } from '../../src/Networking/Server/Commands' import Server from '../../src/Networking/Server/Server' const mockGeckosOnConnection = vi.fn() const mockGeckosServer = { onConnection: mockGeckosOnConnection, - connectionsManager: { connections: [] }, + connectionsManager: { connections: new Map() as Map }, emit: vi.fn(), } @@ -153,4 +154,133 @@ describe('server', () => { expect(onCommand).toBeCalledWith(testCommand, randomDelta) }) + + describe('stateSync', () => { + function makeConnection(id: string) { + const emit = vi.fn() + return { + id, + emit, + channel: { emit }, + } + } + + function addPlayer(server: TestServer, id: string, x = 0) { + const player = new TestClient(id) + player.position.set(x, 0, 0) + server.game.world.entities.items.set(id, player) + return player + } + + it('emits SV_STATE to each connected player', () => { + const server = new TestServer(logger) + server.getStateSyncDistance = () => 100 + + const conn1 = makeConnection('p1') + const conn2 = makeConnection('p2') + mockGeckosServer.connectionsManager.connections = new Map([ + ['p1', conn1], + ['p2', conn2], + ]) + + const _p1 = addPlayer(server, 'p1', 0) + const _p2 = addPlayer(server, 'p2', 5) + + ;(server as any).stateSync() + + expect(conn1.channel.emit).toHaveBeenCalledWith( + ServerCommand.SV_STATE, + expect.objectContaining({ type: ServerCommand.SV_STATE }), + ) + expect(conn2.channel.emit).toHaveBeenCalledWith( + ServerCommand.SV_STATE, + expect.objectContaining({ type: ServerCommand.SV_STATE }), + ) + }) + + it('only includes entities within the sync distance', () => { + const server = new TestServer(logger) + server.getStateSyncDistance = () => 10 + + const conn = makeConnection('p1') + mockGeckosServer.connectionsManager.connections = new Map([['p1', conn]]) + + const p1 = addPlayer(server, 'p1', 0) + const nearby = addPlayer(server, 'nearby', 5) + const farAway = addPlayer(server, 'faraway', 50) + + ;(server as any).stateSync() + + const emittedState = conn.channel.emit.mock.calls[0][1] + const ids = emittedState.entities.map((e: any) => e.id) + expect(ids).toContain(p1.id) + expect(ids).toContain(nearby.id) + expect(ids).not.toContain(farAway.id) + }) + + it('calls markSyncd on entities that needed sync', () => { + const server = new TestServer(logger) + server.getStateSyncDistance = () => 100 + + const conn = makeConnection('p1') + mockGeckosServer.connectionsManager.connections = new Map([['p1', conn]]) + + const p1 = addPlayer(server, 'p1', 0) + p1.needsSync = true + + const markSyncd = vi.spyOn(p1, 'markSyncd') + + ;(server as any).stateSync() + + expect(markSyncd).toHaveBeenCalled() + }) + + it('does not call markSyncd on entities that do not need sync and are already tracked', () => { + const server = new TestServer(logger) + server.getStateSyncDistance = () => 100 + + const conn = makeConnection('p1') + mockGeckosServer.connectionsManager.connections = new Map([['p1', conn]]) + + const p1 = addPlayer(server, 'p1', 0) + p1.needsSync = false + p1.trackedEntities.add(p1.id) + + const markSyncd = vi.spyOn(p1, 'markSyncd') + + ;(server as any).stateSync() + + expect(markSyncd).not.toHaveBeenCalled() + }) + + it('removes out-of-range entities from player trackedEntities', () => { + const server = new TestServer(logger) + server.getStateSyncDistance = () => 10 + + const conn = makeConnection('p1') + mockGeckosServer.connectionsManager.connections = new Map([['p1', conn]]) + + const p1 = addPlayer(server, 'p1', 0) + const farAway = addPlayer(server, 'faraway', 50) + p1.trackedEntities.add(farAway.id) + + ;(server as any).stateSync() + + expect(p1.trackedEntities.has(farAway.id)).toBe(false) + }) + + it('skips a player connection that has no matching entity', () => { + const server = new TestServer(logger) + server.getStateSyncDistance = () => 100 + + const conn = makeConnection('ghost') + mockGeckosServer.connectionsManager.connections = new Map([['ghost', conn]]) + + // no entity added for 'ghost' + + ;(server as any).stateSync() + + expect(conn.channel.emit).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/core/tests/Networking/Server/stateSync.bench.ts b/packages/core/tests/Networking/Server/stateSync.bench.ts new file mode 100644 index 0000000..e4648d9 --- /dev/null +++ b/packages/core/tests/Networking/Server/stateSync.bench.ts @@ -0,0 +1,89 @@ +import type { ServerChannel } from '@geckos.io/server' +import { bench, describe, vi } from 'vitest' +import winston from 'winston' +import Player from '../../../src/Networking/Entities/Player' +import Server from '../../../src/Networking/Server/Server' + +const mockGeckosOnConnection = vi.fn() +const mockGeckosServer = { + onConnection: mockGeckosOnConnection, + connectionsManager: { connections: new Map() as Map }, + emit: vi.fn(), +} + +vi.mock('@geckos.io/server', () => ({ + default: vi.fn(() => mockGeckosServer), +})) + +class BenchClient extends Player { + health = 100 + maxHealth = 100 + takeDamage(_amount: number): void {} + heal(_amount: number): void {} + update(_delta: number): void {} + updateFromNetwork(_data: object): void {} +} + +class BenchServer extends Server { + private syncDistance: number + + constructor(logger: winston.Logger, syncDistance: number) { + super(logger) + this.syncDistance = syncDistance + } + + getStateSyncDistance(): number { + return this.syncDistance + } + + protected onCommand(_command: any, _delta: number): void {} + + protected onConnection(channel: ServerChannel): BenchClient { + return new BenchClient(channel.id) + } +} + +const PLAYER_COUNT = 20 + +function buildScenario(syncDistance: number) { + const logger = winston.createLogger({ transports: [], silent: true }) + const server = new BenchServer(logger, syncDistance) + + const connections = new Map() + + for (let i = 0; i < PLAYER_COUNT; i++) { + const id = `player-${i}` + const emit = vi.fn() + connections.set(id, { id, emit, channel: { emit } }) + + const player = new BenchClient(id) + // Spread players in a line so some are in range, some are not + player.position.set(i * 5, 0, 0) + player.needsSync = true + server.game.world.entities.items.set(id, player) + } + + mockGeckosServer.connectionsManager.connections = connections + + return server +} + +describe('stateSync - 20 connections', () => { + // syncDistance large enough to reach all 20 players spread 5 units apart (max dist = 95) + const allInRange = buildScenario(200) + bench('all players within sync distance', () => { + ;(allInRange as any).stateSync() + }) + + // syncDistance covers only first ~10 players (spread 5 units apart, so 50 units) + const halfInRange = buildScenario(50) + bench('half players within sync distance', () => { + ;(halfInRange as any).stateSync() + }) + + // syncDistance too small to reach any neighbour + const noneInRange = buildScenario(1) + bench('no players within sync distance', () => { + ;(noneInRange as any).stateSync() + }) +})