Improve netcode and simplify #4
18 changed files with 155 additions and 223 deletions
more
commit
98c4d63756
|
|
@ -1,5 +1,6 @@
|
||||||
import { PhysicalContainer } from './physics/containers/physical-container';
|
import { PhysicalContainer } from './physics/containers/physical-container';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
TransportEvents,
|
TransportEvents,
|
||||||
deserialize,
|
deserialize,
|
||||||
|
|
@ -19,7 +20,7 @@ import {
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { createWorld } from './create-world';
|
import { createWorld } from './create-world';
|
||||||
import { Options } from './options';
|
import { Options } from './options';
|
||||||
import { PlayerContainer } from './players/player-container';
|
import { CarriedScore, PlayerContainer } from './players/player-container';
|
||||||
import { ServerFullError } from './players/server-full-error';
|
import { ServerFullError } from './players/server-full-error';
|
||||||
import { Player } from './players/player';
|
import { Player } from './players/player';
|
||||||
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
|
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
|
||||||
|
|
@ -29,7 +30,7 @@ const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
||||||
export class GameServer extends CommandReceiver {
|
export class GameServer extends CommandReceiver {
|
||||||
private objects!: PhysicalContainer;
|
private objects!: PhysicalContainer;
|
||||||
private players!: PlayerContainer;
|
private players!: PlayerContainer;
|
||||||
private lastPhysicsBase: [number, number] = process.hrtime();
|
private lastPhysicsBase = process.hrtime.bigint();
|
||||||
|
|
||||||
private bluePoints = 0;
|
private bluePoints = 0;
|
||||||
private redPoints = 0;
|
private redPoints = 0;
|
||||||
|
|
@ -50,7 +51,7 @@ export class GameServer extends CommandReceiver {
|
||||||
this.options.playerLimit,
|
this.options.playerLimit,
|
||||||
this.options.npcCount,
|
this.options.npcCount,
|
||||||
);
|
);
|
||||||
this.lastPhysicsBase = process.hrtime();
|
this.lastPhysicsBase = process.hrtime.bigint();
|
||||||
this.bluePoints = 0;
|
this.bluePoints = 0;
|
||||||
this.redPoints = 0;
|
this.redPoints = 0;
|
||||||
this.matchPointAnnounced = {};
|
this.matchPointAnnounced = {};
|
||||||
|
|
@ -85,7 +86,10 @@ export class GameServer extends CommandReceiver {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly joinedSockets = new Map<Socket, Player>();
|
private readonly joinedSockets = new Map<
|
||||||
|
Socket,
|
||||||
|
{ player: Player; release: () => void }
|
||||||
|
>();
|
||||||
|
|
||||||
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
|
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
|
||||||
if (this.joinedSockets.has(socket)) {
|
if (this.joinedSockets.has(socket)) {
|
||||||
|
|
@ -98,6 +102,11 @@ export class GameServer extends CommandReceiver {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A reconnect arrives seconds after the drop, long before engine.io times
|
||||||
|
// the dead socket out. Retire that ghost first so its slot, team and score
|
||||||
|
// go back to the returning client.
|
||||||
|
const carried = this.retireGhost(playerInfo.reconnectToken);
|
||||||
|
|
||||||
if (this.players.isFull) {
|
if (this.players.isFull) {
|
||||||
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull);
|
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull);
|
||||||
return;
|
return;
|
||||||
|
|
@ -105,7 +114,7 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
let player: Player;
|
let player: Player;
|
||||||
try {
|
try {
|
||||||
player = this.players.createPlayer(playerInfo, socket);
|
player = this.players.createPlayer(playerInfo, socket, carried);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to register joining player', e);
|
console.error('Failed to register joining player', e);
|
||||||
socket.emit(
|
socket.emit(
|
||||||
|
|
@ -134,31 +143,22 @@ export class GameServer extends CommandReceiver {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDisconnect = () => {
|
const onDisconnect = () => {
|
||||||
const p = this.joinedSockets.get(socket);
|
this.removeJoined(socket);
|
||||||
this.joinedSockets.delete(socket);
|
|
||||||
this.inboundBudget.delete(socket);
|
|
||||||
if (!p) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
p.detachFromSocket();
|
|
||||||
const { kills, deaths } = player.scoreSnapshot;
|
|
||||||
this.players.reserveScore(
|
|
||||||
player.reconnectToken,
|
|
||||||
player.team,
|
|
||||||
kills,
|
|
||||||
deaths,
|
|
||||||
Date.now(),
|
|
||||||
);
|
|
||||||
player.destroy();
|
|
||||||
this.players.deletePlayer(player);
|
|
||||||
this.sendServerStateUpdate();
|
this.sendServerStateUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
|
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
|
||||||
socket.on('disconnect', onDisconnect);
|
socket.on('disconnect', onDisconnect);
|
||||||
this.joinedSockets.set(socket, player);
|
this.joinedSockets.set(socket, {
|
||||||
|
player,
|
||||||
|
release: () => {
|
||||||
|
socket.off(TransportEvents.PlayerToServer, onPlayerToServer);
|
||||||
|
socket.off('disconnect', onDisconnect);
|
||||||
|
player.detachFromSocket();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
player.reconnectToken = this.players.issueToken();
|
player.reconnectToken = randomUUID();
|
||||||
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
|
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
|
||||||
|
|
||||||
this.sendServerStateUpdate();
|
this.sendServerStateUpdate();
|
||||||
|
|
@ -188,11 +188,36 @@ export class GameServer extends CommandReceiver {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private releaseJoinedSockets() {
|
private retireGhost(token?: string): CarriedScore | undefined {
|
||||||
for (const [socket, player] of this.joinedSockets) {
|
if (!token) {
|
||||||
socket.removeAllListeners();
|
return undefined;
|
||||||
player.detachFromSocket();
|
|
||||||
}
|
}
|
||||||
|
for (const [socket, { player }] of this.joinedSockets) {
|
||||||
|
if (player.reconnectToken === token) {
|
||||||
|
const carried = { team: player.team, ...player.scoreSnapshot };
|
||||||
|
this.removeJoined(socket);
|
||||||
|
return carried;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeJoined(socket: Socket) {
|
||||||
|
const joined = this.joinedSockets.get(socket);
|
||||||
|
if (!joined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.joinedSockets.delete(socket);
|
||||||
|
this.inboundBudget.delete(socket);
|
||||||
|
// Only the listeners this join added: socket.io keeps its own internal
|
||||||
|
// 'error' guard and the connection-scoped PlayerJoining handler here.
|
||||||
|
joined.release();
|
||||||
|
joined.player.destroy();
|
||||||
|
this.players.deletePlayer(joined.player);
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseJoinedSockets() {
|
||||||
|
this.joinedSockets.forEach(({ release }) => release());
|
||||||
this.joinedSockets.clear();
|
this.joinedSockets.clear();
|
||||||
this.inboundBudget.clear();
|
this.inboundBudget.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -255,9 +280,9 @@ export class GameServer extends CommandReceiver {
|
||||||
private saturatedFrames = 0;
|
private saturatedFrames = 0;
|
||||||
|
|
||||||
private handlePhysics() {
|
private handlePhysics() {
|
||||||
const now = process.hrtime(this.lastPhysicsBase);
|
const frameStart = process.hrtime.bigint();
|
||||||
const delta = now[0] + now[1] / 1e9;
|
const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
|
||||||
this.lastPhysicsBase = process.hrtime();
|
this.lastPhysicsBase = frameStart;
|
||||||
|
|
||||||
if (Date.now() - this.statReportMs > 30000) {
|
if (Date.now() - this.statReportMs > 30000) {
|
||||||
this.statReportMs = Date.now();
|
this.statReportMs = Date.now();
|
||||||
|
|
@ -317,13 +342,9 @@ export class GameServer extends CommandReceiver {
|
||||||
this.players.stepCommunication(delta);
|
this.players.stepCommunication(delta);
|
||||||
this.objects.resetRemoteCalls();
|
this.objects.resetRemoteCalls();
|
||||||
|
|
||||||
const elapsed = process.hrtime(now);
|
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
|
||||||
const physicsDelta = elapsed[0] + elapsed[1] / 1e9;
|
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000);
|
||||||
this.handlePhysics.bind(this),
|
|
||||||
Math.max(0, fixedDelta - physicsDelta) * 1000,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get serverInfo(): ServerInformation {
|
public get serverInfo(): ServerInformation {
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public shootTowards(position: vec2, charge = 0, catchUpSeconds = 0) {
|
public shootTowards(position: vec2, charge = 0) {
|
||||||
if (
|
if (
|
||||||
!this.isAlive ||
|
!this.isAlive ||
|
||||||
this.timeSinceLastShot < settings.projectileCreationInterval ||
|
this.timeSinceLastShot < settings.projectileCreationInterval ||
|
||||||
|
|
@ -295,7 +295,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
this.container.addObject(projectile);
|
this.container.addObject(projectile);
|
||||||
projectile.fastForward(catchUpSeconds);
|
|
||||||
|
|
||||||
if (c > 0) {
|
if (c > 0) {
|
||||||
vec2.scaleAndAdd(
|
vec2.scaleAndAdd(
|
||||||
|
|
|
||||||
|
|
@ -175,24 +175,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private get flipTeam(): CharacterTeam {
|
|
||||||
const control = this.ownership - 0.5;
|
|
||||||
const enter = settings.planetControlThreshold + settings.planetFlipHysteresis;
|
|
||||||
|
|
||||||
if (control > enter) {
|
|
||||||
return CharacterTeam.red;
|
|
||||||
}
|
|
||||||
if (control < -enter) {
|
|
||||||
return CharacterTeam.blue;
|
|
||||||
}
|
|
||||||
if (Math.abs(control) < settings.planetControlThreshold) {
|
|
||||||
return CharacterTeam.neutral;
|
|
||||||
}
|
|
||||||
return this.lastTeam;
|
|
||||||
}
|
|
||||||
|
|
||||||
private detectFlip(game: CommandReceiver) {
|
private detectFlip(game: CommandReceiver) {
|
||||||
const currentTeam = this.flipTeam;
|
const currentTeam = this.team;
|
||||||
if (currentTeam === this.lastTeam) {
|
if (currentTeam === this.lastTeam) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,25 +82,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
wasCollision = hitSurface;
|
wasCollision = hitSurface;
|
||||||
this.object.syncBoundingBox();
|
this.object.syncBoundingBox();
|
||||||
}
|
}
|
||||||
vec2.add(this.center, this.center, delta);
|
|
||||||
vec2.add(this.center, this.center, delta);
|
|
||||||
this.object.syncBoundingBox();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lag compensation: runs the projectile forward by the time its command spent
|
|
||||||
* travelling to the server. Capped by settings.maxProjectileCatchUpSeconds so
|
|
||||||
* a forged timestamp cannot spawn a shot arbitrarily far downrange.
|
|
||||||
*/
|
|
||||||
public fastForward(seconds: number) {
|
|
||||||
const step = settings.targetPhysicsDeltaTimeInSeconds;
|
|
||||||
let remaining = Math.min(Math.max(seconds, 0), settings.maxProjectileCatchUpSeconds);
|
|
||||||
|
|
||||||
while (remaining > 0 && this.isAlive) {
|
|
||||||
const delta = Math.min(step, remaining);
|
|
||||||
this.advance(delta);
|
|
||||||
remaining -= delta;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live view of the circle's box, not a snapshot — the circle keeps it in sync
|
// Live view of the circle's box, not a snapshot — the circle keeps it in sync
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ export class BoundingBoxList {
|
||||||
}
|
}
|
||||||
|
|
||||||
public remove(object: DynamicPhysical) {
|
public remove(object: DynamicPhysical) {
|
||||||
this.objects.splice(
|
const index = this.objects.indexOf(object);
|
||||||
this.objects.findIndex((i) => i === object),
|
if (index >= 0) {
|
||||||
1,
|
this.objects.splice(index, 1);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public forEach(func: (object: DynamicPhysical) => unknown) {
|
public forEach(func: (object: DynamicPhysical) => unknown) {
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ import {
|
||||||
settings,
|
settings,
|
||||||
sanitizeName,
|
sanitizeName,
|
||||||
boundRadius,
|
boundRadius,
|
||||||
|
evaluateSdf,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||||
import { evaluateSdf } from 'shared';
|
|
||||||
import { CharacterPhysical } from '../objects/character-physical';
|
import { CharacterPhysical } from '../objects/character-physical';
|
||||||
import { PlanetPhysical } from '../objects/planet-physical';
|
import { PlanetPhysical } from '../objects/planet-physical';
|
||||||
import { PlayerContainer } from './player-container';
|
import { PlayerContainer } from './player-container';
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,18 @@ import { NPC } from './npc';
|
||||||
import { Player } from './player';
|
import { Player } from './player';
|
||||||
import { PlayerBase } from './player-base';
|
import { PlayerBase } from './player-base';
|
||||||
import { ServerFullError } from './server-full-error';
|
import { ServerFullError } from './server-full-error';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
// Score held for a dropped player so a reconnect inside the grace window
|
// Team and score carried over from the connection a reconnecting client is
|
||||||
// resumes the match instead of starting from zero.
|
// replacing, so it resumes the match instead of starting from zero.
|
||||||
interface ReservedScore {
|
export interface CarriedScore {
|
||||||
team: CharacterTeam;
|
team: CharacterTeam;
|
||||||
kills: number;
|
kills: number;
|
||||||
deaths: number;
|
deaths: number;
|
||||||
expiresAtMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PlayerContainer {
|
export class PlayerContainer {
|
||||||
private _players: Array<Player> = [];
|
private _players: Array<Player> = [];
|
||||||
private _npcs: Array<NPC> = [];
|
private _npcs: Array<NPC> = [];
|
||||||
private reservedScores = new Map<string, ReservedScore>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly objects: PhysicalContainer,
|
private readonly objects: PhysicalContainer,
|
||||||
|
|
@ -42,80 +39,34 @@ export class PlayerContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
|
public createPlayer(
|
||||||
|
playerInfo: PlayerInformation,
|
||||||
|
socket: Socket,
|
||||||
|
carried?: CarriedScore,
|
||||||
|
): Player {
|
||||||
if (this._players.length >= this.playerMaxCount) {
|
if (this._players.length >= this.playerMaxCount) {
|
||||||
throw new ServerFullError();
|
throw new ServerFullError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const reserved = this.claimReservedScore(playerInfo.reconnectToken);
|
const team = carried ? carried.team : this.getTeamOfNextPlayer();
|
||||||
const team = reserved ? reserved.team : this.getTeamOfNextPlayer();
|
|
||||||
|
|
||||||
const player = new Player(playerInfo, this, this.objects, team, socket);
|
// Retire the bot before the player spawns: its body would otherwise push
|
||||||
|
// the spawn search away from the position it picked.
|
||||||
let npcToReplace = this._npcs.find((n) => n.team === team);
|
const npcToReplace =
|
||||||
if (!npcToReplace) {
|
this._npcs.find((n) => n.team === team) ?? this._npcs.find((n) => n.team !== team);
|
||||||
npcToReplace = this._npcs.find((n) => n.team !== team);
|
|
||||||
}
|
|
||||||
npcToReplace?.destroy();
|
npcToReplace?.destroy();
|
||||||
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
|
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
|
||||||
|
|
||||||
|
const player = new Player(playerInfo, this, this.objects, team, socket);
|
||||||
this._players.push(player);
|
this._players.push(player);
|
||||||
|
|
||||||
if (reserved) {
|
if (carried) {
|
||||||
player.restoreScore(reserved.kills, reserved.deaths);
|
player.restoreScore(carried.kills, carried.deaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A fresh token for a joining player; only redeemable once the player
|
|
||||||
* actually drops (see reserveScore).
|
|
||||||
*/
|
|
||||||
public issueToken(): string {
|
|
||||||
return randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hold a dropped player's team and score against its token for the grace
|
|
||||||
* window, so a reconnecting client rejoins as itself.
|
|
||||||
*/
|
|
||||||
public reserveScore(
|
|
||||||
token: string,
|
|
||||||
team: CharacterTeam,
|
|
||||||
kills: number,
|
|
||||||
deaths: number,
|
|
||||||
nowMs: number,
|
|
||||||
) {
|
|
||||||
this.expireReservations(nowMs);
|
|
||||||
this.reservedScores.set(token, {
|
|
||||||
team,
|
|
||||||
kills,
|
|
||||||
deaths,
|
|
||||||
expiresAtMs: nowMs + settings.reconnectGraceSeconds * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private claimReservedScore(token?: string): ReservedScore | undefined {
|
|
||||||
if (!token || typeof token !== 'string') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const reserved = this.reservedScores.get(token);
|
|
||||||
if (!reserved) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
this.reservedScores.delete(token);
|
|
||||||
return reserved;
|
|
||||||
}
|
|
||||||
|
|
||||||
private expireReservations(nowMs: number) {
|
|
||||||
for (const [token, reserved] of this.reservedScores) {
|
|
||||||
if (reserved.expiresAtMs <= nowMs) {
|
|
||||||
this.reservedScores.delete(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public get players(): Array<PlayerBase> {
|
public get players(): Array<PlayerBase> {
|
||||||
return [...this._players, ...this._npcs];
|
return [...this._players, ...this._npcs];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,7 @@ export class Player extends PlayerBase {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.observeClientTime(c.clientTimeMs);
|
this.observeClientTime(c.clientTimeMs);
|
||||||
this.character?.shootTowards(
|
this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0));
|
||||||
position,
|
|
||||||
finiteInRange(c.charge, 0, 1, 0),
|
|
||||||
this.catchUpSeconds,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[LeapActionCommand.type]: (c: LeapActionCommand) => {
|
[LeapActionCommand.type]: (c: LeapActionCommand) => {
|
||||||
if (!isFiniteNumber(c.clientTimeMs)) {
|
if (!isFiniteNumber(c.clientTimeMs)) {
|
||||||
|
|
@ -125,12 +121,6 @@ export class Player extends PlayerBase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derived from the measured RTT, NOT the client's timestamp, so a forged
|
|
||||||
// stamp cannot buy extra lag compensation. fastForward caps the result.
|
|
||||||
private get catchUpSeconds(): number {
|
|
||||||
return Math.max(0, this.rttMs / 2 / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
playerInfo: PlayerInformation,
|
playerInfo: PlayerInformation,
|
||||||
playerContainer: PlayerContainer,
|
playerContainer: PlayerContainer,
|
||||||
|
|
|
||||||
|
|
@ -173,4 +173,9 @@ const main = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
main();
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
hide(spinner);
|
||||||
|
joinNotice.innerText = 'Something went wrong. Please reload the page.';
|
||||||
|
joinNotice.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ export class Game extends CommandReceiver {
|
||||||
this.gameObjects = new GameObjectContainer(this);
|
this.gameObjects = new GameObjectContainer(this);
|
||||||
this.overlay.innerHTML = '';
|
this.overlay.innerHTML = '';
|
||||||
this.keystoneArrow = undefined;
|
this.keystoneArrow = undefined;
|
||||||
|
this.connectionBanner = undefined;
|
||||||
this.lastMinimap = undefined;
|
this.lastMinimap = undefined;
|
||||||
this.isEnding = false;
|
this.isEnding = false;
|
||||||
this.lastAnnouncementText = '';
|
this.lastAnnouncementText = '';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"target": "es6",
|
"target": "es6",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"ignoreDeprecations": "6.0",
|
"ignoreDeprecations": "6.0",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,7 @@
|
||||||
let _seed = Math.random();
|
let seed = Math.random();
|
||||||
|
|
||||||
export function setRandomSeed(value: number) {
|
function getRandom(): number {
|
||||||
_seed = value;
|
let t = (seed += 0x6d2b79f5);
|
||||||
}
|
|
||||||
|
|
||||||
export function chooseRandom<T>(values: Array<T>): T | undefined {
|
|
||||||
const to = values.length;
|
|
||||||
if (to === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return values[Math.floor(getRandomInRange(0, to))];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRandomInRange(from: number, to: number): number {
|
|
||||||
return from + getRandom() * (to - from);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRandom(): number {
|
|
||||||
let t = (_seed += 0x6d2b79f5);
|
|
||||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
|
@ -25,12 +9,18 @@ export function getRandom(): number {
|
||||||
|
|
||||||
export const Random = {
|
export const Random = {
|
||||||
get seed() {
|
get seed() {
|
||||||
return _seed;
|
return seed;
|
||||||
},
|
},
|
||||||
set seed(v: number) {
|
set seed(value: number) {
|
||||||
_seed = v;
|
seed = value;
|
||||||
},
|
},
|
||||||
choose: chooseRandom,
|
|
||||||
getRandomInRange,
|
|
||||||
getRandom,
|
getRandom,
|
||||||
|
getRandomInRange(from: number, to: number): number {
|
||||||
|
return from + getRandom() * (to - from);
|
||||||
|
},
|
||||||
|
choose<T>(values: Array<T>): T | undefined {
|
||||||
|
return values.length === 0
|
||||||
|
? undefined
|
||||||
|
: values[Math.floor(getRandom() * values.length)];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ export class PlanetBase extends GameObject {
|
||||||
this.vertices.length;
|
this.vertices.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single rule for the dead-band: scoring (server) and every tint (client)
|
// The one capture rule: server flips and scoring, client tints and the
|
||||||
// read this rather than duplicating the comparison.
|
// keystone arrow all read this.
|
||||||
public get team(): CharacterTeam {
|
public get team(): CharacterTeam {
|
||||||
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
|
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
|
||||||
? CharacterTeam.neutral
|
? CharacterTeam.neutral
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,9 @@ export const marchCircle = (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
vec2.scaleAndAdd(body.center, body.center, direction, deltaLength);
|
// lastFreeDistance, not deltaLength: on a completed march they are equal, and
|
||||||
|
// if the step budget ran out it is the furthest point actually checked.
|
||||||
|
vec2.scaleAndAdd(body.center, body.center, direction, lastFreeDistance);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hitSurface: false,
|
hitSurface: false,
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,9 @@ export const settings = {
|
||||||
maxGravityDistance: 800,
|
maxGravityDistance: 800,
|
||||||
minGravityDistance: 1,
|
minGravityDistance: 1,
|
||||||
maxGravityQ: 5000,
|
maxGravityQ: 5000,
|
||||||
// Neutral dead-band around 50% ownership; the rendered ring uses the same
|
// Neutral dead-band around 50% ownership. The single capture rule: flips,
|
||||||
// threshold as scoring so visuals match state.
|
// scoring and every client-side tint all read PlanetBase.team.
|
||||||
planetControlThreshold: 0.12,
|
planetControlThreshold: 0.12,
|
||||||
// Schmitt trigger: extra margin beyond planetControlThreshold before a flip
|
|
||||||
// pays out again, so boundary-hovering doesn't re-trigger the reward.
|
|
||||||
planetFlipHysteresis: 0.06,
|
|
||||||
playerMaxHealth: 100,
|
playerMaxHealth: 100,
|
||||||
maxGravityStrength: 50000,
|
maxGravityStrength: 50000,
|
||||||
planetMinReferenceRadius: 150,
|
planetMinReferenceRadius: 150,
|
||||||
|
|
@ -171,11 +168,6 @@ export const settings = {
|
||||||
|
|
||||||
chargeShotRecoilMax: 650,
|
chargeShotRecoilMax: 650,
|
||||||
|
|
||||||
// Capped so a forged timestamp cannot spawn a shot arbitrarily far downrange.
|
|
||||||
maxProjectileCatchUpSeconds: 0.2,
|
|
||||||
|
|
||||||
reconnectGraceSeconds: 30,
|
|
||||||
|
|
||||||
// DoS guard: parsing is synchronous with the physics loop, so one oversized
|
// DoS guard: parsing is synchronous with the physics loop, so one oversized
|
||||||
// message stalls the tick for everyone.
|
// message stalls the tick for everyone.
|
||||||
maxInboundMessageBytes: 16 * 1024,
|
maxInboundMessageBytes: 16 * 1024,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"target": "es6",
|
"target": "es6",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"ignoreDeprecations": "6.0",
|
"ignoreDeprecations": "6.0",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
|
|
|
||||||
|
|
@ -69,31 +69,7 @@ describe('projectile broadphase registration', () => {
|
||||||
expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile);
|
expect(container.findIntersecting(boxAround([0, 0], 100))).not.toContain(projectile);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fast-forwards a shot by the sender latency', () => {
|
it('spawns a shot just clear of the shooter, not a jump downrange', () => {
|
||||||
const container = new PhysicalContainer();
|
|
||||||
container.initialize();
|
|
||||||
const spawn = vec2.fromValues(0, 0);
|
|
||||||
|
|
||||||
const make = () =>
|
|
||||||
new ProjectilePhysical(
|
|
||||||
vec2.clone(spawn),
|
|
||||||
20,
|
|
||||||
40,
|
|
||||||
CharacterTeam.blue,
|
|
||||||
vec2.fromValues(3000, 0),
|
|
||||||
{ team: CharacterTeam.blue } as never,
|
|
||||||
container,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const plain = make();
|
|
||||||
const compensated = make();
|
|
||||||
compensated.fastForward(0.1);
|
|
||||||
|
|
||||||
expect(compensated.center[0]).toBeGreaterThan(plain.center[0] + 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never fast-forwards further than the cap', () => {
|
|
||||||
const container = new PhysicalContainer();
|
const container = new PhysicalContainer();
|
||||||
container.initialize();
|
container.initialize();
|
||||||
const projectile = new ProjectilePhysical(
|
const projectile = new ProjectilePhysical(
|
||||||
|
|
@ -106,9 +82,8 @@ describe('projectile broadphase registration', () => {
|
||||||
container,
|
container,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
projectile.fastForward(10);
|
|
||||||
const cap = settings.chargeShotSpeedMax * settings.maxProjectileCatchUpSeconds;
|
expect(projectile.center[0]).toBeLessThanOrEqual(15);
|
||||||
expect(projectile.center[0]).toBeLessThanOrEqual(cap + 50);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,44 @@ describe('reconnect tokens', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reconnect while the dropped socket is still live', () => {
|
||||||
|
// engine.io needs ~45 s to notice a transport drop; the client is back in ~1 s.
|
||||||
|
it('retires the ghost so the returning player keeps its slot, team and score', () => {
|
||||||
|
const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 });
|
||||||
|
|
||||||
|
const first = attach();
|
||||||
|
first.fire(TransportEvents.PlayerJoining, { name: 'flaky' });
|
||||||
|
const token = first.lastPayloadFor(TransportEvents.PlayerJoined) as string;
|
||||||
|
expect(server.serverInfo.playerCount).toBe(1);
|
||||||
|
|
||||||
|
// No 'disconnect' fired: the server still holds the dead socket's player.
|
||||||
|
const second = attach();
|
||||||
|
second.fire(TransportEvents.PlayerJoining, { name: 'flaky', reconnectToken: token });
|
||||||
|
|
||||||
|
expect(second.lastPayloadFor(TransportEvents.JoinRejected)).toBeUndefined();
|
||||||
|
expect(second.lastPayloadFor(TransportEvents.PlayerJoined)).toBeTruthy();
|
||||||
|
expect(server.serverInfo.playerCount).toBe(1);
|
||||||
|
expect(first.listenerCount(TransportEvents.PlayerToServer)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not hand a slot to an unknown token', () => {
|
||||||
|
const { server, attach } = startServer({ playerLimit: 1, npcCount: 0 });
|
||||||
|
|
||||||
|
attach().fire(TransportEvents.PlayerJoining, { name: 'holder' });
|
||||||
|
|
||||||
|
const stranger = attach();
|
||||||
|
stranger.fire(TransportEvents.PlayerJoining, {
|
||||||
|
name: 'stranger',
|
||||||
|
reconnectToken: 'not-a-real-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stranger.lastPayloadFor(TransportEvents.JoinRejected)).toBe(
|
||||||
|
JoinRejectionReason.ServerFull,
|
||||||
|
);
|
||||||
|
expect(server.serverInfo.playerCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('socket listeners', () => {
|
describe('socket listeners', () => {
|
||||||
it('takes every listener back off when the player drops', () => {
|
it('takes every listener back off when the player drops', () => {
|
||||||
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
|
const { attach } = startServer({ playerLimit: 4, npcCount: 0 });
|
||||||
|
|
@ -266,7 +304,8 @@ describe('socket listeners', () => {
|
||||||
socket.fire('disconnect');
|
socket.fire('disconnect');
|
||||||
|
|
||||||
expect(socket.listenerCount(TransportEvents.Pong)).toBe(0);
|
expect(socket.listenerCount(TransportEvents.Pong)).toBe(0);
|
||||||
expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(1);
|
expect(socket.listenerCount(TransportEvents.PlayerToServer)).toBe(0);
|
||||||
|
expect(socket.listenerCount('disconnect')).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not stack a listener per reconnect', () => {
|
it('does not stack a listener per reconnect', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue