Claude fixes
This commit is contained in:
parent
5d854440ec
commit
1295828ac8
12 changed files with 2086 additions and 205 deletions
19
shared/src/commands/types/actions/client-heartbeat.ts
Normal file
19
shared/src/commands/types/actions/client-heartbeat.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { serializable } from '../../../serialization/serializable';
|
||||
import { Command } from '../../command';
|
||||
|
||||
// Appended to every client -> server batch. Movement is only transmitted when it
|
||||
// changes, so on its own the server's "newest input I have applied" marker
|
||||
// freezes whenever a key is simply held down — which pins the client predictor's
|
||||
// replay window and stops reconciliation from covering the un-acknowledged
|
||||
// input. This carries the client's wall-clock for the batch, so the marker keeps
|
||||
// advancing at the send rate whether or not the input changed.
|
||||
@serializable
|
||||
export class ClientHeartbeatCommand extends Command {
|
||||
public constructor(public readonly clientTimeMs: number = 0) {
|
||||
super();
|
||||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [this.clientTimeMs];
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,17 @@ export class PrimaryActionCommand extends Command {
|
|||
public constructor(
|
||||
public readonly position: vec2,
|
||||
public readonly charge: number = 0,
|
||||
// Client wall-clock at the moment the shot was released. Appended last so
|
||||
// the existing wire order is unchanged. The server uses it (together with
|
||||
// the measured RTT) to fast-forward the projectile by the time the command
|
||||
// spent in flight, so the shooter does not have to lead by their own
|
||||
// latency on top of the projectile's travel time.
|
||||
public readonly clientTimeMs: number = 0,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [this.position, this.charge];
|
||||
return [this.position, this.charge, this.clientTimeMs];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,24 @@ export class InputAcknowledgement extends Command {
|
|||
// or before it is already reflected in bodyVelocity, so the predictor must
|
||||
// not replay it.
|
||||
public readonly lastLeapClientTimeMs: number,
|
||||
// How long before this snapshot the acknowledged input arrived. The client
|
||||
// sends at its frame rate, so by the time a snapshot is taken the newest
|
||||
// input it has is already 0..1 frame old — and that age varies with where
|
||||
// the client's frames happened to fall between two snapshots. Adding it
|
||||
// back places the replay anchor at the snapshot instant itself instead of
|
||||
// at whichever input happened to land last, which is what keeps the
|
||||
// predicted pose free of the send-cadence beat. See LocalCharacterPredictor.
|
||||
public readonly ackAgeMs: number = 0,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs];
|
||||
return [
|
||||
this.clientTimeMs,
|
||||
this.bodyVelocity,
|
||||
this.lastLeapClientTimeMs,
|
||||
this.ackAgeMs,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
export interface PlayerInformation {
|
||||
name: string;
|
||||
// Issued by the server on a previous join. Presented on reconnect to reclaim
|
||||
// the team and score held during the grace window; absent on a first join.
|
||||
reconnectToken?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,13 @@ export enum TransportEvents {
|
|||
ServerInfoUpdate = 'ServerInfoUpdate',
|
||||
Ping = 'Ping',
|
||||
Pong = 'Pong',
|
||||
PlayerJoined = 'PlayerJoined',
|
||||
JoinRejected = 'JoinRejected',
|
||||
}
|
||||
|
||||
export enum JoinRejectionReason {
|
||||
ServerFull = 'ServerFull',
|
||||
RoundEnding = 'RoundEnding',
|
||||
AlreadyJoined = 'AlreadyJoined',
|
||||
InvalidRequest = 'InvalidRequest',
|
||||
}
|
||||
|
|
|
|||
60
shared/src/helper/finite.ts
Normal file
60
shared/src/helper/finite.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
|
||||
export const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
/** A finite number within [min, max], or `fallback` if the input is unusable. */
|
||||
export const finiteInRange = (
|
||||
value: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number,
|
||||
): number => {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(max, Math.max(min, value));
|
||||
};
|
||||
|
||||
/**
|
||||
* A finite 2-vector whose magnitude is within `maxMagnitude`, or `undefined` if
|
||||
* the input is not a usable pair of finite numbers. Callers should treat
|
||||
* `undefined` as "drop this command" rather than substituting a default, so a
|
||||
* malformed action is ignored instead of being silently reinterpreted.
|
||||
*/
|
||||
export const finiteVec2 = (value: unknown, maxMagnitude: number): vec2 | undefined => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidate = value as ArrayLike<unknown>;
|
||||
if (candidate.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const x = candidate[0];
|
||||
const y = candidate[1];
|
||||
if (!isFiniteNumber(x) || !isFiniteNumber(y)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = vec2.fromValues(x, y);
|
||||
const length = vec2.length(result);
|
||||
if (length > maxMagnitude) {
|
||||
vec2.scale(result, result, maxMagnitude / length);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* A display name that is safe to store and re-serialize. Anything that is not a
|
||||
* string is coerced, because a non-string survives `.slice()` — an array name
|
||||
* round-trips through the serializer and is revived as a class on every peer
|
||||
* that receives it, throwing inside the deserializer's reviver.
|
||||
*/
|
||||
export const sanitizeName = (value: unknown, maxLength: number): string => {
|
||||
const text = typeof value === 'string' ? value : String(value ?? '');
|
||||
return text.slice(0, maxLength);
|
||||
};
|
||||
|
|
@ -15,6 +15,7 @@ export * from './commands/command-generator';
|
|||
export * from './commands/types/actions/move-action';
|
||||
export * from './commands/types/actions/primary-action';
|
||||
export * from './commands/types/actions/leap-action';
|
||||
export * from './commands/types/actions/client-heartbeat';
|
||||
export * from './commands/types/actions/set-aspect-ratio-action';
|
||||
export * from './helper/array';
|
||||
export * from './helper/last';
|
||||
|
|
@ -23,6 +24,7 @@ export * from './helper/hsl';
|
|||
export * from './helper/rgb255';
|
||||
export * from './helper/mix-rgb';
|
||||
export * from './helper/clamp';
|
||||
export * from './helper/finite';
|
||||
export * from './helper/charge';
|
||||
export * from './helper/calculate-view-area';
|
||||
export * from './helper/circle';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export class RemoteCall {
|
|||
constructor(
|
||||
public readonly functionName: string,
|
||||
public readonly args: Array<any>,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [this.functionName, this.args];
|
||||
|
|
@ -49,15 +49,22 @@ export class PropertyUpdatesForObject {
|
|||
constructor(
|
||||
public readonly id: Id,
|
||||
public readonly updates: Array<UpdatePropertyCommand>,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [this.id, this.updates];
|
||||
}
|
||||
}
|
||||
|
||||
let currentUpdateGeneration = 0;
|
||||
export const beginPropertyUpdateGeneration = (): void => {
|
||||
currentUpdateGeneration++;
|
||||
};
|
||||
|
||||
export abstract class GameObject extends CommandReceiver {
|
||||
private remoteCalls: Array<RemoteCall> = [];
|
||||
private updateGeneration = -1;
|
||||
private cachedPropertyUpdates?: PropertyUpdatesForObject;
|
||||
|
||||
// The only methods a peer may invoke over the wire. processRemoteCalls
|
||||
// dispatches by a raw string taken straight off the network, so without this
|
||||
|
|
@ -95,7 +102,15 @@ export abstract class GameObject extends CommandReceiver {
|
|||
});
|
||||
}
|
||||
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject | void {}
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject | void { }
|
||||
|
||||
public getPropertyUpdatesForFrame(): PropertyUpdatesForObject | undefined {
|
||||
if (this.updateGeneration !== currentUpdateGeneration) {
|
||||
this.updateGeneration = currentUpdateGeneration;
|
||||
this.cachedPropertyUpdates = this.getPropertyUpdates() ?? undefined;
|
||||
}
|
||||
return this.cachedPropertyUpdates;
|
||||
}
|
||||
|
||||
public getRemoteCalls(): Array<RemoteCall> {
|
||||
return this.remoteCalls;
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ export const decayMomentum = (
|
|||
: settings.airMomentumFriction;
|
||||
const target = Math.min(
|
||||
speed * Math.exp(-friction * deltaTimeInSeconds) -
|
||||
settings.momentumStopDeceleration * deltaTimeInSeconds,
|
||||
settings.momentumStopDeceleration * deltaTimeInSeconds,
|
||||
settings.maxBodyMomentum,
|
||||
);
|
||||
if (target <= 1) {
|
||||
|
|
@ -326,9 +326,13 @@ export const stepCharacterMovement = (
|
|||
vec2.add(leftFootGravity, leftFootGravity, rightFootGravity);
|
||||
const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5);
|
||||
|
||||
const movementLength = vec2.length(movementForce);
|
||||
const gravityLength = vec2.length(gravity);
|
||||
if (
|
||||
movementLength > 0 &&
|
||||
gravityLength > 0 &&
|
||||
vec2.dot(movementForce, gravity) <
|
||||
-vec2.length(movementForce) * settings.climbDotThreshold
|
||||
-movementLength * gravityLength * settings.climbDotThreshold
|
||||
) {
|
||||
vec2.scale(gravity, gravity, settings.climbGravityScale);
|
||||
}
|
||||
|
|
@ -359,7 +363,7 @@ export const stepCharacterMovement = (
|
|||
|
||||
latchGround(state, world.stepBody(state.leftFoot, deltaTimeInSeconds));
|
||||
latchGround(state, world.stepBody(state.rightFoot, deltaTimeInSeconds));
|
||||
latchGround(state, world.stepBody(state.head, deltaTimeInSeconds));
|
||||
world.stepBody(state.head, deltaTimeInSeconds);
|
||||
|
||||
decayBodyMomentum(state, deltaTimeInSeconds);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,11 +26,6 @@ export const depenetrateCircle = (
|
|||
}
|
||||
|
||||
vec2.copy(body.lastNormal, normal);
|
||||
body.center = vec2.scaleAndAdd(
|
||||
vec2.create(),
|
||||
body.center,
|
||||
normal,
|
||||
body.radius - distance + 0.01,
|
||||
);
|
||||
vec2.scaleAndAdd(body.center, body.center, normal, body.radius - distance + 0.01);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,12 +9,18 @@ export interface MarchResult {
|
|||
hitObject?: Sdf;
|
||||
}
|
||||
|
||||
const minimumStep = 0.5;
|
||||
const maximumSteps = 256;
|
||||
|
||||
// Raymarch a circle by `delta`, stopping at the first surface it would overlap.
|
||||
// Extracted verbatim from the backend's move-circle so server and client
|
||||
// resolve motion identically. The collision *reaction* is no longer dispatched
|
||||
// here: on a real (non-ignored) hit, `onHit(intersecting)` is invoked at the
|
||||
// exact point the backend used to dispatch its ReactToCollisionCommands, and
|
||||
// the backend wrapper supplies that callback. The client passes none.
|
||||
// Shared so server and client resolve motion identically. The collision
|
||||
// *reaction* is not dispatched here: on a real (non-ignored) hit,
|
||||
// `onHit(intersecting)` is invoked at the point the backend used to dispatch its
|
||||
// ReactToCollisionCommands, and the backend wrapper supplies that callback. The
|
||||
// client passes none.
|
||||
//
|
||||
// The march advances by the *free gap* (distance minus the circle's radius),
|
||||
// which is what sphere-tracing a circle of radius r requires.
|
||||
export const marchCircle = (
|
||||
body: PhysicsBody,
|
||||
delta: vec2,
|
||||
|
|
@ -22,23 +28,23 @@ export const marchCircle = (
|
|||
ignoreCollision = false,
|
||||
onHit?: (intersecting: Sdf) => void,
|
||||
): MarchResult => {
|
||||
const direction = vec2.clone(delta);
|
||||
const deltaLength = vec2.length(delta);
|
||||
|
||||
if (vec2.length(delta) > 0) {
|
||||
vec2.normalize(direction, direction);
|
||||
// A zero-length move cannot resolve a contact by advancing, so report no hit
|
||||
// rather than letting a caller that loops on `hitSurface` spin forever.
|
||||
if (!(deltaLength > 0)) {
|
||||
return { hitSurface: false };
|
||||
}
|
||||
|
||||
const deltaLength = vec2.length(delta);
|
||||
let travelled = 0;
|
||||
const direction = vec2.normalize(vec2.create(), delta);
|
||||
|
||||
const rayEnd = vec2.create();
|
||||
let prevMinDistance = 0;
|
||||
while (travelled < deltaLength) {
|
||||
travelled += prevMinDistance;
|
||||
vec2.add(
|
||||
rayEnd,
|
||||
body.center,
|
||||
vec2.scale(vec2.create(), direction, Math.min(travelled, deltaLength)),
|
||||
);
|
||||
let travelled = 0;
|
||||
// Furthest point along the ray known to be overlap-free; a hit rewinds here.
|
||||
let lastFreeDistance = 0;
|
||||
|
||||
for (let step = 0; step < maximumSteps; step++) {
|
||||
vec2.scaleAndAdd(rayEnd, body.center, direction, travelled);
|
||||
|
||||
const minDistance = evaluateSdf(rayEnd, possibleIntersectors);
|
||||
|
||||
|
|
@ -48,31 +54,37 @@ export const marchCircle = (
|
|||
)!;
|
||||
|
||||
if (ignoreCollision) {
|
||||
body.center = vec2.add(body.center, body.center, delta);
|
||||
} else {
|
||||
onHit?.(intersecting);
|
||||
// Pass straight through, but still report the contact so callers that
|
||||
// march repeatedly to escape geometry keep making progress.
|
||||
vec2.scaleAndAdd(body.center, body.center, direction, deltaLength);
|
||||
return { hitSurface: true, hitObject: intersecting };
|
||||
}
|
||||
|
||||
vec2.add(
|
||||
rayEnd,
|
||||
body.center,
|
||||
vec2.scale(vec2.create(), direction, travelled - prevMinDistance),
|
||||
);
|
||||
onHit?.(intersecting);
|
||||
|
||||
vec2.scaleAndAdd(rayEnd, body.center, direction, lastFreeDistance);
|
||||
vec2.copy(body.center, rayEnd);
|
||||
|
||||
const normal = sdfNormal(rayEnd, [intersecting]);
|
||||
return {
|
||||
hitSurface: true,
|
||||
normal,
|
||||
normal: sdfNormal(rayEnd, [intersecting]),
|
||||
hitObject: intersecting,
|
||||
};
|
||||
}
|
||||
|
||||
prevMinDistance = minDistance;
|
||||
lastFreeDistance = travelled;
|
||||
|
||||
if (travelled >= deltaLength) {
|
||||
break;
|
||||
}
|
||||
|
||||
travelled = Math.min(
|
||||
travelled + Math.max(minDistance - body.radius, minimumStep),
|
||||
deltaLength,
|
||||
);
|
||||
}
|
||||
|
||||
vec2.add(body.center, body.center, delta);
|
||||
vec2.scaleAndAdd(body.center, body.center, direction, deltaLength);
|
||||
|
||||
return {
|
||||
hitSurface: false,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ export const settings = {
|
|||
// exceeds this, and the rendered ownership ring stays neutral until the same
|
||||
// point — so what you see matches what scores.
|
||||
planetControlThreshold: 0.12,
|
||||
// Extra margin a planet must clear *beyond* planetControlThreshold before a
|
||||
// capture flip pays out again (a Schmitt trigger). Without it, ownership
|
||||
// hovering on the dead-band edge re-triggers the reward, the announcement and
|
||||
// the flare every time it grazes the boundary.
|
||||
planetFlipHysteresis: 0.06,
|
||||
playerMaxHealth: 100,
|
||||
maxGravityStrength: 50000,
|
||||
planetMinReferenceRadius: 150,
|
||||
|
|
@ -202,6 +207,53 @@ export const settings = {
|
|||
// Recoil speed imparted opposite a shot, scaled by its charge (0 for taps).
|
||||
chargeShotRecoilMax: 650,
|
||||
|
||||
// Lag compensation for projectiles. A shot is fast-forwarded by the time its
|
||||
// command spent reaching the server, so the shooter does not have to lead by
|
||||
// their own latency on top of the projectile's travel time. Capped so a very
|
||||
// bad (or forged) timestamp cannot spawn a shot arbitrarily far downrange.
|
||||
maxProjectileCatchUpSeconds: 0.2,
|
||||
|
||||
// How long a dropped player's score and team are held so a reconnect within
|
||||
// the window rejoins as the same player instead of a blank slate.
|
||||
reconnectGraceSeconds: 30,
|
||||
|
||||
// Hard ceiling on one inbound client message. socket.io's 1 MB default is far
|
||||
// more than any legitimate command batch, and parsing is synchronous with the
|
||||
// physics loop — a single oversized message stalls the tick for everyone.
|
||||
maxInboundMessageBytes: 16 * 1024,
|
||||
|
||||
// How often an idle client flushes its command queue. Input is flushed on the
|
||||
// frame it happens, so this only paces the heartbeat that keeps the server's
|
||||
// input acknowledgement moving while a key is simply held.
|
||||
//
|
||||
// It must NOT be the frame rate. Sending once per rendered frame put a 144 Hz
|
||||
// display permanently over the inbound allowance below, and once the burst was
|
||||
// spent the server silently discarded whole batches — including the
|
||||
// edge-triggered movement commands, which are never re-sent, so the player
|
||||
// kept walking the old way until they pressed something else.
|
||||
clientSendInterval: 1 / 30,
|
||||
|
||||
// Inbound allowance per socket: a token bucket sized far above anything a
|
||||
// legitimate client produces (a 30 Hz heartbeat plus whatever discrete input
|
||||
// events a human generates), so it only ever trips on a flood. Dropping a
|
||||
// batch loses real input, so the headroom is deliberate.
|
||||
maxInboundMessagesPerSecond: 240,
|
||||
maxInboundMessageBurst: 480,
|
||||
|
||||
// Ceiling on a measured round-trip time. The Pong that carries it comes from
|
||||
// the client, which can always answer late; the nonce check makes the reply
|
||||
// unforgeable and single-use, and this bounds what stalling one can buy.
|
||||
maxMeasuredRttMs: 1000,
|
||||
|
||||
// Skip the bulky part of a snapshot when the socket already has this much
|
||||
// unflushed. A slow or stalled client otherwise accumulates a backlog it will
|
||||
// never catch up on, and the newest snapshot is the only one that matters.
|
||||
maxBufferedBytesPerClient: 256 * 1024,
|
||||
|
||||
// Sanity bound on a client-supplied world position (the arena is
|
||||
// worldRadius across, so anything past this is nonsense).
|
||||
maxClientPositionMagnitude: 1e6,
|
||||
|
||||
// The central giant is a named, always-contested focus. Its neutral decay is
|
||||
// slowed so control lingers and teams keep fighting over it; flips are
|
||||
// announced to everyone and an off-screen arrow points the way.
|
||||
|
|
|
|||
Loading…
Reference in a new issue