Claude fixes

This commit is contained in:
Andras Schmelczer 2026-08-22 11:03:58 +01:00
commit 1295828ac8
12 changed files with 2086 additions and 205 deletions

View 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];
}
}

View file

@ -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];
}
}

View file

@ -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,
];
}
}

View file

@ -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;
}

View file

@ -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',
}

View 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);
};

View file

@ -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';

View file

@ -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;

View file

@ -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);
};

View file

@ -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);
}
};

View file

@ -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,

View file

@ -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.