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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server/smooth.js +316 -251
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.6.0-next.21",
3
+ "version": "0.6.0-next.22",
4
4
  "publishConfig": {
5
5
  "tag": "next"
6
6
  },
@@ -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 cmd = payload.cmd;
1418
- const now = wallEpoch();
1419
- // The ring is keyed on a monotonic axis, so the rewind works on that axis too:
1420
- // `nowMono` is the present on it and `monoCorr` (the wall->monotonic offset, zero
1421
- // in normal operation) maps the client's wall-axis render-time onto it.
1422
- const nowMono = rec.monoClock.mono(now);
1423
- const monoCorr = nowMono - now;
1424
- // Rewind DIRECTLY to the client's absolute synced-clock renderTime (idTech3-
1425
- // faithful) - reconstructing `now - rtt` at receipt would re-add the uplink leg
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
- // Arm the tick so the injected damage drains and broadcasts this frame.
1663
- if (armed) _armSmoothTick(rec);
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;