svelte-realtime 0.6.0-next.21 → 0.6.0-next.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/server/smooth.js +316 -251
package/package.json
CHANGED
package/src/server/smooth.js
CHANGED
|
@@ -269,6 +269,12 @@ function _smoothRecord(name, cfg, platform, rt) {
|
|
|
269
269
|
lastSeenOwner: null,
|
|
270
270
|
lastRenew: 0,
|
|
271
271
|
owned: false,
|
|
272
|
+
// Owner wall-clock basis captured at a non-owner from inbound acks (which
|
|
273
|
+
// carry the owner's `t`): a forwarding edge reconstructs the owner's clock
|
|
274
|
+
// from this to measure a shot's latency on the owner's axis. Null until the
|
|
275
|
+
// first ack with a `t` lands; read only on the forwarded-shoot path.
|
|
276
|
+
lastOwnerT: null,
|
|
277
|
+
lastOwnerWall: 0,
|
|
272
278
|
// Warm-handoff snapshot state (opt-in; null/0 on the default path).
|
|
273
279
|
// `lastSnap` throttles the owner's debounced write; `pendingSnapshot`
|
|
274
280
|
// holds states recovered on acquire until each entity's real client
|
|
@@ -886,6 +892,13 @@ function _ensureSmoothCluster(smooth) {
|
|
|
886
892
|
onAck: (wireTopic, identity, payload) => {
|
|
887
893
|
const rec = _smoothRecByWire(wireTopic);
|
|
888
894
|
if (!rec) return;
|
|
895
|
+
// Capture the owner's wall stamp (carried on every ack) so a non-owner can
|
|
896
|
+
// reconstruct the owner's clock for an edge-measured forwarded shot. Gated
|
|
897
|
+
// on hitTest so a non-lag-comp topic pays nothing.
|
|
898
|
+
if (rec.cfg.hitTest !== undefined && payload && typeof payload.t === 'number' && Number.isFinite(payload.t)) {
|
|
899
|
+
rec.lastOwnerT = payload.t;
|
|
900
|
+
rec.lastOwnerWall = wallEpoch();
|
|
901
|
+
}
|
|
889
902
|
const ws = rec.registry.get(identity);
|
|
890
903
|
if (ws !== undefined) _smoothSendTo(rec, ws, 'ack', payload);
|
|
891
904
|
},
|
|
@@ -902,6 +915,41 @@ function _ensureSmoothCluster(smooth) {
|
|
|
902
915
|
_smoothRelayRemove(rec, removed[i]);
|
|
903
916
|
}
|
|
904
917
|
if (rec.authority.size === 0) _smoothForget(rec);
|
|
918
|
+
},
|
|
919
|
+
// Owner: a non-owner forwarded a client's shot. Resolve it against the ring
|
|
920
|
+
// using the EDGE-measured durations (reach width + rewind age) applied to the
|
|
921
|
+
// owner's OWN present - never re-measuring across the inter-instance hop, which
|
|
922
|
+
// would fold that hop into the window. The authoritative hit rides the owner's
|
|
923
|
+
// existing event broadcast back to the shooter's instance, so a forwarded shot
|
|
924
|
+
// needs no correlated reply.
|
|
925
|
+
onShoot: (wireTopic, identity, originInstance, payload) => {
|
|
926
|
+
const rec = _smoothRecByWire(wireTopic);
|
|
927
|
+
if (!rec || !rec.owned || rec.lagComp === null) return;
|
|
928
|
+
if (!payload || typeof payload !== 'object') return;
|
|
929
|
+
const shooterEntity = rec.authority.get(identity);
|
|
930
|
+
if (shooterEntity === undefined) return; // no entity here: this shooter cannot aim
|
|
931
|
+
const ht = rec.cfg.hitTest;
|
|
932
|
+
const reach = typeof payload.reach === 'number' && Number.isFinite(payload.reach)
|
|
933
|
+
? Math.min(payload.reach, ht.maxRewindMs)
|
|
934
|
+
: ht.maxRewindMs;
|
|
935
|
+
const rewindAge =
|
|
936
|
+
typeof payload.rewindAge === 'number' && Number.isFinite(payload.rewindAge) && payload.rewindAge >= 0
|
|
937
|
+
? payload.rewindAge
|
|
938
|
+
: null;
|
|
939
|
+
// The detection signal fires from the edge-measured picture the owner cannot
|
|
940
|
+
// recompute; a throwing hook never affects the shot.
|
|
941
|
+
if (ht.detectionHook !== undefined && payload.detect && typeof payload.detect === 'object') {
|
|
942
|
+
try {
|
|
943
|
+
// Spread the forwarded picture first, then the trusted identity, so a
|
|
944
|
+
// forged payload.detect.identity can never override the authoritative shooter.
|
|
945
|
+
ht.detectionHook({ ...payload.detect, identity });
|
|
946
|
+
} catch {
|
|
947
|
+
/* observability only */
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const nowMono = rec.monoClock.mono(wallEpoch());
|
|
951
|
+
const rewindAt = _smoothRewindAt(nowMono, reach, rewindAge);
|
|
952
|
+
_smoothResolveShot(rec, rec.name, identity, shooterEntity, rec.platform, payload.cmd, rewindAt).catch(() => {});
|
|
905
953
|
}
|
|
906
954
|
});
|
|
907
955
|
}
|
|
@@ -1082,6 +1130,240 @@ function _shotUnitDir(d) {
|
|
|
1082
1130
|
return null;
|
|
1083
1131
|
}
|
|
1084
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* Reconstruct the topic owner's wall clock at a non-owner (the forwarding edge).
|
|
1135
|
+
* The owner stamps an absolute `t` on every ack; the edge captures it with its
|
|
1136
|
+
* own wall time (`onAck`), so the owner's clock "now" is that stamp plus the
|
|
1137
|
+
* wall time elapsed since. Returns null until an ack with a `t` has been seen
|
|
1138
|
+
* (cold start) - the edge then forwards no rewind age and the owner resolves the
|
|
1139
|
+
* shot at the present (favor the defender). Wall-elapsed (not the monotonic
|
|
1140
|
+
* seam) keeps it deterministic under a seeded/faked clock.
|
|
1141
|
+
* @param {any} rec
|
|
1142
|
+
* @returns {number | null}
|
|
1143
|
+
*/
|
|
1144
|
+
function _edgeOwnerNow(rec) {
|
|
1145
|
+
if (rec.lastOwnerT === null) return null;
|
|
1146
|
+
return rec.lastOwnerT + (wallEpoch() - rec.lastOwnerWall);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Convert a reach window width + a rewind age (both DURATIONS, milliseconds)
|
|
1151
|
+
* into a rewindAt on a ring's own monotonic axis. A null age resolves at the
|
|
1152
|
+
* present (favor the defender); otherwise the aimed instant `now - age` is
|
|
1153
|
+
* floored by the reach window and capped at the present. This is the age-form of
|
|
1154
|
+
* the single-instance clamp `max(now - reach, min(rtMono, now))` and is
|
|
1155
|
+
* bit-identical to it for a local shot (where `age = now - rt`).
|
|
1156
|
+
* @param {number} nowMono @param {number} reach @param {number | null} rewindAge
|
|
1157
|
+
* @returns {number}
|
|
1158
|
+
*/
|
|
1159
|
+
function _smoothRewindAt(nowMono, reach, rewindAge) {
|
|
1160
|
+
if (rewindAge === null || rewindAge === undefined) return nowMono;
|
|
1161
|
+
return Math.min(nowMono, Math.max(nowMono - reach, nowMono - rewindAge));
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Edge measurement for a shot: from the shot payload compute the favor-shooter
|
|
1166
|
+
* reach WIDTH and the rewind AGE (both durations, axis-free), run the per-
|
|
1167
|
+
* connection replay defense + latch, and - when a `detectionHook` is configured
|
|
1168
|
+
* - the latency detection picture. `now` is the wall time on the OWNER's axis:
|
|
1169
|
+
* `wallEpoch()` on the owner / single instance, the reconstructed owner clock on
|
|
1170
|
+
* a forwarding edge (null on edge cold start -> resolve at present). Both the
|
|
1171
|
+
* uplink sample and the replay latch live on this connection's `ws` (the edge
|
|
1172
|
+
* always holds the real shooter socket, so the WeakMap already keys on the
|
|
1173
|
+
* origin client, never the inter-instance hop). Returns the forwarded payload
|
|
1174
|
+
* shape `{ cmd, reach, rewindAge, detect, nowMono }`, or null when the shot is a
|
|
1175
|
+
* replayed / older render-time the latch rejects.
|
|
1176
|
+
* @param {any} rec @param {any} ctx @param {any} payload @param {string} shooterKey @param {number | null} now
|
|
1177
|
+
* @returns {{ cmd: any, reach: number, rewindAge: number | null, detect: any, nowMono: number | null } | null}
|
|
1178
|
+
*/
|
|
1179
|
+
function _smoothEdgeMeasure(rec, ctx, payload, shooterKey, now) {
|
|
1180
|
+
const ht = rec.cfg.hitTest;
|
|
1181
|
+
const cmd = payload.cmd;
|
|
1182
|
+
// No owner-clock basis yet (edge cold start): forward at the present.
|
|
1183
|
+
if (now === null) return { cmd, reach: ht.maxRewindMs, rewindAge: null, detect: null, nowMono: null };
|
|
1184
|
+
const nowMono = rec.monoClock.mono(now);
|
|
1185
|
+
const monoCorr = nowMono - now;
|
|
1186
|
+
const rtStamp = payload.rt;
|
|
1187
|
+
let reach = ht.maxRewindMs;
|
|
1188
|
+
let rewindAge = null;
|
|
1189
|
+
let detect = null;
|
|
1190
|
+
if (typeof rtStamp === 'number' && Number.isFinite(rtStamp)) {
|
|
1191
|
+
let st = ctx.ws ? _lcRtt.get(ctx.ws) : undefined;
|
|
1192
|
+
if (ctx.ws && st === undefined) {
|
|
1193
|
+
st = { tracker: createRttTracker(), lastRt: -Infinity };
|
|
1194
|
+
_lcRtt.set(ctx.ws, st);
|
|
1195
|
+
}
|
|
1196
|
+
// Map the wall-axis render-time onto the monotonic axis so the replay
|
|
1197
|
+
// defense, the latch, and the age all live on one axis (a wall backstep
|
|
1198
|
+
// then stays continuous). monoCorr is zero in normal operation.
|
|
1199
|
+
const rtMono = rtStamp + monoCorr;
|
|
1200
|
+
// Replay defense: a real rendered instant only advances, so a render-time
|
|
1201
|
+
// strictly OLDER than the last accepted one (a captured shot resent to
|
|
1202
|
+
// re-resolve an old lineup) is dropped before it can resolve or forward.
|
|
1203
|
+
if (st && rtMono < st.lastRt) return null;
|
|
1204
|
+
const ackT = payload.ackT;
|
|
1205
|
+
if (st && typeof ackT === 'number' && Number.isFinite(ackT) && ackT <= now && now - ackT <= ht.maxRewindMs) {
|
|
1206
|
+
st.tracker.sample((now - ackT) / 2, nowMono);
|
|
1207
|
+
}
|
|
1208
|
+
// Favor-the-shooter reach width = measured uplink (max-of-recent) + the
|
|
1209
|
+
// client's interpolation delay; both server-measured, clamped to the cap.
|
|
1210
|
+
const maxUp = st ? st.tracker.maxUplink() : null;
|
|
1211
|
+
const serverInterp = rec.interest.interpDelayMs(shooterKey, rec.tickMs, now);
|
|
1212
|
+
reach = maxUp === null ? ht.maxRewindMs : Math.min(ht.maxRewindMs, maxUp + serverInterp);
|
|
1213
|
+
// The rewind age is a pure duration the owner applies to its own present.
|
|
1214
|
+
rewindAge = Math.max(0, now - rtStamp);
|
|
1215
|
+
if (st) st.lastRt = Math.max(st.lastRt, Math.min(rtMono, nowMono));
|
|
1216
|
+
if (ht.detectionHook !== undefined && st) {
|
|
1217
|
+
const minUp = st.tracker.minUplink();
|
|
1218
|
+
detect = {
|
|
1219
|
+
minUplink: minUp,
|
|
1220
|
+
maxUplink: maxUp,
|
|
1221
|
+
reach,
|
|
1222
|
+
interpDelay: serverInterp,
|
|
1223
|
+
divergence: minUp !== null && maxUp !== null ? maxUp - minUp : 0
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return { cmd, reach, rewindAge, detect, nowMono };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Resolve a shot against the rewound world: gate candidates at `rewindAt`, run
|
|
1232
|
+
* the shot geometry from the shooter's CURRENT state, the broadphase + per-
|
|
1233
|
+
* candidate narrowphase, the nearest-first `onHit` consequence, and the hit-
|
|
1234
|
+
* event broadcast (plus cluster relay). Shared by the local / owner-direct shot
|
|
1235
|
+
* path and the forwarded-shot owner handler; the caller computes `rewindAt`
|
|
1236
|
+
* (directly from a local measurement, or from forwarded durations on the owner)
|
|
1237
|
+
* and supplies the platform whose `smooth` coordinator relays the hit events.
|
|
1238
|
+
* @param {any} rec @param {string} name @param {string} shooterKey
|
|
1239
|
+
* @param {any} shooterEntity @param {any} ctxPlatform @param {any} cmd @param {number} rewindAt
|
|
1240
|
+
*/
|
|
1241
|
+
async function _smoothResolveShot(rec, name, shooterKey, shooterEntity, ctxPlatform, cmd, rewindAt) {
|
|
1242
|
+
const ht = rec.cfg.hitTest;
|
|
1243
|
+
const cluster = ctxPlatform && ctxPlatform.smooth;
|
|
1244
|
+
// Candidate set, gated at the REWIND instant rather than at receipt: a target
|
|
1245
|
+
// the shooter had on screen when it fired is a valid hit even if it drifted
|
|
1246
|
+
// out of range in flight, and one that drifted in only after firing is not.
|
|
1247
|
+
const candKeys = new Set();
|
|
1248
|
+
const liveCand = rec.interest.getCandidates(shooterKey);
|
|
1249
|
+
if (liveCand !== undefined) for (const k of liveCand) if (k !== shooterKey) candKeys.add(k);
|
|
1250
|
+
// The geometric gate compares ring positions against the interest radius, so
|
|
1251
|
+
// it is only sound when the ring records the SAME position the interest set
|
|
1252
|
+
// uses (the default, where hitTest.position falls back to interest.position).
|
|
1253
|
+
// A custom hitTest.position in another space, or a null rewound center, skips
|
|
1254
|
+
// the gate and falls back to the receipt-time membership.
|
|
1255
|
+
const gateInRingSpace = rec.cfg.hitTest.position === rec.cfg.interest.position;
|
|
1256
|
+
const shooterAt = gateInRingSpace ? rec.lagComp.sample(shooterKey, rewindAt) : null;
|
|
1257
|
+
let world;
|
|
1258
|
+
if (shooterAt === null) {
|
|
1259
|
+
if (candKeys.size === 0) return;
|
|
1260
|
+
world = rec.lagComp.rewind(candKeys, rewindAt);
|
|
1261
|
+
} else {
|
|
1262
|
+
const radius = rec.interest.radius;
|
|
1263
|
+
// Also broadphase the departed shell - entities near the shooter's rewound
|
|
1264
|
+
// position the receipt-time set no longer lists (they left in flight). The
|
|
1265
|
+
// exact gate below trims it back, so over-pulling is safe.
|
|
1266
|
+
const near = rec.interest.candidatesAt(shooterAt.x, shooterAt.y, radius * 2);
|
|
1267
|
+
for (let i = 0; i < near.length; i++) if (near[i] !== shooterKey) candKeys.add(near[i]);
|
|
1268
|
+
if (candKeys.size === 0) return;
|
|
1269
|
+
world = rec.lagComp.rewindWithin(candKeys, rewindAt, shooterAt.x, shooterAt.y, radius * radius);
|
|
1270
|
+
}
|
|
1271
|
+
if (world.size === 0) return;
|
|
1272
|
+
// Shot geometry from the shooter's CURRENT state: only the targets rewind. A
|
|
1273
|
+
// throw on malformed state drops the shot (favor-defender miss).
|
|
1274
|
+
let origin, dir;
|
|
1275
|
+
try {
|
|
1276
|
+
origin = ht.shot.origin(cmd, shooterEntity.state);
|
|
1277
|
+
dir = _shotUnitDir(ht.shot.dir(cmd, shooterEntity.state));
|
|
1278
|
+
} catch {
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
if (origin === null || typeof origin !== 'object' || !Number.isFinite(origin.x) || !Number.isFinite(origin.y)) return;
|
|
1282
|
+
if (dir === null) return;
|
|
1283
|
+
const maxDist = ht.shot.maxDist;
|
|
1284
|
+
const useResolve = typeof ht.resolve === 'function';
|
|
1285
|
+
// Broadphase distance cull, defaulting to maxDist plus the hitbox's own reach
|
|
1286
|
+
// so a target centred just past maxDist can still be struck on its near edge.
|
|
1287
|
+
const hitboxReach = useResolve
|
|
1288
|
+
? Infinity
|
|
1289
|
+
: ht.hitbox.shape === 'circle'
|
|
1290
|
+
? ht.hitbox.radius
|
|
1291
|
+
: 0.5 * Math.sqrt(ht.hitbox.w * ht.hitbox.w + ht.hitbox.h * ht.hitbox.h);
|
|
1292
|
+
const bpMaxDist = ht.broadphase && ht.broadphase.maxDist ? ht.broadphase.maxDist : maxDist + hitboxReach;
|
|
1293
|
+
const bpMaxSq = bpMaxDist * bpMaxDist;
|
|
1294
|
+
const cone = ht.broadphase ? ht.broadphase.cone : undefined;
|
|
1295
|
+
const shot = { origin, dir, maxDist };
|
|
1296
|
+
// The shoot ctx is per-shot (not per-target): applyTo (authoritative cross-
|
|
1297
|
+
// entity mutation) and emitEvent (the hit signal) are plain locals.
|
|
1298
|
+
let armed = false;
|
|
1299
|
+
const pendingEvents = [];
|
|
1300
|
+
const shootCtx = {
|
|
1301
|
+
identity: shooterKey,
|
|
1302
|
+
platform: ctxPlatform,
|
|
1303
|
+
applyTo(victimKey, victimCmd) {
|
|
1304
|
+
if (typeof victimKey !== 'string') return false;
|
|
1305
|
+
if (rec.authority.inject(victimKey, victimCmd)) {
|
|
1306
|
+
armed = true;
|
|
1307
|
+
return true;
|
|
1308
|
+
}
|
|
1309
|
+
return false;
|
|
1310
|
+
},
|
|
1311
|
+
emitEvent(type, data, opts) {
|
|
1312
|
+
if (typeof type !== 'string') return;
|
|
1313
|
+
pendingEvents.push({ type, data, opts });
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
// Broadphase cull + narrowphase per candidate, nearest-first. Penetration is
|
|
1317
|
+
// ON by default: every aligned candidate is hit unless onHit returns { stop: true }.
|
|
1318
|
+
const hits = [];
|
|
1319
|
+
for (const [key, s] of world) {
|
|
1320
|
+
const vx = s.x - origin.x;
|
|
1321
|
+
const vy = s.y - origin.y;
|
|
1322
|
+
const distSq = vx * vx + vy * vy;
|
|
1323
|
+
if (distSq > bpMaxSq) continue;
|
|
1324
|
+
if (cone !== undefined && cone !== null && distSq > 0) {
|
|
1325
|
+
if ((vx * dir.x + vy * dir.y) / Math.sqrt(distSq) < cone) continue;
|
|
1326
|
+
}
|
|
1327
|
+
let hit;
|
|
1328
|
+
if (useResolve) {
|
|
1329
|
+
hit = ht.resolve(shot, { key, pos: { x: s.x, y: s.y }, state: s.state }, shootCtx);
|
|
1330
|
+
} else if (ht.hitbox.shape === 'circle') {
|
|
1331
|
+
hit = rayCircleHit(origin.x, origin.y, dir.x, dir.y, maxDist, s.x, s.y, ht.hitbox.radius);
|
|
1332
|
+
} else {
|
|
1333
|
+
hit = rayAabbHit(origin.x, origin.y, dir.x, dir.y, maxDist, s.x, s.y, ht.hitbox.w, ht.hitbox.h);
|
|
1334
|
+
}
|
|
1335
|
+
if (hit !== null && hit !== undefined && Number.isFinite(hit.dist)) {
|
|
1336
|
+
hits.push({ key, pos: { x: s.x, y: s.y }, state: s.state, dist: hit.dist, point: hit.point, fallback: s.fallback });
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (hits.length === 0) return;
|
|
1340
|
+
hits.sort((a, b) => a.dist - b.dist);
|
|
1341
|
+
for (let i = 0; i < hits.length; i++) {
|
|
1342
|
+
const h = hits[i];
|
|
1343
|
+
const target = { key: h.key, pos: h.pos, state: h.state };
|
|
1344
|
+
const info = { dist: h.dist, point: h.point, fraction: maxDist > 0 ? h.dist / maxDist : 0, rewindAt, fallback: h.fallback };
|
|
1345
|
+
const verdict = await ht.onHit(shootCtx, target, info);
|
|
1346
|
+
if (verdict && verdict.stop) break;
|
|
1347
|
+
}
|
|
1348
|
+
// onHit may have awaited; if the topic was forgotten or this instance lost
|
|
1349
|
+
// ownership meanwhile, do not publish the events or arm a dead/demoted record.
|
|
1350
|
+
if (_smoothTopics.get(name) !== rec || (cluster && !rec.owned)) return;
|
|
1351
|
+
for (let i = 0; i < pendingEvents.length; i++) {
|
|
1352
|
+
const pe = pendingEvents[i];
|
|
1353
|
+
const wire = {
|
|
1354
|
+
type: pe.type,
|
|
1355
|
+
key: pe.opts && typeof pe.opts.key === 'string' ? pe.opts.key : shooterKey,
|
|
1356
|
+
data: pe.data,
|
|
1357
|
+
id: ++rec.shootEventSeq
|
|
1358
|
+
};
|
|
1359
|
+
_smoothPublish(rec, 'event', wire, undefined);
|
|
1360
|
+
if (cluster && typeof cluster.relayBroadcast === 'function') {
|
|
1361
|
+
cluster.relayBroadcast(rec.wireTopic, 'event', wire, undefined, rec.eventSeq++);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
if (armed) _armSmoothTick(rec);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1085
1367
|
/**
|
|
1086
1368
|
* Declare a topic of smoothed (predicted / reconciled) entities.
|
|
1087
1369
|
*
|
|
@@ -1404,263 +1686,46 @@ export const _smoothRegister = function smooth(config) {
|
|
|
1404
1686
|
// No live record, or hit testing off: nothing to resolve. (Defense in depth -
|
|
1405
1687
|
// the RPC is only registered when hitTest is configured.)
|
|
1406
1688
|
if (rec === undefined || rec.lagComp === null) return;
|
|
1407
|
-
// The ring and the authoritative catalog live only on the ticking owner. A
|
|
1408
|
-
// non-owner shooter's shot is forwarded to the owner in a later step; until
|
|
1409
|
-
// then it is inert on a non-owning instance (never resolved against an empty
|
|
1410
|
-
// local ring, which would be a silent miss).
|
|
1411
|
-
const cluster = ctx.platform && ctx.platform.smooth;
|
|
1412
|
-
if (cluster && !rec.owned) return;
|
|
1413
1689
|
const shooterKey = _getIdentityKey(ctx);
|
|
1690
|
+
const cluster = ctx.platform && ctx.platform.smooth;
|
|
1691
|
+
if (cluster && !rec.owned) {
|
|
1692
|
+
// EDGE: this instance does not own the ring (the authoritative catalog and
|
|
1693
|
+
// the history ring live on the owner). Measure latency against the
|
|
1694
|
+
// reconstructed owner clock, run the replay defense, and forward the
|
|
1695
|
+
// bounded DURATIONS (reach width + rewind age) to the owner, which resolves
|
|
1696
|
+
// the shot on its own ring axis - never re-measuring across the hop, which
|
|
1697
|
+
// would fold the inter-instance latency into the reach. Inert when the
|
|
1698
|
+
// coordinator predates relayShoot (an older extensions build): the shot
|
|
1699
|
+
// stays a no-op, exactly as it did before forwarded shots existed.
|
|
1700
|
+
if (typeof cluster.relayShoot !== 'function') return;
|
|
1701
|
+
const fwd = _smoothEdgeMeasure(rec, ctx, payload, shooterKey, _edgeOwnerNow(rec));
|
|
1702
|
+
if (fwd === null) return; // a replayed / older render-time, dropped at the edge
|
|
1703
|
+
cluster.relayShoot(rec.wireTopic, shooterKey, cluster.instanceId, {
|
|
1704
|
+
cmd: fwd.cmd,
|
|
1705
|
+
reach: fwd.reach,
|
|
1706
|
+
rewindAge: fwd.rewindAge,
|
|
1707
|
+
...(fwd.detect && { detect: fwd.detect })
|
|
1708
|
+
});
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
// OWNER / single instance: this instance holds the ring. Measure against the
|
|
1712
|
+
// local wall clock and resolve the shot here.
|
|
1414
1713
|
const shooterEntity = rec.authority.get(shooterKey);
|
|
1415
1714
|
if (shooterEntity === undefined) return; // a shooter with no entity cannot aim
|
|
1416
1715
|
const ht = rec.cfg.hitTest;
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
// and under-compensate. The client proposes WHERE in time; the server bounds HOW
|
|
1427
|
-
// WIDE the window may be from latency it measures itself. A missing / non-finite
|
|
1428
|
-
// stamp resolves at the present (favor defender).
|
|
1429
|
-
const rtStamp = payload.rt;
|
|
1430
|
-
let rewindAt = nowMono;
|
|
1431
|
-
if (typeof rtStamp === 'number' && Number.isFinite(rtStamp)) {
|
|
1432
|
-
// Per-connection server-anchored latency: the client echoed the latest
|
|
1433
|
-
// server stamp it saw (ackT); roundTrip = now - ackT, BOTH ends server wall
|
|
1434
|
-
// times, so the client cannot fake a lower latency (only inflate it, which
|
|
1435
|
-
// costs real responsiveness and is bounded below by the policy cap). Feed
|
|
1436
|
-
// this shot's own sample first so even a first shot self-seeds its reach.
|
|
1437
|
-
let st = ctx.ws ? _lcRtt.get(ctx.ws) : undefined;
|
|
1438
|
-
if (ctx.ws && st === undefined) {
|
|
1439
|
-
st = { tracker: createRttTracker(), lastRt: -Infinity };
|
|
1440
|
-
_lcRtt.set(ctx.ws, st);
|
|
1441
|
-
}
|
|
1442
|
-
// Map the wall-axis render-time onto the ring's monotonic axis up front, so the
|
|
1443
|
-
// replay defense, the latch, AND the rewind all live on that one axis. A server
|
|
1444
|
-
// wall backstep steps the client's synced render-time DOWN by the same offset, so
|
|
1445
|
-
// the raw stamp would look like it moved backward; on the monotonic axis it stays
|
|
1446
|
-
// continuous (rtMono = the stepped-down stamp + the absorbed offset). monoCorr is
|
|
1447
|
-
// zero in normal operation, so rtMono == rtStamp and nothing below changes.
|
|
1448
|
-
const rtMono = rtStamp + monoCorr;
|
|
1449
|
-
// Replay defense: a real rendered instant only advances, so a renderTime OLDER
|
|
1450
|
-
// than the last accepted one (a captured shot resent to re-resolve an old enemy
|
|
1451
|
-
// lineup) is dropped. Strict `<` admits an EQUAL stamp: a shotgun's pellets / a
|
|
1452
|
-
// burst fired in one frame share the same render-time and must all resolve. A
|
|
1453
|
-
// replay of a stale lineup is strictly older once the shooter has fired since, so
|
|
1454
|
-
// it is still rejected. One number per connection, on the monotonic axis - so a
|
|
1455
|
-
// wall backstep (which steps the raw stamp down) is not mistaken for a replay.
|
|
1456
|
-
if (st && rtMono < st.lastRt) return;
|
|
1457
|
-
const ackT = payload.ackT;
|
|
1458
|
-
if (st && typeof ackT === 'number' && Number.isFinite(ackT) && ackT <= now && now - ackT <= ht.maxRewindMs) {
|
|
1459
|
-
// Uplink is a wall-axis duration (ackT is a server wall stamp); rotate the
|
|
1460
|
-
// bucket window on the monotonic axis so a wall backstep cannot disturb it.
|
|
1461
|
-
st.tracker.sample((now - ackT) / 2, nowMono);
|
|
1462
|
-
}
|
|
1463
|
-
// Favor-the-shooter reach width = measured uplink (max-of-recent, so a latency
|
|
1464
|
-
// spike never clamps an honest shot) + the client's interpolation delay. BOTH
|
|
1465
|
-
// legs are now server-measured: the uplink from the ackT round trips, and the
|
|
1466
|
-
// interp delay from how often the server sends THIS shooter frames (the same
|
|
1467
|
-
// cadence the client measures to set its render delay). So a sparsely-served
|
|
1468
|
-
// shooter, which legitimately renders further in the past, gets the wider reach
|
|
1469
|
-
// it needs instead of clamping short - while a densely-served low-latency shooter
|
|
1470
|
-
// stays tight (cadence == tick rate) and cannot borrow a laggy player's budget.
|
|
1471
|
-
// All clamped to the policy cap.
|
|
1472
|
-
const maxUp = st ? st.tracker.maxUplink() : null;
|
|
1473
|
-
const serverInterp = rec.interest.interpDelayMs(shooterKey, rec.tickMs, now);
|
|
1474
|
-
const reach = maxUp === null ? ht.maxRewindMs : Math.min(ht.maxRewindMs, maxUp + serverInterp);
|
|
1475
|
-
// Clamp the mapped render-time into the rewind window. `min(rtMono, nowMono)`
|
|
1476
|
-
// caps an over-mapped stamp (the brief post-backstep window before the client
|
|
1477
|
-
// re-syncs to the stepped clock) to the present - favor defender, never a read
|
|
1478
|
-
// outside the ring.
|
|
1479
|
-
rewindAt = Math.max(nowMono - reach, Math.min(rtMono, nowMono));
|
|
1480
|
-
// Latch the clamped monotonic value: a one-off future/overshooting renderTime is
|
|
1481
|
-
// bounded by `min(rtMono, nowMono)` so it cannot strand subsequent honest shots
|
|
1482
|
-
// behind an inflated floor, and because the floor lives on the monotonic axis a
|
|
1483
|
-
// wall backstep (which steps the raw stamp down) does not read as a replay.
|
|
1484
|
-
if (st) st.lastRt = Math.max(st.lastRt, Math.min(rtMono, nowMono));
|
|
1485
|
-
// Detection signal (opt-in, off by default): surface the per-shot latency picture
|
|
1486
|
-
// to an app/anti-cheat callback. The discriminating lag-switch tell is the
|
|
1487
|
-
// divergence between the un-inflatable floor (minUplink) and the reach-driving max
|
|
1488
|
-
// (maxUplink), plus an abrupt floor jump the app derives from the minUplink series -
|
|
1489
|
-
// NOT the raw clamp rate (honest jittery/mobile players clamp routinely). This
|
|
1490
|
-
// subsystem only emits; detection action lives in the app's module. A throwing hook
|
|
1491
|
-
// never affects the shot (observability only).
|
|
1492
|
-
if (ht.detectionHook !== undefined && st) {
|
|
1493
|
-
const minUp = st.tracker.minUplink();
|
|
1494
|
-
try {
|
|
1495
|
-
ht.detectionHook({
|
|
1496
|
-
identity: shooterKey,
|
|
1497
|
-
minUplink: minUp,
|
|
1498
|
-
maxUplink: maxUp,
|
|
1499
|
-
reach,
|
|
1500
|
-
interpDelay: serverInterp,
|
|
1501
|
-
divergence: minUp !== null && maxUp !== null ? maxUp - minUp : 0
|
|
1502
|
-
});
|
|
1503
|
-
} catch {
|
|
1504
|
-
/* observability only */
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
// Candidate set, gated at the REWIND instant rather than at receipt. The shooter
|
|
1509
|
-
// aimed at the world it saw when it fired (rewindAt), so membership belongs there:
|
|
1510
|
-
// a target that drifted out of the shooter's area of interest while the shot was in
|
|
1511
|
-
// flight is still a valid hit (it was replicated when fired - the honest miss the
|
|
1512
|
-
// receipt-time gate dropped), and one that drifted IN only after the shot was fired
|
|
1513
|
-
// is not (it was never replicated at that instant). Both reduce to one geometric
|
|
1514
|
-
// test on historical positions: in-gate iff dist(shooter, target) <= interest
|
|
1515
|
-
// radius, both sampled at rewindAt. The transmit-bit guarantee is unchanged - you
|
|
1516
|
-
// still cannot hit what the shooter never had - it is just evaluated at the right
|
|
1517
|
-
// time. The set is server-computed; the client supplies no candidate list.
|
|
1518
|
-
const candKeys = new Set();
|
|
1519
|
-
const liveCand = rec.interest.getCandidates(shooterKey);
|
|
1520
|
-
if (liveCand !== undefined) for (const k of liveCand) if (k !== shooterKey) candKeys.add(k);
|
|
1521
|
-
// The geometric gate compares ring positions against the interest radius, so it is
|
|
1522
|
-
// only sound when the ring records the SAME position the interest membership uses.
|
|
1523
|
-
// That holds on the default path (hitTest.position falls back to interest.position,
|
|
1524
|
-
// same reference); a custom hitTest.position in a different coordinate space would
|
|
1525
|
-
// make the gate mix spaces, so there we skip it and fall back to the receipt-time
|
|
1526
|
-
// membership (interest-space correct, just not rewindAt-precise). A null sample
|
|
1527
|
-
// (shooter just spawned, pre-history, or a ring discontinuity at rewindAt) also
|
|
1528
|
-
// has no usable rewound center, so it takes the same fallback.
|
|
1529
|
-
const gateInRingSpace = rec.cfg.hitTest.position === rec.cfg.interest.position;
|
|
1530
|
-
const shooterAt = gateInRingSpace ? rec.lagComp.sample(shooterKey, rewindAt) : null;
|
|
1531
|
-
let world;
|
|
1532
|
-
if (shooterAt === null) {
|
|
1533
|
-
// No usable rewound gate: fall back to the receipt-time membership ungated -
|
|
1534
|
-
// never worse than the pre-gate behavior.
|
|
1535
|
-
if (candKeys.size === 0) return;
|
|
1536
|
-
world = rec.lagComp.rewind(candKeys, rewindAt);
|
|
1537
|
-
} else {
|
|
1538
|
-
const radius = rec.interest.radius;
|
|
1539
|
-
// Also broadphase the departed shell: entities near the shooter's rewound
|
|
1540
|
-
// position that the receipt-time set no longer lists (they left during the
|
|
1541
|
-
// flight window). A target must cross a full interest radius within the rewind
|
|
1542
|
-
// window (<= maxRewindMs) to escape the doubled query - implausible for a radius
|
|
1543
|
-
// sized to the arena - and the exact gate below trims the broadphase back to the
|
|
1544
|
-
// true membership, so over-pulling is safe; under-pulling is the only real risk.
|
|
1545
|
-
const near = rec.interest.candidatesAt(shooterAt.x, shooterAt.y, radius * 2);
|
|
1546
|
-
for (let i = 0; i < near.length; i++) if (near[i] !== shooterKey) candKeys.add(near[i]);
|
|
1547
|
-
if (candKeys.size === 0) return;
|
|
1548
|
-
world = rec.lagComp.rewindWithin(candKeys, rewindAt, shooterAt.x, shooterAt.y, radius * radius);
|
|
1549
|
-
}
|
|
1550
|
-
if (world.size === 0) return;
|
|
1551
|
-
// Shot geometry from the shooter's CURRENT state: only the targets rewind, the
|
|
1552
|
-
// shooter fires from where the server says it is. The app's origin/dir are
|
|
1553
|
-
// guarded like the ring's position() (lagcomp.record): a throw on malformed
|
|
1554
|
-
// state drops the shot (favor-defender miss) rather than rejecting the handler.
|
|
1555
|
-
let origin, dir;
|
|
1556
|
-
try {
|
|
1557
|
-
origin = ht.shot.origin(cmd, shooterEntity.state);
|
|
1558
|
-
dir = _shotUnitDir(ht.shot.dir(cmd, shooterEntity.state));
|
|
1559
|
-
} catch {
|
|
1560
|
-
return;
|
|
1561
|
-
}
|
|
1562
|
-
if (origin === null || typeof origin !== 'object' || !Number.isFinite(origin.x) || !Number.isFinite(origin.y)) return;
|
|
1563
|
-
if (dir === null) return;
|
|
1564
|
-
const maxDist = ht.shot.maxDist;
|
|
1565
|
-
const useResolve = typeof ht.resolve === 'function';
|
|
1566
|
-
// Broadphase distance cull. The narrowphase accepts a hit whose ray ENTRY is
|
|
1567
|
-
// within maxDist, but a hitbox reaches one radius (or half-diagonal) past its
|
|
1568
|
-
// centre - so a target centred just beyond maxDist can still be struck on its
|
|
1569
|
-
// near edge. Default the cull to maxDist + that reach so it never drops a valid
|
|
1570
|
-
// hit; for a custom resolve (unknown reach) skip the distance cull entirely
|
|
1571
|
-
// unless the app set an explicit broadphase.maxDist.
|
|
1572
|
-
const hitboxReach = useResolve
|
|
1573
|
-
? Infinity
|
|
1574
|
-
: ht.hitbox.shape === 'circle'
|
|
1575
|
-
? ht.hitbox.radius
|
|
1576
|
-
: 0.5 * Math.sqrt(ht.hitbox.w * ht.hitbox.w + ht.hitbox.h * ht.hitbox.h);
|
|
1577
|
-
const bpMaxDist = ht.broadphase && ht.broadphase.maxDist ? ht.broadphase.maxDist : maxDist + hitboxReach;
|
|
1578
|
-
const bpMaxSq = bpMaxDist * bpMaxDist;
|
|
1579
|
-
const cone = ht.broadphase ? ht.broadphase.cone : undefined;
|
|
1580
|
-
const shot = { origin, dir, maxDist };
|
|
1581
|
-
// Build the shoot ctx once (it is per-shot, not per-target): the seam where
|
|
1582
|
-
// `applyTo` (authoritative cross-entity mutation) and `emitEvent` (the hit
|
|
1583
|
-
// signal) are plain locals, never threaded through the authority's time-pure
|
|
1584
|
-
// synchronous apply ctx.
|
|
1585
|
-
let armed = false;
|
|
1586
|
-
const pendingEvents = [];
|
|
1587
|
-
const shootCtx = {
|
|
1588
|
-
identity: shooterKey,
|
|
1589
|
-
platform: ctx.platform,
|
|
1590
|
-
// Apply a server-initiated command to any entity: a non-commanded update
|
|
1591
|
-
// (broadcast to all incl. the victim, no ack), via the adapter authority's
|
|
1592
|
-
// inject primitive. The victim's predictor is undisturbed (its ack
|
|
1593
|
-
// watermark never moves); it sees the change through the normal broadcast.
|
|
1594
|
-
applyTo(victimKey, victimCmd) {
|
|
1595
|
-
if (typeof victimKey !== 'string') return false;
|
|
1596
|
-
if (rec.authority.inject(victimKey, victimCmd)) {
|
|
1597
|
-
armed = true;
|
|
1598
|
-
return true;
|
|
1599
|
-
}
|
|
1600
|
-
return false;
|
|
1601
|
-
},
|
|
1602
|
-
// Queue a discrete one-shot event (a hit) for broadcast after resolution.
|
|
1603
|
-
emitEvent(type, data, opts) {
|
|
1604
|
-
if (typeof type !== 'string') return;
|
|
1605
|
-
pendingEvents.push({ type, data, opts });
|
|
1606
|
-
}
|
|
1607
|
-
};
|
|
1608
|
-
// Broadphase cull + narrowphase per candidate, collecting hits to order
|
|
1609
|
-
// nearest-first. Penetration is ON by default: every aligned candidate is hit
|
|
1610
|
-
// unless the app stops after the nearest by returning `{ stop: true }`.
|
|
1611
|
-
const hits = [];
|
|
1612
|
-
for (const [key, s] of world) {
|
|
1613
|
-
const vx = s.x - origin.x;
|
|
1614
|
-
const vy = s.y - origin.y;
|
|
1615
|
-
const distSq = vx * vx + vy * vy;
|
|
1616
|
-
if (distSq > bpMaxSq) continue;
|
|
1617
|
-
if (cone !== undefined && cone !== null && distSq > 0) {
|
|
1618
|
-
if ((vx * dir.x + vy * dir.y) / Math.sqrt(distSq) < cone) continue;
|
|
1619
|
-
}
|
|
1620
|
-
let hit;
|
|
1621
|
-
if (useResolve) {
|
|
1622
|
-
hit = ht.resolve(shot, { key, pos: { x: s.x, y: s.y }, state: s.state }, shootCtx);
|
|
1623
|
-
} else if (ht.hitbox.shape === 'circle') {
|
|
1624
|
-
hit = rayCircleHit(origin.x, origin.y, dir.x, dir.y, maxDist, s.x, s.y, ht.hitbox.radius);
|
|
1625
|
-
} else {
|
|
1626
|
-
hit = rayAabbHit(origin.x, origin.y, dir.x, dir.y, maxDist, s.x, s.y, ht.hitbox.w, ht.hitbox.h);
|
|
1627
|
-
}
|
|
1628
|
-
if (hit !== null && hit !== undefined && Number.isFinite(hit.dist)) {
|
|
1629
|
-
hits.push({ key, pos: { x: s.x, y: s.y }, state: s.state, dist: hit.dist, point: hit.point, fallback: s.fallback });
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
if (hits.length === 0) return;
|
|
1633
|
-
hits.sort((a, b) => a.dist - b.dist);
|
|
1634
|
-
for (let i = 0; i < hits.length; i++) {
|
|
1635
|
-
const h = hits[i];
|
|
1636
|
-
const target = { key: h.key, pos: h.pos, state: h.state };
|
|
1637
|
-
const info = { dist: h.dist, point: h.point, fraction: maxDist > 0 ? h.dist / maxDist : 0, rewindAt, fallback: h.fallback };
|
|
1638
|
-
const verdict = await ht.onHit(shootCtx, target, info);
|
|
1639
|
-
if (verdict && verdict.stop) break;
|
|
1640
|
-
}
|
|
1641
|
-
// The app's onHit may have awaited. If the topic was forgotten (its last
|
|
1642
|
-
// entity left) or this instance lost ownership in that window, do not publish
|
|
1643
|
-
// the events or arm a tick on a dead or demoted record.
|
|
1644
|
-
if (_smoothTopics.get(name) !== rec || (cluster && !rec.owned)) return;
|
|
1645
|
-
// Broadcast the hit events. A shot bypasses the prediction ring, so there is
|
|
1646
|
-
// no optimistic client copy to suppress: the event reaches everyone, the
|
|
1647
|
-
// shooter (its hit marker) and the victim alike. The wire frame carries only
|
|
1648
|
-
// {type,key,data,id}, exactly as the tick's event broadcast does.
|
|
1649
|
-
for (let i = 0; i < pendingEvents.length; i++) {
|
|
1650
|
-
const pe = pendingEvents[i];
|
|
1651
|
-
const wire = {
|
|
1652
|
-
type: pe.type,
|
|
1653
|
-
key: pe.opts && typeof pe.opts.key === 'string' ? pe.opts.key : shooterKey,
|
|
1654
|
-
data: pe.data,
|
|
1655
|
-
id: ++rec.shootEventSeq
|
|
1656
|
-
};
|
|
1657
|
-
_smoothPublish(rec, 'event', wire, undefined);
|
|
1658
|
-
if (cluster && typeof cluster.relayBroadcast === 'function') {
|
|
1659
|
-
cluster.relayBroadcast(rec.wireTopic, 'event', wire, undefined, rec.eventSeq++);
|
|
1716
|
+
const m = _smoothEdgeMeasure(rec, ctx, payload, shooterKey, wallEpoch());
|
|
1717
|
+
if (m === null) return;
|
|
1718
|
+
// Detection signal (opt-in): fire from this shot's locally-measured picture.
|
|
1719
|
+
// A throwing hook never affects the shot.
|
|
1720
|
+
if (ht.detectionHook !== undefined && m.detect) {
|
|
1721
|
+
try {
|
|
1722
|
+
ht.detectionHook({ ...m.detect, identity: shooterKey });
|
|
1723
|
+
} catch {
|
|
1724
|
+
/* observability only */
|
|
1660
1725
|
}
|
|
1661
1726
|
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1727
|
+
const rewindAt = _smoothRewindAt(m.nowMono, m.reach, m.rewindAge);
|
|
1728
|
+
await _smoothResolveShot(rec, name, shooterKey, shooterEntity, ctx.platform, m.cmd, rewindAt);
|
|
1664
1729
|
});
|
|
1665
1730
|
|
|
1666
1731
|
return smoothExport;
|