@woosh/meep-engine 3.14.3 → 3.14.4

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 (75) hide show
  1. package/package.json +1 -1
  2. package/src/engine/Engine.d.ts.map +1 -1
  3. package/src/engine/Engine.js +733 -726
  4. package/src/engine/network/NetworkSession.d.ts +24 -4
  5. package/src/engine/network/NetworkSession.d.ts.map +1 -1
  6. package/src/engine/network/NetworkSession.js +28 -4
  7. package/src/engine/network/orchestrator/NetworkPeer.d.ts +47 -6
  8. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
  9. package/src/engine/network/orchestrator/NetworkPeer.js +184 -56
  10. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +97 -11
  11. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
  12. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +304 -22
  13. package/src/engine/network/replication/Replicator.d.ts +14 -1
  14. package/src/engine/network/replication/Replicator.d.ts.map +1 -1
  15. package/src/engine/network/replication/Replicator.js +44 -3
  16. package/src/engine/network/sim/ActionLog.d.ts +21 -3
  17. package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
  18. package/src/engine/network/sim/ActionLog.js +24 -3
  19. package/src/engine/network/sim/SimAction.d.ts +15 -0
  20. package/src/engine/network/sim/SimAction.d.ts.map +1 -1
  21. package/src/engine/network/sim/SimAction.js +15 -0
  22. package/src/engine/network/sim/SimActionExecutor.d.ts +51 -1
  23. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
  24. package/src/engine/network/sim/SimActionExecutor.js +73 -5
  25. package/src/engine/network/transport/Channel.d.ts +1 -1
  26. package/src/engine/network/transport/Channel.js +1 -1
  27. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  28. package/src/engine/simulation/Ticker.js +4 -1
  29. package/src/engine/ui/GUIEngine.d.ts.map +1 -1
  30. package/src/engine/ui/GUIEngine.js +6 -0
  31. package/src/shade/playground/particle_system/README.md +98 -0
  32. package/src/shade/playground/particle_system/index.html +124 -0
  33. package/src/shade/playground/particle_system/main.d.ts +2 -0
  34. package/src/shade/playground/particle_system/main.d.ts.map +1 -0
  35. package/src/shade/playground/particle_system/main.js +180 -0
  36. package/src/shade/playground/particle_system/particle_scene.d.ts +52 -0
  37. package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -0
  38. package/src/shade/playground/particle_system/particle_scene.js +552 -0
  39. package/src/shade/playground/skinned_mesh_soup/README.md +122 -0
  40. package/src/shade/playground/skinned_mesh_soup/index.html +199 -0
  41. package/src/shade/playground/skinned_mesh_soup/main.d.ts +7 -0
  42. package/src/shade/playground/skinned_mesh_soup/main.d.ts.map +1 -0
  43. package/src/shade/playground/skinned_mesh_soup/main.js +564 -0
  44. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts +23 -0
  45. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts.map +1 -0
  46. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.js +409 -0
  47. package/src/shade/renderer/particles/cull/chunk_particle_cull.d.ts.map +1 -1
  48. package/src/shade/renderer/particles/cull/chunk_particle_cull.js +3 -1
  49. package/src/shade/renderer/particles/data/chunk_particle_emitter.js +1 -1
  50. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts +1 -1
  51. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
  52. package/src/shade/renderer/particles/isa/InstructionStream.d.ts +89 -0
  53. package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -0
  54. package/src/shade/renderer/particles/isa/InstructionStream.js +162 -0
  55. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts +1 -48
  56. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts.map +1 -1
  57. package/src/shade/renderer/particles/isa/ParticleAssembler.js +25 -114
  58. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts.map +1 -1
  59. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.js +4 -2
  60. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +4 -4
  61. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts.map +1 -1
  62. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +7 -5
  63. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts.map +1 -1
  64. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.js +3 -1
  65. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  66. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +3 -1
  67. package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
  68. package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +5 -2
  69. package/src/shade/renderer/particles/shaders/shader_particle_render.d.ts.map +1 -1
  70. package/src/shade/renderer/particles/shaders/shader_particle_render.js +9 -2
  71. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  72. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +3 -1
  73. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts +0 -2
  74. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts.map +0 -1
  75. package/src/shade/renderer/particles/prototypeParticleSystem.js +0 -448
@@ -9,20 +9,44 @@
9
9
  * tagged with `(client_frame, sender_id, type_id, payload_bytes)`.
10
10
  * 2. Once per server tick, `tick(current_frame)` consumes the pending log:
11
11
  * - Rejects entries older than the action_log's rewindable window.
12
- * - Determines the oldest pending frame `replay_start`.
12
+ * - Determines `replay_start`: the oldest pending frame carrying an
13
+ * action the log does not already hold. The action stream re-sends
14
+ * every unacked frame each tick, so most of pending is copies of
15
+ * already-applied records; rewinding for those would replay the
16
+ * world into the state it was already in.
13
17
  * - Rewinds the server world back to end-of-(replay_start - 1) via the
14
18
  * action_log's prior-state captures (see {@link RewindEngine}).
15
19
  * - Replays forward `[replay_start, current_frame]`. For each frame `f`:
16
20
  * - Read historical actions out of `action_log[f]` BEFORE
17
21
  * `begin_frame(f)` recycles the buffer.
18
22
  * - Append pending entries for `f`.
23
+ * - Append anything queued by {@link enqueue_action}.
19
24
  * - Stable-sort the merged list by `sender_id` (so multi-client order
20
25
  * is deterministic across peers — relies on per-record sender_id in
21
- * the action log).
22
- * - `executor.execute` each in sorted order, then run the user-
23
- * supplied local sim via {@link onLocalSim}.
26
+ * the action log), and `executor.execute` each in that order.
27
+ * - Run the user-supplied local sim via {@link onLocalSim}.
24
28
  * - Clear pending.
25
29
  *
30
+ * A frame's records have one of two provenances, and the replay treats them
31
+ * differently:
32
+ *
33
+ * - **Input** — actions that arrived from a peer, and actions the server
34
+ * authored via {@link enqueue_action}. Each happened once, at a point in
35
+ * time nothing can recompute, so a replay reapplies it from the log.
36
+ * - **Derived output** — everything {@link onLocalSim} executes. The handler
37
+ * runs again on every replay of the frame, against a world that now
38
+ * includes whatever late input forced the replay, so the replay recomputes
39
+ * it. Reapplying the previous pass's copy as well would apply the effect
40
+ * twice — and three times on the next replay, and so on, without bound.
41
+ * The executor stamps these {@link SENDER_DERIVED} while the handler runs
42
+ * and {@link __read_historical} drops them.
43
+ *
44
+ * Derived records are still written, and still replicate: the prior-state
45
+ * capture is what lets a rewind undo the local sim's effect on replicated
46
+ * components (so derived output need not be idempotent — only deterministic
47
+ * in the frame's post-input state), and the action stream carries them to
48
+ * clients like any other record.
49
+ *
26
50
  * Net effect: a client action tagged at client tick K lands on the server as
27
51
  * if applied against end-of-K-1 server state, regardless of arrival timing
28
52
  * or order. Both peers compute identical world states for identical inputs.
@@ -109,6 +133,15 @@ export class ServerAuthoritativeServer {
109
133
  * @type {number}
110
134
  */
111
135
  simulation_delay_ticks: number;
136
+ /**
137
+ * Peer id of this authoritative server, or `-1` when the session has no
138
+ * privileged peer. Actions from {@link enqueue_action} are tagged with
139
+ * it so they pass the owner-authorization gate on any entity (see
140
+ * {@link make_owner_authorization}), which is the documented convention
141
+ * for the server's own mutations.
142
+ * @type {number}
143
+ */
144
+ server_peer_id: number;
112
145
  /**
113
146
  * Upper bound on how far ahead of the server's own simulation a peer
114
147
  * may tag an inbound action. `frame_number` is an attacker-controlled
@@ -132,13 +165,35 @@ export class ServerAuthoritativeServer {
132
165
  */
133
166
  max_pending_total: number;
134
167
  /**
135
- * Fires once per frame during the replay loop, AFTER all merged
136
- * actions for that frame have been applied and BEFORE the action_log
137
- * frame is closed. Use this for server-authoritative local sim
138
- * (clamps, collision, AI step, etc.). The current world state
139
- * reflects all inputs for this frame; the local sim should be
140
- * idempotent under repeated application (because it may run many
141
- * times under rollback).
168
+ * Fires once per execution of a frame, AFTER all input for that frame
169
+ * has been applied and BEFORE the action_log frame is closed. Use it
170
+ * for server-authoritative local sim: clamps, collision, AI step,
171
+ * damage resolution, spawning the effects a hit produces.
172
+ *
173
+ * **This is a derivation, not an event.** The handler runs again on
174
+ * every replay of the frame, and must produce the same result for the
175
+ * same post-input world state — it is a function OF that state, not a
176
+ * one-shot that happens to be called from it. Concretely:
177
+ *
178
+ * - Decide from world state and the frame number. Both are restored
179
+ * before the handler re-runs.
180
+ * - Do NOT latch on handler-local flags (`if (already_fired) return`)
181
+ * to make something happen once. The replay needs the handler to
182
+ * re-emit; a latch makes it emit nothing, and the action is lost
183
+ * the first time a rollback crosses its frame. For a genuine
184
+ * one-shot — a lobby spawn, an admin command, anything decided
185
+ * outside the simulation — use {@link enqueue_action}, which is
186
+ * recorded as input and replayed rather than recomputed.
187
+ *
188
+ * Actions the handler executes are recorded as derived output: they
189
+ * replicate to clients and their prior state is captured so a rewind
190
+ * undoes them, but a replay recomputes them instead of reapplying the
191
+ * previous pass's copy. So they need not be idempotent — a handler may
192
+ * subtract hit points — only deterministic in the frame's post-input
193
+ * state. Components the handler mutates DIRECTLY, without going through
194
+ * an action, have no prior-state capture and are not undone by a
195
+ * rewind; those must additionally be idempotent, which is why a clamp
196
+ * is safe to write that way and `hp -= 10` is not.
142
197
  *
143
198
  * Args: `(frame_number)`.
144
199
  * @type {Signal}
@@ -215,6 +270,37 @@ export class ServerAuthoritativeServer {
215
270
  * @param {number} peer_id
216
271
  */
217
272
  send_time_dilation_feedback(peer_id: number): void;
273
+ /**
274
+ * Author a server-side action, to be applied on the next simulated frame.
275
+ *
276
+ * This is the supported path for a mutation the simulation cannot
277
+ * recompute — a spawn triggered by a lobby event, an admin command, a
278
+ * scripted trigger — as opposed to {@link onLocalSim}, which derives a
279
+ * frame's output from its state and is re-run on every replay.
280
+ *
281
+ * The action is serialized immediately (so the caller may reuse or discard
282
+ * the instance) and applied exactly once, on the next frame {@link tick}
283
+ * simulates for the first time, merged into that frame's input and ordered
284
+ * with it by sender. From then on it is ordinary history: replayed from the
285
+ * log like any other input, and never re-queued.
286
+ *
287
+ * Callable at any time EXCEPT from inside {@link onLocalSim}, which throws:
288
+ * that handler re-runs on every replay of its frame, so it would queue a
289
+ * fresh copy each time. (`executor.execute` is the opposite — it requires
290
+ * an open action_log frame, so it only works from inside the handler.)
291
+ *
292
+ * Recorded with {@link server_peer_id} as the sender when the session has
293
+ * one, so it passes the owner-authorization gate on any entity; with
294
+ * {@link SENDER_LOCAL} otherwise, which bypasses the gate.
295
+ *
296
+ * @param {SimAction} action
297
+ */
298
+ enqueue_action(action: SimAction): void;
299
+ /**
300
+ * Number of locally-authored actions waiting for the next simulated frame.
301
+ * @returns {number}
302
+ */
303
+ queued_local_action_count(): number;
218
304
  /**
219
305
  * Drive the rollback flow for one tick.
220
306
  *
@@ -1 +1 @@
1
- {"version":3,"file":"ServerAuthoritativeServer.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/orchestrator/ServerAuthoritativeServer.js"],"names":[],"mappings":"AAaA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH;IA6BI;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,uOApBW;QACV,KAAS,yBAAyB;QAClC,eAAmB,8BAA8B;QACjD,qBAAyB,EAAE,UAAU,CAAC;QACtC,cAAkB,EAAE,UAAU,CAAC;QAC/B,YAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,cAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,mBAAuB,CAAC,EAAE,MAAM,CAAC;QACjC,sBAA0B,CAAC,EAAE,MAAM,CAAC;KACjC,EAyLH;IA3MD;;;;;OAKG;IACH,mBAFU,MAAM,CAEO;IAwCnB,0BAA0B;IAC1B,MADW,WAAW,CASpB;IAEF,qCAAqC;IACrC,8BAAkB;IAClB,gCAAgC;IAChC,4BAAkC;IAclC,mCAAmC;IACnC,iCAAsC;IACtC,wBAAwB;IACxB,sBAAsC;IACtC,gCAAgC;IAChC,mCAAgD;IAChD,0CAA0C;IAC1C,gDAAsD;IAEtD,2BAA2B;IAC3B,eADW,YAAY,CAKrB;IAEF;;;;;;OAMG;IACH,6BAFU,MAAM,CAEqB;IAErC;;;;OAIG;IACH,wBAFU,MAAM,CAEoC;IAEpD;;;;;;;;;OASG;IACH,yBAFU,MAAM,CAEsC;IAEtD;;;;;;;;;OASG;IACH,mBAFU,MAAM,CAE0B;IA4B1C;;;;;;;;;;;OAWG;IACH,YAFU,MAAM,CAEc;IAE9B;;;;;;;OAOG;IACH,gBAFU,MAAM,CAEkB;IAElC;;;;;;;;;;;OAWG;IACH,UAFU,MAAM,CAEY;IAE5B;;;;;;;OAOG;IACH,wBAFU,MAAM,CAE0B;IAG9C;;;;;;;OAOG;IACH,sBAHW,MAAM,aACN,MAAM,QAIhB;IAED;;;OAGG;IACH,yBAFW,MAAM,QAIhB;IAED;;;;;;;;OAQG;IACH,yBALW,MAAM,gBACN,MAAM,cACN,MAAM,kBACA,YAAY,KAAK,IAAI,QAIrC;IAED;;;;OAIG;IACH,qBAFW,MAAM,6CAIhB;IAED;;;;;;;;OAQG;IACH,qCAFW,MAAM,QAIhB;IAED;;;;;;;;;;;;OAYG;IACH,oBAFW,MAAM,QA2ChB;IAED;;;;;;;;OAQG;IACH,+BAHW,MAAM,GACJ,MAAM,CAOlB;IA2GD;;;;OAIG;IACH,yBAFa,MAAM,CAIlB;;CA0HJ;4BAvnB2B,kBAAkB;6BADjB,wBAAwB;mBANlC,uCAAuC;6BAH7B,sCAAsC"}
1
+ {"version":3,"file":"ServerAuthoritativeServer.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/orchestrator/ServerAuthoritativeServer.js"],"names":[],"mappings":"AAcA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH;IAwCI;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,uOApBW;QACV,KAAS,yBAAyB;QAClC,eAAmB,8BAA8B;QACjD,qBAAyB,EAAE,UAAU,CAAC;QACtC,cAAkB,EAAE,UAAU,CAAC;QAC/B,YAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,cAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,mBAAuB,CAAC,EAAE,MAAM,CAAC;QACjC,sBAA0B,CAAC,EAAE,MAAM,CAAC;KACjC,EA4NH;IA9OD;;;;;OAKG;IACH,mBAFU,MAAM,CAEO;IAwCnB,0BAA0B;IAC1B,MADW,WAAW,CASpB;IAEF,qCAAqC;IACrC,8BAAkB;IAClB,gCAAgC;IAChC,4BAAkC;IAclC,mCAAmC;IACnC,iCAAsC;IACtC,wBAAwB;IACxB,sBAAsC;IACtC,gCAAgC;IAChC,mCAAgD;IAChD,0CAA0C;IAC1C,gDAAsD;IAEtD,2BAA2B;IAC3B,eADW,YAAY,CAKrB;IAEF;;;;;;OAMG;IACH,6BAFU,MAAM,CAEqB;IAErC;;;;OAIG;IACH,wBAFU,MAAM,CAEoC;IAEpD;;;;;;;OAOG;IACH,gBAFU,MAAM,CAEoB;IAEpC;;;;;;;;;OASG;IACH,yBAFU,MAAM,CAEsC;IAEtD;;;;;;;;;OASG;IACH,mBAFU,MAAM,CAE0B;IA+B1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,YAFU,MAAM,CAEc;IAE9B;;;;;;;OAOG;IACH,gBAFU,MAAM,CAEkB;IAElC;;;;;;;;;;;OAWG;IACH,UAFU,MAAM,CAEY;IAE5B;;;;;;;OAOG;IACH,wBAFU,MAAM,CAE0B;IAG9C;;;;;;;OAOG;IACH,sBAHW,MAAM,aACN,MAAM,QAIhB;IAED;;;OAGG;IACH,yBAFW,MAAM,QAIhB;IAED;;;;;;;;OAQG;IACH,yBALW,MAAM,gBACN,MAAM,cACN,MAAM,kBACA,YAAY,KAAK,IAAI,QAIrC;IAED;;;;OAIG;IACH,qBAFW,MAAM,6CAIhB;IAED;;;;;;;;OAQG;IACH,qCAFW,MAAM,QAIhB;IAED;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,wCA8BC;IAED;;;OAGG;IACH,6BAFa,MAAM,CAIlB;IAED;;;;;;;;;;;;OAYG;IACH,oBAFW,MAAM,QAiEhB;IAED;;;;;;;;OAQG;IACH,+BAHW,MAAM,GACJ,MAAM,CAOlB;IAuOD;;;;OAIG;IACH,yBAFa,MAAM,CAIlB;;CA0HJ;4BAh5B2B,kBAAkB;6BAFjB,wBAAwB;mBANlC,uCAAuC;6BAH7B,sCAAsC"}
@@ -9,6 +9,7 @@ import { make_owner_authorization } from "../ecs/owner_authorization.js";
9
9
  import { Replicator } from "../replication/Replicator.js";
10
10
  import { AlwaysRelevantScope } from "../replication/ScopeFilter.js";
11
11
  import { RewindEngine } from "../sim/RewindEngine.js";
12
+ import { SENDER_DERIVED, SENDER_LOCAL } from "../sim/SimActionExecutor.js";
12
13
  import { NetworkPeer } from "./NetworkPeer.js";
13
14
 
14
15
  /**
@@ -22,20 +23,44 @@ import { NetworkPeer } from "./NetworkPeer.js";
22
23
  * tagged with `(client_frame, sender_id, type_id, payload_bytes)`.
23
24
  * 2. Once per server tick, `tick(current_frame)` consumes the pending log:
24
25
  * - Rejects entries older than the action_log's rewindable window.
25
- * - Determines the oldest pending frame `replay_start`.
26
+ * - Determines `replay_start`: the oldest pending frame carrying an
27
+ * action the log does not already hold. The action stream re-sends
28
+ * every unacked frame each tick, so most of pending is copies of
29
+ * already-applied records; rewinding for those would replay the
30
+ * world into the state it was already in.
26
31
  * - Rewinds the server world back to end-of-(replay_start - 1) via the
27
32
  * action_log's prior-state captures (see {@link RewindEngine}).
28
33
  * - Replays forward `[replay_start, current_frame]`. For each frame `f`:
29
34
  * - Read historical actions out of `action_log[f]` BEFORE
30
35
  * `begin_frame(f)` recycles the buffer.
31
36
  * - Append pending entries for `f`.
37
+ * - Append anything queued by {@link enqueue_action}.
32
38
  * - Stable-sort the merged list by `sender_id` (so multi-client order
33
39
  * is deterministic across peers — relies on per-record sender_id in
34
- * the action log).
35
- * - `executor.execute` each in sorted order, then run the user-
36
- * supplied local sim via {@link onLocalSim}.
40
+ * the action log), and `executor.execute` each in that order.
41
+ * - Run the user-supplied local sim via {@link onLocalSim}.
37
42
  * - Clear pending.
38
43
  *
44
+ * A frame's records have one of two provenances, and the replay treats them
45
+ * differently:
46
+ *
47
+ * - **Input** — actions that arrived from a peer, and actions the server
48
+ * authored via {@link enqueue_action}. Each happened once, at a point in
49
+ * time nothing can recompute, so a replay reapplies it from the log.
50
+ * - **Derived output** — everything {@link onLocalSim} executes. The handler
51
+ * runs again on every replay of the frame, against a world that now
52
+ * includes whatever late input forced the replay, so the replay recomputes
53
+ * it. Reapplying the previous pass's copy as well would apply the effect
54
+ * twice — and three times on the next replay, and so on, without bound.
55
+ * The executor stamps these {@link SENDER_DERIVED} while the handler runs
56
+ * and {@link __read_historical} drops them.
57
+ *
58
+ * Derived records are still written, and still replicate: the prior-state
59
+ * capture is what lets a rewind undo the local sim's effect on replicated
60
+ * components (so derived output need not be idempotent — only deterministic
61
+ * in the frame's post-input state), and the action stream carries them to
62
+ * clients like any other record.
63
+ *
39
64
  * Net effect: a client action tagged at client tick K lands on the server as
40
65
  * if applied against end-of-K-1 server state, regardless of arrival timing
41
66
  * or order. Both peers compute identical world states for identical inputs.
@@ -67,6 +92,17 @@ export class ServerAuthoritativeServer {
67
92
  #historical_scratch = /** @type {Array<{type_id:number, sender_id:number, payload:Uint8Array}>} */ ([]);
68
93
  #sort_scratch = /** @type {Array<{sender_id:number, type_id:number, payload:Uint8Array}>} */ ([]);
69
94
  #payload_buf;
95
+ // Locally-authored actions waiting for the next fresh frame. Parallel
96
+ // arrays, serialized at enqueue time so the caller's instance is not
97
+ // retained (and so a bad action class is rejected at the call site rather
98
+ // than a tick later).
99
+ #local_type_ids = /** @type {number[]} */ ([]);
100
+ #local_payloads = /** @type {Uint8Array[]} */ ([]);
101
+ #local_buf;
102
+ // The frame this tick is simulating for the first time. Only it may take
103
+ // the locally-authored queue; every earlier frame in the replay window
104
+ // already holds, in its history, the actions it was given the first time.
105
+ #fresh_frame = -1;
70
106
  /**
71
107
  * Count of inbound pending entries dropped by the future-lead / total
72
108
  * bounds. See {@link onPendingActionDropped}.
@@ -180,6 +216,16 @@ export class ServerAuthoritativeServer {
180
216
  */
181
217
  this.simulation_delay_ticks = simulation_delay_ticks;
182
218
 
219
+ /**
220
+ * Peer id of this authoritative server, or `-1` when the session has no
221
+ * privileged peer. Actions from {@link enqueue_action} are tagged with
222
+ * it so they pass the owner-authorization gate on any entity (see
223
+ * {@link make_owner_authorization}), which is the documented convention
224
+ * for the server's own mutations.
225
+ * @type {number}
226
+ */
227
+ this.server_peer_id = server_peer_id;
228
+
183
229
  /**
184
230
  * Upper bound on how far ahead of the server's own simulation a peer
185
231
  * may tag an inbound action. `frame_number` is an attacker-controlled
@@ -215,6 +261,9 @@ export class ServerAuthoritativeServer {
215
261
  this.#payload_buf = new BinaryBuffer();
216
262
  this.#payload_buf.setCapacity(64);
217
263
 
264
+ this.#local_buf = new BinaryBuffer();
265
+ this.#local_buf.setCapacity(64);
266
+
218
267
  // Switch the Replicator to deferred-execute mode: incoming actions
219
268
  // accumulate in pending until tick() drains them.
220
269
  this.peer.replicator.on_pending_action = (peer_id, frame_number, type_id, buf, off, len) => {
@@ -231,13 +280,35 @@ export class ServerAuthoritativeServer {
231
280
  };
232
281
 
233
282
  /**
234
- * Fires once per frame during the replay loop, AFTER all merged
235
- * actions for that frame have been applied and BEFORE the action_log
236
- * frame is closed. Use this for server-authoritative local sim
237
- * (clamps, collision, AI step, etc.). The current world state
238
- * reflects all inputs for this frame; the local sim should be
239
- * idempotent under repeated application (because it may run many
240
- * times under rollback).
283
+ * Fires once per execution of a frame, AFTER all input for that frame
284
+ * has been applied and BEFORE the action_log frame is closed. Use it
285
+ * for server-authoritative local sim: clamps, collision, AI step,
286
+ * damage resolution, spawning the effects a hit produces.
287
+ *
288
+ * **This is a derivation, not an event.** The handler runs again on
289
+ * every replay of the frame, and must produce the same result for the
290
+ * same post-input world state — it is a function OF that state, not a
291
+ * one-shot that happens to be called from it. Concretely:
292
+ *
293
+ * - Decide from world state and the frame number. Both are restored
294
+ * before the handler re-runs.
295
+ * - Do NOT latch on handler-local flags (`if (already_fired) return`)
296
+ * to make something happen once. The replay needs the handler to
297
+ * re-emit; a latch makes it emit nothing, and the action is lost
298
+ * the first time a rollback crosses its frame. For a genuine
299
+ * one-shot — a lobby spawn, an admin command, anything decided
300
+ * outside the simulation — use {@link enqueue_action}, which is
301
+ * recorded as input and replayed rather than recomputed.
302
+ *
303
+ * Actions the handler executes are recorded as derived output: they
304
+ * replicate to clients and their prior state is captured so a rewind
305
+ * undoes them, but a replay recomputes them instead of reapplying the
306
+ * previous pass's copy. So they need not be idempotent — a handler may
307
+ * subtract hit points — only deterministic in the frame's post-input
308
+ * state. Components the handler mutates DIRECTLY, without going through
309
+ * an action, have no prior-state capture and are not undone by a
310
+ * rewind; those must additionally be idempotent, which is why a clamp
311
+ * is safe to write that way and `hp -= 10` is not.
241
312
  *
242
313
  * Args: `(frame_number)`.
243
314
  * @type {Signal}
@@ -334,6 +405,71 @@ export class ServerAuthoritativeServer {
334
405
  this.peer.send_time_dilation_feedback(peer_id, this.buffer_depth_for_peer(peer_id));
335
406
  }
336
407
 
408
+ /**
409
+ * Author a server-side action, to be applied on the next simulated frame.
410
+ *
411
+ * This is the supported path for a mutation the simulation cannot
412
+ * recompute — a spawn triggered by a lobby event, an admin command, a
413
+ * scripted trigger — as opposed to {@link onLocalSim}, which derives a
414
+ * frame's output from its state and is re-run on every replay.
415
+ *
416
+ * The action is serialized immediately (so the caller may reuse or discard
417
+ * the instance) and applied exactly once, on the next frame {@link tick}
418
+ * simulates for the first time, merged into that frame's input and ordered
419
+ * with it by sender. From then on it is ordinary history: replayed from the
420
+ * log like any other input, and never re-queued.
421
+ *
422
+ * Callable at any time EXCEPT from inside {@link onLocalSim}, which throws:
423
+ * that handler re-runs on every replay of its frame, so it would queue a
424
+ * fresh copy each time. (`executor.execute` is the opposite — it requires
425
+ * an open action_log frame, so it only works from inside the handler.)
426
+ *
427
+ * Recorded with {@link server_peer_id} as the sender when the session has
428
+ * one, so it passes the owner-authorization gate on any entity; with
429
+ * {@link SENDER_LOCAL} otherwise, which bypasses the gate.
430
+ *
431
+ * @param {SimAction} action
432
+ */
433
+ enqueue_action(action) {
434
+ assert.ok(action && action.isSimAction, 'enqueue_action: action must be a SimAction');
435
+ // Authoring from inside a derivation is always wrong, and unlike a
436
+ // latched handler it is exactly detectable, so it fails loudly here.
437
+ // onLocalSim runs again on every replay of its frame; each run would
438
+ // queue another copy, and each copy is recorded as input, so the
439
+ // applications compound per replay — the very shape this provenance
440
+ // split exists to stop. A handler that wants an effect every time the
441
+ // frame runs should `executor.execute` it as derived output instead.
442
+ // Real runtime check: asserts strip in prod.
443
+ if (this.executor.deriving) {
444
+ throw new Error(
445
+ 'ServerAuthoritativeServer.enqueue_action: cannot author an action from inside onLocalSim — the handler re-runs on every replay, so the action would be queued again each time. Execute it through the executor to record it as derived output, or enqueue it from outside the local sim.',
446
+ );
447
+ }
448
+ const type_id = action.constructor.type_id;
449
+ assert.ok(
450
+ type_id >= 0,
451
+ `enqueue_action: action class '${action.constructor.action_type_name ?? action.constructor.name}' has no wire type_id — register it before enqueueing it`,
452
+ );
453
+
454
+ this.#local_buf.position = 0;
455
+ action.serialize(this.#local_buf);
456
+ const len = this.#local_buf.position;
457
+
458
+ const payload = new Uint8Array(len);
459
+ payload.set(this.#local_buf.raw_bytes.subarray(0, len));
460
+
461
+ this.#local_type_ids.push(type_id);
462
+ this.#local_payloads.push(payload);
463
+ }
464
+
465
+ /**
466
+ * Number of locally-authored actions waiting for the next simulated frame.
467
+ * @returns {number}
468
+ */
469
+ queued_local_action_count() {
470
+ return this.#local_type_ids.length;
471
+ }
472
+
337
473
  /**
338
474
  * Drive the rollback flow for one tick.
339
475
  *
@@ -360,12 +496,31 @@ export class ServerAuthoritativeServer {
360
496
  this.#pending_trim_to_window(window_oldest);
361
497
  }
362
498
 
363
- // 2. Pick the replay window. `__pending_referenced_frames` is
364
- // sorted ascending; entries `> sim_frame` stay buffered.
499
+ // 2. Pick the replay window: the OLDEST pending frame that carries
500
+ // input the action log doesn't already hold.
501
+ //
502
+ // `__pending_referenced_frames` is sorted ascending; entries
503
+ // `> sim_frame` stay buffered. Most of what it holds at any real
504
+ // latency is retransmission: `Replicator.pack_for_peer` packs
505
+ // every frame in [last_acked + 1, current] each tick, so each
506
+ // action arrives once per tick until its ack round-trips, and the
507
+ // deferral hook logs every copy. Taking `refs[0]` verbatim would
508
+ // therefore rewind by the ACK round trip on every tick — and
509
+ // `__replay_frame`'s dedup would then discard the very entries
510
+ // that caused the rewind, leaving the world exactly as it was.
511
+ // So run the same comparison here, before the window is chosen:
512
+ // a frame whose every pending entry is already in the log needs
513
+ // no rewind. What's left is genuinely late input, which is what
514
+ // the rollback exists for.
365
515
  let replay_start = sim_frame;
366
516
  const refs = this.#pending_referenced_frames;
367
- if (refs.length > 0 && refs[0] <= sim_frame) {
368
- replay_start = refs[0];
517
+ for (let i = 0; i < refs.length; i++) {
518
+ const f = refs[i];
519
+ if (f > sim_frame) break;
520
+ if (this.#frame_has_unapplied_input(f)) {
521
+ replay_start = f;
522
+ break;
523
+ }
369
524
  }
370
525
 
371
526
  // 3. Rewind only if the replay window covers committed frames.
@@ -375,7 +530,10 @@ export class ServerAuthoritativeServer {
375
530
  this.onRewind.send3(committed_top, replay_start - 1, committed_top - (replay_start - 1));
376
531
  }
377
532
 
378
- // 4. Replay [replay_start, sim_frame].
533
+ // 4. Replay [replay_start, sim_frame]. Only `sim_frame` is being
534
+ // simulated for the first time; the rest are re-executions of
535
+ // frames the log already holds.
536
+ this.#fresh_frame = sim_frame;
379
537
  for (let f = replay_start; f <= sim_frame; f++) {
380
538
  this.#replay_frame(f);
381
539
  }
@@ -406,6 +564,87 @@ export class ServerAuthoritativeServer {
406
564
  return max - this.current_sim_frame;
407
565
  }
408
566
 
567
+ /**
568
+ * Does frame `f` carry at least one pending entry the action log does not
569
+ * already hold? Only such a frame can change the world when replayed, so
570
+ * only such a frame is worth rewinding to.
571
+ *
572
+ * Same key as {@link __replay_frame}'s dedup — `(sender_id, type_id,
573
+ * payload bytes)` — asked one step earlier, against the log rather than
574
+ * against a scratch copy of it. The two must stay in step: a frame this
575
+ * call reports clean is never replayed, so an entry it treats as a
576
+ * duplicate that `__replay_frame` would have treated as new would be
577
+ * dropped from pending without ever being applied.
578
+ *
579
+ * Note the key can only recognise records the log actually holds. An
580
+ * action the executor REJECTED (owner authorization) writes nothing, so
581
+ * each of its retransmissions still reads as new input and still costs a
582
+ * rewind — unchanged from before this check existed, and self-limiting,
583
+ * because the sender stops re-sending the frame once it is acked.
584
+ *
585
+ * @param {number} f
586
+ * @returns {boolean}
587
+ * @private
588
+ */
589
+ #frame_has_unapplied_input(f) {
590
+ const has_history = this.action_log.has_frame(f);
591
+ for (let i = 0; i < this.#pending_frames.length; i++) {
592
+ if (this.#pending_frames[i] !== f) continue;
593
+ // Nothing recorded for this frame yet: every entry is new.
594
+ if (!has_history) return true;
595
+ if (!this.#log_holds_record(f, this.#pending_senders[i], this.#pending_type_ids[i], this.#pending_payloads[i])) {
596
+ return true;
597
+ }
598
+ }
599
+ return false;
600
+ }
601
+
602
+ /**
603
+ * Walk `action_log[frame_number]` looking for a record identical to
604
+ * `(sender_id, type_id, payload)`. Compares the payload in place against
605
+ * the log's own bytes — no copy, unlike {@link __read_historical}, because
606
+ * this runs per pending entry on every tick.
607
+ *
608
+ * Caller must have checked `action_log.has_frame(frame_number)`.
609
+ *
610
+ * @param {number} frame_number
611
+ * @param {number} sender_id
612
+ * @param {number} type_id
613
+ * @param {Uint8Array} payload
614
+ * @returns {boolean}
615
+ * @private
616
+ */
617
+ #log_holds_record(frame_number, sender_id, type_id, payload) {
618
+ const buf = this.action_log.buffer_for(frame_number); // sets position = 0
619
+ const end = this.action_log.write_end_for(frame_number);
620
+ const bytes = buf.raw_bytes;
621
+ while (buf.position < end) {
622
+ const prior_count = buf.readUintVar();
623
+ for (let i = 0; i < prior_count; i++) {
624
+ buf.readUintVar(); // entity_id
625
+ buf.readUint8(); // component_type_id
626
+ const plen = buf.readUint32();
627
+ buf.position += plen;
628
+ }
629
+ const r_type_id = buf.readUint8();
630
+ const r_sender_id = buf.readUint8();
631
+ const r_payload_len = buf.readUint32();
632
+ const r_payload_start = buf.position;
633
+ buf.position += r_payload_len;
634
+
635
+ if (r_type_id !== type_id) continue;
636
+ if (r_sender_id !== sender_id) continue;
637
+ if (r_payload_len !== payload.length) continue;
638
+
639
+ let same = true;
640
+ for (let i = 0; i < r_payload_len; i++) {
641
+ if (bytes[r_payload_start + i] !== payload[i]) { same = false; break; }
642
+ }
643
+ if (same) return true;
644
+ }
645
+ return false;
646
+ }
647
+
409
648
  /** @private */
410
649
  #replay_frame(f) {
411
650
  this.#sort_scratch.length = 0;
@@ -450,6 +689,26 @@ export class ServerAuthoritativeServer {
450
689
  this.#sort_scratch.push({ sender_id: p_sender, type_id: p_type, payload: p_payload });
451
690
  }
452
691
 
692
+ // Locally-authored actions join the merge as input, and only on the
693
+ // frame being simulated for the first time — a replayed frame already
694
+ // holds, in the history read above, the ones it was given when it was
695
+ // fresh. They go in BEFORE the sort so a frame's input order does not
696
+ // depend on whether it is being simulated fresh or replayed: on replay
697
+ // these come back through __read_historical and are sorted with
698
+ // everything else, so the fresh pass has to sort them the same way.
699
+ if (f === this.#fresh_frame && this.#local_type_ids.length > 0) {
700
+ const sender = this.server_peer_id >= 0 ? this.server_peer_id : SENDER_LOCAL;
701
+ for (let i = 0; i < this.#local_type_ids.length; i++) {
702
+ this.#sort_scratch.push({
703
+ sender_id: sender,
704
+ type_id: this.#local_type_ids[i],
705
+ payload: this.#local_payloads[i],
706
+ });
707
+ }
708
+ this.#local_type_ids.length = 0;
709
+ this.#local_payloads.length = 0;
710
+ }
711
+
453
712
  // Stable sort by sender. Array.prototype.sort is stable since ES2019;
454
713
  // equal sender_ids preserve insertion order, with historical entries
455
714
  // appearing before newly-arrived pending entries for the same sender.
@@ -461,10 +720,24 @@ export class ServerAuthoritativeServer {
461
720
  const s = this.#sort_scratch[i];
462
721
  this.#apply_payload(s.type_id, s.sender_id, s.payload);
463
722
  }
464
- // Notify user code so server-side local sim runs at the right point
465
- // in the frame (after inputs applied, before frame closed). Idempotent
466
- // under repeated application across rollback replays.
467
- this.onLocalSim.send1(f);
723
+
724
+ // Notify user code so server-side local sim runs at the right point in
725
+ // the frame: after all input for the frame is applied, before the frame
726
+ // is closed.
727
+ //
728
+ // Everything the handler executes is DERIVED output — the handler runs
729
+ // again on every replay of this frame, so the replay recomputes it
730
+ // rather than reapplying it. `deriving` makes the executor stamp those
731
+ // records SENDER_DERIVED, which is how __read_historical tells them
732
+ // from the input records above. Cleared in a finally: a handler that
733
+ // throws must not leave the executor stamping every later record as
734
+ // derived, which would silently drop that input from every replay.
735
+ this.executor.deriving = true;
736
+ try {
737
+ this.onLocalSim.send1(f);
738
+ } finally {
739
+ this.executor.deriving = false;
740
+ }
468
741
  this.action_log.end_frame();
469
742
  }
470
743
 
@@ -504,9 +777,18 @@ export class ServerAuthoritativeServer {
504
777
  const type_id = buf.readUint8();
505
778
  const sender_id = buf.readUint8();
506
779
  const payload_len = buf.readUint32();
507
- const payload = new Uint8Array(payload_len);
508
- payload.set(buf.raw_bytes.subarray(buf.position, buf.position + payload_len));
780
+ const payload_start = buf.position;
509
781
  buf.position += payload_len;
782
+
783
+ // Derived records are the previous pass's local-sim output. The
784
+ // caller re-runs onLocalSim for this frame, which recomputes them
785
+ // against a world that now includes the late input that forced the
786
+ // replay; carrying the stale copy forward as well would apply the
787
+ // effect twice, and the copies would accumulate one per replay.
788
+ if (sender_id === SENDER_DERIVED) continue;
789
+
790
+ const payload = new Uint8Array(payload_len);
791
+ payload.set(buf.raw_bytes.subarray(payload_start, payload_start + payload_len));
510
792
  out_array.push({ type_id, sender_id, payload });
511
793
  }
512
794
  }
@@ -111,12 +111,25 @@ export class Replicator {
111
111
  *
112
112
  * Writes nothing if no in-scope actions are found.
113
113
  *
114
+ * `max_bytes` caps how many bytes this call may add. Frame groups are
115
+ * written whole or not at all: when the next group would cross the cap, the
116
+ * pack stops before it and the return value reports how far it got, so the
117
+ * caller can credit exactly what it sent and pick the rest up on a later
118
+ * pass. The one exception is a first group that does not fit by itself —
119
+ * that one is written anyway, because no smaller pack could ever carry the
120
+ * frame and dropping it would strand those actions forever.
121
+ *
114
122
  * @param {number} peer_id
115
123
  * @param {number} start_frame inclusive; if no frame in range is in the log, that frame is skipped
116
124
  * @param {number} end_frame inclusive
117
125
  * @param {BinaryBuffer} out_buffer
126
+ * @param {number} [max_bytes] byte budget for this call; unbounded by default
127
+ * @returns {number} the last frame this pack covers, or `start_frame - 1` if
128
+ * it covers nothing. A frame that was skipped — absent from the log, or
129
+ * holding nothing in scope — counts as covered: the receiver has nothing
130
+ * to apply for it, so the caller may credit it.
118
131
  */
119
- pack_for_peer(peer_id: number, start_frame: number, end_frame: number, out_buffer: BinaryBuffer): void;
132
+ pack_for_peer(peer_id: number, start_frame: number, end_frame: number, out_buffer: BinaryBuffer, max_bytes?: number): number;
120
133
  /**
121
134
  * Parse and apply actions from `in_buffer` into the local world via the executor.
122
135
  * Each frame in the packet opens a new entry in the local action log under the
@@ -1 +1 @@
1
- {"version":3,"file":"Replicator.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/replication/Replicator.js"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH;IA2DI;;;;;;;;OAQG;IACH;;;;;;wCAHiD,MAAM,cAAc,MAAM,GAAG,OAAO;;OA8BpF;IAvED;;;;;;;;;;;;OAYG;IACH,gBAFU,MAAM,CAEc;IAE9B;;;;;;;;;;;;;;;;;OAiBG;IACH,6BAFqB,MAAM,gBAAgB,MAAM,kBAAkB,MAAM,2CAA2C,MAAM,eAAe,MAAM,KAAK,IAAI,CAE/H;IAYrB;;OAEG;IACH,sBAA4B;IAE5B;;OAEG;IACH,mCAAsC;IAEtC;;OAEG;IACH,4BAAwB;IAExB;;OAEG;IACH,iCAA4B;IAE5B;;;;OAIG;IACH;oCAFwC,MAAM,cAAc,MAAM,GAAG,OAAO;MAE5C;IAGpC;;;;;;;;;;OAUG;IACH,uBALW,MAAM,eACN,MAAM,aACN,MAAM,kCA2ChB;IAED;;;;;;;;;OASG;IACH,0BAJW,MAAM,0CAEN,MAAM,QA4GhB;IAED;;;;;;OAMG;IACH,qBAFW,MAAM,QAKhB;;CAuFJ;mBAnZkB,uCAAuC"}
1
+ {"version":3,"file":"Replicator.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/replication/Replicator.js"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH;IA2DI;;;;;;;;OAQG;IACH;;;;;;wCAHiD,MAAM,cAAc,MAAM,GAAG,OAAO;;OA8BpF;IAvED;;;;;;;;;;;;OAYG;IACH,gBAFU,MAAM,CAEc;IAE9B;;;;;;;;;;;;;;;;;OAiBG;IACH,6BAFqB,MAAM,gBAAgB,MAAM,kBAAkB,MAAM,2CAA2C,MAAM,eAAe,MAAM,KAAK,IAAI,CAE/H;IAYrB;;OAEG;IACH,sBAA4B;IAE5B;;OAEG;IACH,mCAAsC;IAEtC;;OAEG;IACH,4BAAwB;IAExB;;OAEG;IACH,iCAA4B;IAE5B;;;;OAIG;IACH;oCAFwC,MAAM,cAAc,MAAM,GAAG,OAAO;MAE5C;IAGpC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,uBAVW,MAAM,eACN,MAAM,aACN,MAAM,wCAEN,MAAM,GACJ,MAAM,CAyElB;IAED;;;;;;;;;OASG;IACH,0BAJW,MAAM,0CAEN,MAAM,QA4GhB;IAED;;;;;;OAMG;IACH,qBAFW,MAAM,QAKhB;;CAuFJ;mBA5bkB,uCAAuC"}