Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/Networking/Server/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export default abstract class Server<TClient extends GameObject> {
})

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)
Expand Down Expand Up @@ -174,7 +174,7 @@ export default abstract class Server<TClient extends GameObject> {
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)
})

Expand Down
132 changes: 131 additions & 1 deletion packages/core/tests/Networking/Server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> },
emit: vi.fn(),
}

Expand Down Expand Up @@ -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()
})
})
})
89 changes: 89 additions & 0 deletions packages/core/tests/Networking/Server/stateSync.bench.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> },
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<BenchClient> {
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<string, any>()

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()
})
})
Loading