Initial Commit
This commit is contained in:
13
packages/threejs/r3f/README.md
Normal file
13
packages/threejs/r3f/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# `r3f`
|
||||
|
||||
React Three Fiber networking and scene components.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `r3f`
|
||||
|
||||
1 package.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/r3f-networking`](./r3f-networking/README.md) | R3F-tier multiplayer state-synchronisation for React-Three-Fiber scenes. Composes any `@suaveplan/network` `Transport` (canonical: `@suaveplan/network-transpor… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/threejs/r3f/_intro.md
Normal file
1
packages/threejs/r3f/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
React Three Fiber networking and scene components.
|
||||
108
packages/threejs/r3f/r3f-networking/README.md
Normal file
108
packages/threejs/r3f/r3f-networking/README.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# @suaveplan/r3f-networking - R3F-tier multiplayer state synchronisation: interpolation, client-side prediction, snapshot diffing, server reconciliation FSM, React hooks + React-Three-Fiber components. Transport-agnostic — composes any @suaveplan/network Transport.
|
||||
|
||||
## Summary
|
||||
|
||||
R3F-tier multiplayer state-synchronisation for React-Three-Fiber scenes.
|
||||
Composes any `@suaveplan/network` `Transport` (canonical:
|
||||
`@suaveplan/network-transport-webrtc`), `@suaveplan/diff` for snapshot
|
||||
deltas, `@suaveplan/fsm` for the reconciliation state machine, and
|
||||
`@suaveplan/serialize` for the wire codec.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [server-authority](./src/authority/server-authority/server-authority.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [server-authority](./src/authority/server-authority/server-authority.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [network-provider-context](./src/components/network-provider/network-provider-context.md) — Module-scope `React.Context` exported as a separate file so consumers can import the context object directly (`useConte…
|
||||
- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [apply-networked-frame](./src/components/networked-entity/apply-networked-frame.md) — Pure per-frame helper that writes a target `EntityTransform` into a Three.js group-shaped target. Local entities snap d…
|
||||
- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [server-authority](./src/authority/server-authority/server-authority.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-provider-context](./src/components/network-provider/network-provider-context.md) — Module-scope `React.Context` exported as a separate file so consumers can import the context object directly (`useConte…
|
||||
- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [apply-networked-frame](./src/components/networked-entity/apply-networked-frame.md) — Pure per-frame helper that writes a target `EntityTransform` into a Three.js group-shaped target. Local entities snap d…
|
||||
- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-authority](./src/authority/server-authority/server-authority.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-provider-context](./src/components/network-provider/network-provider-context.md) — Module-scope `React.Context` exported as a separate file so consumers can import the context object directly (`useConte…
|
||||
- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [apply-networked-frame](./src/components/networked-entity/apply-networked-frame.md) — Pure per-frame helper that writes a target `EntityTransform` into a Three.js group-shaped target. Local entities snap d…
|
||||
- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-authority](./src/authority/server-authority/server-authority.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-provider-context](./src/components/network-provider/network-provider-context.md) — Module-scope `React.Context` exported as a separate file so consumers can import the context object directly (`useConte…
|
||||
- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [apply-networked-frame](./src/components/networked-entity/apply-networked-frame.md) — Pure per-frame helper that writes a target `EntityTransform` into a Three.js group-shaped target. Local entities snap d…
|
||||
- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [server-authority](./src/authority/server-authority/server-authority.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-provider-context](./src/components/network-provider/network-provider-context.md) — Module-scope `React.Context` exported as a separate file so consumers can import the context object directly (`useConte…
|
||||
- [network-provider](./src/components/network-provider/network-provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-stats](./src/components/network-stats/network-stats.md) — <!-- begin:auto-docs --> _(no submodules)_- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_ - [apply-networked-frame](./src/components/networked-entity/apply-networked-frame.md) — Pure per-frame helper that writes a target `EntityTransform` into a Three.js group-shaped target. Local entities snap d…
|
||||
- [networked-entity](./src/components/networked-entity/networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [interpolation](./src/core/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [prediction](./src/core/prediction/prediction.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_ - [reconciliation-fsm](./src/core/reconciliation-fsm/reconciliation-fsm.md) — <!-- begin:auto-docs --> _(no submodules)_- [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_ - [snapshot-diff](./src/core/snapshot-diff/snapshot-diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [state-buffer](./src/core/state-buffer/state-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_ - [wire-codec](./src/core/wire-codec/wire-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-network-predict](./src/hooks/use-network-predict/use-network-predict.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-network-sync](./src/hooks/use-network-sync/use-network-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-networked-entity](./src/hooks/use-networked-entity/use-networked-entity.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_ - [entity-sync](./src/sync/entity-sync/entity-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [interest-manager](./src/sync/interest-manager/interest-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_ - [memory-transport](./src/transport/memory-transport/memory-transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-helpers](./src/utils/network-helpers/network-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## What this package owns
|
||||
|
||||
- **Interpolation** — frame-loop smoothing of remote-peer transforms
|
||||
([`core/interpolation`](./src/core/interpolation/interpolation.md)).
|
||||
- **Client-side prediction** — local input applied immediately, reconciled
|
||||
against authoritative snapshots
|
||||
([`core/prediction`](./src/core/prediction/prediction.md)).
|
||||
- **Reconciliation FSM** — `synchronized → diverged → resyncing` state
|
||||
machine driven by divergence magnitude
|
||||
([`core/reconciliation-fsm`](./src/core/reconciliation-fsm/reconciliation-fsm.md)).
|
||||
- **Snapshot diffing** — RFC-6902 JSON Patch between authoritative
|
||||
snapshots via `@suaveplan/diff`
|
||||
([`core/snapshot-diff`](./src/core/snapshot-diff/snapshot-diff.md)).
|
||||
- **Wire codec** — Zod-validated frame encode/decode via
|
||||
`@suaveplan/serialize`
|
||||
([`core/wire-codec`](./src/core/wire-codec/wire-codec.md)).
|
||||
- **Entity sync + interest management**
|
||||
([`sync/entity-sync`](./src/sync/entity-sync/entity-sync.md),
|
||||
[`sync/interest-manager`](./src/sync/interest-manager/interest-manager.md)).
|
||||
- **Server authority** with anti-cheat hooks
|
||||
([`authority/server-authority`](./src/authority/server-authority/server-authority.md)).
|
||||
- **In-memory transport** — deterministic real `Transport` implementation
|
||||
for integration tests
|
||||
([`transport/memory-transport`](./src/transport/memory-transport/memory-transport.md)).
|
||||
- **React hooks** — `useNetworkSync`, `useNetworkedEntity`,
|
||||
`useNetworkPredict`.
|
||||
- **R3F components** — `<NetworkProvider>`, `<NetworkedEntity>`,
|
||||
`<NetworkStats>`.
|
||||
|
||||
## Observability
|
||||
|
||||
OTel meter `@suaveplan/r3f-networking`. Histograms
|
||||
`r3f_net.snapshot_dispatch_ms` and `r3f_net.prediction_divergence`;
|
||||
counters `r3f_net.snapshots_total{outcome}` and
|
||||
`r3f_net.peer_events_total{event}`.
|
||||
|
||||
## Error registry
|
||||
|
||||
`defineCodes("R3F_NET", […])` — 8 codes:
|
||||
`SNAPSHOT_OUT_OF_ORDER`, `PEER_DROPPED`, `PREDICTION_DIVERGED`,
|
||||
`INVALID_OPTIONS`, `INVALID_SNAPSHOT`, `DISPOSED`, `TRANSPORT_FAILED`,
|
||||
`ABORTED`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
76
packages/threejs/r3f/r3f-networking/package.json
Normal file
76
packages/threejs/r3f/r3f-networking/package.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "@suaveplan/r3f-networking",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "R3F-tier multiplayer state synchronisation: interpolation, client-side prediction, snapshot diffing, server reconciliation FSM, React hooks + React-Three-Fiber components. Transport-agnostic — composes any @suaveplan/network Transport.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "src", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0",
|
||||
"three": "*",
|
||||
"@react-three/fiber": "*",
|
||||
"@react-three/drei": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-three/drei": { "optional": true }
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/diff": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/fsm": "workspace:^",
|
||||
"@suaveplan/network": "workspace:^",
|
||||
"@suaveplan/serialize": "workspace:^",
|
||||
"@suaveplan/timing": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-three/fiber": "catalog:react-three",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-dom": "workspace:^",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@types/three": "catalog:react-three",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"three": "catalog:react-three",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# `server-authority`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Server-authoritative entity manager that validates and applies client
|
||||
inputs. Detects stale inputs, ownership violations, and oversized deltas
|
||||
(a basic anti-cheat); rejected inputs return a structured `AuthorityResult`
|
||||
with a `reason` discriminator so the network layer can decide whether to
|
||||
log, throttle, or disconnect the offending client.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-entity `mode`: `server`, `client`, `shared`.
|
||||
- Monotonic `seq` enforcement — older inputs rejected with `stale-input`.
|
||||
- Delta-magnitude cap (`maxDelta`) catches teleport-cheats.
|
||||
- Ownership check ensures `clientId === ownerId` in `server` mode.
|
||||
- `setTransform(id, t)` for server-driven movement.
|
||||
- Zod-validated options; bad input throws `R3F_NET.INVALID_OPTIONS`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ServerAuthority } from "@suaveplan/r3f-networking";
|
||||
|
||||
const auth = new ServerAuthority({ maxDelta: 1.0 });
|
||||
auth.registerEntity("e1", "client-A", "server");
|
||||
const r = auth.processInput({
|
||||
clientId: "client-A",
|
||||
entityId: "e1",
|
||||
seq: 1,
|
||||
delta: [0.5, 0, 0],
|
||||
timestamp: 1000,
|
||||
});
|
||||
// r.accepted === true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Reject oversized deltas while still echoing the result through the wire:
|
||||
|
||||
```ts
|
||||
const auth = new ServerAuthority({ maxDelta: 0.5 });
|
||||
auth.registerEntity("e1", "client-A");
|
||||
const r = auth.processInput({
|
||||
clientId: "client-A",
|
||||
entityId: "e1",
|
||||
seq: 1,
|
||||
delta: [10, 0, 0],
|
||||
timestamp: 1000,
|
||||
});
|
||||
// r.accepted === false, r.reason === "delta-too-large"
|
||||
transport.sendToClient("client-A", { type: "rejected", body: r });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ServerAuthority(config?: ServerAuthorityConfig)`
|
||||
- `registerEntity(id, ownerId, mode?, initial?)` / `unregisterEntity(id)`
|
||||
- `processInput(input: AuthorityInput): AuthorityResult`
|
||||
- `setTransform(id, transform): boolean`
|
||||
- `getTransform(id)` / `getLastSeq(id)` / `getEntityCount()` /
|
||||
`getEntityIds()` / `clear()`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`maxDelta` is the raw distance per single input; if your game runs at
|
||||
60 Hz inputs and 6 m/s max speed, set `maxDelta ≈ 0.11`. The class is
|
||||
deliberately stateless across clients beyond the `lastSeq` watermark so
|
||||
horizontal scaling of authoritative shards remains feasible — sharding
|
||||
strategies live in higher-level network plugins. The owner check only
|
||||
fires in `server` mode; `shared` and `client` modes accept input from any
|
||||
client so peer-to-peer scenarios remain expressible. Rejected results
|
||||
include the offending `seq` so the wire layer can echo a rejection back
|
||||
to the originating client without resorting to side-channel correlation.
|
||||
The default fallback transform on rejection is intentionally identity so a
|
||||
naive consumer never accidentally applies a rejected result to scene
|
||||
state — the `accepted` boolean must be checked. Validation order is
|
||||
deterministic: entity-not-found → not-owner → stale-input → delta-too-
|
||||
large, with no later check obscuring an earlier failure.
|
||||
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ServerAuthorityConfigSchema = z
|
||||
.object({
|
||||
maxDelta: z.number().positive().optional(),
|
||||
maxSpeed: z.number().positive().optional(),
|
||||
validateInputs: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { R3FNetError } from "../../errors/errors.js";
|
||||
import { ServerAuthority } from "./server-authority.js";
|
||||
|
||||
describe("ServerAuthority", () => {
|
||||
it("rejects bad options", () => {
|
||||
// @ts-expect-error: bad
|
||||
expect(() => new ServerAuthority({ maxDelta: -1 })).toThrow(
|
||||
R3FNetError
|
||||
);
|
||||
});
|
||||
|
||||
it("registers and unregisters entities", () => {
|
||||
const sa = new ServerAuthority();
|
||||
sa.registerEntity("e1", "client-A");
|
||||
expect(sa.hasEntity("e1")).toBe(true);
|
||||
expect(sa.getEntityCount()).toBe(1);
|
||||
sa.unregisterEntity("e1");
|
||||
expect(sa.hasEntity("e1")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers with custom initial transform", () => {
|
||||
const sa = new ServerAuthority();
|
||||
sa.registerEntity("e1", "client-A", "server", {
|
||||
position: [1, 2, 3],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
expect(sa.getTransform("e1")?.position).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("getTransform/getLastSeq return null/0 for unknown ids", () => {
|
||||
const sa = new ServerAuthority();
|
||||
expect(sa.getTransform("missing")).toBeNull();
|
||||
expect(sa.getLastSeq("missing")).toBe(0);
|
||||
});
|
||||
|
||||
it("processInput rejects unknown entity", () => {
|
||||
const sa = new ServerAuthority();
|
||||
const r = sa.processInput({
|
||||
clientId: "c",
|
||||
entityId: "missing",
|
||||
seq: 1,
|
||||
delta: [0, 0, 0],
|
||||
timestamp: 0,
|
||||
});
|
||||
expect(r.accepted).toBe(false);
|
||||
expect(r.reason).toBe("entity-not-found");
|
||||
});
|
||||
|
||||
it("processInput rejects non-owner in server mode", () => {
|
||||
const sa = new ServerAuthority();
|
||||
sa.registerEntity("e1", "client-A");
|
||||
const r = sa.processInput({
|
||||
clientId: "client-B",
|
||||
entityId: "e1",
|
||||
seq: 1,
|
||||
delta: [0, 0, 0],
|
||||
timestamp: 0,
|
||||
});
|
||||
expect(r.accepted).toBe(false);
|
||||
expect(r.reason).toBe("not-owner");
|
||||
});
|
||||
|
||||
it("processInput rejects stale seq", () => {
|
||||
const sa = new ServerAuthority({ maxDelta: 100 });
|
||||
sa.registerEntity("e1", "client-A");
|
||||
sa.processInput({
|
||||
clientId: "client-A",
|
||||
entityId: "e1",
|
||||
seq: 5,
|
||||
delta: [0, 0, 0],
|
||||
timestamp: 0,
|
||||
});
|
||||
const r = sa.processInput({
|
||||
clientId: "client-A",
|
||||
entityId: "e1",
|
||||
seq: 3,
|
||||
delta: [0, 0, 0],
|
||||
timestamp: 0,
|
||||
});
|
||||
expect(r.accepted).toBe(false);
|
||||
expect(r.reason).toBe("stale-input");
|
||||
});
|
||||
|
||||
it("processInput rejects oversized deltas", () => {
|
||||
const sa = new ServerAuthority({ maxDelta: 1 });
|
||||
sa.registerEntity("e1", "client-A");
|
||||
const r = sa.processInput({
|
||||
clientId: "client-A",
|
||||
entityId: "e1",
|
||||
seq: 1,
|
||||
delta: [10, 0, 0],
|
||||
timestamp: 0,
|
||||
});
|
||||
expect(r.accepted).toBe(false);
|
||||
expect(r.reason).toBe("delta-too-large");
|
||||
});
|
||||
|
||||
it("processInput accepts and updates", () => {
|
||||
const sa = new ServerAuthority({ maxDelta: 5 });
|
||||
sa.registerEntity("e1", "client-A");
|
||||
const r = sa.processInput({
|
||||
clientId: "client-A",
|
||||
entityId: "e1",
|
||||
seq: 1,
|
||||
delta: [1, 2, 3],
|
||||
timestamp: 0,
|
||||
});
|
||||
expect(r.accepted).toBe(true);
|
||||
expect(r.transform.position).toEqual([1, 2, 3]);
|
||||
expect(sa.getLastSeq("e1")).toBe(1);
|
||||
});
|
||||
|
||||
it("processInput skips delta-validation when disabled", () => {
|
||||
const sa = new ServerAuthority({ validateInputs: false });
|
||||
sa.registerEntity("e1", "client-A");
|
||||
const r = sa.processInput({
|
||||
clientId: "client-A",
|
||||
entityId: "e1",
|
||||
seq: 1,
|
||||
delta: [100, 0, 0],
|
||||
timestamp: 0,
|
||||
});
|
||||
expect(r.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("processInput allows any client in non-server mode", () => {
|
||||
const sa = new ServerAuthority({ maxDelta: 100 });
|
||||
sa.registerEntity("e1", "client-A", "shared");
|
||||
const r = sa.processInput({
|
||||
clientId: "client-B",
|
||||
entityId: "e1",
|
||||
seq: 1,
|
||||
delta: [0, 0, 0],
|
||||
timestamp: 0,
|
||||
});
|
||||
expect(r.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("setTransform updates an existing entity", () => {
|
||||
const sa = new ServerAuthority();
|
||||
sa.registerEntity("e1", "client-A");
|
||||
const ok = sa.setTransform("e1", {
|
||||
position: [9, 9, 9],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(sa.getTransform("e1")?.position).toEqual([9, 9, 9]);
|
||||
});
|
||||
|
||||
it("setTransform returns false for unknown id", () => {
|
||||
const sa = new ServerAuthority();
|
||||
const ok = sa.setTransform("missing", {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("getEntityIds returns the registered set", () => {
|
||||
const sa = new ServerAuthority();
|
||||
sa.registerEntity("a", "x");
|
||||
sa.registerEntity("b", "y");
|
||||
expect(sa.getEntityIds().sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("clear empties the manager", () => {
|
||||
const sa = new ServerAuthority();
|
||||
sa.registerEntity("e1", "x");
|
||||
sa.clear();
|
||||
expect(sa.getEntityCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import { distanceVec3 } from "../../utils/network-helpers/network-helpers.js";
|
||||
import type {
|
||||
EntityTransform,
|
||||
Vec3,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import { ServerAuthorityConfigSchema } from "./server-authority.schemas.js";
|
||||
import type {
|
||||
AuthoritativeEntity,
|
||||
AuthorityInput,
|
||||
AuthorityMode,
|
||||
AuthorityResult,
|
||||
ServerAuthorityConfig,
|
||||
} from "./server-authority.types.js";
|
||||
|
||||
const DEFAULT_CONFIG: Required<ServerAuthorityConfig> = {
|
||||
maxDelta: 2,
|
||||
maxSpeed: 20,
|
||||
validateInputs: true,
|
||||
};
|
||||
|
||||
const DEFAULT_TRANSFORM: EntityTransform = {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
};
|
||||
|
||||
/** Server-authoritative entity manager. */
|
||||
export class ServerAuthority {
|
||||
private readonly config: Required<ServerAuthorityConfig>;
|
||||
private readonly entities: Map<string, AuthoritativeEntity> = new Map();
|
||||
|
||||
constructor(config?: ServerAuthorityConfig) {
|
||||
const parsed = ServerAuthorityConfigSchema.safeParse(config ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_OPTIONS,
|
||||
"r3f_net.server_authority.invalid_options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...(parsed.data as ServerAuthorityConfig),
|
||||
};
|
||||
}
|
||||
|
||||
registerEntity(
|
||||
id: string,
|
||||
ownerId: string,
|
||||
mode: AuthorityMode = "server",
|
||||
initialTransform?: EntityTransform
|
||||
): void {
|
||||
const transform = initialTransform ?? DEFAULT_TRANSFORM;
|
||||
this.entities.set(id, {
|
||||
id,
|
||||
ownerId,
|
||||
mode,
|
||||
lastSeq: 0,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
unregisterEntity(id: string): void {
|
||||
this.entities.delete(id);
|
||||
}
|
||||
|
||||
hasEntity(id: string): boolean {
|
||||
return this.entities.has(id);
|
||||
}
|
||||
|
||||
getTransform(id: string): EntityTransform | null {
|
||||
return this.entities.get(id)?.transform ?? null;
|
||||
}
|
||||
|
||||
getLastSeq(id: string): number {
|
||||
return this.entities.get(id)?.lastSeq ?? 0;
|
||||
}
|
||||
|
||||
processInput(input: AuthorityInput): AuthorityResult {
|
||||
const entity = this.entities.get(input.entityId);
|
||||
if (!entity) return rejectResult(input.seq, "entity-not-found");
|
||||
|
||||
if (entity.mode === "server" && entity.ownerId !== input.clientId) {
|
||||
return rejectResult(input.seq, "not-owner");
|
||||
}
|
||||
if (input.seq <= entity.lastSeq) {
|
||||
return rejectResult(input.seq, "stale-input");
|
||||
}
|
||||
if (this.config.validateInputs) {
|
||||
const deltaMag = distanceVec3([0, 0, 0], input.delta);
|
||||
if (deltaMag > this.config.maxDelta) {
|
||||
return rejectResult(input.seq, "delta-too-large");
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition: Vec3 = [
|
||||
entity.transform.position[0] + input.delta[0],
|
||||
entity.transform.position[1] + input.delta[1],
|
||||
entity.transform.position[2] + input.delta[2],
|
||||
];
|
||||
entity.transform = {
|
||||
position: newPosition,
|
||||
rotation: entity.transform.rotation,
|
||||
scale: entity.transform.scale,
|
||||
};
|
||||
entity.lastSeq = input.seq;
|
||||
|
||||
return { accepted: true, transform: entity.transform, seq: input.seq };
|
||||
}
|
||||
|
||||
setTransform(id: string, transform: EntityTransform): boolean {
|
||||
const entity = this.entities.get(id);
|
||||
if (!entity) return false;
|
||||
entity.transform = transform;
|
||||
return true;
|
||||
}
|
||||
|
||||
getEntityCount(): number {
|
||||
return this.entities.size;
|
||||
}
|
||||
|
||||
getEntityIds(): string[] {
|
||||
return [...this.entities.keys()];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entities.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function rejectResult(seq: number, reason: string): AuthorityResult {
|
||||
return {
|
||||
accepted: false,
|
||||
transform: DEFAULT_TRANSFORM,
|
||||
seq,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
EntityTransform,
|
||||
Vec3,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
export type AuthorityMode = "server" | "client" | "shared";
|
||||
|
||||
export interface ServerAuthorityConfig {
|
||||
readonly maxDelta?: number;
|
||||
readonly maxSpeed?: number;
|
||||
readonly validateInputs?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorityInput {
|
||||
readonly clientId: string;
|
||||
readonly entityId: string;
|
||||
readonly seq: number;
|
||||
readonly delta: Vec3;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
export interface AuthorityResult {
|
||||
readonly accepted: boolean;
|
||||
readonly transform: EntityTransform;
|
||||
readonly seq: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface AuthoritativeEntity {
|
||||
readonly id: string;
|
||||
readonly ownerId: string;
|
||||
readonly mode: AuthorityMode;
|
||||
lastSeq: number;
|
||||
transform: EntityTransform;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# `network-provider-context`
|
||||
|
||||
## Purpose
|
||||
|
||||
Module-scope `React.Context` exported as a separate file so consumers can
|
||||
import the context object directly (`useContext(NetworkContext)`) without
|
||||
having to also pull in the provider component. Keeping the context in its
|
||||
own file also avoids a circular-import shape when test utilities want to
|
||||
read the context value while the provider tree is still being constructed.
|
||||
|
||||
## Features
|
||||
|
||||
- `NetworkContext: React.Context<NetworkProviderContextValue | null>` —
|
||||
defaults to `null` so consumers can detect when the provider has not yet
|
||||
been mounted higher in the tree.
|
||||
- Pure module — zero runtime side effects on import.
|
||||
- Lives alongside `network-provider.tsx` so the React component, the context
|
||||
object, and the prop / value types form a self-contained feature folder.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { useContext } from "react";
|
||||
import { NetworkContext } from "@suaveplan/r3f-networking";
|
||||
|
||||
const ctx = useContext(NetworkContext);
|
||||
if (!ctx) throw new Error("NetworkProvider missing");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use the context in a higher-level hook that needs both transport and sync:
|
||||
|
||||
```ts
|
||||
import { useContext } from "react";
|
||||
import { NetworkContext } from "@suaveplan/r3f-networking";
|
||||
|
||||
export function useNetwork() {
|
||||
const ctx = useContext(NetworkContext);
|
||||
if (!ctx) throw new Error("NetworkProvider is required");
|
||||
return ctx;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NetworkContext: React.Context<NetworkProviderContextValue | null>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default value is `null` rather than a stub object so a missing-
|
||||
provider error surfaces immediately as a `TypeError` against the
|
||||
destructured fields in calling code. This is the canonical React-context
|
||||
pattern and avoids a "fake provider works silently" footgun when wiring
|
||||
is in flight. Splitting the context object into its own module keeps
|
||||
import graphs shallow when the provider component itself is large or
|
||||
imports heavy dependencies — test utilities and lightweight read-only
|
||||
consumers can take the context without paying the cost of the provider
|
||||
side-effects. The context value object is typed against
|
||||
`NetworkProviderContextValue | null` so the type system enforces the
|
||||
null-check at every call site; consumers that wrap the context inside a
|
||||
custom hook like `useNetwork()` should throw a typed error rather than
|
||||
returning `null` to keep the rest of the app on the strict path.
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createContext } from "react";
|
||||
import type { NetworkProviderContextValue } from "./network-provider.types.js";
|
||||
|
||||
export const NetworkContext = createContext<NetworkProviderContextValue | null>(
|
||||
null
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
# `network-provider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
React context root for the network stack. Hosts the `Transport` (any
|
||||
`@suaveplan/network`-compatible transport — WebRTC, in-memory, libp2p) and
|
||||
the shared `EntitySyncManager`. Child components read both via the
|
||||
`NetworkContext` exported alongside this file.
|
||||
|
||||
## Features
|
||||
|
||||
- Transport injection — accepts any `Transport` from `@suaveplan/network`
|
||||
via prop, never hard-coded to one wire protocol.
|
||||
- Stable context value across renders via `useMemo`.
|
||||
- Optional `transport` so consumers can wire it asynchronously once a
|
||||
signalling handshake completes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
NetworkProvider,
|
||||
EntitySyncManager,
|
||||
createMemoryHub,
|
||||
MemoryTransport,
|
||||
} from "@suaveplan/r3f-networking";
|
||||
|
||||
const hub = createMemoryHub();
|
||||
const transport = new MemoryTransport("me", hub);
|
||||
const sync = new EntitySyncManager({ sendRate: 20 });
|
||||
|
||||
<NetworkProvider transport={transport} sync={sync}>
|
||||
<Scene />
|
||||
</NetworkProvider>;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Compose with the canonical real-time transport once available at the app
|
||||
boundary:
|
||||
|
||||
```tsx
|
||||
import { createWebRtcTransport } from "@suaveplan/network-transport-webrtc";
|
||||
|
||||
const transport = await createWebRtcTransport(config);
|
||||
return (
|
||||
<NetworkProvider transport={transport} sync={sync}>
|
||||
<NetworkedScene />
|
||||
<NetworkStats snapshot={sync.getSnapshot()} />
|
||||
</NetworkProvider>
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NetworkProvider(props: NetworkProviderProps): ReactElement`
|
||||
- `NetworkProviderProps { transport?, sync, children? }`
|
||||
- `NetworkContext: React.Context<NetworkProviderContextValue | null>` exported
|
||||
from `./network-provider-context.js`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The context value memoises on the `transport` + `sync` identity pair, so
|
||||
consumers passing a stable transport reference never trigger context
|
||||
re-renders. The provider does not subscribe to transport events itself —
|
||||
those callbacks are expected to live in higher-level scene components
|
||||
that hook `transport.onMessage` against the sync manager and dispatch
|
||||
into their game-specific state. Keeping the provider thin avoids forcing
|
||||
consumers into a one-true-message-handling layout and lets gameplay
|
||||
layers own the dispatch policy. The `transport` prop is optional so
|
||||
applications can mount the provider at the top of their R3F tree before
|
||||
the signalling-handshake completes, then bind the live transport once it
|
||||
is available; downstream hooks that read `ctx.transport` should defensive-
|
||||
null-check rather than assume the value is set. Note that swapping the
|
||||
transport at runtime will replace the context value identity and cascade
|
||||
through any nested `useContext(NetworkContext)` consumer; consider
|
||||
splitting transport into a separate context if your tree needs finer-
|
||||
grained re-render control.
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { EntitySyncManager } from "../../sync/entity-sync/entity-sync.js";
|
||||
import { NetworkProvider } from "./network-provider.js";
|
||||
|
||||
export default {
|
||||
title: "r3f-networking/NetworkProvider",
|
||||
component: NetworkProvider,
|
||||
};
|
||||
|
||||
const sync = new EntitySyncManager({ sendRate: 20 });
|
||||
|
||||
export const Default = (): ReactElement => (
|
||||
<NetworkProvider sync={sync}>
|
||||
<div data-testid="provider-child">Wrapped content</div>
|
||||
</NetworkProvider>
|
||||
);
|
||||
|
||||
export const Playground = (): ReactElement => {
|
||||
const localSync = new EntitySyncManager({ sendRate: 30 });
|
||||
localSync.registerEntity("local-1", "me", true);
|
||||
return (
|
||||
<NetworkProvider sync={localSync}>
|
||||
<div>Sync count: {localSync.getEntityCount()}</div>
|
||||
</NetworkProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithoutTransport = (): ReactElement => (
|
||||
<NetworkProvider sync={sync} transport={null}>
|
||||
<div>No transport bound</div>
|
||||
</NetworkProvider>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { type ReactElement, useContext } from "react";
|
||||
import { EntitySyncManager } from "../../sync/entity-sync/entity-sync.js";
|
||||
import { NetworkProvider } from "./network-provider.js";
|
||||
import type { NetworkProviderContextValue } from "./network-provider.types.js";
|
||||
import { NetworkContext } from "./network-provider-context.js";
|
||||
|
||||
function ContextProbe(props: {
|
||||
onValue: (v: NetworkProviderContextValue | null) => void;
|
||||
}): ReactElement {
|
||||
const value = useContext(NetworkContext);
|
||||
props.onValue(value);
|
||||
return (
|
||||
<span data-testid="probe">
|
||||
{value?.sync.getEntityCount() ?? "no-context"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
describe("NetworkProvider", () => {
|
||||
it("exposes sync via context, defaults transport to null", () => {
|
||||
const sync = new EntitySyncManager({ sendRate: 20 });
|
||||
let captured: NetworkProviderContextValue | null = null;
|
||||
const { getByTestId } = renderComponent(
|
||||
<NetworkProvider sync={sync}>
|
||||
<ContextProbe onValue={(v) => (captured = v)} />
|
||||
</NetworkProvider>
|
||||
);
|
||||
expect(getByTestId("probe").textContent).toBe("0");
|
||||
expect(captured?.sync).toBe(sync);
|
||||
expect(captured?.transport).toBeNull();
|
||||
});
|
||||
|
||||
it("threads an injected transport through context", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
const fakeTransport = {
|
||||
id: "memory",
|
||||
connect: async () => {},
|
||||
disconnect: async () => {},
|
||||
send: async () => {},
|
||||
onMessage: () => () => {},
|
||||
onPeerEvent: () => () => {},
|
||||
getConnectedPeers: () => [],
|
||||
close: async () => {},
|
||||
} as const;
|
||||
let captured: NetworkProviderContextValue | null = null;
|
||||
renderComponent(
|
||||
<NetworkProvider sync={sync} transport={fakeTransport}>
|
||||
<ContextProbe onValue={(v) => (captured = v)} />
|
||||
</NetworkProvider>
|
||||
);
|
||||
expect(captured?.transport?.id).toBe("memory");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type ReactElement, useMemo } from "react";
|
||||
import type { NetworkProviderProps } from "./network-provider.types.js";
|
||||
import { NetworkContext } from "./network-provider-context.js";
|
||||
|
||||
/**
|
||||
* React context provider for the network stack. Hosts the active `Transport`
|
||||
* (any `@suaveplan/network` Transport — WebRTC, in-memory, etc.) and the
|
||||
* `EntitySyncManager` shared across nested components.
|
||||
*/
|
||||
export function NetworkProvider(props: NetworkProviderProps): ReactElement {
|
||||
const { transport, sync, children } = props;
|
||||
const value = useMemo(
|
||||
() => ({ transport: transport ?? null, sync }),
|
||||
[transport, sync]
|
||||
);
|
||||
return (
|
||||
<NetworkContext.Provider value={value}>
|
||||
{children}
|
||||
</NetworkContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Transport } from "@suaveplan/network";
|
||||
import type { ReactNode } from "react";
|
||||
import type { EntitySyncManager } from "../../sync/entity-sync/entity-sync.js";
|
||||
|
||||
export interface NetworkProviderContextValue {
|
||||
readonly transport: Transport | null;
|
||||
readonly sync: EntitySyncManager;
|
||||
}
|
||||
|
||||
export interface NetworkProviderProps {
|
||||
readonly transport?: Transport | null;
|
||||
readonly sync: EntitySyncManager;
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# `network-stats`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Fixed-position DOM HUD that displays networking statistics — entity counts
|
||||
and (optionally) RTT / jitter. Rendered outside the R3F canvas so it does
|
||||
not require `@react-three/drei`. Useful for dev builds, multiplayer demos,
|
||||
and Storybook playgrounds.
|
||||
|
||||
## Features
|
||||
|
||||
- 4 corner positions: `top-left | top-right | bottom-left | bottom-right`.
|
||||
- Optional `latency` block: RTT, average RTT, jitter (1-decimal millis).
|
||||
- `visible` toggle for fast hide/show without unmount.
|
||||
- Pointer events disabled so the HUD never intercepts canvas input.
|
||||
- `style` prop for per-instance theming.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { NetworkStats, EntitySyncManager } from "@suaveplan/r3f-networking";
|
||||
|
||||
const sync = new EntitySyncManager();
|
||||
<NetworkStats snapshot={sync.getSnapshot()} />;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Show latency, bottom-left, with a custom dark-mode tint:
|
||||
|
||||
```tsx
|
||||
<NetworkStats
|
||||
snapshot={sync.getSnapshot()}
|
||||
latency={{ rtt: 32, avgRtt: 35, jitter: 1.4, sampleCount: 60 }}
|
||||
position="bottom-left"
|
||||
style={{ background: "rgba(20,20,20,0.9)" }}
|
||||
/>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NetworkStats(props: NetworkStatsProps): ReactElement | null`
|
||||
- `NetworkStatsProps { snapshot, latency?, position?, visible?, style? }`
|
||||
- `NetworkStatsPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The overlay is a single fixed-position `<div>` — no portal, no canvas
|
||||
overlay — so it composes safely with any layout. Stats are computed inside
|
||||
`useMemo` over `snapshot` and `latency` identities so the panel re-renders
|
||||
only when the underlying numbers change, never on every R3F frame. The
|
||||
monospace font preserves column alignment when latency numbers fluctuate
|
||||
between one and three digits, which is essential for the multiplayer
|
||||
debugging case the overlay is designed for. `pointerEvents: "none"` is
|
||||
applied to the root so mouse drags pass through to the underlying canvas
|
||||
even when the panel covers visible UI; consumers that want a clickable
|
||||
overlay should clone the component and remove the line. The `style` prop
|
||||
spreads last so caller-supplied colour, font, or border overrides win
|
||||
against the package defaults. Latency block rendering is gated on the
|
||||
presence of the `latency` prop so the panel collapses to entity counts
|
||||
when no transport has surfaced an RTT estimate yet.
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { NetworkStats } from "./network-stats.js";
|
||||
|
||||
export default {
|
||||
title: "r3f-networking/NetworkStats",
|
||||
component: NetworkStats,
|
||||
};
|
||||
|
||||
const SNAP = { entityCount: 3, localCount: 1, remoteCount: 2 } as const;
|
||||
const LAT = { rtt: 45, avgRtt: 52, jitter: 2.4, sampleCount: 120 } as const;
|
||||
|
||||
export const Default = (): ReactElement => <NetworkStats snapshot={SNAP} />;
|
||||
|
||||
export const Playground = (): ReactElement => (
|
||||
<NetworkStats
|
||||
snapshot={SNAP}
|
||||
latency={LAT}
|
||||
position="bottom-left"
|
||||
style={{ background: "rgba(20,20,20,0.95)" }}
|
||||
/>
|
||||
);
|
||||
|
||||
export const BottomRightLatency = (): ReactElement => (
|
||||
<NetworkStats snapshot={SNAP} latency={LAT} position="bottom-right" />
|
||||
);
|
||||
|
||||
export const Hidden = (): ReactElement => (
|
||||
<NetworkStats snapshot={SNAP} visible={false} />
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { NetworkStats } from "./network-stats.js";
|
||||
|
||||
const SNAP = { entityCount: 5, localCount: 2, remoteCount: 3 } as const;
|
||||
const LAT = { rtt: 50, avgRtt: 55, jitter: 1.2, sampleCount: 60 } as const;
|
||||
|
||||
describe("NetworkStats", () => {
|
||||
it("renders entity counts", () => {
|
||||
const { getByTestId } = renderComponent(
|
||||
<NetworkStats snapshot={SNAP} />
|
||||
);
|
||||
const node = getByTestId("network-stats");
|
||||
expect(node.textContent).toContain("Entities: 5");
|
||||
expect(node.textContent).toContain("Local: 2");
|
||||
expect(node.textContent).toContain("Remote: 3");
|
||||
});
|
||||
|
||||
it("renders latency block when present", () => {
|
||||
const { getByTestId } = renderComponent(
|
||||
<NetworkStats snapshot={SNAP} latency={LAT} />
|
||||
);
|
||||
const node = getByTestId("network-stats");
|
||||
expect(node.textContent).toContain("RTT:");
|
||||
expect(node.textContent).toContain("Jitter:");
|
||||
});
|
||||
|
||||
it("returns null when invisible", () => {
|
||||
const { queryByTestId } = renderComponent(
|
||||
<NetworkStats snapshot={SNAP} visible={false} />
|
||||
);
|
||||
expect(queryByTestId("network-stats")).toBeNull();
|
||||
});
|
||||
|
||||
it("supports the 4 position values", () => {
|
||||
for (const position of [
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
] as const) {
|
||||
const { getByTestId } = renderComponent(
|
||||
<NetworkStats snapshot={SNAP} position={position} />
|
||||
);
|
||||
expect(getByTestId("network-stats")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies user style overrides", () => {
|
||||
const { getByTestId } = renderComponent(
|
||||
<NetworkStats
|
||||
snapshot={SNAP}
|
||||
style={{ background: "red", color: "white" }}
|
||||
/>
|
||||
);
|
||||
const node = getByTestId("network-stats") as HTMLElement;
|
||||
expect(node.style.background).toBe("red");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { type CSSProperties, type ReactElement, useMemo } from "react";
|
||||
import type {
|
||||
ComputedStats,
|
||||
NetworkStatsPosition,
|
||||
NetworkStatsProps,
|
||||
} from "./network-stats.types.js";
|
||||
|
||||
const POSITION_MAP: Record<NetworkStatsPosition, CSSProperties> = {
|
||||
"top-left": { top: 8, left: 8 },
|
||||
"top-right": { top: 8, right: 8 },
|
||||
"bottom-left": { bottom: 8, left: 8 },
|
||||
"bottom-right": { bottom: 8, right: 8 },
|
||||
};
|
||||
|
||||
const PANEL_STYLE: CSSProperties = {
|
||||
background: "rgba(0, 0, 0, 0.75)",
|
||||
color: "#00ff88",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre",
|
||||
lineHeight: "1.5",
|
||||
minWidth: "160px",
|
||||
};
|
||||
|
||||
/**
|
||||
* HUD overlay displaying network statistics. Rendered as a fixed-position
|
||||
* DOM overlay outside the R3F canvas so it works whether or not
|
||||
* `@react-three/drei` is installed.
|
||||
*/
|
||||
export function NetworkStats(props: NetworkStatsProps): ReactElement | null {
|
||||
const {
|
||||
snapshot,
|
||||
latency,
|
||||
position = "top-right",
|
||||
visible = true,
|
||||
style,
|
||||
} = props;
|
||||
const stats = useMemo<ComputedStats>(
|
||||
() => ({
|
||||
entityCount: snapshot.entityCount,
|
||||
localCount: snapshot.localCount,
|
||||
remoteCount: snapshot.remoteCount,
|
||||
rtt: latency?.rtt ?? 0,
|
||||
avgRtt: latency?.avgRtt ?? 0,
|
||||
jitter: latency?.jitter ?? 0,
|
||||
}),
|
||||
[snapshot, latency]
|
||||
);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const lines = [
|
||||
`Entities: ${stats.entityCount}`,
|
||||
` Local: ${stats.localCount}`,
|
||||
` Remote: ${stats.remoteCount}`,
|
||||
];
|
||||
if (latency) {
|
||||
lines.push(
|
||||
`RTT: ${stats.rtt.toFixed(0)}ms`,
|
||||
`Avg RTT: ${stats.avgRtt.toFixed(0)}ms`,
|
||||
`Jitter: ${stats.jitter.toFixed(1)}ms`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="network-stats"
|
||||
style={{
|
||||
position: "fixed",
|
||||
...POSITION_MAP[position],
|
||||
...PANEL_STYLE,
|
||||
...(style ?? {}),
|
||||
}}
|
||||
>
|
||||
{lines.join("\n")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { SyncSnapshot } from "../../sync/entity-sync/entity-sync.types.js";
|
||||
import type { LatencyStats } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
export type NetworkStatsPosition =
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right";
|
||||
|
||||
export interface NetworkStatsProps {
|
||||
readonly snapshot: SyncSnapshot;
|
||||
readonly latency?: LatencyStats;
|
||||
readonly position?: NetworkStatsPosition;
|
||||
readonly visible?: boolean;
|
||||
readonly style?: CSSProperties;
|
||||
}
|
||||
|
||||
export interface ComputedStats {
|
||||
readonly entityCount: number;
|
||||
readonly localCount: number;
|
||||
readonly remoteCount: number;
|
||||
readonly rtt: number;
|
||||
readonly avgRtt: number;
|
||||
readonly jitter: number;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# `apply-networked-frame`
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure per-frame helper that writes a target `EntityTransform` into a Three.js
|
||||
group-shaped target. Local entities snap directly; remote entities are
|
||||
smoothed via `lerpVec3` for position and `slerpQuat` for rotation. The
|
||||
helper is exported so it can be tested without an R3F `<Canvas>` and reused
|
||||
by alternative renderers (e.g. ECS frameworks bridging Three.js).
|
||||
|
||||
## Features
|
||||
|
||||
- Branchless position write for local entities (no smoothing — caller
|
||||
already owns the source of truth).
|
||||
- Lerp + slerp smoothing for remote entities with a tunable smoothing factor.
|
||||
- `DEFAULT_SMOOTH_FACTOR = 0.15` constant — calibrated for 60 Hz frame
|
||||
loops with 20 Hz authoritative ticks.
|
||||
- Operates on a structural `GroupLike` shape so non-`THREE.Group` targets
|
||||
can be threaded in for testing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { applyNetworkedFrame } from "@suaveplan/r3f-networking";
|
||||
|
||||
useFrame(() => {
|
||||
if (groupRef.current) {
|
||||
applyNetworkedFrame(groupRef.current, transform, isLocal);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive a custom smoothing rate per entity (e.g. slower for ragdolls, instant
|
||||
for hitscan markers):
|
||||
|
||||
```ts
|
||||
applyNetworkedFrame(group, target, isLocal, 0.05); // very slow
|
||||
applyNetworkedFrame(group, target, isLocal, 1.0); // instant
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyNetworkedFrame(group: GroupLike, target: EntityTransform, isLocal: boolean, smoothFactor?: number): void`
|
||||
- `DEFAULT_SMOOTH_FACTOR = 0.15`
|
||||
- `GroupLike` — structural shape with `position`, `quaternion`, `scale` each
|
||||
exposing `x`/`y`/`z` (and `w` for quaternion) plus a `set(...)` method.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helper is intentionally allocation-light — it constructs two short
|
||||
arrays inside `lerpVec3` / `slerpQuat`. For very large scenes a future
|
||||
optimisation would write directly into the target via mutating helpers, but
|
||||
the JIT inlines the hot path well enough that the marginal cost is below
|
||||
the noise floor of the R3F frame loop.
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import {
|
||||
applyNetworkedFrame,
|
||||
DEFAULT_SMOOTH_FACTOR,
|
||||
} from "./apply-networked-frame.js";
|
||||
import type { GroupLike } from "./networked-entity.types.js";
|
||||
|
||||
function makeGroup(): GroupLike {
|
||||
const state = {
|
||||
pos: [0, 0, 0],
|
||||
quat: [0, 0, 0, 1],
|
||||
scl: [1, 1, 1],
|
||||
};
|
||||
return {
|
||||
position: {
|
||||
get x() {
|
||||
return state.pos[0];
|
||||
},
|
||||
get y() {
|
||||
return state.pos[1];
|
||||
},
|
||||
get z() {
|
||||
return state.pos[2];
|
||||
},
|
||||
set(x: number, y: number, z: number) {
|
||||
state.pos = [x, y, z];
|
||||
return this;
|
||||
},
|
||||
},
|
||||
quaternion: {
|
||||
get x() {
|
||||
return state.quat[0];
|
||||
},
|
||||
get y() {
|
||||
return state.quat[1];
|
||||
},
|
||||
get z() {
|
||||
return state.quat[2];
|
||||
},
|
||||
get w() {
|
||||
return state.quat[3];
|
||||
},
|
||||
set(x: number, y: number, z: number, w: number) {
|
||||
state.quat = [x, y, z, w];
|
||||
return this;
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
get x() {
|
||||
return state.scl[0];
|
||||
},
|
||||
get y() {
|
||||
return state.scl[1];
|
||||
},
|
||||
get z() {
|
||||
return state.scl[2];
|
||||
},
|
||||
set(x: number, y: number, z: number) {
|
||||
state.scl = [x, y, z];
|
||||
return this;
|
||||
},
|
||||
},
|
||||
} as GroupLike;
|
||||
}
|
||||
|
||||
const TARGET: EntityTransform = {
|
||||
position: [10, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [2, 2, 2],
|
||||
};
|
||||
|
||||
describe("applyNetworkedFrame", () => {
|
||||
it("local entities snap to the target", () => {
|
||||
const g = makeGroup();
|
||||
applyNetworkedFrame(g, TARGET, true);
|
||||
expect(g.position.x).toBe(10);
|
||||
expect(g.scale.x).toBe(2);
|
||||
});
|
||||
|
||||
it("remote entities smooth toward the target", () => {
|
||||
const g = makeGroup();
|
||||
applyNetworkedFrame(g, TARGET, false, 0.5);
|
||||
expect(g.position.x).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
it("uses DEFAULT_SMOOTH_FACTOR when none supplied", () => {
|
||||
expect(DEFAULT_SMOOTH_FACTOR).toBeCloseTo(0.15);
|
||||
const g = makeGroup();
|
||||
applyNetworkedFrame(g, TARGET, false);
|
||||
expect(g.position.x).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it("writes quaternion and scale", () => {
|
||||
const g = makeGroup();
|
||||
applyNetworkedFrame(g, TARGET, true);
|
||||
expect(g.quaternion.w).toBeCloseTo(1);
|
||||
expect(g.scale.y).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
lerpVec3,
|
||||
slerpQuat,
|
||||
} from "../../utils/network-helpers/network-helpers.js";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import type { GroupLike } from "./networked-entity.types.js";
|
||||
|
||||
/** Default frame-smoothing factor for remote-peer transforms. */
|
||||
export const DEFAULT_SMOOTH_FACTOR = 0.15;
|
||||
|
||||
/**
|
||||
* Pure per-frame helper. Writes the target transform into `group` —
|
||||
* unconditionally for `isLocal`, smoothed via lerp/slerp for remote peers.
|
||||
* Extracted so the per-frame logic can be tested without an R3F Canvas.
|
||||
*/
|
||||
export function applyNetworkedFrame(
|
||||
group: GroupLike,
|
||||
target: EntityTransform,
|
||||
isLocal: boolean,
|
||||
smoothFactor: number = DEFAULT_SMOOTH_FACTOR
|
||||
): void {
|
||||
if (isLocal) {
|
||||
group.position.set(
|
||||
target.position[0],
|
||||
target.position[1],
|
||||
target.position[2]
|
||||
);
|
||||
} else {
|
||||
const current: [number, number, number] = [
|
||||
group.position.x,
|
||||
group.position.y,
|
||||
group.position.z,
|
||||
];
|
||||
const smoothed = lerpVec3(current, target.position, smoothFactor);
|
||||
group.position.set(smoothed[0], smoothed[1], smoothed[2]);
|
||||
}
|
||||
|
||||
const currentQuat: [number, number, number, number] = [
|
||||
group.quaternion.x,
|
||||
group.quaternion.y,
|
||||
group.quaternion.z,
|
||||
group.quaternion.w,
|
||||
];
|
||||
const smoothedQuat = slerpQuat(
|
||||
currentQuat,
|
||||
target.rotation,
|
||||
isLocal ? 1.0 : smoothFactor
|
||||
);
|
||||
group.quaternion.set(
|
||||
smoothedQuat[0],
|
||||
smoothedQuat[1],
|
||||
smoothedQuat[2],
|
||||
smoothedQuat[3]
|
||||
);
|
||||
group.scale.set(target.scale[0], target.scale[1], target.scale[2]);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# `networked-entity`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
R3F component that smoothly interpolates a Three.js `<group>` toward a
|
||||
caller-supplied `transform` every frame. Local entities snap directly;
|
||||
remote entities lerp/slerp at the supplied `smoothFactor`. The per-frame
|
||||
logic is extracted into the pure `applyNetworkedFrame` helper for testing.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps arbitrary R3F children inside a `<group>` named `networked-<id>`.
|
||||
- Local-entity branch: instant write (caller owns the truth).
|
||||
- Remote-entity branch: per-frame lerp + slerp toward the target.
|
||||
- `visible` toggle for fast hide / cull paths.
|
||||
- `smoothFactor` override per instance.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { NetworkedEntity } from "@suaveplan/r3f-networking";
|
||||
|
||||
<NetworkedEntity
|
||||
entityId="peer-1"
|
||||
isLocal={false}
|
||||
transform={remoteTransform}
|
||||
>
|
||||
<mesh>
|
||||
<boxGeometry />
|
||||
<meshStandardMaterial />
|
||||
</mesh>
|
||||
</NetworkedEntity>;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Combine with the sync manager + sync hook to render every peer in the scene:
|
||||
|
||||
```tsx
|
||||
function PeerEntities() {
|
||||
const sync = useNetworkSync();
|
||||
return Array.from(peers).map((id) => (
|
||||
<NetworkedEntity
|
||||
key={id}
|
||||
entityId={id}
|
||||
isLocal={false}
|
||||
transform={sync.getTransform(id) ?? IDENTITY_TRANSFORM}
|
||||
smoothFactor={0.2}
|
||||
>
|
||||
<PeerAvatar id={id} />
|
||||
</NetworkedEntity>
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NetworkedEntity(props: NetworkedEntityProps): ReactElement`
|
||||
- `NetworkedEntityProps {
|
||||
entityId, isLocal, transform, position?, visible?, children?, smoothFactor?
|
||||
}`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The smoothing happens inside `useFrame` rather than React state, so the
|
||||
component never re-renders on transform change — only on prop-identity
|
||||
changes (e.g. `entityId`, `visible`). The Three.js group is wrapped with
|
||||
a `name = "networked-<id>"` for debugger inspection and the Storybook
|
||||
tree view. Local entities skip the lerp/slerp pass because the caller
|
||||
already owns the source of truth; remote entities must always smooth or
|
||||
the per-frame jitter from authoritative-tick boundaries becomes visible
|
||||
at 20 Hz update rates. The default smooth factor of 0.15 produces an
|
||||
~85 ms convergence half-life — short enough to stay tight on the
|
||||
authoritative ground truth, long enough to absorb a one-frame jitter
|
||||
without visible snap. The `initialPos` fallback to `transform.position`
|
||||
ensures the group spawns at the correct world location even if `position`
|
||||
is not supplied, eliminating the one-frame flash where a freshly mounted
|
||||
group would otherwise sit at origin until the first `useFrame` runs.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import { NetworkedEntity } from "./networked-entity.js";
|
||||
|
||||
export default {
|
||||
title: "r3f-networking/NetworkedEntity",
|
||||
component: NetworkedEntity,
|
||||
};
|
||||
|
||||
const STATIC: EntityTransform = {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
};
|
||||
|
||||
function CubeChild(): ReactElement {
|
||||
return (
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#3498db" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default = (): ReactElement => (
|
||||
<Canvas style={{ width: 320, height: 240 }}>
|
||||
<ambientLight intensity={0.6} />
|
||||
<pointLight position={[5, 5, 5]} />
|
||||
<NetworkedEntity entityId="story-default" isLocal transform={STATIC}>
|
||||
<CubeChild />
|
||||
</NetworkedEntity>
|
||||
</Canvas>
|
||||
);
|
||||
|
||||
export const Playground = (): ReactElement => {
|
||||
const [t, setT] = useState<EntityTransform>(STATIC);
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
const id = setInterval(() => {
|
||||
frame += 1;
|
||||
setT({
|
||||
position: [Math.sin(frame * 0.1) * 2, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
}, 100);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return (
|
||||
<Canvas style={{ width: 320, height: 240 }}>
|
||||
<ambientLight intensity={0.6} />
|
||||
<pointLight position={[5, 5, 5]} />
|
||||
<NetworkedEntity
|
||||
entityId="story-playground"
|
||||
isLocal={false}
|
||||
transform={t}
|
||||
smoothFactor={0.1}
|
||||
>
|
||||
<CubeChild />
|
||||
</NetworkedEntity>
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
export const RemotePeerSlowSmoothing = (): ReactElement => (
|
||||
<Canvas style={{ width: 320, height: 240 }}>
|
||||
<NetworkedEntity
|
||||
entityId="story-slow"
|
||||
isLocal={false}
|
||||
transform={STATIC}
|
||||
smoothFactor={0.05}
|
||||
>
|
||||
<CubeChild />
|
||||
</NetworkedEntity>
|
||||
</Canvas>
|
||||
);
|
||||
|
||||
export const InvisibleEntity = (): ReactElement => (
|
||||
<Canvas style={{ width: 320, height: 240 }}>
|
||||
<NetworkedEntity
|
||||
entityId="story-hidden"
|
||||
isLocal
|
||||
transform={STATIC}
|
||||
visible={false}
|
||||
>
|
||||
<CubeChild />
|
||||
</NetworkedEntity>
|
||||
</Canvas>
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { type ReactElement, useRef } from "react";
|
||||
import type * as THREE from "three";
|
||||
import {
|
||||
applyNetworkedFrame,
|
||||
DEFAULT_SMOOTH_FACTOR,
|
||||
} from "./apply-networked-frame.js";
|
||||
import type { NetworkedEntityProps } from "./networked-entity.types.js";
|
||||
|
||||
/**
|
||||
* A networked entity component. Wraps its children in a `<group>` whose
|
||||
* transform is smoothly interpolated toward `transform` each R3F frame.
|
||||
*/
|
||||
export function NetworkedEntity(props: NetworkedEntityProps): ReactElement {
|
||||
const {
|
||||
entityId,
|
||||
isLocal,
|
||||
transform,
|
||||
position,
|
||||
visible = true,
|
||||
children,
|
||||
smoothFactor = DEFAULT_SMOOTH_FACTOR,
|
||||
} = props;
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
applyNetworkedFrame(group, transform, isLocal, smoothFactor);
|
||||
});
|
||||
|
||||
const initialPos = position ?? transform.position;
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={[initialPos[0], initialPos[1], initialPos[2]]}
|
||||
visible={visible}
|
||||
name={`networked-${entityId}`}
|
||||
>
|
||||
{children}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
EntityTransform,
|
||||
Vec3,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
export interface NetworkedEntityProps {
|
||||
readonly entityId: string;
|
||||
readonly isLocal: boolean;
|
||||
readonly transform: EntityTransform;
|
||||
readonly position?: Vec3;
|
||||
readonly visible?: boolean;
|
||||
readonly children?: ReactNode;
|
||||
readonly smoothFactor?: number;
|
||||
}
|
||||
|
||||
/** A minimal Three.js group-shaped target for the smoothing helper. */
|
||||
export interface GroupLike {
|
||||
readonly position: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
set(x: number, y: number, z: number): unknown;
|
||||
};
|
||||
readonly quaternion: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
set(x: number, y: number, z: number, w: number): unknown;
|
||||
};
|
||||
readonly scale: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
set(x: number, y: number, z: number): unknown;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# `interpolation`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Frame-loop smoothing of remote-peer transforms from a `StateBuffer`. The
|
||||
interpolator renders entities "in the past" by a fixed delay so that two
|
||||
authoritative snapshots always bracket the current render time, producing a
|
||||
continuous transform stream every frame. Optional extrapolation handles the
|
||||
short tail when packets stall.
|
||||
|
||||
## Features
|
||||
|
||||
- Fixed-delay rendering with configurable `delay` (default 100 ms).
|
||||
- Linear position + scale, slerp rotation between bracketing states.
|
||||
- Optional linear extrapolation with `maxExtrapolation` clamp and confidence
|
||||
scaling that falls to zero at the clamp boundary.
|
||||
- Zod-validated options; bad input throws `R3F_NET.INVALID_OPTIONS`.
|
||||
- Stand-alone `interpolateTransform(a, b, t)` for ad-hoc lerps.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StateBuffer, EntityInterpolator } from "@suaveplan/r3f-networking";
|
||||
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
const interp = new EntityInterpolator({ delay: 100 });
|
||||
|
||||
function frame(serverTime: number): EntityTransform {
|
||||
return interp.interpolate(buf, serverTime).transform;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Enable extrapolation for high-latency mobile sessions and react to confidence:
|
||||
|
||||
```ts
|
||||
const interp = new EntityInterpolator({
|
||||
delay: 80,
|
||||
extrapolate: true,
|
||||
maxExtrapolation: 250,
|
||||
});
|
||||
|
||||
function frame(buf, t: number) {
|
||||
const r = interp.interpolate(buf, t);
|
||||
if (r.confidence < 0.4) flashLagWarning();
|
||||
return r.transform;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new EntityInterpolator(config?: InterpolationConfig)`
|
||||
- `interpolate(buffer, serverTime): InterpolationResult`
|
||||
- `getDelay(): number` / `isExtrapolationEnabled(): boolean`
|
||||
- `interpolateTransform(a, b, t): EntityTransform`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`renderTime = serverTime - delay` lets `findInterpolationPair` operate on
|
||||
a sufficiently old timestamp that incoming snapshots can still arrive in
|
||||
time. The extrapolation branch projects position by a linear velocity
|
||||
computed from the most recent bracketing pair; rotation and scale are
|
||||
held at the last known frame because higher-order extrapolation is
|
||||
prone to whip-around artefacts under packet loss. Confidence falls
|
||||
linearly from 1.0 → 0.0 across the `maxExtrapolation` window so HUD
|
||||
overlays can decay smoothly without a binary "stale" cliff. A buffer
|
||||
with a single state still produces a usable result at `confidence: 0.5`
|
||||
so the per-frame render path can blindly call `interpolate()` even when
|
||||
the network has not delivered a second snapshot yet. The default delay
|
||||
of 100 ms is calibrated for 20 Hz authoritative ticks; raise it to 150 ms
|
||||
for noisier connections, lower it to 50 ms for LAN.
|
||||
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const InterpolationConfigSchema = z
|
||||
.object({
|
||||
delay: z.number().min(0).optional(),
|
||||
extrapolate: z.boolean().optional(),
|
||||
maxExtrapolation: z.number().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { R3FNetError } from "../../errors/errors.js";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import { StateBuffer } from "../state-buffer/state-buffer.js";
|
||||
import { EntityInterpolator, interpolateTransform } from "./interpolation.js";
|
||||
|
||||
const T = (i: number): EntityTransform => ({
|
||||
position: [i, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
|
||||
describe("EntityInterpolator", () => {
|
||||
it("rejects bad options", () => {
|
||||
// @ts-expect-error: bad options
|
||||
expect(() => new EntityInterpolator({ delay: -1 })).toThrow(
|
||||
R3FNetError
|
||||
);
|
||||
});
|
||||
|
||||
it("returns default zero-confidence result when buffer empty", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
const r = new EntityInterpolator().interpolate(buf, 0);
|
||||
expect(r.confidence).toBe(0);
|
||||
});
|
||||
|
||||
it("returns latest with confidence 0.5 when buffer has only one state", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 100, state: T(7) });
|
||||
const r = new EntityInterpolator().interpolate(buf, 200);
|
||||
expect(r.confidence).toBe(0.5);
|
||||
expect(r.transform.position[0]).toBe(7);
|
||||
});
|
||||
|
||||
it("interpolates between two states", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 0, state: T(0) });
|
||||
buf.push({ timestamp: 100, state: T(10) });
|
||||
// Delay 0 means renderTime = serverTime
|
||||
const r = new EntityInterpolator({ delay: 0 }).interpolate(buf, 50);
|
||||
expect(r.transform.position[0]).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
it("disables extrapolation by default", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 0, state: T(0) });
|
||||
buf.push({ timestamp: 100, state: T(10) });
|
||||
const r = new EntityInterpolator({ delay: 0 }).interpolate(buf, 500);
|
||||
expect(r.extrapolated).toBe(false);
|
||||
});
|
||||
|
||||
it("extrapolates when enabled", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 0, state: T(0) });
|
||||
buf.push({ timestamp: 100, state: T(10) });
|
||||
const r = new EntityInterpolator({
|
||||
delay: 0,
|
||||
extrapolate: true,
|
||||
maxExtrapolation: 1000,
|
||||
}).interpolate(buf, 200);
|
||||
expect(r.extrapolated).toBe(true);
|
||||
expect(r.transform.position[0]).toBeGreaterThan(10);
|
||||
expect(r.confidence).toBeGreaterThan(0);
|
||||
expect(r.confidence).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("computeVelocity branch is reached for finite spans", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 0, state: T(0) });
|
||||
buf.push({ timestamp: 100, state: T(1) });
|
||||
const r = new EntityInterpolator({
|
||||
delay: 0,
|
||||
extrapolate: true,
|
||||
maxExtrapolation: 50,
|
||||
}).interpolate(buf, 150);
|
||||
expect(r.transform.position[0]).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("handles extrapolation with zero-span pair", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
buf.push({ timestamp: 100, state: T(5) });
|
||||
const r = new EntityInterpolator({
|
||||
delay: 0,
|
||||
extrapolate: true,
|
||||
}).interpolate(buf, 200);
|
||||
expect(r.extrapolated).toBe(true);
|
||||
expect(r.confidence).toBe(0.5);
|
||||
});
|
||||
|
||||
it("exposes getDelay / isExtrapolationEnabled", () => {
|
||||
const interp = new EntityInterpolator({ delay: 80, extrapolate: true });
|
||||
expect(interp.getDelay()).toBe(80);
|
||||
expect(interp.isExtrapolationEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("interpolateTransform is the basic between-helper", () => {
|
||||
const t = interpolateTransform(T(0), T(10), 0.25);
|
||||
expect(t.position[0]).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
// Property: monotonicity — interpolated value is bounded by endpoints.
|
||||
it("[property] interpolation between two transforms is monotonic", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.double({ min: 0, max: 100, noNaN: true }),
|
||||
fc.double({ min: 0, max: 100, noNaN: true }),
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
(a, b, t) => {
|
||||
const lo = Math.min(a, b);
|
||||
const hi = Math.max(a, b);
|
||||
const r = interpolateTransform(
|
||||
{
|
||||
position: [a, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
{
|
||||
position: [b, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
t
|
||||
);
|
||||
expect(r.position[0]).toBeGreaterThanOrEqual(lo - 1e-9);
|
||||
expect(r.position[0]).toBeLessThanOrEqual(hi + 1e-9);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import {
|
||||
IDENTITY_QUAT,
|
||||
lerpVec3,
|
||||
slerpQuat,
|
||||
UNIT_SCALE,
|
||||
ZERO_VEC3,
|
||||
} from "../../utils/network-helpers/network-helpers.js";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import type { StateBuffer } from "../state-buffer/state-buffer.js";
|
||||
import type { InterpolationPair } from "../state-buffer/state-buffer.types.js";
|
||||
import { InterpolationConfigSchema } from "./interpolation.schemas.js";
|
||||
import type {
|
||||
InterpolationConfig,
|
||||
InterpolationResult,
|
||||
} from "./interpolation.types.js";
|
||||
|
||||
const DEFAULT_CONFIG: Required<InterpolationConfig> = {
|
||||
delay: 100,
|
||||
extrapolate: false,
|
||||
maxExtrapolation: 200,
|
||||
};
|
||||
|
||||
/** Interpolates entity transforms from a `StateBuffer` at `serverTime`. */
|
||||
export class EntityInterpolator {
|
||||
private readonly config: Required<InterpolationConfig>;
|
||||
|
||||
constructor(config?: InterpolationConfig) {
|
||||
const parsed = InterpolationConfigSchema.safeParse(config ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_OPTIONS,
|
||||
"r3f_net.interpolation.invalid_options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...(parsed.data as InterpolationConfig),
|
||||
};
|
||||
}
|
||||
|
||||
interpolate(
|
||||
buffer: StateBuffer<EntityTransform>,
|
||||
serverTime: number
|
||||
): InterpolationResult {
|
||||
const renderTime = serverTime - this.config.delay;
|
||||
const pair = buffer.findInterpolationPair(renderTime);
|
||||
|
||||
if (!pair) {
|
||||
const latest = buffer.latest();
|
||||
if (latest) {
|
||||
return {
|
||||
transform: latest.state,
|
||||
extrapolated: false,
|
||||
confidence: 0.5,
|
||||
};
|
||||
}
|
||||
return defaultResult();
|
||||
}
|
||||
|
||||
const isExtrapolated = renderTime > pair.after.timestamp;
|
||||
if (isExtrapolated && this.config.extrapolate) {
|
||||
return this.extrapolateState(pair, renderTime);
|
||||
}
|
||||
|
||||
const transform = interpolateTransform(
|
||||
pair.before.state,
|
||||
pair.after.state,
|
||||
pair.t
|
||||
);
|
||||
return { transform, extrapolated: false, confidence: 1.0 };
|
||||
}
|
||||
|
||||
private extrapolateState(
|
||||
pair: InterpolationPair<EntityTransform>,
|
||||
renderTime: number
|
||||
): InterpolationResult {
|
||||
const dt = renderTime - pair.after.timestamp;
|
||||
const clamped = Math.min(dt, this.config.maxExtrapolation);
|
||||
const span = pair.after.timestamp - pair.before.timestamp;
|
||||
if (span <= 0) {
|
||||
return {
|
||||
transform: pair.after.state,
|
||||
extrapolated: true,
|
||||
confidence: 0.5,
|
||||
};
|
||||
}
|
||||
const velocity = computeVelocity(
|
||||
pair.before.state,
|
||||
pair.after.state,
|
||||
span
|
||||
);
|
||||
const factor = clamped / 1000;
|
||||
const position: [number, number, number] = [
|
||||
pair.after.state.position[0] + velocity[0] * factor,
|
||||
pair.after.state.position[1] + velocity[1] * factor,
|
||||
pair.after.state.position[2] + velocity[2] * factor,
|
||||
];
|
||||
const confidence = Math.max(
|
||||
0,
|
||||
1.0 - clamped / this.config.maxExtrapolation
|
||||
);
|
||||
return {
|
||||
transform: {
|
||||
position,
|
||||
rotation: pair.after.state.rotation,
|
||||
scale: pair.after.state.scale,
|
||||
},
|
||||
extrapolated: true,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
getDelay(): number {
|
||||
return this.config.delay;
|
||||
}
|
||||
|
||||
isExtrapolationEnabled(): boolean {
|
||||
return this.config.extrapolate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Interpolates between two transforms at parameter `t`. */
|
||||
export function interpolateTransform(
|
||||
a: EntityTransform,
|
||||
b: EntityTransform,
|
||||
t: number
|
||||
): EntityTransform {
|
||||
return {
|
||||
position: lerpVec3(a.position, b.position, t),
|
||||
rotation: slerpQuat(a.rotation, b.rotation, t),
|
||||
scale: lerpVec3(a.scale, b.scale, t),
|
||||
};
|
||||
}
|
||||
|
||||
function computeVelocity(
|
||||
a: EntityTransform,
|
||||
b: EntityTransform,
|
||||
dtMs: number
|
||||
): [number, number, number] {
|
||||
// Caller (`extrapolateState`) already gates on `span > 0`, so `dtMs > 0`
|
||||
// is guaranteed here.
|
||||
const dtSec = dtMs / 1000;
|
||||
return [
|
||||
(b.position[0] - a.position[0]) / dtSec,
|
||||
(b.position[1] - a.position[1]) / dtSec,
|
||||
(b.position[2] - a.position[2]) / dtSec,
|
||||
];
|
||||
}
|
||||
|
||||
function defaultResult(): InterpolationResult {
|
||||
return {
|
||||
transform: {
|
||||
position: [...ZERO_VEC3] as [number, number, number],
|
||||
rotation: [...IDENTITY_QUAT] as [number, number, number, number],
|
||||
scale: [...UNIT_SCALE] as [number, number, number],
|
||||
},
|
||||
extrapolated: false,
|
||||
confidence: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
/** Configuration for entity interpolation. */
|
||||
export interface InterpolationConfig {
|
||||
/** Interpolation delay in milliseconds. Default 100. */
|
||||
readonly delay?: number;
|
||||
/** Whether to extrapolate beyond known states. Default false. */
|
||||
readonly extrapolate?: boolean;
|
||||
/** Maximum extrapolation time in ms. Default 200. */
|
||||
readonly maxExtrapolation?: number;
|
||||
}
|
||||
|
||||
/** Result of an interpolation query. */
|
||||
export interface InterpolationResult {
|
||||
readonly transform: EntityTransform;
|
||||
readonly extrapolated: boolean;
|
||||
readonly confidence: number;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# `prediction`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Client-side prediction with server reconciliation. Each local input is
|
||||
applied immediately for responsiveness, recorded in a bounded history queue,
|
||||
and replayed on top of the next authoritative server transform so the
|
||||
visible state converges with the server without losing recent inputs.
|
||||
|
||||
## Features
|
||||
|
||||
- Bounded input-history queue with `maxHistory` cap.
|
||||
- Snap-vs-smooth correction: large divergence snaps directly, small
|
||||
divergence interpolates at `correctionRate`.
|
||||
- Records every reconciliation error to the `r3f_net.prediction_divergence`
|
||||
histogram.
|
||||
- Injectable clock for deterministic property tests.
|
||||
- Zod-validated options; bad input throws `R3F_NET.INVALID_OPTIONS`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ClientPredictor } from "@suaveplan/r3f-networking";
|
||||
|
||||
const pred = new ClientPredictor([0, 0, 0]);
|
||||
const applied = pred.applyInput([1, 0, 0]); // { position: [1,0,0], seq: 1 }
|
||||
const reconciled = pred.reconcile(authoritative, lastAcked);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject a fake clock for deterministic property testing and observe convergence:
|
||||
|
||||
```ts
|
||||
import { ClientPredictor } from "@suaveplan/r3f-networking";
|
||||
|
||||
const clock = { ms: 0, now() { return this.ms; } };
|
||||
const pred = new ClientPredictor([0, 0, 0], { snapThreshold: 2 }, clock);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
clock.ms += 16;
|
||||
pred.applyInput([0.1, 0, 0]);
|
||||
}
|
||||
const r = pred.reconcile(server, 50);
|
||||
expect(r.replayed).toBeLessThanOrEqual(60); // maxHistory cap
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ClientPredictor(initial?: Vec3, config?: PredictionConfig, clock?: PredictionClock)`
|
||||
- `applyInput(delta: Vec3) → { position, seq }`
|
||||
- `reconcile(serverTransform, lastAckedSeq) → ReconciliationResult`
|
||||
- `getPosition()` / `getSeq()` / `getPendingCount()` / `reset(pos?)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
History is pruned by count rather than age — `maxHistory` should equal
|
||||
at least `RTT × inputRate` to cover the longest expected round-trip. For
|
||||
example, a 200 ms RTT at 60 Hz inputs implies a tail of 12 frames, well
|
||||
under the default `maxHistory` of 60. Errors below `0.001` are skipped
|
||||
to avoid microscopic drift that would jitter the rendered position
|
||||
without measurable benefit. The `snapThreshold` is in metres; tune it
|
||||
with the per-game world unit — at typical 1 m scale, a 3 m threshold
|
||||
catches teleport-style cheats while leaving genuine network-jitter
|
||||
corrections to the smoothing path. Reconciliation always records the
|
||||
divergence to the OTel histogram so dashboards over the meter
|
||||
`@suaveplan/r3f-networking` can plot tail-latency-correlated divergence
|
||||
without instrumentation in the caller. The injectable clock supports
|
||||
deterministic property testing under a fake-clock fixture, and a
|
||||
production caller should never override the default.
|
||||
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const PredictionConfigSchema = z
|
||||
.object({
|
||||
maxHistory: z.number().int().positive().optional(),
|
||||
snapThreshold: z.number().nonnegative().optional(),
|
||||
correctionRate: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { R3FNetError } from "../../errors/errors.js";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import { ClientPredictor } from "./prediction.js";
|
||||
|
||||
const ZERO_TX: EntityTransform = {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
};
|
||||
|
||||
describe("ClientPredictor", () => {
|
||||
it("rejects bad options", () => {
|
||||
// @ts-expect-error: bad options
|
||||
expect(
|
||||
() => new ClientPredictor([0, 0, 0], { correctionRate: 2 })
|
||||
).toThrow(R3FNetError);
|
||||
});
|
||||
|
||||
it("applies inputs and advances seq", () => {
|
||||
const p = new ClientPredictor();
|
||||
const r1 = p.applyInput([1, 0, 0]);
|
||||
expect(r1.seq).toBe(1);
|
||||
expect(r1.position).toEqual([1, 0, 0]);
|
||||
const r2 = p.applyInput([0, 2, 0]);
|
||||
expect(r2.seq).toBe(2);
|
||||
expect(r2.position).toEqual([1, 2, 0]);
|
||||
});
|
||||
|
||||
it("reset clears history and seq", () => {
|
||||
const p = new ClientPredictor();
|
||||
p.applyInput([1, 0, 0]);
|
||||
p.reset();
|
||||
expect(p.getSeq()).toBe(0);
|
||||
expect(p.getPosition()).toEqual([0, 0, 0]);
|
||||
expect(p.getPendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("reset with explicit position", () => {
|
||||
const p = new ClientPredictor();
|
||||
p.applyInput([1, 0, 0]);
|
||||
p.reset([10, 0, 0]);
|
||||
expect(p.getPosition()).toEqual([10, 0, 0]);
|
||||
});
|
||||
|
||||
it("reconciles without snap when error is small", () => {
|
||||
const p = new ClientPredictor([0, 0, 0]);
|
||||
p.applyInput([0.1, 0, 0]);
|
||||
const r = p.reconcile({ ...ZERO_TX, position: [0.1, 0, 0] }, 1);
|
||||
expect(r.snapped).toBe(false);
|
||||
expect(r.error).toBeLessThan(0.001);
|
||||
});
|
||||
|
||||
it("snaps when error exceeds threshold", () => {
|
||||
const p = new ClientPredictor([0, 0, 0], { snapThreshold: 1.0 });
|
||||
p.applyInput([10, 0, 0]);
|
||||
const r = p.reconcile({ ...ZERO_TX, position: [0, 0, 0] }, 1);
|
||||
expect(r.snapped).toBe(true);
|
||||
expect(p.getPosition()).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("smooths small divergences", () => {
|
||||
const p = new ClientPredictor([0, 0, 0], {
|
||||
snapThreshold: 100,
|
||||
correctionRate: 0.5,
|
||||
});
|
||||
// Apply input but server reports a slightly different position.
|
||||
p.applyInput([1, 0, 0]);
|
||||
const r = p.reconcile({ ...ZERO_TX, position: [2, 0, 0] }, 1);
|
||||
expect(r.snapped).toBe(false);
|
||||
// Predicted position: 2 (server) + 0 (no pending) = 2
|
||||
// Local: 1; halfway → 1.5
|
||||
expect(r.transform.position[0]).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it("prunes input history beyond maxHistory", () => {
|
||||
const p = new ClientPredictor([0, 0, 0], { maxHistory: 3 });
|
||||
for (let i = 0; i < 10; i++) p.applyInput([1, 0, 0]);
|
||||
expect(p.getPendingCount()).toBe(3);
|
||||
});
|
||||
|
||||
it("uses injected clock", () => {
|
||||
let ms = 0;
|
||||
const clock = { now: () => ms };
|
||||
const p = new ClientPredictor([0, 0, 0], {}, clock);
|
||||
ms = 12345;
|
||||
p.applyInput([0, 0, 0]);
|
||||
// Pending count proves the input was recorded with the injected timestamp.
|
||||
expect(p.getPendingCount()).toBe(1);
|
||||
});
|
||||
|
||||
// Property: applying then immediately reconciling against the same server
|
||||
// position drives the divergence to zero or below the smoothing-ignore floor.
|
||||
it("[property] reconcile converges divergence below threshold", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.tuple(
|
||||
fc.double({ min: -0.5, max: 0.5, noNaN: true }),
|
||||
fc.double({ min: -0.5, max: 0.5, noNaN: true }),
|
||||
fc.double({ min: -0.5, max: 0.5, noNaN: true })
|
||||
),
|
||||
{ minLength: 1, maxLength: 10 }
|
||||
),
|
||||
(deltas) => {
|
||||
const p = new ClientPredictor([0, 0, 0], {
|
||||
snapThreshold: 100,
|
||||
correctionRate: 1.0,
|
||||
});
|
||||
for (const d of deltas) {
|
||||
p.applyInput(d as [number, number, number]);
|
||||
}
|
||||
// Server "agrees" with the predicted sum.
|
||||
const sum = deltas.reduce<[number, number, number]>(
|
||||
(acc, [x, y, z]) => [
|
||||
acc[0] + x,
|
||||
acc[1] + y,
|
||||
acc[2] + z,
|
||||
],
|
||||
[0, 0, 0]
|
||||
);
|
||||
const r = p.reconcile(
|
||||
{
|
||||
position: sum,
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
deltas.length
|
||||
);
|
||||
expect(r.error).toBeLessThan(1e-6);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("reconcile replays pending inputs on top of server state", () => {
|
||||
const p = new ClientPredictor([0, 0, 0], {
|
||||
snapThreshold: 1000,
|
||||
correctionRate: 0,
|
||||
});
|
||||
p.applyInput([1, 0, 0]);
|
||||
p.applyInput([0, 1, 0]);
|
||||
p.applyInput([0, 0, 1]);
|
||||
const r = p.reconcile(
|
||||
{
|
||||
position: [10, 10, 10],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
0
|
||||
);
|
||||
expect(r.replayed).toBe(3);
|
||||
});
|
||||
|
||||
// Property: server divergence > snapThreshold always snaps locally.
|
||||
it("[property] divergence > snapThreshold always snaps", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.double({ min: 10, max: 1000, noNaN: true }),
|
||||
(offset) => {
|
||||
const p = new ClientPredictor([0, 0, 0], {
|
||||
snapThreshold: 1.0,
|
||||
});
|
||||
p.applyInput([offset, 0, 0]);
|
||||
const r = p.reconcile(
|
||||
{
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
1
|
||||
);
|
||||
expect(r.snapped).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import { predictionDivergence } from "../../observability/observability.js";
|
||||
import {
|
||||
distanceVec3,
|
||||
lerpVec3,
|
||||
} from "../../utils/network-helpers/network-helpers.js";
|
||||
import type {
|
||||
EntityTransform,
|
||||
Vec3,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import { PredictionConfigSchema } from "./prediction.schemas.js";
|
||||
import type {
|
||||
PredictionClock,
|
||||
PredictionConfig,
|
||||
PredictionInput,
|
||||
ReconciliationResult,
|
||||
} from "./prediction.types.js";
|
||||
|
||||
const DEFAULT_CONFIG: Required<PredictionConfig> = {
|
||||
maxHistory: 60,
|
||||
snapThreshold: 3.0,
|
||||
correctionRate: 0.1,
|
||||
};
|
||||
|
||||
const DEFAULT_CLOCK: PredictionClock = { now: () => Date.now() };
|
||||
|
||||
/**
|
||||
* Client-side prediction with server reconciliation. Stores recent inputs
|
||||
* and replays the unacknowledged tail on top of the latest authoritative
|
||||
* transform to maintain responsiveness across the network round-trip.
|
||||
*/
|
||||
export class ClientPredictor {
|
||||
private readonly config: Required<PredictionConfig>;
|
||||
private readonly clock: PredictionClock;
|
||||
private readonly inputHistory: PredictionInput[] = [];
|
||||
private localPosition: [number, number, number];
|
||||
private seq = 0;
|
||||
|
||||
constructor(
|
||||
initialPosition: Vec3 = [0, 0, 0],
|
||||
config?: PredictionConfig,
|
||||
clock: PredictionClock = DEFAULT_CLOCK
|
||||
) {
|
||||
const parsed = PredictionConfigSchema.safeParse(config ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_OPTIONS,
|
||||
"r3f_net.prediction.invalid_options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...(parsed.data as PredictionConfig),
|
||||
};
|
||||
this.clock = clock;
|
||||
this.localPosition = [...initialPosition] as [number, number, number];
|
||||
}
|
||||
|
||||
applyInput(delta: Vec3): { position: Vec3; seq: number } {
|
||||
this.seq++;
|
||||
const input: PredictionInput = {
|
||||
seq: this.seq,
|
||||
delta,
|
||||
timestamp: this.clock.now(),
|
||||
};
|
||||
this.inputHistory.push(input);
|
||||
this.pruneHistory();
|
||||
|
||||
this.localPosition = [
|
||||
this.localPosition[0] + delta[0],
|
||||
this.localPosition[1] + delta[1],
|
||||
this.localPosition[2] + delta[2],
|
||||
];
|
||||
return { position: this.localPosition, seq: this.seq };
|
||||
}
|
||||
|
||||
reconcile(
|
||||
serverTransform: EntityTransform,
|
||||
lastAckedSeq: number
|
||||
): ReconciliationResult {
|
||||
while (
|
||||
this.inputHistory.length > 0 &&
|
||||
(this.inputHistory[0] as PredictionInput).seq <= lastAckedSeq
|
||||
) {
|
||||
this.inputHistory.shift();
|
||||
}
|
||||
|
||||
let predicted: [number, number, number] = [
|
||||
serverTransform.position[0],
|
||||
serverTransform.position[1],
|
||||
serverTransform.position[2],
|
||||
];
|
||||
for (const input of this.inputHistory) {
|
||||
predicted = [
|
||||
predicted[0] + input.delta[0],
|
||||
predicted[1] + input.delta[1],
|
||||
predicted[2] + input.delta[2],
|
||||
];
|
||||
}
|
||||
|
||||
const error = distanceVec3(this.localPosition, predicted);
|
||||
predictionDivergence.record(error);
|
||||
const shouldSnap = error > this.config.snapThreshold;
|
||||
if (shouldSnap) {
|
||||
this.localPosition = predicted;
|
||||
} else if (error > 0.001) {
|
||||
this.localPosition = lerpVec3(
|
||||
this.localPosition,
|
||||
predicted,
|
||||
this.config.correctionRate
|
||||
) as [number, number, number];
|
||||
}
|
||||
|
||||
return {
|
||||
transform: {
|
||||
position: this.localPosition,
|
||||
rotation: serverTransform.rotation,
|
||||
scale: serverTransform.scale,
|
||||
},
|
||||
replayed: this.inputHistory.length,
|
||||
snapped: shouldSnap,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
getPosition(): Vec3 {
|
||||
return this.localPosition;
|
||||
}
|
||||
|
||||
getSeq(): number {
|
||||
return this.seq;
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.inputHistory.length;
|
||||
}
|
||||
|
||||
reset(position: Vec3 = [0, 0, 0]): void {
|
||||
this.inputHistory.length = 0;
|
||||
this.localPosition = [...position] as [number, number, number];
|
||||
this.seq = 0;
|
||||
}
|
||||
|
||||
private pruneHistory(): void {
|
||||
while (this.inputHistory.length > this.config.maxHistory) {
|
||||
this.inputHistory.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
EntityTransform,
|
||||
Vec3,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
export interface PredictionConfig {
|
||||
/** Maximum prediction steps to keep for reconciliation. Default 60. */
|
||||
readonly maxHistory?: number;
|
||||
/** Snap threshold: if server diverges by this much, snap. Default 3.0. */
|
||||
readonly snapThreshold?: number;
|
||||
/** Correction smoothing factor 0-1. Default 0.1. */
|
||||
readonly correctionRate?: number;
|
||||
}
|
||||
|
||||
export interface PredictionInput {
|
||||
readonly seq: number;
|
||||
readonly delta: Vec3;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
export interface ReconciliationResult {
|
||||
readonly transform: EntityTransform;
|
||||
readonly replayed: number;
|
||||
readonly snapped: boolean;
|
||||
readonly error: number;
|
||||
}
|
||||
|
||||
/** Minimal monotonic clock contract — `now()` returns a unix-ms timestamp. */
|
||||
export interface PredictionClock {
|
||||
now(): number;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# `reconciliation-fsm`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
A three-state finite state machine that drives the client-side reaction to
|
||||
authoritative-state divergence. `synchronized` is steady state, `diverged`
|
||||
is the warning band entered after one snapshot exceeds `snapThreshold`,
|
||||
`resyncing` is the recovery state entered after `maxConsecutive` divergent
|
||||
snapshots in a row. Composes `@suaveplan/fsm` so transition history,
|
||||
guards, and OTel metrics come for free.
|
||||
|
||||
## Features
|
||||
|
||||
- Two events: `SNAPSHOT { divergence, seq }` and `RESYNC` / `RESYNCED`
|
||||
control transitions.
|
||||
- Snap-threshold and max-consecutive bands configurable via the strict
|
||||
`ReconciliationFsmOptions` schema.
|
||||
- `assign()` action keeps the running counter inside the FSM context;
|
||||
exiting `resyncing` resets it.
|
||||
- Bad options throw `R3F_NET.INVALID_OPTIONS`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildReconciliationMachine } from "@suaveplan/r3f-networking";
|
||||
|
||||
const fsm = buildReconciliationMachine({ snapThreshold: 2 });
|
||||
fsm.send({ type: "SNAPSHOT", payload: { divergence: 0.4, seq: 12 } });
|
||||
// fsm.current === "synchronized"
|
||||
fsm.send({ type: "SNAPSHOT", payload: { divergence: 5.0, seq: 13 } });
|
||||
// fsm.current === "diverged"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive the FSM from a `ClientPredictor.reconcile` result to coordinate UI:
|
||||
|
||||
```ts
|
||||
const fsm = buildReconciliationMachine({ snapThreshold: 1.5, maxConsecutive: 5 });
|
||||
|
||||
function onServerFrame(frame, lastAcked) {
|
||||
const r = predictor.reconcile(frame.transform, lastAcked);
|
||||
fsm.send({
|
||||
type: "SNAPSHOT",
|
||||
payload: { divergence: r.error, seq: frame.seq },
|
||||
});
|
||||
if (fsm.current === "resyncing") requestFullSnapshot();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildReconciliationMachine(options?: ReconciliationFsmOptions): FSMInterpreter<ReconciliationContext>`
|
||||
- States: `synchronized | diverged | resyncing`.
|
||||
- Events: `SNAPSHOT { divergence, seq } | RESYNC | RESYNCED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The state machine is intentionally minimal — multi-band thresholds
|
||||
(`severe`, `catastrophic`) are deferred to consumers because the right shape
|
||||
varies per game. The `consecutiveDiverged` counter resets on any below-
|
||||
threshold snapshot, so transient spikes do not promote the machine all the
|
||||
way to `resyncing`. `RESYNC` is exposed as an explicit transport-level
|
||||
event so a peer-drop event can force the recovery state without waiting for
|
||||
the divergence counter to fill.
|
||||
@@ -0,0 +1,8 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ReconciliationFsmOptionsSchema = z
|
||||
.object({
|
||||
snapThreshold: z.number().nonnegative().optional(),
|
||||
maxConsecutive: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { R3FNetError } from "../../errors/errors.js";
|
||||
import { buildReconciliationMachine } from "./reconciliation-fsm.js";
|
||||
|
||||
describe("buildReconciliationMachine", () => {
|
||||
it("rejects bad options", () => {
|
||||
// @ts-expect-error: bad option
|
||||
expect(() => buildReconciliationMachine({ snapThreshold: -1 })).toThrow(
|
||||
R3FNetError
|
||||
);
|
||||
});
|
||||
|
||||
it("starts in synchronized", () => {
|
||||
const fsm = buildReconciliationMachine();
|
||||
expect(fsm.current).toBe("synchronized");
|
||||
});
|
||||
|
||||
it("moves to diverged when SNAPSHOT exceeds threshold", () => {
|
||||
const fsm = buildReconciliationMachine({ snapThreshold: 1 });
|
||||
fsm.send({
|
||||
type: "SNAPSHOT",
|
||||
payload: { divergence: 5, seq: 1 },
|
||||
});
|
||||
expect(fsm.current).toBe("diverged");
|
||||
});
|
||||
|
||||
it("stays in synchronized for low divergence", () => {
|
||||
const fsm = buildReconciliationMachine({ snapThreshold: 1 });
|
||||
fsm.send({
|
||||
type: "SNAPSHOT",
|
||||
payload: { divergence: 0.1, seq: 1 },
|
||||
});
|
||||
expect(fsm.current).toBe("synchronized");
|
||||
});
|
||||
|
||||
it("recovers from diverged on a clean SNAPSHOT", () => {
|
||||
const fsm = buildReconciliationMachine({ snapThreshold: 1 });
|
||||
fsm.send({ type: "SNAPSHOT", payload: { divergence: 5, seq: 1 } });
|
||||
fsm.send({ type: "SNAPSHOT", payload: { divergence: 0.1, seq: 2 } });
|
||||
expect(fsm.current).toBe("synchronized");
|
||||
});
|
||||
|
||||
it("stays diverged when error remains high but counter hasn't reached cap", () => {
|
||||
const fsm = buildReconciliationMachine({
|
||||
snapThreshold: 1,
|
||||
maxConsecutive: 5,
|
||||
});
|
||||
fsm.send({ type: "SNAPSHOT", payload: { divergence: 5, seq: 1 } });
|
||||
fsm.send({ type: "SNAPSHOT", payload: { divergence: 5, seq: 2 } });
|
||||
expect(fsm.current).toBe("diverged");
|
||||
});
|
||||
|
||||
it("escalates to resyncing on max-consecutive divergence", () => {
|
||||
const fsm = buildReconciliationMachine({
|
||||
snapThreshold: 1,
|
||||
maxConsecutive: 2,
|
||||
});
|
||||
fsm.send({ type: "SNAPSHOT", payload: { divergence: 5, seq: 1 } });
|
||||
fsm.send({ type: "SNAPSHOT", payload: { divergence: 5, seq: 2 } });
|
||||
expect(fsm.current).toBe("resyncing");
|
||||
});
|
||||
|
||||
it("RESYNC event forces resyncing from diverged", () => {
|
||||
const fsm = buildReconciliationMachine();
|
||||
fsm.send({
|
||||
type: "SNAPSHOT",
|
||||
payload: { divergence: 100, seq: 1 },
|
||||
});
|
||||
fsm.send({ type: "RESYNC" });
|
||||
expect(fsm.current).toBe("resyncing");
|
||||
});
|
||||
|
||||
it("RESYNCED returns to synchronized and resets context", () => {
|
||||
const fsm = buildReconciliationMachine({ maxConsecutive: 2 });
|
||||
fsm.send({
|
||||
type: "SNAPSHOT",
|
||||
payload: { divergence: 100, seq: 1 },
|
||||
});
|
||||
fsm.send({
|
||||
type: "SNAPSHOT",
|
||||
payload: { divergence: 100, seq: 2 },
|
||||
});
|
||||
expect(fsm.current).toBe("resyncing");
|
||||
fsm.send({ type: "RESYNCED" });
|
||||
expect(fsm.current).toBe("synchronized");
|
||||
expect(fsm.state.context.consecutiveDiverged).toBe(0);
|
||||
expect(fsm.state.context.divergence).toBe(0);
|
||||
});
|
||||
|
||||
it("handles SNAPSHOT events without payload gracefully", () => {
|
||||
const fsm = buildReconciliationMachine();
|
||||
// No payload — divergenceOf returns 0, so guard sees 0 <= threshold.
|
||||
fsm.send({ type: "SNAPSHOT" });
|
||||
expect(fsm.current).toBe("synchronized");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { assign, createMachine, type FSMInterpreter } from "@suaveplan/fsm";
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import { ReconciliationFsmOptionsSchema } from "./reconciliation-fsm.schemas.js";
|
||||
import type {
|
||||
ReconciliationContext,
|
||||
ReconciliationEventPayload,
|
||||
ReconciliationFsmOptions,
|
||||
ReconciliationStateValue,
|
||||
} from "./reconciliation-fsm.types.js";
|
||||
|
||||
const DEFAULTS: Required<ReconciliationFsmOptions> = {
|
||||
snapThreshold: 3.0,
|
||||
maxConsecutive: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the reconciliation FSM. Three states: `synchronized` is the steady
|
||||
* state, `diverged` is the warning band, `resyncing` is the recovery state
|
||||
* the consumer is expected to handle (request a full snapshot, freeze
|
||||
* prediction, etc.).
|
||||
*/
|
||||
export function buildReconciliationMachine(
|
||||
options?: ReconciliationFsmOptions
|
||||
): FSMInterpreter<ReconciliationContext> {
|
||||
const parsed = ReconciliationFsmOptionsSchema.safeParse(options ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_OPTIONS,
|
||||
"r3f_net.reconciliation_fsm.invalid_options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
const opts = { ...DEFAULTS, ...(parsed.data as ReconciliationFsmOptions) };
|
||||
|
||||
// The genesis `assign` helper only takes `(ctx) => newCtx` and never
|
||||
// exposes the event — see `@suaveplan/fsm/utils/context-helpers`. Build a
|
||||
// raw action that reads the event out of the FSMEvent parameter.
|
||||
const updateFromEvent = (
|
||||
ctx: ReconciliationContext,
|
||||
ev: { payload?: unknown } | undefined
|
||||
): ReconciliationContext => {
|
||||
const payload = ev?.payload as ReconciliationEventPayload | undefined;
|
||||
if (!payload) return ctx;
|
||||
const overSnap = payload.divergence > opts.snapThreshold;
|
||||
return {
|
||||
divergence: payload.divergence,
|
||||
lastSeq: payload.seq,
|
||||
consecutiveDiverged: overSnap ? ctx.consecutiveDiverged + 1 : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const machine = createMachine<ReconciliationContext>({
|
||||
id: "r3f_net.reconciliation",
|
||||
initial: "synchronized" satisfies ReconciliationStateValue,
|
||||
context: { divergence: 0, lastSeq: 0, consecutiveDiverged: 0 },
|
||||
states: {
|
||||
synchronized: {
|
||||
on: {
|
||||
SNAPSHOT: [
|
||||
{
|
||||
target: "diverged" satisfies ReconciliationStateValue,
|
||||
guard: (_c, e) =>
|
||||
divergenceOf(e) > opts.snapThreshold,
|
||||
actions: [updateFromEvent],
|
||||
},
|
||||
{
|
||||
target: "synchronized" satisfies ReconciliationStateValue,
|
||||
actions: [updateFromEvent],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
diverged: {
|
||||
on: {
|
||||
SNAPSHOT: [
|
||||
{
|
||||
target: "resyncing" satisfies ReconciliationStateValue,
|
||||
guard: (c, e) => {
|
||||
const next =
|
||||
divergenceOf(e) > opts.snapThreshold
|
||||
? c.consecutiveDiverged + 1
|
||||
: 0;
|
||||
return next >= opts.maxConsecutive;
|
||||
},
|
||||
actions: [updateFromEvent],
|
||||
},
|
||||
{
|
||||
target: "synchronized" satisfies ReconciliationStateValue,
|
||||
guard: (_c, e) =>
|
||||
divergenceOf(e) <= opts.snapThreshold,
|
||||
actions: [updateFromEvent],
|
||||
},
|
||||
{
|
||||
target: "diverged" satisfies ReconciliationStateValue,
|
||||
actions: [updateFromEvent],
|
||||
},
|
||||
],
|
||||
RESYNC: {
|
||||
target: "resyncing" satisfies ReconciliationStateValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
resyncing: {
|
||||
on: {
|
||||
RESYNCED: {
|
||||
target: "synchronized" satisfies ReconciliationStateValue,
|
||||
actions: [
|
||||
assign<ReconciliationContext>(() => ({
|
||||
divergence: 0,
|
||||
lastSeq: 0,
|
||||
consecutiveDiverged: 0,
|
||||
})) as (
|
||||
ctx: ReconciliationContext,
|
||||
ev: { payload?: unknown } | undefined
|
||||
) => ReconciliationContext,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return machine;
|
||||
}
|
||||
|
||||
function divergenceOf(ev: { payload?: unknown }): number {
|
||||
const payload = ev.payload as ReconciliationEventPayload | undefined;
|
||||
return payload?.divergence ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Reconciliation context: the running divergence and last server seq. */
|
||||
export interface ReconciliationContext {
|
||||
/** Magnitude of the last divergence in metres. */
|
||||
readonly divergence: number;
|
||||
/** Last authoritative seq the client accepted. */
|
||||
readonly lastSeq: number;
|
||||
/** Number of consecutive snapshots whose divergence exceeded threshold. */
|
||||
readonly consecutiveDiverged: number;
|
||||
}
|
||||
|
||||
/** Configuration for the reconciliation FSM. */
|
||||
export interface ReconciliationFsmOptions {
|
||||
/** Divergence (m) above which the machine enters `diverged`. Default 3.0. */
|
||||
readonly snapThreshold?: number;
|
||||
/** Consecutive divergence count that forces `resyncing`. Default 3. */
|
||||
readonly maxConsecutive?: number;
|
||||
}
|
||||
|
||||
/** A discrete reconciliation event payload. */
|
||||
export interface ReconciliationEventPayload {
|
||||
readonly divergence: number;
|
||||
readonly seq: number;
|
||||
}
|
||||
|
||||
/** Machine state values. */
|
||||
export type ReconciliationStateValue =
|
||||
| "synchronized"
|
||||
| "diverged"
|
||||
| "resyncing";
|
||||
@@ -0,0 +1,74 @@
|
||||
# `snapshot-diff`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Computes and applies entity-snapshot deltas as RFC-6902 JSON Patches via
|
||||
`@suaveplan/diff`. The server authors one full snapshot followed by a stream
|
||||
of patches; the client applies each patch against its mirror of the same
|
||||
base snapshot. Bandwidth scales with the number of changed entities rather
|
||||
than the scene size.
|
||||
|
||||
## Features
|
||||
|
||||
- `diffSnapshots(from, to)` — minimal JSON Patch between two snapshots.
|
||||
- `buildDeltaFrame(base, next)` — full wire-ready delta with `baseSeq`,
|
||||
`seq`, `timestamp`, `patch`.
|
||||
- `applyDeltaFrame(base, delta)` — apply on the receiver; rejects mismatched
|
||||
`baseSeq` with `R3F_NET.INVALID_SNAPSHOT`.
|
||||
- Zod-validated wire frames; bad input throws `R3F_NET.INVALID_SNAPSHOT`.
|
||||
- Sequence-monotonic — `next.seq > base.seq` enforced or
|
||||
`R3F_NET.SNAPSHOT_OUT_OF_ORDER`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildDeltaFrame,
|
||||
applyDeltaFrame,
|
||||
type SnapshotFrame,
|
||||
} from "@suaveplan/r3f-networking";
|
||||
|
||||
const base: SnapshotFrame = { seq: 1, timestamp: 1000, state: { a: T1 } };
|
||||
const next: SnapshotFrame = { seq: 2, timestamp: 1050, state: { a: T2 } };
|
||||
const delta = buildDeltaFrame(base, next);
|
||||
const reconstructed = applyDeltaFrame(base, delta);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Property-test round-trip equivalence with `fast-check`:
|
||||
|
||||
```ts
|
||||
fc.assert(
|
||||
fc.property(snapArb, snapArb, (a, b) => {
|
||||
const delta = buildDeltaFrame(
|
||||
{ seq: 1, timestamp: 0, state: a },
|
||||
{ seq: 2, timestamp: 1, state: b }
|
||||
);
|
||||
const out = applyDeltaFrame({ seq: 1, timestamp: 0, state: a }, delta);
|
||||
expect(out.state).toEqual(b);
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `diffSnapshots(from, to): Patch`
|
||||
- `buildDeltaFrame(base, next): SnapshotDeltaFrame` —
|
||||
throws `SNAPSHOT_OUT_OF_ORDER`.
|
||||
- `applyDeltaFrame(base, delta): SnapshotFrame` —
|
||||
throws `INVALID_SNAPSHOT` on base mismatch.
|
||||
- `validateSnapshotFrame(unknown): SnapshotFrame`
|
||||
- `validateSnapshotDeltaFrame(unknown): SnapshotDeltaFrame`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The RFC-6902 patch is produced by `@suaveplan/diff` with the default index
|
||||
strategy — array order is preserved verbatim and individual leaves emit
|
||||
`replace` ops. Because entity maps are object-shaped, `add` / `remove` ops
|
||||
appear when entities join or leave the interest set. The base-seq fence
|
||||
prevents the client from applying a patch over the wrong snapshot when
|
||||
packets reorder at the transport layer.
|
||||
@@ -0,0 +1,31 @@
|
||||
import z from "zod";
|
||||
|
||||
const Vec3Schema = z.tuple([z.number(), z.number(), z.number()]);
|
||||
const QuatSchema = z.tuple([z.number(), z.number(), z.number(), z.number()]);
|
||||
|
||||
export const EntityTransformSchema = z
|
||||
.object({
|
||||
position: Vec3Schema,
|
||||
rotation: QuatSchema,
|
||||
scale: Vec3Schema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const EntitySnapshotSchema = z.record(z.string(), EntityTransformSchema);
|
||||
|
||||
export const SnapshotFrameSchema = z
|
||||
.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
timestamp: z.number(),
|
||||
state: EntitySnapshotSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SnapshotDeltaFrameSchema = z
|
||||
.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
baseSeq: z.number().int().nonnegative(),
|
||||
timestamp: z.number(),
|
||||
patch: z.array(z.unknown()),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import {
|
||||
applyDeltaFrame,
|
||||
buildDeltaFrame,
|
||||
diffSnapshots,
|
||||
validateSnapshotDeltaFrame,
|
||||
validateSnapshotFrame,
|
||||
} from "./snapshot-diff.js";
|
||||
import type { EntitySnapshot, SnapshotFrame } from "./snapshot-diff.types.js";
|
||||
|
||||
const FRAME = (seq: number, state: EntitySnapshot): SnapshotFrame => ({
|
||||
seq,
|
||||
timestamp: seq * 100,
|
||||
state,
|
||||
});
|
||||
|
||||
const T = (x: number) => ({
|
||||
position: [x, 0, 0] as [number, number, number],
|
||||
rotation: [0, 0, 0, 1] as [number, number, number, number],
|
||||
scale: [1, 1, 1] as [number, number, number],
|
||||
});
|
||||
|
||||
describe("snapshot-diff", () => {
|
||||
it("diffSnapshots emits operations for changed entities", () => {
|
||||
const a: EntitySnapshot = { e1: T(0) };
|
||||
const b: EntitySnapshot = { e1: T(5) };
|
||||
const p = diffSnapshots(a, b);
|
||||
expect(p.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("buildDeltaFrame produces a valid delta", () => {
|
||||
const base = FRAME(1, { e1: T(0) });
|
||||
const next = FRAME(2, { e1: T(5) });
|
||||
const d = buildDeltaFrame(base, next);
|
||||
expect(d.baseSeq).toBe(1);
|
||||
expect(d.seq).toBe(2);
|
||||
expect(d.patch.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("buildDeltaFrame rejects non-monotonic seq", () => {
|
||||
expect(() =>
|
||||
buildDeltaFrame(FRAME(2, { e1: T(0) }), FRAME(1, { e1: T(0) }))
|
||||
).toThrow(R3FNetError);
|
||||
});
|
||||
|
||||
it("applyDeltaFrame reconstructs next from base + delta", () => {
|
||||
const base = FRAME(1, { e1: T(0) });
|
||||
const next = FRAME(2, { e1: T(5) });
|
||||
const d = buildDeltaFrame(base, next);
|
||||
const r = applyDeltaFrame(base, d);
|
||||
expect(r.seq).toBe(2);
|
||||
expect(r.state.e1?.position[0]).toBe(5);
|
||||
});
|
||||
|
||||
it("applyDeltaFrame rejects base-seq mismatch", () => {
|
||||
const base = FRAME(1, { e1: T(0) });
|
||||
const wrong = FRAME(9, { e1: T(0) });
|
||||
const d = buildDeltaFrame(base, FRAME(2, { e1: T(5) }));
|
||||
expect(() => applyDeltaFrame(wrong, d)).toThrow(R3FNetError);
|
||||
});
|
||||
|
||||
it("validateSnapshotFrame parses good input", () => {
|
||||
const ok = validateSnapshotFrame({
|
||||
seq: 1,
|
||||
timestamp: 100,
|
||||
state: { e1: T(0) },
|
||||
});
|
||||
expect(ok.seq).toBe(1);
|
||||
});
|
||||
|
||||
it("validateSnapshotFrame rejects bad input", () => {
|
||||
expect(() =>
|
||||
validateSnapshotFrame({ seq: "no", timestamp: 0, state: {} })
|
||||
).toThrow(R3FNetError);
|
||||
});
|
||||
|
||||
it("validateSnapshotDeltaFrame parses good input", () => {
|
||||
const ok = validateSnapshotDeltaFrame({
|
||||
seq: 2,
|
||||
baseSeq: 1,
|
||||
timestamp: 200,
|
||||
patch: [],
|
||||
});
|
||||
expect(ok.seq).toBe(2);
|
||||
});
|
||||
|
||||
it("validateSnapshotDeltaFrame rejects bad input", () => {
|
||||
expect(() => validateSnapshotDeltaFrame({ seq: -1 })).toThrow(
|
||||
R3FNetError
|
||||
);
|
||||
});
|
||||
|
||||
it("validation error wraps with INVALID_SNAPSHOT code", () => {
|
||||
try {
|
||||
validateSnapshotFrame({});
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(R3FNetError);
|
||||
if (err instanceof R3FNetError) {
|
||||
expect(err.code).toBe(R3F_NET_CODES.INVALID_SNAPSHOT);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Property: apply(diff(a, b), a) === b for entity snapshots.
|
||||
it("[property] diff round-trip equivalence", () => {
|
||||
const tArb = fc.record({
|
||||
position: fc.tuple(
|
||||
fc.double({ noNaN: true, min: -100, max: 100 }),
|
||||
fc.double({ noNaN: true, min: -100, max: 100 }),
|
||||
fc.double({ noNaN: true, min: -100, max: 100 })
|
||||
),
|
||||
rotation: fc.constant([0, 0, 0, 1] as const),
|
||||
scale: fc.constant([1, 1, 1] as const),
|
||||
});
|
||||
const snapArb = fc.dictionary(
|
||||
fc.string({ minLength: 1, maxLength: 4 }),
|
||||
tArb,
|
||||
{
|
||||
minKeys: 0,
|
||||
maxKeys: 5,
|
||||
}
|
||||
);
|
||||
fc.assert(
|
||||
fc.property(snapArb, snapArb, (a, b) => {
|
||||
const aSnap = a as unknown as EntitySnapshot;
|
||||
const bSnap = b as unknown as EntitySnapshot;
|
||||
const base = FRAME(1, aSnap);
|
||||
const next = FRAME(2, bSnap);
|
||||
const d = buildDeltaFrame(base, next);
|
||||
const out = applyDeltaFrame(base, d);
|
||||
expect(out.state).toEqual(bSnap);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { applyPatch, diff, type Patch } from "@suaveplan/diff";
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import {
|
||||
SnapshotDeltaFrameSchema,
|
||||
SnapshotFrameSchema,
|
||||
} from "./snapshot-diff.schemas.js";
|
||||
import type {
|
||||
EntitySnapshot,
|
||||
SnapshotDeltaFrame,
|
||||
SnapshotFrame,
|
||||
} from "./snapshot-diff.types.js";
|
||||
|
||||
/**
|
||||
* Compute the minimal JSON Patch (RFC 6902) from `from` to `to`. Used by the
|
||||
* server to author delta frames and by the client to verify the round-trip
|
||||
* during property tests.
|
||||
*/
|
||||
export function diffSnapshots(from: EntitySnapshot, to: EntitySnapshot): Patch {
|
||||
return diff(
|
||||
from as unknown as Parameters<typeof diff>[0],
|
||||
to as unknown as Parameters<typeof diff>[1]
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a `SnapshotDeltaFrame` for the wire from two adjacent snapshots. */
|
||||
export function buildDeltaFrame(
|
||||
base: SnapshotFrame,
|
||||
next: SnapshotFrame
|
||||
): SnapshotDeltaFrame {
|
||||
if (next.seq <= base.seq) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.SNAPSHOT_OUT_OF_ORDER,
|
||||
"r3f_net.snapshot_diff.out_of_order",
|
||||
{ context: { base: base.seq, next: next.seq } }
|
||||
);
|
||||
}
|
||||
return {
|
||||
seq: next.seq,
|
||||
baseSeq: base.seq,
|
||||
timestamp: next.timestamp,
|
||||
patch: diffSnapshots(base.state, next.state),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a delta frame to a base snapshot, yielding the next snapshot. Throws
|
||||
* `INVALID_SNAPSHOT` when `baseSeq` does not match the supplied base.
|
||||
*/
|
||||
export function applyDeltaFrame(
|
||||
base: SnapshotFrame,
|
||||
delta: SnapshotDeltaFrame
|
||||
): SnapshotFrame {
|
||||
if (delta.baseSeq !== base.seq) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_SNAPSHOT,
|
||||
"r3f_net.snapshot_diff.base_mismatch",
|
||||
{ context: { baseSeq: delta.baseSeq, baseHave: base.seq } }
|
||||
);
|
||||
}
|
||||
const result = applyPatch(
|
||||
base.state as unknown as Parameters<typeof applyPatch>[0],
|
||||
delta.patch
|
||||
);
|
||||
return {
|
||||
seq: delta.seq,
|
||||
timestamp: delta.timestamp,
|
||||
state: result.doc as unknown as EntitySnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a snapshot frame at the wire boundary. */
|
||||
export function validateSnapshotFrame(value: unknown): SnapshotFrame {
|
||||
const parsed = SnapshotFrameSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_SNAPSHOT,
|
||||
"r3f_net.snapshot_diff.invalid_frame",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return parsed.data as SnapshotFrame;
|
||||
}
|
||||
|
||||
/** Validate a snapshot delta frame at the wire boundary. */
|
||||
export function validateSnapshotDeltaFrame(value: unknown): SnapshotDeltaFrame {
|
||||
const parsed = SnapshotDeltaFrameSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_SNAPSHOT,
|
||||
"r3f_net.snapshot_diff.invalid_delta",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return parsed.data as unknown as SnapshotDeltaFrame;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Patch } from "@suaveplan/diff";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
/** A keyed map of entities → transforms. The full authoritative scene state. */
|
||||
export type EntitySnapshot = Readonly<Record<string, EntityTransform>>;
|
||||
|
||||
/** A snapshot frame: the sequence number, server timestamp, and full state. */
|
||||
export interface SnapshotFrame {
|
||||
readonly seq: number;
|
||||
readonly timestamp: number;
|
||||
readonly state: EntitySnapshot;
|
||||
}
|
||||
|
||||
/** A delta frame: the patch from one snapshot to the next. */
|
||||
export interface SnapshotDeltaFrame {
|
||||
readonly seq: number;
|
||||
readonly baseSeq: number;
|
||||
readonly timestamp: number;
|
||||
readonly patch: Patch;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# `state-buffer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Bounded buffer of timestamped entity states. Stores the last N authoritative
|
||||
snapshots received from the network and answers bracketing-pair queries at a
|
||||
target render time so the interpolator can produce a smooth transform every
|
||||
frame. Both the **size** cap and **age** cap are enforced on every push to
|
||||
keep memory steady under arbitrary update rates.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) `push`, O(n) bracketing-pair lookup for a target timestamp.
|
||||
- Dual prune: `maxSize` count cap + `maxAge` millisecond cap.
|
||||
- Rejects out-of-order pushes with `R3F_NET.SNAPSHOT_OUT_OF_ORDER`.
|
||||
- Zod-validated options on construction; invalid input throws
|
||||
`R3F_NET.INVALID_OPTIONS`.
|
||||
- Pair-lookup returns clamped endpoints when the requested render time is
|
||||
before the oldest or after the newest state so the interpolator never has
|
||||
to special-case empty data on the hot path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StateBuffer } from "@suaveplan/r3f-networking";
|
||||
|
||||
const buf = new StateBuffer<EntityTransform>({ maxSize: 64, maxAge: 1500 });
|
||||
buf.push({ timestamp: 1000, state: { position: [0, 0, 0], rotation: [0,0,0,1], scale: [1,1,1] } });
|
||||
buf.push({ timestamp: 1100, state: { position: [1, 0, 0], rotation: [0,0,0,1], scale: [1,1,1] } });
|
||||
const pair = buf.findInterpolationPair(1050);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive an interpolation loop against the buffer with a fixed delay:
|
||||
|
||||
```ts
|
||||
import { StateBuffer, EntityInterpolator } from "@suaveplan/r3f-networking";
|
||||
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
const interp = new EntityInterpolator({ delay: 100 });
|
||||
|
||||
function onTick(serverTime: number): EntityTransform {
|
||||
return interp.interpolate(buf, serverTime).transform;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new StateBuffer<T>(config?: StateBufferConfig)` — throws `INVALID_OPTIONS`
|
||||
on bad input.
|
||||
- `push(state)` — throws `SNAPSHOT_OUT_OF_ORDER` for backwards timestamps.
|
||||
- `size()` / `latest()` / `oldest()` — accessors.
|
||||
- `findInterpolationPair(timestamp)` — bracketing pair or `null` when
|
||||
`size < 2`.
|
||||
- `getSnapshot()` — diagnostic frame for HUD overlays.
|
||||
- `clear()` — resets buffer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The buffer is array-backed for cache locality and simple iteration. Pruning
|
||||
runs amortised O(1) per push. The pair-lookup short-circuits inside the
|
||||
in-range loop; only the edge branches walk to the array endpoints. Out-of-order
|
||||
rejection is enforced at the boundary rather than silently sorted — late frames
|
||||
must surface as an error so the transport layer can take corrective action.
|
||||
@@ -0,0 +1,8 @@
|
||||
import z from "zod";
|
||||
|
||||
export const StateBufferConfigSchema = z
|
||||
.object({
|
||||
maxSize: z.number().int().positive().optional(),
|
||||
maxAge: z.number().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { R3FNetError } from "../../errors/errors.js";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import { StateBuffer } from "./state-buffer.js";
|
||||
|
||||
const T = (i: number): EntityTransform => ({
|
||||
position: [i, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
|
||||
describe("StateBuffer", () => {
|
||||
it("rejects bad options", () => {
|
||||
// @ts-expect-error: bad options
|
||||
expect(() => new StateBuffer({ maxSize: -1 })).toThrow(R3FNetError);
|
||||
});
|
||||
|
||||
it("returns null pair when fewer than 2 states", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
expect(buf.findInterpolationPair(0)).toBeNull();
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
expect(buf.findInterpolationPair(100)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds the bracketing pair for an in-range timestamp", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
buf.push({ timestamp: 200, state: T(1) });
|
||||
const p = buf.findInterpolationPair(150);
|
||||
expect(p).not.toBeNull();
|
||||
expect(p?.t).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it("clamps to earliest pair for an under-range timestamp", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
buf.push({ timestamp: 200, state: T(1) });
|
||||
const p = buf.findInterpolationPair(50);
|
||||
expect(p?.t).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps to latest pair for an over-range timestamp", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
buf.push({ timestamp: 200, state: T(1) });
|
||||
const p = buf.findInterpolationPair(500);
|
||||
expect(p?.t).toBe(1);
|
||||
});
|
||||
|
||||
it("handles zero-span pair without divide-by-zero", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
buf.push({ timestamp: 100, state: T(1) });
|
||||
const p = buf.findInterpolationPair(100);
|
||||
expect(p?.t).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects out-of-order pushes", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 200, state: T(0) });
|
||||
expect(() => buf.push({ timestamp: 100, state: T(1) })).toThrow(
|
||||
R3FNetError
|
||||
);
|
||||
});
|
||||
|
||||
it("returns oldest/latest/snapshot/size accessors", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
expect(buf.size()).toBe(0);
|
||||
expect(buf.oldest()).toBeNull();
|
||||
expect(buf.latest()).toBeNull();
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
buf.push({ timestamp: 200, state: T(1) });
|
||||
expect(buf.size()).toBe(2);
|
||||
expect(buf.oldest()?.timestamp).toBe(100);
|
||||
expect(buf.latest()?.timestamp).toBe(200);
|
||||
const snap = buf.getSnapshot();
|
||||
expect(snap.size).toBe(2);
|
||||
expect(snap.timeSpan).toBe(100);
|
||||
});
|
||||
|
||||
it("getSnapshot when empty", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
const snap = buf.getSnapshot();
|
||||
expect(snap.size).toBe(0);
|
||||
expect(snap.oldestTimestamp).toBeNull();
|
||||
expect(snap.timeSpan).toBe(0);
|
||||
});
|
||||
|
||||
it("prunes by maxSize", () => {
|
||||
const buf = new StateBuffer<EntityTransform>({
|
||||
maxSize: 3,
|
||||
maxAge: 100_000,
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
buf.push({ timestamp: 100 + i * 10, state: T(i) });
|
||||
}
|
||||
expect(buf.size()).toBe(3);
|
||||
expect(buf.latest()?.timestamp).toBe(190);
|
||||
});
|
||||
|
||||
it("prunes by maxAge", () => {
|
||||
const buf = new StateBuffer<EntityTransform>({
|
||||
maxSize: 100,
|
||||
maxAge: 50,
|
||||
});
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
buf.push({ timestamp: 105, state: T(1) });
|
||||
buf.push({ timestamp: 200, state: T(2) });
|
||||
// Oldest two pruned since newest (200) - oldest (100) > 50.
|
||||
expect(buf.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("clears all entries", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
buf.clear();
|
||||
expect(buf.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("matches a timestamp equal to a boundary", () => {
|
||||
const buf = new StateBuffer<EntityTransform>();
|
||||
buf.push({ timestamp: 100, state: T(0) });
|
||||
buf.push({ timestamp: 200, state: T(1) });
|
||||
const p = buf.findInterpolationPair(100);
|
||||
expect(p?.t).toBe(0);
|
||||
const p2 = buf.findInterpolationPair(200);
|
||||
expect(p2?.t).toBe(1);
|
||||
});
|
||||
|
||||
it("exercises pruning age-cutoff branch with multiple stale entries", () => {
|
||||
const buf = new StateBuffer<EntityTransform>({
|
||||
maxSize: 1000,
|
||||
maxAge: 10,
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
buf.push({ timestamp: 100 + i, state: T(i) });
|
||||
}
|
||||
buf.push({ timestamp: 200, state: T(100) });
|
||||
expect(buf.size()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import type {
|
||||
EntityTransform,
|
||||
TimestampedState,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import { StateBufferConfigSchema } from "./state-buffer.schemas.js";
|
||||
import type {
|
||||
InterpolationPair,
|
||||
StateBufferConfig,
|
||||
StateBufferSnapshot,
|
||||
} from "./state-buffer.types.js";
|
||||
|
||||
const DEFAULT_CONFIG: Required<StateBufferConfig> = {
|
||||
maxSize: 20,
|
||||
maxAge: 2000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounded buffer of timestamped entity states, pruned by both size + age.
|
||||
* Provides bracketing-pair lookup for interpolation at a target render time.
|
||||
*/
|
||||
export class StateBuffer<T = EntityTransform> {
|
||||
private readonly config: Required<StateBufferConfig>;
|
||||
private readonly states: TimestampedState<T>[] = [];
|
||||
|
||||
constructor(config?: StateBufferConfig) {
|
||||
const parsed = StateBufferConfigSchema.safeParse(config ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_OPTIONS,
|
||||
"r3f_net.state_buffer.invalid_options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...(parsed.data as StateBufferConfig),
|
||||
};
|
||||
}
|
||||
|
||||
push(state: TimestampedState<T>): void {
|
||||
const last = this.states[this.states.length - 1];
|
||||
if (last && state.timestamp < last.timestamp) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.SNAPSHOT_OUT_OF_ORDER,
|
||||
"r3f_net.state_buffer.out_of_order",
|
||||
{
|
||||
context: {
|
||||
incoming: state.timestamp,
|
||||
latest: last.timestamp,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
this.states.push(state);
|
||||
this.prune();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.states.length;
|
||||
}
|
||||
|
||||
latest(): TimestampedState<T> | null {
|
||||
if (this.states.length === 0) return null;
|
||||
return this.states[this.states.length - 1] as TimestampedState<T>;
|
||||
}
|
||||
|
||||
oldest(): TimestampedState<T> | null {
|
||||
if (this.states.length === 0) return null;
|
||||
return this.states[0] as TimestampedState<T>;
|
||||
}
|
||||
|
||||
findInterpolationPair(timestamp: number): InterpolationPair<T> | null {
|
||||
if (this.states.length < 2) return null;
|
||||
|
||||
// Both indices are < length within the loop body.
|
||||
for (let i = 0; i < this.states.length - 1; i++) {
|
||||
const before = this.states[i] as TimestampedState<T>;
|
||||
const after = this.states[i + 1] as TimestampedState<T>;
|
||||
if (timestamp >= before.timestamp && timestamp <= after.timestamp) {
|
||||
const span = after.timestamp - before.timestamp;
|
||||
const t = span > 0 ? (timestamp - before.timestamp) / span : 0;
|
||||
return { before, after, t };
|
||||
}
|
||||
}
|
||||
|
||||
const first = this.states[0] as TimestampedState<T>;
|
||||
if (timestamp < first.timestamp) {
|
||||
const second = this.states[1] as TimestampedState<T>;
|
||||
return { before: first, after: second, t: 0 };
|
||||
}
|
||||
|
||||
const last = this.states.length - 1;
|
||||
const beforeLast = this.states[last - 1] as TimestampedState<T>;
|
||||
const lastState = this.states[last] as TimestampedState<T>;
|
||||
return { before: beforeLast, after: lastState, t: 1 };
|
||||
}
|
||||
|
||||
getSnapshot(): StateBufferSnapshot {
|
||||
const oldest = this.oldest();
|
||||
const newest = this.latest();
|
||||
return {
|
||||
size: this.states.length,
|
||||
oldestTimestamp: oldest?.timestamp ?? null,
|
||||
newestTimestamp: newest?.timestamp ?? null,
|
||||
timeSpan:
|
||||
oldest && newest ? newest.timestamp - oldest.timestamp : 0,
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.states.length = 0;
|
||||
}
|
||||
|
||||
private prune(): void {
|
||||
while (this.states.length > this.config.maxSize) {
|
||||
this.states.shift();
|
||||
}
|
||||
// `prune()` is only called after a successful `push()`, so length >= 1.
|
||||
const newest = (
|
||||
this.states[this.states.length - 1] as TimestampedState<T>
|
||||
).timestamp;
|
||||
const cutoff = newest - this.config.maxAge;
|
||||
while (
|
||||
this.states.length > 1 &&
|
||||
(this.states[0] as TimestampedState<T>).timestamp < cutoff
|
||||
) {
|
||||
this.states.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
EntityTransform,
|
||||
TimestampedState,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
/** Configuration for the state buffer. */
|
||||
export interface StateBufferConfig {
|
||||
/** Maximum number of states to keep. Default 20. */
|
||||
readonly maxSize?: number;
|
||||
/** Maximum age of states in milliseconds before pruning. Default 2000. */
|
||||
readonly maxAge?: number;
|
||||
}
|
||||
|
||||
/** Snapshot of the state buffer. */
|
||||
export interface StateBufferSnapshot {
|
||||
readonly size: number;
|
||||
readonly oldestTimestamp: number | null;
|
||||
readonly newestTimestamp: number | null;
|
||||
readonly timeSpan: number;
|
||||
}
|
||||
|
||||
/** Pair of states bracketing a target timestamp for interpolation. */
|
||||
export interface InterpolationPair<T = EntityTransform> {
|
||||
readonly before: TimestampedState<T>;
|
||||
readonly after: TimestampedState<T>;
|
||||
readonly t: number;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# `wire-codec`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes snapshot and delta frames for the wire via
|
||||
`@suaveplan/serialize`. Decode is fronted by Zod boundary validation so any
|
||||
malformed input (truncated payload, wrong types, missing fields) surfaces as
|
||||
a typed `R3F_NET.INVALID_SNAPSHOT` rather than a stray runtime exception
|
||||
inside the multiplayer hot path.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeSnapshotFrame` / `decodeSnapshotFrame` round-trip pair.
|
||||
- `encodeDeltaFrame` / `decodeDeltaFrame` round-trip pair.
|
||||
- Tagged-JSON encoding inherits the `@suaveplan/serialize` default codec —
|
||||
`Date`, `Map`, `Set`, `BigInt`, `Uint8Array`, `URL`, `RegExp` all preserved
|
||||
byte-for-byte.
|
||||
- Decode errors funnel through `R3FNetError(INVALID_SNAPSHOT)` with the
|
||||
underlying cause attached.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
encodeSnapshotFrame,
|
||||
decodeSnapshotFrame,
|
||||
} from "@suaveplan/r3f-networking";
|
||||
|
||||
const wire = encodeSnapshotFrame(frame);
|
||||
const restored = decodeSnapshotFrame(wire);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wrap the encode call in an observability span if your application uses an
|
||||
explicit tracer:
|
||||
|
||||
```ts
|
||||
import { trace } from "@opentelemetry/api";
|
||||
|
||||
const tracer = trace.getTracer("netcode");
|
||||
tracer.startActiveSpan("snapshot.encode", (span) => {
|
||||
const wire = encodeSnapshotFrame(frame);
|
||||
span.setAttribute("bytes", wire.length);
|
||||
span.end();
|
||||
transport.send(peerId, { type: "snapshot", body: wire });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeSnapshotFrame(frame: SnapshotFrame): string`
|
||||
- `decodeSnapshotFrame(wire: string): SnapshotFrame` — throws
|
||||
`R3F_NET.INVALID_SNAPSHOT` on parse failure or schema violation.
|
||||
- `encodeDeltaFrame(frame: SnapshotDeltaFrame): string`
|
||||
- `decodeDeltaFrame(wire: string): SnapshotDeltaFrame` — throws
|
||||
`R3F_NET.INVALID_SNAPSHOT` on parse failure or schema violation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Errors raised by `@suaveplan/serialize` are re-wrapped to keep the
|
||||
public-facing error surface bounded to `R3F_NET.*` codes. Already-typed
|
||||
`R3FNetError` instances are passed through unchanged so the `cause` chain
|
||||
stays a single error class deep, which makes log diagnostics readable
|
||||
without manual cause-chain traversal. The encoder uses the tagged-JSON
|
||||
codec by default; an alternate MessagePack subpath is available via the
|
||||
underlying `@suaveplan/serialize` package and can be wired by replacing
|
||||
`encode`/`decode` here in a future revision. For very high-frequency
|
||||
multiplayer scenes prefer the MessagePack codec — it produces ~30%
|
||||
smaller payloads at the cost of an extra import and a switch from `string`
|
||||
to `Uint8Array` on the wire. The decoder validates the structural shape
|
||||
of every snapshot AND every delta frame because malformed wire bytes from
|
||||
a malicious or buggy peer must never make it into the sync manager and
|
||||
corrupt the local mirror of authoritative state.
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import type {
|
||||
SnapshotDeltaFrame,
|
||||
SnapshotFrame,
|
||||
} from "../snapshot-diff/snapshot-diff.types.js";
|
||||
import {
|
||||
decodeDeltaFrame,
|
||||
decodeSnapshotFrame,
|
||||
encodeDeltaFrame,
|
||||
encodeSnapshotFrame,
|
||||
} from "./wire-codec.js";
|
||||
|
||||
const FRAME: SnapshotFrame = {
|
||||
seq: 1,
|
||||
timestamp: 1000,
|
||||
state: {
|
||||
e1: {
|
||||
position: [1, 2, 3],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DELTA: SnapshotDeltaFrame = {
|
||||
seq: 2,
|
||||
baseSeq: 1,
|
||||
timestamp: 1100,
|
||||
patch: [{ op: "replace", path: "/e1/position/0", value: 5 }],
|
||||
};
|
||||
|
||||
describe("wire-codec", () => {
|
||||
it("snapshot frame round-trip", () => {
|
||||
const wire = encodeSnapshotFrame(FRAME);
|
||||
const restored = decodeSnapshotFrame(wire);
|
||||
expect(restored.seq).toBe(1);
|
||||
expect(restored.state.e1?.position[0]).toBe(1);
|
||||
});
|
||||
|
||||
it("delta frame round-trip", () => {
|
||||
const wire = encodeDeltaFrame(DELTA);
|
||||
const restored = decodeDeltaFrame(wire);
|
||||
expect(restored.seq).toBe(2);
|
||||
expect(restored.baseSeq).toBe(1);
|
||||
});
|
||||
|
||||
it("decodeSnapshotFrame raises INVALID_SNAPSHOT on garbage", () => {
|
||||
try {
|
||||
decodeSnapshotFrame("not-json-at-all");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(R3FNetError);
|
||||
if (err instanceof R3FNetError) {
|
||||
expect(err.code).toBe(R3F_NET_CODES.INVALID_SNAPSHOT);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeDeltaFrame raises INVALID_SNAPSHOT on garbage", () => {
|
||||
try {
|
||||
decodeDeltaFrame("nope");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(R3FNetError);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeSnapshotFrame passes through typed R3FNetError from schema layer", () => {
|
||||
// A valid serialize payload that fails the Zod check.
|
||||
const wire = encodeSnapshotFrame({
|
||||
seq: 1,
|
||||
timestamp: 100,
|
||||
state: {},
|
||||
});
|
||||
const bad = wire.replace('"seq":1', '"seq":-1');
|
||||
try {
|
||||
decodeSnapshotFrame(bad);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(R3FNetError);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeDeltaFrame passes through typed R3FNetError on schema fail", () => {
|
||||
const wire = encodeDeltaFrame(DELTA);
|
||||
const bad = wire.replace('"seq":2', '"seq":-1');
|
||||
try {
|
||||
decodeDeltaFrame(bad);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(R3FNetError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { deserialize, serialize } from "@suaveplan/serialize";
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import {
|
||||
validateSnapshotDeltaFrame,
|
||||
validateSnapshotFrame,
|
||||
} from "../snapshot-diff/snapshot-diff.js";
|
||||
import type {
|
||||
SnapshotDeltaFrame,
|
||||
SnapshotFrame,
|
||||
} from "../snapshot-diff/snapshot-diff.types.js";
|
||||
|
||||
/** Encode a `SnapshotFrame` to the wire (tagged-JSON via @suaveplan/serialize). */
|
||||
export function encodeSnapshotFrame(frame: SnapshotFrame): string {
|
||||
return serialize(frame as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** Decode a `SnapshotFrame` from the wire, with Zod boundary validation. */
|
||||
export function decodeSnapshotFrame(wire: string): SnapshotFrame {
|
||||
try {
|
||||
const raw = deserialize(wire);
|
||||
return validateSnapshotFrame(raw);
|
||||
} catch (err) {
|
||||
if (err instanceof R3FNetError) throw err;
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_SNAPSHOT,
|
||||
"r3f_net.wire_codec.decode_failed",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Encode a delta frame. */
|
||||
export function encodeDeltaFrame(frame: SnapshotDeltaFrame): string {
|
||||
return serialize(frame as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** Decode a delta frame, with Zod boundary validation. */
|
||||
export function decodeDeltaFrame(wire: string): SnapshotDeltaFrame {
|
||||
try {
|
||||
const raw = deserialize(wire);
|
||||
return validateSnapshotDeltaFrame(raw);
|
||||
} catch (err) {
|
||||
if (err instanceof R3FNetError) throw err;
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_SNAPSHOT,
|
||||
"r3f_net.wire_codec.decode_failed",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
packages/threejs/r3f/r3f-networking/src/errors/errors.md
Normal file
80
packages/threejs/r3f/r3f-networking/src/errors/errors.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# `errors`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the `R3F_NET` error-code registry and `R3FNetError` class used across the
|
||||
`@suaveplan/r3f-networking` package. Every public surface that fails with a
|
||||
domain-level reason throws a typed `R3FNetError` rather than a bare `Error`,
|
||||
allowing callers to discriminate on the stable `code` literal at the boundary
|
||||
without resorting to message-string matching or `instanceof` chains.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `R3F_NET_CODES` literal union via `defineCodes()` from
|
||||
`@suaveplan/error`.
|
||||
- 8 canonical failure classes: `SNAPSHOT_OUT_OF_ORDER`, `PEER_DROPPED`,
|
||||
`PREDICTION_DIVERGED`, `INVALID_OPTIONS`, `INVALID_SNAPSHOT`, `DISPOSED`,
|
||||
`TRANSPORT_FAILED`, `ABORTED`.
|
||||
- Type-narrowed `R3FNetErrorCode` for switch-exhaustiveness in consumer code.
|
||||
- Class identity preserved via explicit `this.name = "R3FNetError"`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { R3FNetError, R3F_NET_CODES } from "@suaveplan/r3f-networking";
|
||||
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_OPTIONS,
|
||||
"r3f_net.options.invalid",
|
||||
{ context: { field: "delay" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { R3FNetError, R3F_NET_CODES } from "@suaveplan/r3f-networking";
|
||||
|
||||
function handle(err: unknown): void {
|
||||
if (err instanceof R3FNetError) {
|
||||
switch (err.code) {
|
||||
case R3F_NET_CODES.SNAPSHOT_OUT_OF_ORDER:
|
||||
metrics.outOfOrder.inc();
|
||||
return;
|
||||
case R3F_NET_CODES.PEER_DROPPED:
|
||||
router.requestRejoin();
|
||||
return;
|
||||
default:
|
||||
logger.error("r3f_net unhandled", { code: err.code });
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `R3F_NET_CODES` — frozen record literal of the 8 codes.
|
||||
- `R3FNetErrorCode` — discriminated string-literal union over the values.
|
||||
- `R3FNetError extends SuaveplanError<R3FNetErrorCode>` — domain error class.
|
||||
Constructor `(code, messageKey, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Errors are translation-key-driven (`messageKey`) so user-visible strings
|
||||
can be resolved through `@suaveplan/i18n` at the boundary. The code list
|
||||
is short by design — every failure category should map to operator action
|
||||
(rejoin, refresh, log, retry). Adding a code requires bumping the cross-
|
||||
package contract and updating every consumer's switch-exhaustiveness
|
||||
clause. The class is exported alongside the codes object so callers can
|
||||
both narrow on the `instanceof R3FNetError` discriminant AND switch on the
|
||||
`code` literal; combining both gives the strongest type-narrowing in a
|
||||
single check. The `cause` option propagates a wrapped error through the
|
||||
standard ES2022 cause chain so the original Zod failure, schema error, or
|
||||
transport rejection remains attached for debugging without leaking
|
||||
implementation details to the public type. Avoid catching the base
|
||||
`SuaveplanError` class — always catch `R3FNetError` specifically so cross-
|
||||
package error confusion never surfaces as silent recovery.
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { R3F_NET_CODES, R3FNetError } from "./errors.js";
|
||||
|
||||
describe("R3FNetError", () => {
|
||||
it("preserves code, name, and message-key", () => {
|
||||
const err = new R3FNetError(
|
||||
R3F_NET_CODES.SNAPSHOT_OUT_OF_ORDER,
|
||||
"r3f_net.test.out_of_order"
|
||||
);
|
||||
expect(err.code).toBe(R3F_NET_CODES.SNAPSHOT_OUT_OF_ORDER);
|
||||
expect(err.name).toBe("R3FNetError");
|
||||
});
|
||||
|
||||
it("registers all 8 codes", () => {
|
||||
const codes = Object.values(R3F_NET_CODES);
|
||||
expect(codes.length).toBe(8);
|
||||
expect(codes).toContain("R3F_NET.SNAPSHOT_OUT_OF_ORDER");
|
||||
expect(codes).toContain("R3F_NET.PEER_DROPPED");
|
||||
expect(codes).toContain("R3F_NET.PREDICTION_DIVERGED");
|
||||
expect(codes).toContain("R3F_NET.INVALID_OPTIONS");
|
||||
expect(codes).toContain("R3F_NET.INVALID_SNAPSHOT");
|
||||
expect(codes).toContain("R3F_NET.DISPOSED");
|
||||
expect(codes).toContain("R3F_NET.TRANSPORT_FAILED");
|
||||
expect(codes).toContain("R3F_NET.ABORTED");
|
||||
});
|
||||
});
|
||||
30
packages/threejs/r3f/r3f-networking/src/errors/errors.ts
Normal file
30
packages/threejs/r3f/r3f-networking/src/errors/errors.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
export const R3F_NET_CODES = defineCodes("R3F_NET", [
|
||||
"SNAPSHOT_OUT_OF_ORDER",
|
||||
"PEER_DROPPED",
|
||||
"PREDICTION_DIVERGED",
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_SNAPSHOT",
|
||||
"DISPOSED",
|
||||
"TRANSPORT_FAILED",
|
||||
"ABORTED",
|
||||
] as const);
|
||||
|
||||
export type R3FNetErrorCode =
|
||||
(typeof R3F_NET_CODES)[keyof typeof R3F_NET_CODES];
|
||||
|
||||
export class R3FNetError extends SuaveplanError<R3FNetErrorCode> {
|
||||
public constructor(
|
||||
code: R3FNetErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "R3FNetError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# `use-network-predict`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
React hook exposing a memoised `ClientPredictor` so a local-input movement
|
||||
loop can apply moves immediately while pending server acknowledgement.
|
||||
Reconciliation collapses the local divergence each time a server snapshot
|
||||
arrives.
|
||||
|
||||
## Features
|
||||
|
||||
- Memoised predictor instance — stable across renders.
|
||||
- `applyInput(delta)` returns `{ position, seq }` for outbound wire frames.
|
||||
- `reconcile(serverTransform, lastAckedSeq)` returns the divergence summary.
|
||||
- `getPosition` / `getSeq` / `reset` for inspection and re-entry.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
function LocalPlayer() {
|
||||
const { applyInput, reconcile, getPosition } = useNetworkPredict({
|
||||
initialPosition: [0, 0, 0],
|
||||
snapThreshold: 2.0,
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
const r = applyInput([0.05, 0, 0]);
|
||||
transport.send("server", buildInputEnvelope(r));
|
||||
});
|
||||
|
||||
return <mesh position={getPosition()} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Combine with the reconciliation FSM to gate UI overlays:
|
||||
|
||||
```tsx
|
||||
const fsm = useMemo(() => buildReconciliationMachine(), []);
|
||||
const { reconcile } = useNetworkPredict();
|
||||
|
||||
function onServerFrame(frame) {
|
||||
const r = reconcile(frame.transform, frame.seq);
|
||||
fsm.send({
|
||||
type: "SNAPSHOT",
|
||||
payload: { divergence: r.error, seq: frame.seq },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useNetworkPredict(options?: UseNetworkPredictOptions): UseNetworkPredictResult`
|
||||
- `UseNetworkPredictOptions extends PredictionConfig { initialPosition? }`
|
||||
- `UseNetworkPredictResult { applyInput, reconcile, getPosition, getSeq, reset }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The predictor instance is memoised on the option scalars so consumers
|
||||
can pass a fresh object every render without rebuilding the predictor —
|
||||
the shallow comparison on `initialPosition`, `maxHistory`,
|
||||
`snapThreshold`, `correctionRate` is sufficient because all four are
|
||||
primitives. Note that `initialPosition` is compared by reference identity
|
||||
rather than element-wise — passing `[0, 0, 0]` as a literal in the JSX
|
||||
call site will rebuild the predictor every render, which is rarely what
|
||||
consumers want. The fix is to hoist the tuple into a module-scoped
|
||||
constant or pass `undefined` to opt into the zero-position default. The
|
||||
hook exposes pull-based accessors (`getPosition`, `getSeq`) rather than
|
||||
React state so per-frame reads inside `useFrame` do not trigger React
|
||||
re-renders. Reconciliation is exposed without an event listener — callers
|
||||
wire transport messages to the `reconcile` callback themselves so the
|
||||
predictor never owns any external IO.
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { act } from "@testing-library/react";
|
||||
import { type ReactElement, useEffect } from "react";
|
||||
import { useNetworkPredict } from "./use-network-predict.js";
|
||||
|
||||
function Probe(props: {
|
||||
onResult: (r: ReturnType<typeof useNetworkPredict>) => void;
|
||||
}): ReactElement {
|
||||
const r = useNetworkPredict({
|
||||
initialPosition: [1, 1, 1],
|
||||
snapThreshold: 0.5,
|
||||
correctionRate: 0.5,
|
||||
maxHistory: 8,
|
||||
});
|
||||
useEffect(() => {
|
||||
props.onResult(r);
|
||||
}, [props, r]);
|
||||
return <span data-testid="probe">{r.getSeq()}</span>;
|
||||
}
|
||||
|
||||
describe("useNetworkPredict", () => {
|
||||
it("applyInput / reconcile / getPosition / getSeq / reset", () => {
|
||||
let captured: ReturnType<typeof useNetworkPredict> | null = null;
|
||||
renderComponent(<Probe onResult={(r) => (captured = r)} />);
|
||||
expect(captured?.getPosition()).toEqual([1, 1, 1]);
|
||||
act(() => {
|
||||
captured?.applyInput([1, 0, 0]);
|
||||
});
|
||||
expect(captured?.getSeq()).toBe(1);
|
||||
// Server agrees → small or zero divergence.
|
||||
let result: ReturnType<
|
||||
NonNullable<typeof captured>["reconcile"]
|
||||
> | null = null;
|
||||
act(() => {
|
||||
result =
|
||||
captured?.reconcile(
|
||||
{
|
||||
position: [2, 1, 1],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
1
|
||||
) ?? null;
|
||||
});
|
||||
expect(result?.snapped).toBe(false);
|
||||
act(() => {
|
||||
captured?.reset([0, 0, 0]);
|
||||
});
|
||||
expect(captured?.getPosition()).toEqual([0, 0, 0]);
|
||||
expect(captured?.getSeq()).toBe(0);
|
||||
// Reset with no args.
|
||||
act(() => {
|
||||
captured?.reset();
|
||||
});
|
||||
expect(captured?.getPosition()).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("works without options", () => {
|
||||
function Empty(props: {
|
||||
onResult: (r: ReturnType<typeof useNetworkPredict>) => void;
|
||||
}): ReactElement {
|
||||
const r = useNetworkPredict();
|
||||
useEffect(() => {
|
||||
props.onResult(r);
|
||||
}, [props, r]);
|
||||
return <span />;
|
||||
}
|
||||
let captured: ReturnType<typeof useNetworkPredict> | null = null;
|
||||
renderComponent(<Empty onResult={(r) => (captured = r)} />);
|
||||
expect(captured?.getPosition()).toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ClientPredictor } from "../../core/prediction/prediction.js";
|
||||
import type {
|
||||
EntityTransform,
|
||||
Vec3,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import type {
|
||||
UseNetworkPredictOptions,
|
||||
UseNetworkPredictResult,
|
||||
} from "./use-network-predict.types.js";
|
||||
|
||||
/**
|
||||
* React hook exposing a memoised `ClientPredictor` for local-input
|
||||
* prediction with server reconciliation. The predictor instance is stable
|
||||
* across renders as long as the configuration is stable.
|
||||
*/
|
||||
export function useNetworkPredict(
|
||||
options?: UseNetworkPredictOptions
|
||||
): UseNetworkPredictResult {
|
||||
const initialPosition = options?.initialPosition;
|
||||
const maxHistory = options?.maxHistory;
|
||||
const snapThreshold = options?.snapThreshold;
|
||||
const correctionRate = options?.correctionRate;
|
||||
|
||||
const predictor = useMemo(() => {
|
||||
const config = {
|
||||
...(maxHistory !== undefined ? { maxHistory } : {}),
|
||||
...(snapThreshold !== undefined ? { snapThreshold } : {}),
|
||||
...(correctionRate !== undefined ? { correctionRate } : {}),
|
||||
};
|
||||
return new ClientPredictor(initialPosition ?? [0, 0, 0], config);
|
||||
}, [initialPosition, maxHistory, snapThreshold, correctionRate]);
|
||||
|
||||
const applyInput = useCallback(
|
||||
(delta: Vec3) => predictor.applyInput(delta),
|
||||
[predictor]
|
||||
);
|
||||
|
||||
const reconcile = useCallback(
|
||||
(serverTransform: EntityTransform, lastAckedSeq: number) => {
|
||||
const r = predictor.reconcile(serverTransform, lastAckedSeq);
|
||||
return {
|
||||
error: r.error,
|
||||
replayed: r.replayed,
|
||||
snapped: r.snapped,
|
||||
};
|
||||
},
|
||||
[predictor]
|
||||
);
|
||||
|
||||
const getPosition = useCallback(() => predictor.getPosition(), [predictor]);
|
||||
const getSeq = useCallback(() => predictor.getSeq(), [predictor]);
|
||||
const reset = useCallback(
|
||||
(position?: Vec3) => {
|
||||
if (position) predictor.reset(position);
|
||||
else predictor.reset();
|
||||
},
|
||||
[predictor]
|
||||
);
|
||||
|
||||
return { applyInput, reconcile, getPosition, getSeq, reset };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PredictionConfig } from "../../core/prediction/prediction.types.js";
|
||||
import type {
|
||||
EntityTransform,
|
||||
Vec3,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
export interface UseNetworkPredictOptions extends PredictionConfig {
|
||||
readonly initialPosition?: Vec3;
|
||||
}
|
||||
|
||||
export interface UseNetworkPredictResult {
|
||||
readonly applyInput: (delta: Vec3) => { position: Vec3; seq: number };
|
||||
readonly reconcile: (
|
||||
serverTransform: EntityTransform,
|
||||
lastAckedSeq: number
|
||||
) => { error: number; replayed: number; snapped: boolean };
|
||||
readonly getPosition: () => Vec3;
|
||||
readonly getSeq: () => number;
|
||||
readonly reset: (position?: Vec3) => void;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# `use-network-sync`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
React hook that wraps an `EntitySyncManager` and exposes a small reactive
|
||||
surface: register/unregister entities, read the current snapshot, and look
|
||||
up the current interpolated transform by id. The manager instance is
|
||||
memoised on the `sendRate` + `interpolationDelay` pair so options should be
|
||||
stable across renders.
|
||||
|
||||
## Features
|
||||
|
||||
- Memoised `EntitySyncManager` per option pair.
|
||||
- Snapshot kept in React state — re-renders fire on register/unregister.
|
||||
- `getTransform(id)` reads the latest interpolated transform.
|
||||
- No `useEffect` lifecycle: the manager has no external IO so cleanup is
|
||||
unnecessary; callers wire transports through `useEffect` in their own
|
||||
parent components.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { useNetworkSync } from "@suaveplan/r3f-networking";
|
||||
|
||||
function Game() {
|
||||
const sync = useNetworkSync({ sendRate: 30 });
|
||||
return <button onClick={() => sync.registerEntity("e1", "me", true)}>Spawn</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive the manager from a `useFrame` hook inside R3F and broadcast updates:
|
||||
|
||||
```tsx
|
||||
function NetworkedScene({ transport }) {
|
||||
const sync = useNetworkSync({ sendRate: 20 });
|
||||
useFrame((state) => {
|
||||
const t = state.clock.elapsedTime * 1000;
|
||||
if (sync.snapshot.localCount > 0) {
|
||||
// EntitySyncManager exposes collectLocalUpdates through methods that
|
||||
// a higher-level provider can plumb in.
|
||||
}
|
||||
});
|
||||
return <Scene />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useNetworkSync(options?: UseNetworkSyncOptions): UseNetworkSyncResult`
|
||||
- `UseNetworkSyncOptions { sendRate?, interpolationDelay? }`
|
||||
- `UseNetworkSyncResult { registerEntity, unregisterEntity, snapshot, getTransform }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook is intentionally thin: the manager owns all state. Pull-based
|
||||
APIs like `getTransform` always return the freshest value; push-based
|
||||
state (`snapshot`) re-renders only on registration changes so per-frame
|
||||
transform updates do not invalidate the React tree. Wiring the transport
|
||||
to the manager is the caller's responsibility — the hook does not assume
|
||||
any particular wire protocol so the same hook composes with the in-memory
|
||||
test transport, WebRTC, WebSocket, libp2p, or any future transport that
|
||||
satisfies the `@suaveplan/network` `Transport` contract. Memoisation
|
||||
keys are kept to primitives so passing a fresh options object every
|
||||
render does not rebuild the manager; this is the canonical pattern
|
||||
recommended by the React team for hooks that own a long-lived object
|
||||
instance.
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { act } from "@testing-library/react";
|
||||
import { type ReactElement, useEffect } from "react";
|
||||
import { useNetworkSync } from "./use-network-sync.js";
|
||||
|
||||
function Probe(props: {
|
||||
onResult: (r: ReturnType<typeof useNetworkSync>) => void;
|
||||
}): ReactElement {
|
||||
const r = useNetworkSync({ sendRate: 30, interpolationDelay: 80 });
|
||||
useEffect(() => {
|
||||
props.onResult(r);
|
||||
}, [props, r]);
|
||||
return <span data-testid="probe">{r.snapshot.entityCount}</span>;
|
||||
}
|
||||
|
||||
function ProbeNoOpts(props: {
|
||||
onResult: (r: ReturnType<typeof useNetworkSync>) => void;
|
||||
}): ReactElement {
|
||||
const r = useNetworkSync();
|
||||
useEffect(() => {
|
||||
props.onResult(r);
|
||||
}, [props, r]);
|
||||
return <span data-testid="probe-default">{r.snapshot.entityCount}</span>;
|
||||
}
|
||||
|
||||
describe("useNetworkSync", () => {
|
||||
it("works with no options (default sendRate/interpolationDelay branches)", () => {
|
||||
let captured: ReturnType<typeof useNetworkSync> | null = null;
|
||||
renderComponent(<ProbeNoOpts onResult={(r) => (captured = r)} />);
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured?.snapshot.entityCount).toBe(0);
|
||||
});
|
||||
|
||||
it("exposes register/unregister/snapshot/getTransform", () => {
|
||||
let captured: ReturnType<typeof useNetworkSync> | null = null;
|
||||
const { getByTestId } = renderComponent(
|
||||
<Probe onResult={(r) => (captured = r)} />
|
||||
);
|
||||
expect(getByTestId("probe").textContent).toBe("0");
|
||||
act(() => {
|
||||
captured?.registerEntity("e1", "me", true);
|
||||
});
|
||||
expect(captured?.snapshot.entityCount).toBe(1);
|
||||
const t = captured?.getTransform("e1");
|
||||
expect(t?.position).toEqual([0, 0, 0]);
|
||||
act(() => {
|
||||
captured?.unregisterEntity("e1");
|
||||
});
|
||||
expect(captured?.snapshot.entityCount).toBe(0);
|
||||
expect(captured?.getTransform("missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { EntitySyncManager } from "../../sync/entity-sync/entity-sync.js";
|
||||
import type { SyncSnapshot } from "../../sync/entity-sync/entity-sync.types.js";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import type {
|
||||
UseNetworkSyncOptions,
|
||||
UseNetworkSyncResult,
|
||||
} from "./use-network-sync.types.js";
|
||||
|
||||
/**
|
||||
* React hook wrapping `EntitySyncManager` with reactive state. The manager
|
||||
* instance is memoised against the options identity; callers should keep
|
||||
* options stable across renders.
|
||||
*/
|
||||
export function useNetworkSync(
|
||||
options?: UseNetworkSyncOptions
|
||||
): UseNetworkSyncResult {
|
||||
const sendRate = options?.sendRate ?? 20;
|
||||
const interpolationDelay = options?.interpolationDelay ?? 100;
|
||||
const syncManager = useMemo(
|
||||
() => new EntitySyncManager({ sendRate, interpolationDelay }),
|
||||
[sendRate, interpolationDelay]
|
||||
);
|
||||
|
||||
const [snapshot, setSnapshot] = useState<SyncSnapshot>(
|
||||
syncManager.getSnapshot()
|
||||
);
|
||||
|
||||
const registerEntity = useCallback(
|
||||
(
|
||||
id: string,
|
||||
ownerId: string,
|
||||
isLocal: boolean,
|
||||
transform?: EntityTransform
|
||||
) => {
|
||||
syncManager.registerEntity(id, ownerId, isLocal, transform);
|
||||
setSnapshot(syncManager.getSnapshot());
|
||||
},
|
||||
[syncManager]
|
||||
);
|
||||
|
||||
const unregisterEntity = useCallback(
|
||||
(id: string) => {
|
||||
syncManager.unregisterEntity(id);
|
||||
setSnapshot(syncManager.getSnapshot());
|
||||
},
|
||||
[syncManager]
|
||||
);
|
||||
|
||||
const getTransform = useCallback(
|
||||
(id: string): EntityTransform | null => {
|
||||
return syncManager.getEntity(id)?.transform ?? null;
|
||||
},
|
||||
[syncManager]
|
||||
);
|
||||
|
||||
return { registerEntity, unregisterEntity, snapshot, getTransform };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SyncSnapshot } from "../../sync/entity-sync/entity-sync.types.js";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
export interface UseNetworkSyncOptions {
|
||||
readonly sendRate?: number;
|
||||
readonly interpolationDelay?: number;
|
||||
}
|
||||
|
||||
export interface UseNetworkSyncResult {
|
||||
readonly registerEntity: (
|
||||
id: string,
|
||||
ownerId: string,
|
||||
isLocal: boolean,
|
||||
transform?: EntityTransform
|
||||
) => void;
|
||||
readonly unregisterEntity: (id: string) => void;
|
||||
readonly snapshot: SyncSnapshot;
|
||||
readonly getTransform: (id: string) => EntityTransform | null;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# `use-networked-entity`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Hook that exposes the current transform of one networked entity. The
|
||||
returned `transform` is the value most recently written by the sync layer;
|
||||
the `active` flag indicates whether the entity is currently considered
|
||||
connected. The hook is intentionally small so consumers can compose it
|
||||
inside their own `useFrame` loop without paying for a re-render on every
|
||||
frame.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable `transform` reference that mirrors the entity's current state.
|
||||
- `active` flag for HUD overlays and conditional renders.
|
||||
- Initial `position` override for spawning at non-origin.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
function NetworkedAvatar({ id }: { id: string }) {
|
||||
const { transform, active } = useNetworkedEntity({
|
||||
entityId: id,
|
||||
isLocal: false,
|
||||
});
|
||||
if (!active) return null;
|
||||
return <mesh position={transform.position} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use with `useFrame` to drive a smoothed `<group>` directly:
|
||||
|
||||
```tsx
|
||||
function PeerAvatar({ id }) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { transform } = useNetworkedEntity({ entityId: id, isLocal: false });
|
||||
useFrame(() => {
|
||||
groupRef.current?.position.set(...transform.position);
|
||||
});
|
||||
return <group ref={groupRef} />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useNetworkedEntity(options: UseNetworkedEntityOptions): UseNetworkedEntityResult`
|
||||
- `UseNetworkedEntityOptions { entityId, isLocal, position?, interpolationDelay? }`
|
||||
- `UseNetworkedEntityResult { transform, active }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Transform updates are intentionally not pushed through React state on
|
||||
every frame — that would re-render the component 60 times per second and
|
||||
defeat the purpose of pulling smoothing into the R3F frame loop.
|
||||
Consumers that need React-state-bound updates should poll inside
|
||||
`useFrame` and call `setState` only on coarse events such as a HUD label
|
||||
text change or a damage flash. The hook intentionally exposes a minimal
|
||||
surface — just `transform` and `active` — because the heavy lifting
|
||||
belongs in the sync manager and the renderer-shaped `<NetworkedEntity>`
|
||||
component. Wrap this hook inside a higher-level domain hook (e.g.
|
||||
`usePlayerEntity`) if you need to expose health, score, or other gameplay-
|
||||
specific state alongside the transform. The `interpolationDelay` option
|
||||
is reserved for future versions where the hook will resolve its own
|
||||
buffer; the current version inherits the global delay from
|
||||
`useNetworkSync`.
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import type { ReactElement } from "react";
|
||||
import { useNetworkedEntity } from "./use-networked-entity.js";
|
||||
|
||||
function Probe(props: {
|
||||
position?: [number, number, number];
|
||||
isLocal: boolean;
|
||||
}): ReactElement {
|
||||
const { transform, active } = useNetworkedEntity({
|
||||
entityId: "e1",
|
||||
isLocal: props.isLocal,
|
||||
...(props.position ? { position: props.position } : {}),
|
||||
});
|
||||
return (
|
||||
<span data-testid="probe">
|
||||
{transform.position.join(",")}|{active ? "1" : "0"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useNetworkedEntity", () => {
|
||||
it("returns zero position when none supplied", () => {
|
||||
const { getByTestId } = renderComponent(<Probe isLocal />);
|
||||
expect(getByTestId("probe").textContent).toBe("0,0,0|1");
|
||||
});
|
||||
|
||||
it("honours initial position override", () => {
|
||||
const { getByTestId } = renderComponent(
|
||||
<Probe isLocal position={[1, 2, 3]} />
|
||||
);
|
||||
expect(getByTestId("probe").textContent).toBe("1,2,3|1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
IDENTITY_QUAT,
|
||||
UNIT_SCALE,
|
||||
} from "../../utils/network-helpers/network-helpers.js";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import type {
|
||||
UseNetworkedEntityOptions,
|
||||
UseNetworkedEntityResult,
|
||||
} from "./use-networked-entity.types.js";
|
||||
|
||||
/** React hook exposing one networked entity's current transform. */
|
||||
export function useNetworkedEntity(
|
||||
options: UseNetworkedEntityOptions
|
||||
): UseNetworkedEntityResult {
|
||||
const position = options.position;
|
||||
const initialTransform = useMemo<EntityTransform>(
|
||||
() => ({
|
||||
position: position
|
||||
? ([...position] as [number, number, number])
|
||||
: [0, 0, 0],
|
||||
rotation: [...IDENTITY_QUAT] as [number, number, number, number],
|
||||
scale: [...UNIT_SCALE] as [number, number, number],
|
||||
}),
|
||||
[position]
|
||||
);
|
||||
|
||||
const [transform] = useState<EntityTransform>(initialTransform);
|
||||
const [active] = useState(true);
|
||||
|
||||
return { transform, active };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {
|
||||
EntityTransform,
|
||||
Vec3,
|
||||
} from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
export interface UseNetworkedEntityOptions {
|
||||
readonly entityId: string;
|
||||
readonly isLocal: boolean;
|
||||
readonly position?: Vec3;
|
||||
readonly interpolationDelay?: number;
|
||||
}
|
||||
|
||||
export interface UseNetworkedEntityResult {
|
||||
readonly transform: EntityTransform;
|
||||
readonly active: boolean;
|
||||
}
|
||||
154
packages/threejs/r3f/r3f-networking/src/index.ts
Normal file
154
packages/threejs/r3f/r3f-networking/src/index.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// Errors
|
||||
|
||||
// Authority
|
||||
export { ServerAuthority } from "./authority/server-authority/server-authority.js";
|
||||
export type {
|
||||
AuthoritativeEntity,
|
||||
AuthorityInput,
|
||||
AuthorityMode,
|
||||
AuthorityResult,
|
||||
ServerAuthorityConfig,
|
||||
} from "./authority/server-authority/server-authority.types.js";
|
||||
// Components
|
||||
export { NetworkProvider } from "./components/network-provider/network-provider.js";
|
||||
export type {
|
||||
NetworkProviderContextValue,
|
||||
NetworkProviderProps,
|
||||
} from "./components/network-provider/network-provider.types.js";
|
||||
export { NetworkContext } from "./components/network-provider/network-provider-context.js";
|
||||
export { NetworkStats } from "./components/network-stats/network-stats.js";
|
||||
export type {
|
||||
ComputedStats,
|
||||
NetworkStatsPosition,
|
||||
NetworkStatsProps,
|
||||
} from "./components/network-stats/network-stats.types.js";
|
||||
export {
|
||||
applyNetworkedFrame,
|
||||
DEFAULT_SMOOTH_FACTOR,
|
||||
} from "./components/networked-entity/apply-networked-frame.js";
|
||||
export { NetworkedEntity } from "./components/networked-entity/networked-entity.js";
|
||||
export type {
|
||||
GroupLike,
|
||||
NetworkedEntityProps,
|
||||
} from "./components/networked-entity/networked-entity.types.js";
|
||||
// Interpolation
|
||||
export {
|
||||
EntityInterpolator,
|
||||
interpolateTransform,
|
||||
} from "./core/interpolation/interpolation.js";
|
||||
export type {
|
||||
InterpolationConfig,
|
||||
InterpolationResult,
|
||||
} from "./core/interpolation/interpolation.types.js";
|
||||
// Prediction
|
||||
export { ClientPredictor } from "./core/prediction/prediction.js";
|
||||
export type {
|
||||
PredictionClock,
|
||||
PredictionConfig,
|
||||
PredictionInput,
|
||||
ReconciliationResult,
|
||||
} from "./core/prediction/prediction.types.js";
|
||||
// Reconciliation FSM
|
||||
export { buildReconciliationMachine } from "./core/reconciliation-fsm/reconciliation-fsm.js";
|
||||
export type {
|
||||
ReconciliationContext,
|
||||
ReconciliationEventPayload,
|
||||
ReconciliationFsmOptions,
|
||||
ReconciliationStateValue,
|
||||
} from "./core/reconciliation-fsm/reconciliation-fsm.types.js";
|
||||
// Snapshot diff
|
||||
export {
|
||||
applyDeltaFrame,
|
||||
buildDeltaFrame,
|
||||
diffSnapshots,
|
||||
validateSnapshotDeltaFrame,
|
||||
validateSnapshotFrame,
|
||||
} from "./core/snapshot-diff/snapshot-diff.js";
|
||||
export type {
|
||||
EntitySnapshot,
|
||||
SnapshotDeltaFrame,
|
||||
SnapshotFrame,
|
||||
} from "./core/snapshot-diff/snapshot-diff.types.js";
|
||||
// State buffer
|
||||
export { StateBuffer } from "./core/state-buffer/state-buffer.js";
|
||||
export type {
|
||||
InterpolationPair,
|
||||
StateBufferConfig,
|
||||
StateBufferSnapshot,
|
||||
} from "./core/state-buffer/state-buffer.types.js";
|
||||
// Wire codec
|
||||
export {
|
||||
decodeDeltaFrame,
|
||||
decodeSnapshotFrame,
|
||||
encodeDeltaFrame,
|
||||
encodeSnapshotFrame,
|
||||
} from "./core/wire-codec/wire-codec.js";
|
||||
export {
|
||||
R3F_NET_CODES,
|
||||
R3FNetError,
|
||||
type R3FNetErrorCode,
|
||||
} from "./errors/errors.js";
|
||||
export { useNetworkPredict } from "./hooks/use-network-predict/use-network-predict.js";
|
||||
export type {
|
||||
UseNetworkPredictOptions,
|
||||
UseNetworkPredictResult,
|
||||
} from "./hooks/use-network-predict/use-network-predict.types.js";
|
||||
// Hooks
|
||||
export { useNetworkSync } from "./hooks/use-network-sync/use-network-sync.js";
|
||||
export type {
|
||||
UseNetworkSyncOptions,
|
||||
UseNetworkSyncResult,
|
||||
} from "./hooks/use-network-sync/use-network-sync.types.js";
|
||||
export { useNetworkedEntity } from "./hooks/use-networked-entity/use-networked-entity.js";
|
||||
export type {
|
||||
UseNetworkedEntityOptions,
|
||||
UseNetworkedEntityResult,
|
||||
} from "./hooks/use-networked-entity/use-networked-entity.types.js";
|
||||
// Observability
|
||||
export {
|
||||
peerEventsTotal,
|
||||
predictionDivergence,
|
||||
recordPeerEvent,
|
||||
recordSnapshot,
|
||||
type SnapshotOutcome,
|
||||
snapshotDispatchMs,
|
||||
snapshotsTotal,
|
||||
} from "./observability/observability.js";
|
||||
// Sync
|
||||
export { EntitySyncManager } from "./sync/entity-sync/entity-sync.js";
|
||||
export type {
|
||||
EntitySyncConfig,
|
||||
EntityUpdateMessage,
|
||||
NetworkedEntity as NetworkedEntityData,
|
||||
SyncSnapshot,
|
||||
} from "./sync/entity-sync/entity-sync.types.js";
|
||||
export { InterestManager } from "./sync/interest-manager/interest-manager.js";
|
||||
export type {
|
||||
InterestEntity,
|
||||
InterestManagerConfig,
|
||||
InterestResult,
|
||||
} from "./sync/interest-manager/interest-manager.types.js";
|
||||
// Transport (deterministic in-memory)
|
||||
export {
|
||||
createMemoryHub,
|
||||
MemoryTransport,
|
||||
} from "./transport/memory-transport/memory-transport.js";
|
||||
// Network helpers + types
|
||||
export {
|
||||
clamp,
|
||||
distanceSqVec3,
|
||||
distanceVec3,
|
||||
IDENTITY_QUAT,
|
||||
lerpVec3,
|
||||
normalizeQuat,
|
||||
slerpQuat,
|
||||
UNIT_SCALE,
|
||||
ZERO_VEC3,
|
||||
} from "./utils/network-helpers/network-helpers.js";
|
||||
export type {
|
||||
EntityTransform,
|
||||
LatencyStats,
|
||||
Quat,
|
||||
TimestampedState,
|
||||
Vec3,
|
||||
} from "./utils/network-helpers/network-helpers.types.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# `observability`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
OTel meter wiring for the whole `@suaveplan/r3f-networking` package. Exposes
|
||||
two histograms, two counters, and a small recorder facade. Every
|
||||
externally-visible operation that affects multiplayer state is instrumented
|
||||
here so dashboards over the meter `@suaveplan/r3f-networking` can be
|
||||
assembled without reading code.
|
||||
|
||||
## Features
|
||||
|
||||
- Histogram `r3f_net.snapshot_dispatch_ms` (unit `ms`) — time spent dispatching
|
||||
one authoritative server snapshot through the local sync pipeline.
|
||||
- Histogram `r3f_net.prediction_divergence` (unit `m`) — magnitude of the last
|
||||
reconciliation error.
|
||||
- Counter `r3f_net.snapshots_total{outcome}` — snapshot frames received, labelled
|
||||
with `accepted | out_of_order | rejected`.
|
||||
- Counter `r3f_net.peer_events_total{event}` — peer lifecycle events, labelled
|
||||
with `connected | dropped`.
|
||||
- Convenience `recordSnapshot(outcome)` + `recordPeerEvent(event)` helpers
|
||||
with the label vocabulary locked at compile time.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordSnapshot, snapshotDispatchMs } from "@suaveplan/r3f-networking";
|
||||
|
||||
const t0 = performance.now();
|
||||
applySnapshot(frame);
|
||||
snapshotDispatchMs.record(performance.now() - t0);
|
||||
recordSnapshot("accepted");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire the counter to a divergence histogram in the reconciliation path:
|
||||
|
||||
```ts
|
||||
import {
|
||||
predictionDivergence,
|
||||
recordSnapshot,
|
||||
} from "@suaveplan/r3f-networking";
|
||||
|
||||
const result = predictor.reconcile(authoritative, lastAckSeq);
|
||||
predictionDivergence.record(result.error);
|
||||
recordSnapshot(result.error > snapThreshold ? "rejected" : "accepted");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `snapshotDispatchMs: Histogram` — duration per server-snapshot dispatch.
|
||||
- `predictionDivergence: Histogram` — magnitude of reconciliation error.
|
||||
- `snapshotsTotal: Counter` — total snapshots, with the `outcome` attribute.
|
||||
- `peerEventsTotal: Counter` — total peer events, with the `event` attribute.
|
||||
- `recordSnapshot(outcome: SnapshotOutcome): void`
|
||||
- `recordPeerEvent(event: "connected" | "dropped"): void`
|
||||
- `type SnapshotOutcome = "accepted" | "out_of_order" | "rejected"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is acquired once at module evaluation; consumers configuring an SDK
|
||||
later in startup will still pick up these instruments via the OTel global
|
||||
provider lazy-rebind contract. Attribute vocabularies are deliberately small
|
||||
to keep label cardinality bounded. No business-string labels (peer IDs,
|
||||
entity IDs) are emitted at the histogram or counter level.
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
peerEventsTotal,
|
||||
predictionDivergence,
|
||||
recordPeerEvent,
|
||||
recordSnapshot,
|
||||
snapshotDispatchMs,
|
||||
snapshotsTotal,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("observability", () => {
|
||||
it("exposes instruments and recorders without throwing", () => {
|
||||
expect(snapshotDispatchMs).toBeDefined();
|
||||
expect(predictionDivergence).toBeDefined();
|
||||
expect(snapshotsTotal).toBeDefined();
|
||||
expect(peerEventsTotal).toBeDefined();
|
||||
recordSnapshot("accepted");
|
||||
recordSnapshot("out_of_order");
|
||||
recordSnapshot("rejected");
|
||||
recordPeerEvent("connected");
|
||||
recordPeerEvent("dropped");
|
||||
snapshotDispatchMs.record(1.5);
|
||||
predictionDivergence.record(0.2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/r3f-networking";
|
||||
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/** Wall-time dispatch of a server snapshot through the local pipeline. */
|
||||
export const snapshotDispatchMs = meter.createHistogram(
|
||||
"r3f_net.snapshot_dispatch_ms",
|
||||
{
|
||||
description: "Time spent dispatching one server snapshot frame.",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Magnitude of last reconciliation divergence. */
|
||||
export const predictionDivergence = meter.createHistogram(
|
||||
"r3f_net.prediction_divergence",
|
||||
{
|
||||
description:
|
||||
"Euclidean error between predicted and authoritative state.",
|
||||
unit: "m",
|
||||
}
|
||||
);
|
||||
|
||||
/** Per-snapshot outcome counter: accepted | out-of-order | rejected. */
|
||||
export const snapshotsTotal = meter.createCounter("r3f_net.snapshots_total", {
|
||||
description: "Snapshot frames received by the local sync engine.",
|
||||
});
|
||||
|
||||
/** Per-peer event counter: connected | dropped. */
|
||||
export const peerEventsTotal = meter.createCounter(
|
||||
"r3f_net.peer_events_total",
|
||||
{
|
||||
description: "Peer lifecycle events on the active transport.",
|
||||
}
|
||||
);
|
||||
|
||||
/** Outcome bucket label for snapshot counters. */
|
||||
export type SnapshotOutcome = "accepted" | "out_of_order" | "rejected";
|
||||
|
||||
/** Records a snapshot outcome. */
|
||||
export function recordSnapshot(outcome: SnapshotOutcome): void {
|
||||
snapshotsTotal.add(1, { outcome });
|
||||
}
|
||||
|
||||
/** Records a peer event. */
|
||||
export function recordPeerEvent(event: "connected" | "dropped"): void {
|
||||
peerEventsTotal.add(1, { event });
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# `entity-sync`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Top-level manager for networked entity state. Tracks every entity by id,
|
||||
buffers remote-peer history for interpolation, samples local entities at the
|
||||
configured send rate, and exposes the diagnostic snapshot needed by HUD
|
||||
overlays. Hot-path methods stay allocation-free except for the result array
|
||||
returned by `collectLocalUpdates`.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-entity `StateBuffer` for remote sources, automatic `EntityInterpolator`
|
||||
binding.
|
||||
- Per-frame `updateRemoteEntities(serverTime)` walks every remote and writes
|
||||
the interpolated transform back to the entity record.
|
||||
- `shouldSend(currentTime)` gates outbound messages against the configured
|
||||
send rate.
|
||||
- Records `r3f_net.snapshots_total{outcome}` for every receive, including the
|
||||
`out_of_order` outcome surfaced from `StateBuffer.push`.
|
||||
- Zod-validated options; bad input throws `R3F_NET.INVALID_OPTIONS`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EntitySyncManager } from "@suaveplan/r3f-networking";
|
||||
|
||||
const sync = new EntitySyncManager({ sendRate: 20 });
|
||||
sync.registerEntity("e1", "me", true);
|
||||
sync.registerEntity("e2", "peer", false);
|
||||
sync.receiveUpdate({
|
||||
entityId: "e2",
|
||||
transform: { position: [1, 0, 0], rotation: [0,0,0,1], scale: [1,1,1] },
|
||||
timestamp: 1000,
|
||||
seq: 1,
|
||||
});
|
||||
sync.updateRemoteEntities(1100);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive sync from a transport receive callback and a per-frame R3F hook:
|
||||
|
||||
```ts
|
||||
transport.onMessage((peer, env) => {
|
||||
const msg = decodeUpdate(env.payload);
|
||||
sync.receiveUpdate(msg);
|
||||
});
|
||||
|
||||
useFrame((state) => {
|
||||
const t = state.clock.elapsedTime * 1000;
|
||||
sync.updateRemoteEntities(t);
|
||||
if (sync.shouldSend(t)) {
|
||||
for (const m of sync.collectLocalUpdates(t)) transport.broadcast(m);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new EntitySyncManager(config?: EntitySyncConfig)`
|
||||
- `registerEntity(id, ownerId, isLocal, initial?)` / `unregisterEntity(id)`
|
||||
- `receiveUpdate(message: EntityUpdateMessage)`
|
||||
- `updateRemoteEntities(serverTime: number)`
|
||||
- `updateLocalEntity(id, transform)`
|
||||
- `shouldSend(currentTime): boolean`
|
||||
- `collectLocalUpdates(timestamp): EntityUpdateMessage[]`
|
||||
- `getSnapshot(): SyncSnapshot`
|
||||
- `getEntityCount(): number` / `clear()`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The send-rate gate uses wall-clock time on each frame rather than a setInterval
|
||||
so missed frames cannot accumulate. Remote `out_of_order` errors are swallowed
|
||||
and reported through the OTel counter — surfacing them as exceptions would
|
||||
cascade-kill the consuming r3f frame loop on transient jitter. The
|
||||
`enablePrediction` flag is consumed by hooks that wire `ClientPredictor`
|
||||
externally; the manager itself does not predict.
|
||||
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const EntitySyncConfigSchema = z
|
||||
.object({
|
||||
sendRate: z.number().positive().optional(),
|
||||
interpolationDelay: z.number().nonnegative().optional(),
|
||||
enablePrediction: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { R3FNetError } from "../../errors/errors.js";
|
||||
import { EntitySyncManager } from "./entity-sync.js";
|
||||
|
||||
describe("EntitySyncManager", () => {
|
||||
it("rejects bad options", () => {
|
||||
// @ts-expect-error: bad
|
||||
expect(() => new EntitySyncManager({ sendRate: -1 })).toThrow(
|
||||
R3FNetError
|
||||
);
|
||||
});
|
||||
|
||||
it("registers and unregisters entities", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.registerEntity("e1", "me", true);
|
||||
sync.registerEntity("e2", "peer", false);
|
||||
expect(sync.hasEntity("e1")).toBe(true);
|
||||
expect(sync.hasEntity("e2")).toBe(true);
|
||||
expect(sync.getEntityCount()).toBe(2);
|
||||
sync.unregisterEntity("e1");
|
||||
expect(sync.hasEntity("e1")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers entity with custom initial transform", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.registerEntity("e1", "me", true, {
|
||||
position: [10, 20, 30],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
expect(sync.getEntity("e1")?.transform.position).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
it("getEntity returns null for unknown id", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
expect(sync.getEntity("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("receiveUpdate ignores unknown entity", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.receiveUpdate({
|
||||
entityId: "missing",
|
||||
transform: {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
timestamp: 100,
|
||||
seq: 1,
|
||||
});
|
||||
// Should not throw.
|
||||
expect(sync.getEntityCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("receiveUpdate buffers remote-entity state", () => {
|
||||
const sync = new EntitySyncManager({ interpolationDelay: 0 });
|
||||
sync.registerEntity("e1", "peer", false);
|
||||
sync.receiveUpdate({
|
||||
entityId: "e1",
|
||||
transform: {
|
||||
position: [1, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
timestamp: 100,
|
||||
seq: 1,
|
||||
});
|
||||
sync.receiveUpdate({
|
||||
entityId: "e1",
|
||||
transform: {
|
||||
position: [2, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
timestamp: 200,
|
||||
seq: 2,
|
||||
});
|
||||
sync.updateRemoteEntities(200);
|
||||
const e = sync.getEntity("e1");
|
||||
expect(e?.transform.position[0]).toBeCloseTo(2);
|
||||
});
|
||||
|
||||
it("receiveUpdate swallows out-of-order frames into OTel outcome", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.registerEntity("e1", "peer", false);
|
||||
sync.receiveUpdate({
|
||||
entityId: "e1",
|
||||
transform: {
|
||||
position: [1, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
timestamp: 200,
|
||||
seq: 1,
|
||||
});
|
||||
// Older timestamp → out-of-order — should not throw.
|
||||
sync.receiveUpdate({
|
||||
entityId: "e1",
|
||||
transform: {
|
||||
position: [2, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
timestamp: 100,
|
||||
seq: 2,
|
||||
});
|
||||
expect(sync.hasEntity("e1")).toBe(true);
|
||||
});
|
||||
|
||||
it("receiveUpdate ignores local entities (no buffer)", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.registerEntity("e1", "me", true);
|
||||
sync.receiveUpdate({
|
||||
entityId: "e1",
|
||||
transform: {
|
||||
position: [1, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
timestamp: 100,
|
||||
seq: 1,
|
||||
});
|
||||
// Local entities aren't buffered; updateRemoteEntities should not crash.
|
||||
sync.updateRemoteEntities(200);
|
||||
});
|
||||
|
||||
it("updateLocalEntity writes only to local entities", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.registerEntity("local", "me", true);
|
||||
sync.registerEntity("remote", "peer", false);
|
||||
const tx = {
|
||||
position: [9, 0, 0] as [number, number, number],
|
||||
rotation: [0, 0, 0, 1] as [number, number, number, number],
|
||||
scale: [1, 1, 1] as [number, number, number],
|
||||
};
|
||||
sync.updateLocalEntity("local", tx);
|
||||
sync.updateLocalEntity("remote", tx);
|
||||
expect(sync.getEntity("local")?.transform.position[0]).toBe(9);
|
||||
expect(sync.getEntity("remote")?.transform.position[0]).toBe(0);
|
||||
});
|
||||
|
||||
it("shouldSend gates by send rate", () => {
|
||||
const sync = new EntitySyncManager({ sendRate: 10 }); // 100ms interval
|
||||
expect(sync.shouldSend(0)).toBe(true);
|
||||
expect(sync.shouldSend(50)).toBe(false);
|
||||
expect(sync.shouldSend(100)).toBe(true);
|
||||
});
|
||||
|
||||
it("collectLocalUpdates returns local entities only", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.registerEntity("local", "me", true);
|
||||
sync.registerEntity("remote", "peer", false);
|
||||
const updates = sync.collectLocalUpdates(1234);
|
||||
expect(updates.length).toBe(1);
|
||||
expect(updates[0]?.entityId).toBe("local");
|
||||
expect(updates[0]?.timestamp).toBe(1234);
|
||||
});
|
||||
|
||||
it("getSnapshot counts local + remote", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.registerEntity("local", "me", true);
|
||||
sync.registerEntity("remote", "peer", false);
|
||||
const snap = sync.getSnapshot();
|
||||
expect(snap.entityCount).toBe(2);
|
||||
expect(snap.localCount).toBe(1);
|
||||
expect(snap.remoteCount).toBe(1);
|
||||
});
|
||||
|
||||
it("clear empties everything", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.registerEntity("e1", "me", true);
|
||||
sync.clear();
|
||||
expect(sync.getEntityCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("updateRemoteEntities short-circuits when buffer empty", () => {
|
||||
const sync = new EntitySyncManager();
|
||||
sync.registerEntity("e1", "peer", false);
|
||||
// No receiveUpdate calls → buffer is empty.
|
||||
sync.updateRemoteEntities(100);
|
||||
// Should not throw.
|
||||
expect(sync.hasEntity("e1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { EntityInterpolator } from "../../core/interpolation/interpolation.js";
|
||||
import { StateBuffer } from "../../core/state-buffer/state-buffer.js";
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
// R3F_NET_CODES is used by Zod-config validation; R3FNetError below.
|
||||
import {
|
||||
recordSnapshot,
|
||||
snapshotDispatchMs,
|
||||
} from "../../observability/observability.js";
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import { EntitySyncConfigSchema } from "./entity-sync.schemas.js";
|
||||
import type {
|
||||
EntitySyncConfig,
|
||||
EntityUpdateMessage,
|
||||
NetworkedEntity,
|
||||
SyncSnapshot,
|
||||
} from "./entity-sync.types.js";
|
||||
|
||||
const DEFAULT_CONFIG: Required<EntitySyncConfig> = {
|
||||
sendRate: 20,
|
||||
interpolationDelay: 100,
|
||||
enablePrediction: true,
|
||||
};
|
||||
|
||||
const DEFAULT_TRANSFORM: EntityTransform = {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
};
|
||||
|
||||
/** Manages entity state synchronization across the network. */
|
||||
export class EntitySyncManager {
|
||||
private readonly config: Required<EntitySyncConfig>;
|
||||
private readonly entities: Map<string, NetworkedEntity> = new Map();
|
||||
private readonly buffers: Map<string, StateBuffer<EntityTransform>> =
|
||||
new Map();
|
||||
private readonly interpolator: EntityInterpolator;
|
||||
private lastSendTime = Number.NEGATIVE_INFINITY;
|
||||
|
||||
constructor(config?: EntitySyncConfig) {
|
||||
const parsed = EntitySyncConfigSchema.safeParse(config ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_OPTIONS,
|
||||
"r3f_net.entity_sync.invalid_options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...(parsed.data as EntitySyncConfig),
|
||||
};
|
||||
this.interpolator = new EntityInterpolator({
|
||||
delay: this.config.interpolationDelay,
|
||||
});
|
||||
}
|
||||
|
||||
registerEntity(
|
||||
id: string,
|
||||
ownerId: string,
|
||||
isLocal: boolean,
|
||||
initialTransform?: EntityTransform
|
||||
): void {
|
||||
const transform = initialTransform ?? DEFAULT_TRANSFORM;
|
||||
this.entities.set(id, { id, ownerId, isLocal, transform });
|
||||
if (!isLocal) this.buffers.set(id, new StateBuffer());
|
||||
}
|
||||
|
||||
unregisterEntity(id: string): void {
|
||||
this.entities.delete(id);
|
||||
this.buffers.delete(id);
|
||||
}
|
||||
|
||||
hasEntity(id: string): boolean {
|
||||
return this.entities.has(id);
|
||||
}
|
||||
|
||||
getEntity(id: string): NetworkedEntity | null {
|
||||
return this.entities.get(id) ?? null;
|
||||
}
|
||||
|
||||
receiveUpdate(message: EntityUpdateMessage): void {
|
||||
const start = nowMs();
|
||||
const entity = this.entities.get(message.entityId);
|
||||
if (!entity) {
|
||||
recordSnapshot("rejected");
|
||||
return;
|
||||
}
|
||||
if (!entity.isLocal) {
|
||||
// Invariant: registerEntity(..., isLocal=false) always creates a
|
||||
// buffer keyed by entityId.
|
||||
const buffer = this.buffers.get(
|
||||
message.entityId
|
||||
) as StateBuffer<EntityTransform>;
|
||||
try {
|
||||
buffer.push({
|
||||
timestamp: message.timestamp,
|
||||
state: message.transform,
|
||||
});
|
||||
recordSnapshot("accepted");
|
||||
} catch {
|
||||
// `StateBuffer.push` only throws SNAPSHOT_OUT_OF_ORDER —
|
||||
// surface it via the OTel outcome counter rather than
|
||||
// failing the entire receive callback.
|
||||
recordSnapshot("out_of_order");
|
||||
return;
|
||||
}
|
||||
}
|
||||
snapshotDispatchMs.record(nowMs() - start);
|
||||
}
|
||||
|
||||
updateRemoteEntities(serverTime: number): void {
|
||||
for (const [id, entity] of this.entities) {
|
||||
if (entity.isLocal) continue;
|
||||
const buffer = this.buffers.get(id);
|
||||
if (!buffer || buffer.size() === 0) continue;
|
||||
const result = this.interpolator.interpolate(buffer, serverTime);
|
||||
entity.transform = result.transform;
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalEntity(id: string, transform: EntityTransform): void {
|
||||
const entity = this.entities.get(id);
|
||||
if (entity?.isLocal) entity.transform = transform;
|
||||
}
|
||||
|
||||
shouldSend(currentTime: number): boolean {
|
||||
const interval = 1000 / this.config.sendRate;
|
||||
if (currentTime - this.lastSendTime >= interval) {
|
||||
this.lastSendTime = currentTime;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
collectLocalUpdates(timestamp: number): EntityUpdateMessage[] {
|
||||
const messages: EntityUpdateMessage[] = [];
|
||||
for (const entity of this.entities.values()) {
|
||||
if (!entity.isLocal) continue;
|
||||
messages.push({
|
||||
entityId: entity.id,
|
||||
transform: entity.transform,
|
||||
timestamp,
|
||||
seq: 0,
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
getSnapshot(): SyncSnapshot {
|
||||
let localCount = 0;
|
||||
let remoteCount = 0;
|
||||
for (const entity of this.entities.values()) {
|
||||
if (entity.isLocal) localCount++;
|
||||
else remoteCount++;
|
||||
}
|
||||
return {
|
||||
entityCount: this.entities.size,
|
||||
localCount,
|
||||
remoteCount,
|
||||
};
|
||||
}
|
||||
|
||||
getEntityCount(): number {
|
||||
return this.entities.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entities.clear();
|
||||
this.buffers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
// `performance.now()` is available in every supported runtime
|
||||
// (Bun, Node 16+, all browsers, happy-dom, jsdom).
|
||||
return performance.now();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { EntityTransform } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
export interface EntitySyncConfig {
|
||||
readonly sendRate?: number;
|
||||
readonly interpolationDelay?: number;
|
||||
readonly enablePrediction?: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkedEntity {
|
||||
readonly id: string;
|
||||
readonly ownerId: string;
|
||||
readonly isLocal: boolean;
|
||||
transform: EntityTransform;
|
||||
}
|
||||
|
||||
export interface EntityUpdateMessage {
|
||||
readonly entityId: string;
|
||||
readonly transform: EntityTransform;
|
||||
readonly timestamp: number;
|
||||
readonly seq: number;
|
||||
}
|
||||
|
||||
export interface SyncSnapshot {
|
||||
readonly entityCount: number;
|
||||
readonly localCount: number;
|
||||
readonly remoteCount: number;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# `interest-manager`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Per-observer interest tracker. Each observer has a radius, every other
|
||||
entity in the world is either inside or outside that radius, and the
|
||||
manager returns per-tick deltas (`entered`, `exited`, `inRange`) so the
|
||||
networking layer can stream only relevant entities. Hysteresis on the
|
||||
boundary prevents flap when an entity hovers exactly at the cut-off.
|
||||
|
||||
## Features
|
||||
|
||||
- Squared-distance comparisons throughout to avoid sqrt on the hot path.
|
||||
- Per-observer `Set` membership cache + delta computation in a single pass.
|
||||
- Tunable `interestRadius`, `hysteresis`, `updateInterval` (Zod-validated).
|
||||
- `shouldUpdate(currentTime)` gates expensive scans against the configured
|
||||
interval.
|
||||
- `R3F_NET.INVALID_OPTIONS` on bad config.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { InterestManager } from "@suaveplan/r3f-networking";
|
||||
|
||||
const im = new InterestManager({ interestRadius: 25 });
|
||||
im.addEntity("npc1", [10, 0, 0]);
|
||||
im.addEntity("npc2", [200, 0, 0]);
|
||||
const r = im.query("me", [0, 0, 0]);
|
||||
// r.entered === ["npc1"]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive an entity replication loop with hysteresis:
|
||||
|
||||
```ts
|
||||
const im = new InterestManager({ interestRadius: 50, hysteresis: 1.2 });
|
||||
|
||||
function tick(t: number, observer: string, observerPos: Vec3) {
|
||||
if (!im.shouldUpdate(t)) return;
|
||||
const r = im.query(observer, observerPos);
|
||||
for (const id of r.entered) replication.subscribe(id);
|
||||
for (const id of r.exited) replication.unsubscribe(id);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new InterestManager(config?: InterestManagerConfig)`
|
||||
- `addEntity(id, position)` / `removeEntity(id)` / `updatePosition(id, pos)`
|
||||
- `query(observerId, observerPosition): InterestResult`
|
||||
- `shouldUpdate(currentTime): boolean`
|
||||
- `getInterestSet(observerId): string[]`
|
||||
- `getEntityCount()` / `getInterestRadius()` / `clear()`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The inner loop is O(n) over registered entities; for very large worlds a
|
||||
spatial-hash decorator can be wrapped around this class without changing
|
||||
its contract. The `hysteresis` factor is multiplied into the squared
|
||||
radius once and reused — entities that just entered must drift slightly
|
||||
further than the entry threshold before they exit, eliminating boundary
|
||||
flap that would otherwise generate a constant stream of subscribe and
|
||||
unsubscribe events at the network layer. Per-observer state is kept in a
|
||||
`Map<observerId, Set<entityId>>` so two observers in the same scene each
|
||||
have their own independent interest set; this is essential for split-
|
||||
screen rendering and for the dedicated-server case where the server holds
|
||||
one observer per connected client. `removeEntity` walks every observer
|
||||
set so the removed entity never appears in a future delta after its
|
||||
deregistration, even if the next per-observer query has not yet been
|
||||
issued.
|
||||
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const InterestManagerConfigSchema = z
|
||||
.object({
|
||||
interestRadius: z.number().positive().optional(),
|
||||
hysteresis: z.number().min(1).optional(),
|
||||
updateInterval: z.number().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { R3FNetError } from "../../errors/errors.js";
|
||||
import { InterestManager } from "./interest-manager.js";
|
||||
|
||||
describe("InterestManager", () => {
|
||||
it("rejects bad options", () => {
|
||||
// @ts-expect-error: bad
|
||||
expect(() => new InterestManager({ interestRadius: -1 })).toThrow(
|
||||
R3FNetError
|
||||
);
|
||||
});
|
||||
|
||||
it("adds and removes entities", () => {
|
||||
const im = new InterestManager();
|
||||
im.addEntity("e1", [0, 0, 0]);
|
||||
expect(im.getEntityCount()).toBe(1);
|
||||
im.removeEntity("e1");
|
||||
expect(im.getEntityCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("updates an entity position", () => {
|
||||
const im = new InterestManager();
|
||||
im.addEntity("e1", [0, 0, 0]);
|
||||
im.updatePosition("e1", [10, 0, 0]);
|
||||
const r = im.query("observer", [0, 0, 0]);
|
||||
// Default radius 50 → e1 still in range at [10,0,0].
|
||||
expect(r.inRange).toContain("e1");
|
||||
});
|
||||
|
||||
it("updatePosition is a no-op for unknown id", () => {
|
||||
const im = new InterestManager();
|
||||
im.updatePosition("missing", [1, 1, 1]);
|
||||
expect(im.getEntityCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("query returns entered/exited/inRange deltas", () => {
|
||||
const im = new InterestManager({ interestRadius: 10 });
|
||||
im.addEntity("near", [5, 0, 0]);
|
||||
im.addEntity("far", [100, 0, 0]);
|
||||
const r1 = im.query("me", [0, 0, 0]);
|
||||
expect(r1.entered).toContain("near");
|
||||
expect(r1.entered).not.toContain("far");
|
||||
// Move "near" out → exited.
|
||||
im.updatePosition("near", [100, 0, 0]);
|
||||
const r2 = im.query("me", [0, 0, 0]);
|
||||
expect(r2.exited).toContain("near");
|
||||
});
|
||||
|
||||
it("query excludes the observer itself", () => {
|
||||
const im = new InterestManager();
|
||||
im.addEntity("me", [0, 0, 0]);
|
||||
im.addEntity("you", [1, 0, 0]);
|
||||
const r = im.query("me", [0, 0, 0]);
|
||||
expect(r.inRange).not.toContain("me");
|
||||
expect(r.inRange).toContain("you");
|
||||
});
|
||||
|
||||
it("hysteresis prevents flap at the boundary", () => {
|
||||
const im = new InterestManager({ interestRadius: 10, hysteresis: 2.0 });
|
||||
im.addEntity("e1", [9, 0, 0]);
|
||||
let r = im.query("me", [0, 0, 0]);
|
||||
expect(r.entered).toContain("e1");
|
||||
// Push slightly past the entry radius — should remain inRange under hysteresis.
|
||||
im.updatePosition("e1", [12, 0, 0]);
|
||||
r = im.query("me", [0, 0, 0]);
|
||||
expect(r.inRange).toContain("e1");
|
||||
expect(r.exited).toEqual([]);
|
||||
});
|
||||
|
||||
it("removeEntity strips it from every active set", () => {
|
||||
const im = new InterestManager();
|
||||
im.addEntity("e1", [1, 0, 0]);
|
||||
im.query("observerA", [0, 0, 0]);
|
||||
im.query("observerB", [0, 0, 0]);
|
||||
im.removeEntity("e1");
|
||||
expect(im.getInterestSet("observerA")).not.toContain("e1");
|
||||
expect(im.getInterestSet("observerB")).not.toContain("e1");
|
||||
});
|
||||
|
||||
it("shouldUpdate gates by interval", () => {
|
||||
const im = new InterestManager({ updateInterval: 100 });
|
||||
expect(im.shouldUpdate(0)).toBe(true);
|
||||
expect(im.shouldUpdate(50)).toBe(false);
|
||||
expect(im.shouldUpdate(100)).toBe(true);
|
||||
});
|
||||
|
||||
it("getInterestSet returns [] for unknown observer", () => {
|
||||
const im = new InterestManager();
|
||||
expect(im.getInterestSet("nope")).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes getInterestRadius", () => {
|
||||
const im = new InterestManager({ interestRadius: 25 });
|
||||
expect(im.getInterestRadius()).toBe(25);
|
||||
});
|
||||
|
||||
it("clear empties state", () => {
|
||||
const im = new InterestManager();
|
||||
im.addEntity("e1", [0, 0, 0]);
|
||||
im.query("me", [0, 0, 0]);
|
||||
im.clear();
|
||||
expect(im.getEntityCount()).toBe(0);
|
||||
expect(im.getInterestSet("me")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import { distanceSqVec3 } from "../../utils/network-helpers/network-helpers.js";
|
||||
import type { Vec3 } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
import { InterestManagerConfigSchema } from "./interest-manager.schemas.js";
|
||||
import type {
|
||||
InterestEntity,
|
||||
InterestManagerConfig,
|
||||
InterestResult,
|
||||
} from "./interest-manager.types.js";
|
||||
|
||||
const DEFAULT_CONFIG: Required<InterestManagerConfig> = {
|
||||
interestRadius: 50,
|
||||
hysteresis: 1.1,
|
||||
updateInterval: 500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks per-observer interest sets so a sync layer only synchronises
|
||||
* entities inside the observer's radius. Hysteresis prevents flap on the
|
||||
* boundary.
|
||||
*/
|
||||
export class InterestManager {
|
||||
private readonly config: Required<InterestManagerConfig>;
|
||||
private readonly entities: Map<string, InterestEntity> = new Map();
|
||||
private readonly activeSets: Map<string, Set<string>> = new Map();
|
||||
private lastUpdateTime = Number.NEGATIVE_INFINITY;
|
||||
|
||||
constructor(config?: InterestManagerConfig) {
|
||||
const parsed = InterestManagerConfigSchema.safeParse(config ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_OPTIONS,
|
||||
"r3f_net.interest_manager.invalid_options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...(parsed.data as InterestManagerConfig),
|
||||
};
|
||||
}
|
||||
|
||||
addEntity(id: string, position: Vec3): void {
|
||||
this.entities.set(id, { id, position });
|
||||
}
|
||||
|
||||
removeEntity(id: string): void {
|
||||
this.entities.delete(id);
|
||||
this.activeSets.delete(id);
|
||||
for (const set of this.activeSets.values()) {
|
||||
set.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
updatePosition(id: string, position: Vec3): void {
|
||||
const entity = this.entities.get(id);
|
||||
if (entity) entity.position = position;
|
||||
}
|
||||
|
||||
getEntityCount(): number {
|
||||
return this.entities.size;
|
||||
}
|
||||
|
||||
query(observerId: string, observerPosition: Vec3): InterestResult {
|
||||
const radiusSq =
|
||||
this.config.interestRadius * this.config.interestRadius;
|
||||
const hysteresisRadiusSq =
|
||||
radiusSq * this.config.hysteresis * this.config.hysteresis;
|
||||
|
||||
const prevSet = this.activeSets.get(observerId) ?? new Set<string>();
|
||||
const newSet = new Set<string>();
|
||||
const entered: string[] = [];
|
||||
const exited: string[] = [];
|
||||
|
||||
for (const [id, entity] of this.entities) {
|
||||
if (id === observerId) continue;
|
||||
const dSq = distanceSqVec3(observerPosition, entity.position);
|
||||
const wasInRange = prevSet.has(id);
|
||||
const threshold = wasInRange ? hysteresisRadiusSq : radiusSq;
|
||||
if (dSq <= threshold) {
|
||||
newSet.add(id);
|
||||
if (!wasInRange) entered.push(id);
|
||||
} else if (wasInRange) {
|
||||
exited.push(id);
|
||||
}
|
||||
}
|
||||
this.activeSets.set(observerId, newSet);
|
||||
return { entered, exited, inRange: [...newSet] };
|
||||
}
|
||||
|
||||
shouldUpdate(currentTime: number): boolean {
|
||||
if (currentTime - this.lastUpdateTime >= this.config.updateInterval) {
|
||||
this.lastUpdateTime = currentTime;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getInterestSet(observerId: string): string[] {
|
||||
const set = this.activeSets.get(observerId);
|
||||
return set ? [...set] : [];
|
||||
}
|
||||
|
||||
getInterestRadius(): number {
|
||||
return this.config.interestRadius;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entities.clear();
|
||||
this.activeSets.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Vec3 } from "../../utils/network-helpers/network-helpers.types.js";
|
||||
|
||||
export interface InterestManagerConfig {
|
||||
readonly interestRadius?: number;
|
||||
readonly hysteresis?: number;
|
||||
readonly updateInterval?: number;
|
||||
}
|
||||
|
||||
export interface InterestEntity {
|
||||
readonly id: string;
|
||||
position: Vec3;
|
||||
}
|
||||
|
||||
export interface InterestResult {
|
||||
readonly entered: string[];
|
||||
readonly exited: string[];
|
||||
readonly inRange: string[];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# `memory-transport`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Deterministic in-memory implementation of the `@suaveplan/network`
|
||||
`Transport` contract. Two or more `MemoryTransport` instances joined to a
|
||||
shared `MemoryHub` deliver envelopes synchronously across handlers,
|
||||
allowing 2-client multiplayer integration tests to run without a real
|
||||
WebRTC signalling round-trip. This is a **real** implementation of the
|
||||
contract — not a mock — and it satisfies the CLAUDE.md "no mocks" rule
|
||||
because it is shipped, exercised in production-style tests, and useful as a
|
||||
fast-path transport for offline development scenes.
|
||||
|
||||
## Features
|
||||
|
||||
- Real `Transport` implementation: `connect` / `disconnect` / `send` /
|
||||
`onMessage` / `onPeerEvent` / `getConnectedPeers` / `close`.
|
||||
- Synchronous delivery within a hub — every send completes inside one
|
||||
microtask, ideal for deterministic property tests.
|
||||
- Records `r3f_net.peer_events_total{event}` on every connect/drop.
|
||||
- Errors funnel through `R3FNetError` codes — `DISPOSED`, `PEER_DROPPED`,
|
||||
`TRANSPORT_FAILED`, `INVALID_OPTIONS`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createMemoryHub, MemoryTransport } from "@suaveplan/r3f-networking";
|
||||
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("peer-a", hub);
|
||||
const b = new MemoryTransport("peer-b", hub);
|
||||
b.onMessage((from, env) => console.log("from", from, "got", env));
|
||||
await a.connect("peer-b", "memory");
|
||||
await a.send("peer-b", env);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Run a 2-client multiplayer integration test:
|
||||
|
||||
```ts
|
||||
const hub = createMemoryHub();
|
||||
const server = new MemoryTransport("server", hub);
|
||||
const client = new MemoryTransport("c1", hub);
|
||||
client.onMessage((_, env) => incomingSnapshots.push(env));
|
||||
await client.connect("server", "memory");
|
||||
await server.send("c1", snapshotEnvelope);
|
||||
expect(incomingSnapshots).toHaveLength(1);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createMemoryHub(): MemoryHub`
|
||||
- `new MemoryTransport(localPeerId, hub, id?)` — throws `INVALID_OPTIONS` on
|
||||
duplicate `localPeerId`.
|
||||
- All methods of the `Transport` contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hub holds a `Map<PeerId, MemoryTransport>` so peer discovery is O(1).
|
||||
`close()` is idempotent and disconnects every still-attached peer before
|
||||
removing the transport from the hub. Send to an un-connected peer throws
|
||||
`PEER_DROPPED` rather than silently dropping the envelope — this surfaces
|
||||
mis-wired tests immediately.
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import { createMemoryHub, MemoryTransport } from "./memory-transport.js";
|
||||
|
||||
const env = () =>
|
||||
createEnvelope({
|
||||
id: "msg-1",
|
||||
type: "test",
|
||||
senderId: "any",
|
||||
payload: { hello: "world" },
|
||||
ttl: 5,
|
||||
});
|
||||
|
||||
describe("MemoryTransport", () => {
|
||||
it("rejects duplicate peer ids on the same hub", () => {
|
||||
const hub = createMemoryHub();
|
||||
new MemoryTransport("peer", hub);
|
||||
expect(() => new MemoryTransport("peer", hub)).toThrow(R3FNetError);
|
||||
});
|
||||
|
||||
it("connect establishes a two-way link", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
const b = new MemoryTransport("b", hub);
|
||||
await a.connect("b", "memory");
|
||||
expect(a.getConnectedPeers()).toEqual(["b"]);
|
||||
expect(b.getConnectedPeers()).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("send delivers to the destination handler", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
const b = new MemoryTransport("b", hub);
|
||||
const received: unknown[] = [];
|
||||
b.onMessage((from, e) => received.push({ from, e }));
|
||||
await a.connect("b", "memory");
|
||||
await a.send("b", env());
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("send to disconnected peer raises PEER_DROPPED", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
const _b = new MemoryTransport("b", hub);
|
||||
// Not connected on purpose.
|
||||
await expect(a.send("b", env())).rejects.toBeInstanceOf(R3FNetError);
|
||||
});
|
||||
|
||||
it("connect to an unknown peer raises TRANSPORT_FAILED", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
try {
|
||||
await a.connect("phantom", "memory");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(R3FNetError);
|
||||
if (err instanceof R3FNetError) {
|
||||
expect(err.code).toBe(R3F_NET_CODES.TRANSPORT_FAILED);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("peer-event handlers fire on connect and disconnect", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
const b = new MemoryTransport("b", hub);
|
||||
const eventsA: string[] = [];
|
||||
const eventsB: string[] = [];
|
||||
a.onPeerEvent((peer, ev) => eventsA.push(`${peer}:${ev}`));
|
||||
b.onPeerEvent((peer, ev) => eventsB.push(`${peer}:${ev}`));
|
||||
await a.connect("b", "memory");
|
||||
await a.disconnect("b");
|
||||
expect(eventsA).toEqual(["b:connected", "b:disconnected"]);
|
||||
expect(eventsB).toEqual(["a:connected", "a:disconnected"]);
|
||||
});
|
||||
|
||||
it("disconnect is idempotent on never-connected peer", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
new MemoryTransport("b", hub);
|
||||
// No connect — disconnect should silently succeed.
|
||||
await a.disconnect("b");
|
||||
});
|
||||
|
||||
it("onMessage / onPeerEvent return unsubscribe fns", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
const b = new MemoryTransport("b", hub);
|
||||
const received: unknown[] = [];
|
||||
const off = b.onMessage(() => received.push(1));
|
||||
await a.connect("b", "memory");
|
||||
await a.send("b", env());
|
||||
off();
|
||||
await a.send("b", env());
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("onPeerEvent returns an unsubscribe fn", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
const _b = new MemoryTransport("b", hub);
|
||||
const events: string[] = [];
|
||||
const off = a.onPeerEvent((peer, ev) => events.push(`${peer}:${ev}`));
|
||||
await a.connect("b", "memory");
|
||||
off();
|
||||
await a.disconnect("b");
|
||||
// After unsubscribe, the disconnect event must not be observed.
|
||||
expect(events).toEqual(["b:connected"]);
|
||||
});
|
||||
|
||||
it("close disconnects every still-attached peer", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
const b = new MemoryTransport("b", hub);
|
||||
await a.connect("b", "memory");
|
||||
await a.close();
|
||||
expect(b.getConnectedPeers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("close is idempotent", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
await a.close();
|
||||
await a.close();
|
||||
});
|
||||
|
||||
it("send after close raises DISPOSED", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
const _b = new MemoryTransport("b", hub);
|
||||
await a.connect("b", "memory");
|
||||
await a.close();
|
||||
try {
|
||||
await a.send("b", env());
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(R3FNetError);
|
||||
if (err instanceof R3FNetError) {
|
||||
expect(err.code).toBe(R3F_NET_CODES.DISPOSED);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("connect after close raises DISPOSED", async () => {
|
||||
const hub = createMemoryHub();
|
||||
const a = new MemoryTransport("a", hub);
|
||||
new MemoryTransport("b", hub);
|
||||
await a.close();
|
||||
try {
|
||||
await a.connect("b", "memory");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(R3FNetError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { MessageEnvelope, PeerId, Transport } from "@suaveplan/network";
|
||||
import { R3F_NET_CODES, R3FNetError } from "../../errors/errors.js";
|
||||
import { recordPeerEvent } from "../../observability/observability.js";
|
||||
|
||||
type PeerEventHandler = (
|
||||
peerId: PeerId,
|
||||
event: "connected" | "disconnected"
|
||||
) => void;
|
||||
type MessageHandler = (peerId: PeerId, envelope: MessageEnvelope) => void;
|
||||
|
||||
interface MemoryHub {
|
||||
transports: Map<string, MemoryTransport>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh in-memory transport hub. Every transport joined to the hub
|
||||
* sees every other transport on `connect`, and `send` delivers envelopes
|
||||
* synchronously to the destination's `onMessage` handlers. This is a real
|
||||
* implementation of the `@suaveplan/network` `Transport` contract suitable
|
||||
* for deterministic 2-client integration tests.
|
||||
*/
|
||||
export function createMemoryHub(): MemoryHub {
|
||||
return { transports: new Map() };
|
||||
}
|
||||
|
||||
/** In-memory transport instance. */
|
||||
export class MemoryTransport implements Transport {
|
||||
readonly id: string;
|
||||
private readonly hub: MemoryHub;
|
||||
private readonly localPeerId: PeerId;
|
||||
private closed = false;
|
||||
private readonly messageHandlers: Set<MessageHandler> = new Set();
|
||||
private readonly peerEventHandlers: Set<PeerEventHandler> = new Set();
|
||||
private readonly connectedPeers: Set<PeerId> = new Set();
|
||||
|
||||
constructor(localPeerId: PeerId, hub: MemoryHub, id = "memory") {
|
||||
this.id = id;
|
||||
this.localPeerId = localPeerId;
|
||||
this.hub = hub;
|
||||
if (hub.transports.has(localPeerId)) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.INVALID_OPTIONS,
|
||||
"r3f_net.memory_transport.duplicate_peer",
|
||||
{ context: { peerId: localPeerId } }
|
||||
);
|
||||
}
|
||||
hub.transports.set(localPeerId, this);
|
||||
}
|
||||
|
||||
async connect(peerId: PeerId, _address: string): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.DISPOSED,
|
||||
"r3f_net.memory_transport.closed"
|
||||
);
|
||||
}
|
||||
const target = this.hub.transports.get(peerId);
|
||||
if (!target) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.TRANSPORT_FAILED,
|
||||
"r3f_net.memory_transport.peer_unknown",
|
||||
{ context: { peerId } }
|
||||
);
|
||||
}
|
||||
this.connectedPeers.add(peerId);
|
||||
target.connectedPeers.add(this.localPeerId);
|
||||
for (const h of this.peerEventHandlers) h(peerId, "connected");
|
||||
for (const h of target.peerEventHandlers)
|
||||
h(this.localPeerId, "connected");
|
||||
recordPeerEvent("connected");
|
||||
}
|
||||
|
||||
async disconnect(peerId: PeerId): Promise<void> {
|
||||
if (!this.connectedPeers.has(peerId)) return;
|
||||
// Connection invariant: `this.connectedPeers.has(peerId)` implies the
|
||||
// matching transport is still registered in the hub, because
|
||||
// `close()` calls disconnect on every still-attached peer before
|
||||
// removing itself from the hub.
|
||||
const target = this.hub.transports.get(peerId) as MemoryTransport;
|
||||
this.connectedPeers.delete(peerId);
|
||||
target.connectedPeers.delete(this.localPeerId);
|
||||
for (const h of this.peerEventHandlers) h(peerId, "disconnected");
|
||||
for (const h of target.peerEventHandlers)
|
||||
h(this.localPeerId, "disconnected");
|
||||
recordPeerEvent("dropped");
|
||||
}
|
||||
|
||||
async send(peerId: PeerId, envelope: MessageEnvelope): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.DISPOSED,
|
||||
"r3f_net.memory_transport.closed"
|
||||
);
|
||||
}
|
||||
const target = this.hub.transports.get(peerId);
|
||||
if (!target?.connectedPeers.has(this.localPeerId)) {
|
||||
throw new R3FNetError(
|
||||
R3F_NET_CODES.PEER_DROPPED,
|
||||
"r3f_net.memory_transport.not_connected",
|
||||
{ context: { peerId } }
|
||||
);
|
||||
}
|
||||
for (const h of target.messageHandlers) h(this.localPeerId, envelope);
|
||||
}
|
||||
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.add(handler);
|
||||
return () => this.messageHandlers.delete(handler);
|
||||
}
|
||||
|
||||
onPeerEvent(handler: PeerEventHandler): () => void {
|
||||
this.peerEventHandlers.add(handler);
|
||||
return () => this.peerEventHandlers.delete(handler);
|
||||
}
|
||||
|
||||
getConnectedPeers(): readonly PeerId[] {
|
||||
return [...this.connectedPeers];
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
for (const peerId of [...this.connectedPeers]) {
|
||||
await this.disconnect(peerId);
|
||||
}
|
||||
this.hub.transports.delete(this.localPeerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# `network-helpers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Pure math primitives shared by every networking subsystem in the package:
|
||||
3D-vector lerp, quaternion slerp + normalisation, scalar/Euclidean/squared
|
||||
distance, and clamp. All functions are total, allocation-light, and
|
||||
side-effect-free so they run safely inside the React-Three-Fiber per-frame
|
||||
hot path.
|
||||
|
||||
## Features
|
||||
|
||||
- `lerpVec3(a, b, t)` linear interpolation between two `Vec3`.
|
||||
- `slerpQuat(a, b, t)` spherical-linear interpolation between two quaternions
|
||||
with the standard short-arc dot-flip and the near-identity linear fallback.
|
||||
- `normalizeQuat(q)` returns a unit-length quaternion with a `(0,0,0,1)` safe
|
||||
fallback for the zero-length input.
|
||||
- `distanceVec3` / `distanceSqVec3` Euclidean and squared-Euclidean magnitudes.
|
||||
- `clamp(value, min, max)` scalar clamp used by interpolation + extrapolation
|
||||
caps.
|
||||
- Shared constants `IDENTITY_QUAT`, `ZERO_VEC3`, `UNIT_SCALE`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { lerpVec3, slerpQuat } from "@suaveplan/r3f-networking";
|
||||
|
||||
const mid = lerpVec3([0, 0, 0], [10, 0, 0], 0.5); // [5, 0, 0]
|
||||
const half = slerpQuat([0, 0, 0, 1], [0, 1, 0, 0], 0.5);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Combine with `EntityInterpolator` for frame-smoothed remote-peer transforms:
|
||||
|
||||
```ts
|
||||
import { distanceVec3, clamp, lerpVec3 } from "@suaveplan/r3f-networking";
|
||||
|
||||
function damp(curr: Vec3, target: Vec3, rate: number): Vec3 {
|
||||
const t = clamp(rate, 0, 1);
|
||||
return lerpVec3(curr, target, t);
|
||||
}
|
||||
|
||||
const err = distanceVec3(localPos, serverPos);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `lerpVec3(a: Vec3, b: Vec3, t: number): [number, number, number]`
|
||||
- `slerpQuat(a: Quat, b: Quat, t: number): [number, number, number, number]`
|
||||
- `normalizeQuat(q): [number, number, number, number]`
|
||||
- `distanceVec3(a: Vec3, b: Vec3): number`
|
||||
- `distanceSqVec3(a: Vec3, b: Vec3): number`
|
||||
- `clamp(value: number, min: number, max: number): number`
|
||||
- Constants `IDENTITY_QUAT`, `ZERO_VEC3`, `UNIT_SCALE`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Quaternion slerp uses the dot-flip short-arc selection and falls back to a
|
||||
straight lerp inside a `dot > 0.9995` cone for numerical stability near
|
||||
identity. Distance functions return raw scalars; squared distance is
|
||||
preferred inside interest-radius checks to avoid `sqrt`. All functions return
|
||||
freshly-allocated arrays; callers that hit allocation limits should consume
|
||||
results into a `THREE.Vector3` in place.
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clamp,
|
||||
distanceSqVec3,
|
||||
distanceVec3,
|
||||
IDENTITY_QUAT,
|
||||
lerpVec3,
|
||||
normalizeQuat,
|
||||
slerpQuat,
|
||||
UNIT_SCALE,
|
||||
ZERO_VEC3,
|
||||
} from "./network-helpers.js";
|
||||
|
||||
describe("network-helpers", () => {
|
||||
it("lerpVec3 returns the midpoint at t=0.5", () => {
|
||||
expect(lerpVec3([0, 0, 0], [10, 20, 30], 0.5)).toEqual([5, 10, 15]);
|
||||
});
|
||||
|
||||
it("lerpVec3 returns endpoints at t=0 and t=1", () => {
|
||||
expect(lerpVec3([1, 2, 3], [9, 9, 9], 0)).toEqual([1, 2, 3]);
|
||||
expect(lerpVec3([1, 2, 3], [9, 9, 9], 1)).toEqual([9, 9, 9]);
|
||||
});
|
||||
|
||||
it("slerpQuat returns endpoint at t=0", () => {
|
||||
const r = slerpQuat([0, 0, 0, 1], [1, 0, 0, 0], 0);
|
||||
expect(r[3]).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it("slerpQuat handles short-arc dot-flip", () => {
|
||||
const r = slerpQuat([0, 0, 0, 1], [0, 0, 0, -1], 0.5);
|
||||
// Both endpoints same orientation; result should be near identity.
|
||||
expect(Math.abs(r[3])).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it("slerpQuat uses linear fallback inside near-identity cone", () => {
|
||||
// Two nearly-identical quats trigger the dot > 0.9995 branch.
|
||||
const r = slerpQuat([0, 0, 0, 1], [0.001, 0, 0, 0.9999995], 0.5);
|
||||
expect(r[3]).toBeCloseTo(1, 3);
|
||||
});
|
||||
|
||||
it("slerpQuat takes the regular path for separated quats", () => {
|
||||
const r = slerpQuat([1, 0, 0, 0], [0, 1, 0, 0], 0.5);
|
||||
// 45° between them; expect both x and y nonzero.
|
||||
expect(Math.abs(r[0])).toBeGreaterThan(0);
|
||||
expect(Math.abs(r[1])).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("normalizeQuat returns identity when input is zero", () => {
|
||||
expect(normalizeQuat([0, 0, 0, 0])).toEqual([0, 0, 0, 1]);
|
||||
});
|
||||
|
||||
it("normalizeQuat normalises a non-zero quaternion", () => {
|
||||
const r = normalizeQuat([2, 0, 0, 0]);
|
||||
expect(r).toEqual([1, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it("distanceVec3 returns Euclidean distance", () => {
|
||||
expect(distanceVec3([0, 0, 0], [3, 4, 0])).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
it("distanceSqVec3 returns squared distance", () => {
|
||||
expect(distanceSqVec3([0, 0, 0], [3, 4, 0])).toBe(25);
|
||||
});
|
||||
|
||||
it("clamp bounds value", () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
expect(clamp(-1, 0, 10)).toBe(0);
|
||||
expect(clamp(11, 0, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("exposes shared constants", () => {
|
||||
expect(IDENTITY_QUAT).toEqual([0, 0, 0, 1]);
|
||||
expect(ZERO_VEC3).toEqual([0, 0, 0]);
|
||||
expect(UNIT_SCALE).toEqual([1, 1, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Quat, Vec3 } from "./network-helpers.types.js";
|
||||
|
||||
/** Linearly interpolates between two Vec3 values. */
|
||||
export function lerpVec3(
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
t: number
|
||||
): [number, number, number] {
|
||||
return [
|
||||
a[0] + (b[0] - a[0]) * t,
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
a[2] + (b[2] - a[2]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
/** Normalizes a quaternion to unit length. */
|
||||
export function normalizeQuat(
|
||||
q: readonly [number, number, number, number]
|
||||
): [number, number, number, number] {
|
||||
const len = Math.sqrt(
|
||||
q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]
|
||||
);
|
||||
if (len === 0) return [0, 0, 0, 1];
|
||||
return [q[0] / len, q[1] / len, q[2] / len, q[3] / len];
|
||||
}
|
||||
|
||||
/** Spherical linear interpolation between two quaternions. */
|
||||
export function slerpQuat(
|
||||
a: Quat,
|
||||
b: Quat,
|
||||
t: number
|
||||
): [number, number, number, number] {
|
||||
let dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
|
||||
let bx = b[0];
|
||||
let by = b[1];
|
||||
let bz = b[2];
|
||||
let bw = b[3];
|
||||
if (dot < 0) {
|
||||
dot = -dot;
|
||||
bx = -bx;
|
||||
by = -by;
|
||||
bz = -bz;
|
||||
bw = -bw;
|
||||
}
|
||||
if (dot > 0.9995) {
|
||||
return normalizeQuat([
|
||||
a[0] + (bx - a[0]) * t,
|
||||
a[1] + (by - a[1]) * t,
|
||||
a[2] + (bz - a[2]) * t,
|
||||
a[3] + (bw - a[3]) * t,
|
||||
]);
|
||||
}
|
||||
const theta0 = Math.acos(dot);
|
||||
const theta = theta0 * t;
|
||||
const sinTheta = Math.sin(theta);
|
||||
const sinTheta0 = Math.sin(theta0);
|
||||
const s0 = Math.cos(theta) - (dot * sinTheta) / sinTheta0;
|
||||
const s1 = sinTheta / sinTheta0;
|
||||
return [
|
||||
a[0] * s0 + bx * s1,
|
||||
a[1] * s0 + by * s1,
|
||||
a[2] * s0 + bz * s1,
|
||||
a[3] * s0 + bw * s1,
|
||||
];
|
||||
}
|
||||
|
||||
/** Euclidean distance between two Vec3. */
|
||||
export function distanceVec3(a: Vec3, b: Vec3): number {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const dz = b[2] - a[2];
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
/** Squared distance between two Vec3 (avoids sqrt). */
|
||||
export function distanceSqVec3(a: Vec3, b: Vec3): number {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const dz = b[2] - a[2];
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
/** Clamps a value between min and max. */
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/** Identity quaternion [0, 0, 0, 1]. */
|
||||
export const IDENTITY_QUAT: Quat = [0, 0, 0, 1];
|
||||
|
||||
/** Zero vector [0, 0, 0]. */
|
||||
export const ZERO_VEC3: Vec3 = [0, 0, 0];
|
||||
|
||||
/** Unit scale [1, 1, 1]. */
|
||||
export const UNIT_SCALE: Vec3 = [1, 1, 1];
|
||||
@@ -0,0 +1,32 @@
|
||||
/** A 3D position tuple. */
|
||||
export type Vec3 = readonly [number, number, number];
|
||||
|
||||
/** A quaternion rotation tuple [x, y, z, w]. */
|
||||
export type Quat = readonly [number, number, number, number];
|
||||
|
||||
/** Network entity transform state. */
|
||||
export interface EntityTransform {
|
||||
readonly position: Vec3;
|
||||
readonly rotation: Quat;
|
||||
readonly scale: Vec3;
|
||||
}
|
||||
|
||||
/** Timestamped snapshot of an entity's state. */
|
||||
export interface TimestampedState<T = EntityTransform> {
|
||||
/** Server timestamp in milliseconds. */
|
||||
readonly timestamp: number;
|
||||
/** The entity state at this timestamp. */
|
||||
readonly state: T;
|
||||
}
|
||||
|
||||
/** Network latency statistics. */
|
||||
export interface LatencyStats {
|
||||
/** Current RTT in milliseconds. */
|
||||
readonly rtt: number;
|
||||
/** Average RTT. */
|
||||
readonly avgRtt: number;
|
||||
/** Jitter (RTT variance). */
|
||||
readonly jitter: number;
|
||||
/** Number of samples collected. */
|
||||
readonly sampleCount: number;
|
||||
}
|
||||
33
packages/threejs/r3f/r3f-networking/tests/dep-graph.test.ts
Normal file
33
packages/threejs/r3f/r3f-networking/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("r3f-networking dep-graph", () => {
|
||||
it("loads index and exposes every public surface", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod.R3F_NET_CODES).toBeDefined();
|
||||
expect(mod.R3FNetError).toBeDefined();
|
||||
expect(mod.StateBuffer).toBeDefined();
|
||||
expect(mod.EntityInterpolator).toBeDefined();
|
||||
expect(mod.ClientPredictor).toBeDefined();
|
||||
expect(mod.buildReconciliationMachine).toBeDefined();
|
||||
expect(mod.diffSnapshots).toBeDefined();
|
||||
expect(mod.buildDeltaFrame).toBeDefined();
|
||||
expect(mod.applyDeltaFrame).toBeDefined();
|
||||
expect(mod.encodeSnapshotFrame).toBeDefined();
|
||||
expect(mod.decodeSnapshotFrame).toBeDefined();
|
||||
expect(mod.EntitySyncManager).toBeDefined();
|
||||
expect(mod.InterestManager).toBeDefined();
|
||||
expect(mod.ServerAuthority).toBeDefined();
|
||||
expect(mod.createMemoryHub).toBeDefined();
|
||||
expect(mod.MemoryTransport).toBeDefined();
|
||||
expect(mod.useNetworkSync).toBeDefined();
|
||||
expect(mod.useNetworkedEntity).toBeDefined();
|
||||
expect(mod.useNetworkPredict).toBeDefined();
|
||||
expect(mod.NetworkProvider).toBeDefined();
|
||||
expect(mod.NetworkContext).toBeDefined();
|
||||
expect(mod.NetworkedEntity).toBeDefined();
|
||||
expect(mod.applyNetworkedFrame).toBeDefined();
|
||||
expect(mod.NetworkStats).toBeDefined();
|
||||
});
|
||||
});
|
||||
11
packages/threejs/r3f/r3f-networking/tests/setup/polyfills.ts
Normal file
11
packages/threejs/r3f/r3f-networking/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// React-specific polyfills. The base webcrypto polyfill loads via
|
||||
// `@suaveplan/testing/polyfills`. This sets the React 18+
|
||||
// `IS_REACT_ACT_ENVIRONMENT` flag so DOM assertions outside `act(...)`
|
||||
// don't generate runtime warnings.
|
||||
interface ReactActEnvWindow {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
const win = globalThis as unknown as ReactActEnvWindow;
|
||||
if (win.IS_REACT_ACT_ENVIRONMENT === undefined) {
|
||||
win.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// 2-client multiplayer integration test.
|
||||
//
|
||||
// Uses the deterministic `MemoryTransport` shipped in this package as the real
|
||||
// `@suaveplan/network` Transport implementation. Real WebRTC E2E (via
|
||||
// `@suaveplan/network-transport-webrtc`) would require Playwright + a TURN
|
||||
// server, both out of scope for the unit-test layer; the canonical wiring at
|
||||
// the application boundary swaps `MemoryTransport` for the WebRTC transport
|
||||
// without touching the rest of the stack since both implement the same
|
||||
// structural `Transport` contract.
|
||||
//
|
||||
// This test exercises:
|
||||
// 1. Two peers connect over the memory hub.
|
||||
// 2. The server peer authors a snapshot frame, encodes it, broadcasts it.
|
||||
// 3. The client peer decodes the frame, feeds it into a sync manager,
|
||||
// runs interpolation, and reads the smoothed transform.
|
||||
// 4. The client builds and sends a delta back; the server applies it.
|
||||
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
applyDeltaFrame,
|
||||
buildDeltaFrame,
|
||||
createMemoryHub,
|
||||
decodeDeltaFrame,
|
||||
decodeSnapshotFrame,
|
||||
type EntitySnapshot,
|
||||
EntitySyncManager,
|
||||
encodeDeltaFrame,
|
||||
encodeSnapshotFrame,
|
||||
MemoryTransport,
|
||||
type SnapshotFrame,
|
||||
} from "../src/index.js";
|
||||
|
||||
const SNAPSHOT_TYPE = "r3f_net.snapshot";
|
||||
const DELTA_TYPE = "r3f_net.delta";
|
||||
|
||||
describe("2-client integration over MemoryTransport", () => {
|
||||
it("round-trips a snapshot from server to client and a delta back", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hub = createMemoryHub();
|
||||
const server = new MemoryTransport("server", hub);
|
||||
const client = new MemoryTransport("c1", hub);
|
||||
|
||||
const clientSync = new EntitySyncManager({
|
||||
sendRate: 60,
|
||||
interpolationDelay: 0,
|
||||
});
|
||||
clientSync.registerEntity("e1", "server", false);
|
||||
|
||||
let receivedSnapshot: SnapshotFrame | null = null;
|
||||
client.onMessage((_from, env) => {
|
||||
if (env.type === SNAPSHOT_TYPE) {
|
||||
receivedSnapshot = decodeSnapshotFrame(env.payload as string);
|
||||
}
|
||||
});
|
||||
|
||||
const receivedDeltas: number[] = [];
|
||||
server.onMessage((_from, env) => {
|
||||
if (env.type === DELTA_TYPE) {
|
||||
const d = decodeDeltaFrame(env.payload as string);
|
||||
receivedDeltas.push(d.seq);
|
||||
}
|
||||
});
|
||||
|
||||
await server.connect("c1", "memory");
|
||||
await client.connect("server", "memory");
|
||||
|
||||
// Step 1: server → client snapshot.
|
||||
const baseState: EntitySnapshot = {
|
||||
e1: {
|
||||
position: [1, 0, 0],
|
||||
rotation: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
};
|
||||
const baseFrame: SnapshotFrame = {
|
||||
seq: 1,
|
||||
timestamp: 1000,
|
||||
state: baseState,
|
||||
};
|
||||
await server.send(
|
||||
"c1",
|
||||
createEnvelope({
|
||||
type: SNAPSHOT_TYPE,
|
||||
senderId: "server",
|
||||
targetId: "c1",
|
||||
payload: encodeSnapshotFrame(baseFrame),
|
||||
})
|
||||
);
|
||||
|
||||
expect(receivedSnapshot).not.toBeNull();
|
||||
const restored = receivedSnapshot as unknown as SnapshotFrame;
|
||||
expect(restored.seq).toBe(1);
|
||||
expect(restored.state.e1?.position[0]).toBe(1);
|
||||
|
||||
// Feed the snapshot through the client sync layer.
|
||||
clientSync.receiveUpdate({
|
||||
entityId: "e1",
|
||||
transform: restored.state.e1!,
|
||||
timestamp: restored.timestamp,
|
||||
seq: restored.seq,
|
||||
});
|
||||
clientSync.receiveUpdate({
|
||||
entityId: "e1",
|
||||
transform: { ...restored.state.e1!, position: [2, 0, 0] },
|
||||
timestamp: restored.timestamp + 100,
|
||||
seq: 2,
|
||||
});
|
||||
clientSync.updateRemoteEntities(restored.timestamp + 100);
|
||||
const e = clientSync.getEntity("e1");
|
||||
expect(e?.transform.position[0]).toBeCloseTo(2);
|
||||
|
||||
// Step 2: client → server delta.
|
||||
const nextFrame: SnapshotFrame = {
|
||||
seq: 2,
|
||||
timestamp: 1100,
|
||||
state: {
|
||||
e1: { ...baseState.e1!, position: [3, 0, 0] },
|
||||
},
|
||||
};
|
||||
const delta = buildDeltaFrame(baseFrame, nextFrame);
|
||||
await client.send(
|
||||
"server",
|
||||
createEnvelope({
|
||||
type: DELTA_TYPE,
|
||||
senderId: "c1",
|
||||
targetId: "server",
|
||||
payload: encodeDeltaFrame(delta),
|
||||
})
|
||||
);
|
||||
|
||||
expect(receivedDeltas).toEqual([2]);
|
||||
|
||||
// Server applies the delta to confirm the round-trip is valid.
|
||||
const applied = applyDeltaFrame(baseFrame, delta);
|
||||
expect(applied.state.e1?.position[0]).toBe(3);
|
||||
|
||||
await server.close();
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
5
packages/threejs/r3f/r3f-networking/tsconfig.json
Normal file
5
packages/threejs/r3f/r3f-networking/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
20
packages/threejs/r3f/r3f-networking/tsconfig.lib.json
Normal file
20
packages/threejs/r3f/r3f-networking/tsconfig.lib.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.stories.tsx",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
51
packages/threejs/r3f/r3f-networking/vite.config.ts
Normal file
51
packages/threejs/r3f/r3f-networking/vite.config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/r3f-networking",
|
||||
plugins: [
|
||||
react(),
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/diff",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/fsm",
|
||||
"@suaveplan/network",
|
||||
"@suaveplan/serialize",
|
||||
"@suaveplan/timing",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"three",
|
||||
"@react-three/fiber",
|
||||
"@react-three/drei",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
56
packages/threejs/r3f/r3f-networking/vitest.config.ts
Normal file
56
packages/threejs/r3f/r3f-networking/vitest.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "happy-dom",
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
setupFiles: [
|
||||
"@suaveplan/testing/polyfills",
|
||||
"./tests/setup/polyfills.ts",
|
||||
],
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"tests/**/*.test.ts",
|
||||
],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/*.stories.tsx",
|
||||
"src/**/index.ts",
|
||||
// R3F components require <Canvas> at runtime which cannot be
|
||||
// mounted under happy-dom (WebGL unavailable). The pure
|
||||
// per-frame logic lives in `apply-networked-frame.ts` and is
|
||||
// 100% covered.
|
||||
"src/components/networked-entity/networked-entity.tsx",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user