Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
# @suaveplan/bitpacker-network - Bitpacker network plugin
## Summary
Network simulation utilities for testing bitpacker under realistic network conditions.
<!-- begin:auto-docs -->
## Documentation tree
- [defaults](./src/defaults/defaults.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-clock](./src/defaults/default-clock.md) — `defaultClock` is the **single allow-listed wall-clock source** in `@suaveplan/bitpacker-network`. The determin…
- [default-rng](./src/defaults/default-rng.md) — `defaultRng` is the canonical CSPRNG-backed `Rng` factory used by `NetworkSimulator` and any other deterministic-when-a…
- [defaults](./src/defaults/defaults.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [register-schemas](./src/register-schemas/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-network` package. Exposes `regis…
- [register-schemas](./src/register-schemas/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-network` package. Exposes `regis…
- [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - simulator.schema — Zod schema for `NetworkSimulator…
- [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - simulator.schema — Zod schema for `NetworkSimulator…
- [simulator.schema](./src/simulator/simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - types.schema — Zod schema for `NetworkSimulationOpt…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - types.schema — Zod schema for `NetworkSimulationOpt…
- [types.schema](./src/types/types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
- [index](./src/index.md) — **Path:** `bitpacker-network/src/index.ts` **Export:** See source for details.
<!-- end:auto-docs --> - [default-clock](./src/defaults/default-clock.md) — `defaultClock` is the **single allow-listed wall-clock source** in `@suaveplan/bitpacker-network`. The determin…
- [default-rng](./src/defaults/default-rng.md) — `defaultRng` is the canonical CSPRNG-backed `Rng` factory used by `NetworkSimulator` and any other deterministic-when-a…
- [defaults](./src/defaults/defaults.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- simulator.schema — Zod schema for `NetworkSimulator…
- [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- simulator.schema — Zod schema for `NetworkSimulator…
- [simulator.schema](./src/simulator/simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schema for `NetworkSimulationOpt…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schema for `NetworkSimulationOpt…
- [types.schema](./src/types/types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
- [index](./src/index.md) — **Path:** `bitpacker-network/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-network` package. Exposes `regis…
- [default-clock](./src/defaults/default-clock.md) — `defaultClock` is the **single allow-listed wall-clock source** in `@suaveplan/bitpacker-network`. The determin…
- [default-rng](./src/defaults/default-rng.md) — `defaultRng` is the canonical CSPRNG-backed `Rng` factory used by `NetworkSimulator` and any other deterministic-when-a…
- [defaults](./src/defaults/defaults.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- simulator.schema — Zod schema for `NetworkSimulator…
- [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- simulator.schema — Zod schema for `NetworkSimulator…
- [simulator.schema](./src/simulator/simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schema for `NetworkSimulationOpt…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schema for `NetworkSimulationOpt…
- [types.schema](./src/types/types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
- [index](./src/index.md) — **Path:** `bitpacker-network/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-network` package. Exposes `regis…
- [default-clock](./src/defaults/default-clock.md) — `defaultClock` is the **single allow-listed wall-clock source** in `@suaveplan/bitpacker-network`. The determin…
- [default-rng](./src/defaults/default-rng.md) — `defaultRng` is the canonical CSPRNG-backed `Rng` factory used by `NetworkSimulator` and any other deterministic-when-a…
- [defaults](./src/defaults/defaults.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- simulator.schema — Zod schema for `NetworkSimulator…
- [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- simulator.schema — Zod schema for `NetworkSimulator…
- [simulator.schema](./src/simulator/simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schema for `NetworkSimulationOpt…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- types.schema — Zod schema for `NetworkSimulationOpt…
- [types.schema](./src/types/types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
- [index](./src/index.md) — **Path:** `bitpacker-network/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-network` package. Exposes `regis…
- [default-clock](./src/defaults/default-clock.md) — `defaultClock` is the **single allow-listed wall-clock source** in `@suaveplan/bitpacker-network`. The determin…
- [default-rng](./src/defaults/default-rng.md) — `defaultRng` is the canonical CSPRNG-backed `Rng` factory used by `NetworkSimulator` and any other deterministic-when-a…
- [defaults](./src/defaults/defaults.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [simulator](./src/simulator/simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [simulator.schema](./src/simulator/simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types.schema](./src/types/types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
- [index](./src/index.md) — **Path:** `bitpacker-network/src/index.ts` **Export:** See source for details.
- [register-schemas](./src/register-schemas.md) — Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-network` package. Exposes `regis…
## Install
```bash
bun add @suaveplan/bitpacker-network
```
## Usage
```typescript
import { NetworkSimulator } from "@suaveplan/bitpacker-network";
const sim = new NetworkSimulator({ latency: 50, jitter: 10, packetLoss: 0.02 });
sim.send(new Uint8Array([1, 2, 3]));
const received = sim.receive(); // may drop packet based on loss rate
```
## Modules
- `src/index.ts` — `NetworkSimulator`: configurable latency, jitter, and packet loss simulation

View File

@@ -0,0 +1,62 @@
{
"name": "@suaveplan/bitpacker-network",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Bitpacker network plugin",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"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"
}
},
"./register-schemas": {
"import": {
"types": "./dist/register-schemas/index.d.ts",
"default": "./dist/register-schemas/index.js"
},
"require": {
"types": "./dist/register-schemas/index.d.ts",
"default": "./dist/register-schemas/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/"
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/bitpacker": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/random": "workspace:^",
"@suaveplan/schema-registry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/pseudo-random": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,45 @@
# default-clock
## Purpose
`defaultClock` is the **single allow-listed wall-clock source** in `@suaveplan/bitpacker-network`. The determinism rule is that `Date.now()` may not appear at module scope in implementation files — every primitive that needs to read time must accept an injected `Clock` from `@suaveplan/types`. This file is the one exception: it provides the production-default `Clock` that callers can use when they don't need deterministic-replay behaviour, while keeping a clean grep boundary for the spec-conformance test that scans the package.
## Features
- Production-grade real-time clock backed by `Date.now()`.
- Implements the canonical `Clock` interface from `@suaveplan/types`.
- Single allow-listed `Date.now()` site in the package — every other module reads time via injection only.
- Trivially overridable in tests by injecting a `FakeClock` from `@suaveplan/testing`.
## Usage: Basic Example
```ts
import { defaultClock } from "@suaveplan/bitpacker-network";
const now = defaultClock(); // milliseconds since UNIX epoch
```
## Usage: Advanced Example
```ts
import { FakeClock } from "@suaveplan/testing";
import { NetworkSimulator } from "@suaveplan/bitpacker-network";
// Deterministic timing in tests: drive the simulator with a FakeClock
// instead of the wall clock, then advance it explicitly.
const fakeClock = new FakeClock(0);
const sim = new NetworkSimulator({ clock: fakeClock.now });
fakeClock.advance(1000);
```
## API Reference
### `defaultClock: Clock`
- **Type:** `Clock` (from `@suaveplan/types`) — i.e. `() => number`.
- **Returns:** Wall-clock milliseconds since the UNIX epoch via `Date.now()`.
## Implementation Notes
- The `Clock` interface in `@suaveplan/types` is `() => number` — no instance state, just a function call.
- This is intentionally a `const` exported value (not a factory). All callers share the same function reference; testing replaces it by passing a different `Clock` into the consumer's constructor, never by mutating this export.
- The spec-conformance test `simulator.spec-conformance.test.ts` scans for `Date.now()` outside this file and fails the build if any other implementation file calls it. That's the contract this module exists to satisfy.

View File

@@ -0,0 +1,11 @@
/**
* Single source of module-scope wall-clock reads for bitpacker-network.
*
* The genesis convention is that determinism rule (no module-scope
* `Date.now()` in implementation files) is enforced by allow-listing ONE
* factory per package. Every other module in this package reads time only
* through the injected `Clock` parameter.
*/
import type { Clock } from "@suaveplan/types";
export const defaultClock: Clock = () => Date.now();

View File

@@ -0,0 +1,45 @@
# default-rng
## Purpose
`defaultRng` is the canonical CSPRNG-backed `Rng` factory used by `NetworkSimulator` and any other deterministic-when-asked, random-by-default primitive in `@suaveplan/bitpacker-network`. It exists so callers don't have to reach into `@suaveplan/random` directly to obtain the standard random source — and so that swapping the simulator to a seeded RNG in tests is a single keyword argument away.
The factory delegates to `cryptoRng()` from `@suaveplan/random`, which uses `globalThis.crypto.getRandomValues` underneath. In environments without a CSPRNG (some embedded runtimes) the underlying call throws a `RANDOM.SOURCE.UNAVAILABLE` error. We surface that directly: the policy is "if you don't have a CSPRNG, you must inject a seeded `Rng` yourself" — never silently downgrade to `Math.random()`.
## Features
- Zero-config default for production — call `defaultRng()` and you get a `Rng` instance backed by Web Crypto.
- Errors surface immediately when the host lacks a CSPRNG.
- Trivially overridable in tests by passing a `Mulberry32Rng` (or any `Rng`) into the simulator constructor.
- No module-scope state; each call returns a fresh `Rng` instance.
## Usage: Basic Example
```ts
import { defaultRng } from "@suaveplan/bitpacker-network";
const rng = defaultRng();
const value = rng.nextFloat(); // [0, 1)
```
## Usage: Advanced Example
```ts
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
import { NetworkSimulator } from "@suaveplan/bitpacker-network";
// Deterministic test: seed a Mulberry32 PRNG instead of using defaultRng().
const sim = new NetworkSimulator({ rng: new Mulberry32Rng(42), clock: testClock });
```
## API Reference
### `defaultRng(): Rng`
- **Returns:** A new `Rng` instance backed by `cryptoRng()` from `@suaveplan/random`.
- **Throws:** Re-throws `RANDOM.SOURCE.UNAVAILABLE` from `cryptoRng()` if `globalThis.crypto.getRandomValues` is unavailable.
## Implementation Notes
- `Rng` interface from `@suaveplan/types` is the canonical contract; `cryptoRng()` and `Mulberry32Rng` both implement it.
- We surface `cryptoRng()` directly rather than wrapping in a try/catch because callers should know if their environment lacks a CSPRNG — silent fallback would defeat determinism guarantees elsewhere in the simulator.
- This factory is used as the default for `NetworkSimulator`'s `rng` constructor option.

View File

@@ -0,0 +1,14 @@
/**
* Default RNG factory for bitpacker-network.
*
* Wraps `cryptoRng()` from `@suaveplan/random` so callers don't have to
* reach into another package for the standard CSPRNG-backed source. The
* `cryptoRng()` factory throws `RANDOM.SOURCE.UNAVAILABLE` if the host
* environment lacks `globalThis.crypto.getRandomValues`; we surface that
* directly so consumers know to inject a seeded `Rng` in environments
* without a CSPRNG (or in tests that need deterministic replay).
*/
import { cryptoRng } from "@suaveplan/random";
import type { Rng } from "@suaveplan/types";
export const defaultRng = (): Rng => cryptoRng();

View File

@@ -0,0 +1,58 @@
# Defaults — Clock and RNG factories
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/bitpacker-network` enforces the determinism rule (no module-scope `Date.now()` or `Math.random()` calls in implementation files). To do that without infecting every consumer with mandatory `clock` / `rng` parameters, this package allow-lists ONE factory per source: `defaultClock` and `defaultRng()`. Every other module reads time and randomness only through the injected primitives passed at construction time, defaulting to these factories.
This is the same pattern `@suaveplan/events` uses (`events/clock/default-clock.ts`) and is the single explicit boundary between deterministic, replayable code paths and the host environment's wall clock / CSPRNG.
## Features
- `defaultClock` — `Clock = () => Date.now()`. Produces ms-since-epoch reads from the host wall clock.
- `defaultRng()` — factory that wraps `@suaveplan/random::cryptoRng()`. Returns a fresh CSPRNG-backed `Rng` per call. Throws if the host has no `crypto.getRandomValues`.
## Usage: Basic Example
```typescript
import { NetworkSimulator } from "@suaveplan/bitpacker-network";
// Without overrides: NetworkSimulator wires defaultClock and defaultRng() internally.
const sim = new NetworkSimulator();
```
## Usage: Advanced Example
```typescript
import { NetworkSimulator } from "@suaveplan/bitpacker-network";
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
// Tests: inject a seeded RNG for reproducible packet-loss patterns.
const seededRng = new Mulberry32Rng(0xDEADBEEF);
const fakeClock = (): number => 0; // every call returns t=0
const sim = new NetworkSimulator({
rng: seededRng,
clock: fakeClock,
});
// Two simulators with the same seed produce byte-identical loss sequences.
```
## API Reference
### `defaultClock: Clock`
Constant. `() => number` returning the current millisecond timestamp via `Date.now()`. Suitable for the simulator's `startTime`, `lastBandwidthCheck`, packet `timestamp`, and `uptime` calculations. NOT monotonic — consumers needing strict monotonicity should inject a different `Clock`.
### `defaultRng(): Rng`
Factory function. Returns a fresh `Rng` backed by `globalThis.crypto.getRandomValues`. Throws `RandomError` (code `RANDOM.SOURCE.UNAVAILABLE`) if the host environment lacks the API. The simulator's constructor calls this lazily — if the consumer always provides their own `rng` option, the factory is never invoked, so older / non-browser hosts that lack `crypto.getRandomValues` can still use the simulator with a seeded `Rng`.
## Implementation Notes
The package's `src/simulator/simulator.spec-conformance.test.ts` includes a grep guard that scans implementation files for `Date.now()` / `Math.random()` and fails the build if either appears outside `defaults/default-clock.ts` and `defaults/default-rng.ts`. Those two files are explicitly allow-listed in the grep — that's the integrity boundary that keeps the rest of the package deterministic given identical injected primitives.
If a future addition needs another determinism-violating API (e.g. `performance.now()`, `process.hrtime()`), the convention is: add a new file under `defaults/` and extend the spec-conformance allow-list to include it. Spreading the violations across implementation files is what the rule prevents.

View File

@@ -0,0 +1,8 @@
## index
**Path:** `bitpacker-network/src/index.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,101 @@
/**
* Network simulation primitives for bit-packed payload pipelines.
*
* Public surface:
* - `NetworkSimulator` + `createNetworkSimulator` — full configurable simulator
* ported from upstream with `Rng` / `Clock` injection (the 622-line
* implementation that was previously misplaced in `bitpacker-game`).
* - `NetworkSimulatorEvents`, `NetworkSimulatorConstructorOptions` — typed
* event map and constructor option shape.
* - Type companions: `NetworkCondition`, `PacketStatus`, `PacketInfo`,
* `NetworkSimulationOptions`, `NetworkSimulationStats`,
* `NETWORK_CONDITIONS`, `DEFAULT_NETWORK_SIMULATION_OPTIONS`.
* - Three free functions (`simulateLatency`, `shouldDropPacket`,
* `applyNetworkConditions`) preserved for back-compat. They now accept an
* optional `Rng` parameter so callers can inject deterministic randomness.
* Without the parameter they fall back to `cryptoRng()`.
* - `defaultClock`, `defaultRng` — single-source determinism factories.
*/
import type { Rng } from "@suaveplan/types";
import { defaultRng } from "./defaults/default-rng.js";
export { defaultClock } from "./defaults/default-clock.js";
export { defaultRng } from "./defaults/default-rng.js";
export {
createNetworkSimulator,
NetworkSimulator,
type NetworkSimulatorConstructorOptions,
type NetworkSimulatorEvents,
} from "./simulator/simulator.js";
export {
DEFAULT_NETWORK_SIMULATION_OPTIONS,
NETWORK_CONDITIONS,
NetworkCondition,
type NetworkSimulationOptions,
type NetworkSimulationStats,
type PacketInfo,
PacketStatus,
} from "./types/types.js";
/**
* Compact triple of network conditions used by the legacy free-function
* helpers (`simulateLatency`, `shouldDropPacket`, `applyNetworkConditions`).
* `NetworkSimulator` consumers should use `NetworkSimulationOptions` instead;
* `NetworkConditions` is preserved for back-compat with code written against
* the pre-port stub.
*/
export interface NetworkConditions {
readonly latencyMs: number;
readonly jitterMs: number;
readonly packetLossRate: number;
}
export const DEFAULT_NETWORK_CONDITIONS: NetworkConditions = {
latencyMs: 50,
jitterMs: 10,
packetLossRate: 0.0,
};
/**
* Simulate network latency with jitter. Returns a non-negative ms delay
* sampled uniformly from `[latencyMs - jitterMs, latencyMs + jitterMs]`,
* clamped to 0. Accepts an optional `Rng`; defaults to `cryptoRng()`.
*/
export function simulateLatency(
conditions: NetworkConditions = DEFAULT_NETWORK_CONDITIONS,
rng?: Rng
): number {
const r = rng ?? defaultRng();
const jitter = (r.nextFloat() - 0.5) * 2 * conditions.jitterMs;
return Math.max(0, conditions.latencyMs + jitter);
}
/**
* Decide whether a packet should be dropped. Probability is
* `conditions.packetLossRate` in [0, 1]. Accepts an optional `Rng`;
* defaults to `cryptoRng()`.
*/
export function shouldDropPacket(
conditions: NetworkConditions = DEFAULT_NETWORK_CONDITIONS,
rng?: Rng
): boolean {
const r = rng ?? defaultRng();
return r.nextFloat() < conditions.packetLossRate;
}
/**
* Apply network conditions to a byte array. Returns the delayed payload
* (or `undefined` if the packet is dropped). Accepts an optional `Rng`;
* defaults to `cryptoRng()`.
*/
export function applyNetworkConditions(
payload: Uint8Array,
conditions: NetworkConditions = DEFAULT_NETWORK_CONDITIONS,
rng?: Rng
): Uint8Array | undefined {
if (shouldDropPacket(conditions, rng)) {
return undefined;
}
return payload;
}

View File

@@ -0,0 +1,107 @@
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
applyNetworkConditions,
shouldDropPacket,
simulateLatency,
} from "./index.js";
const seededRng = (): Mulberry32Rng => new Mulberry32Rng(0xc0ffee);
describe("network legacy helpers", () => {
it("simulates latency within range", () => {
const latency = simulateLatency(
{
latencyMs: 100,
jitterMs: 20,
packetLossRate: 0,
},
seededRng()
);
expect(latency).toBeGreaterThanOrEqual(80);
expect(latency).toBeLessThanOrEqual(120);
});
it("never drops with zero loss rate", () => {
const rng = seededRng();
for (let i = 0; i < 100; i++) {
expect(
shouldDropPacket(
{
latencyMs: 50,
jitterMs: 0,
packetLossRate: 0,
},
rng
)
).toBe(false);
}
});
it("passes payload through network", () => {
const payload = new Uint8Array([1, 2, 3]);
const result = applyNetworkConditions(
payload,
{
latencyMs: 50,
jitterMs: 0,
packetLossRate: 0,
},
seededRng()
);
expect(result).toEqual(payload);
});
it("drops packet with 100% loss rate", () => {
const payload = new Uint8Array([1, 2, 3]);
const result = applyNetworkConditions(
payload,
{
latencyMs: 50,
jitterMs: 0,
packetLossRate: 1,
},
seededRng()
);
expect(result).toBeUndefined();
});
it("uses default conditions when none provided", () => {
const latency = simulateLatency(undefined, seededRng());
expect(latency).toBeGreaterThanOrEqual(40);
expect(latency).toBeLessThanOrEqual(60);
expect(shouldDropPacket(undefined, seededRng())).toBe(false);
const payload = new Uint8Array([1]);
expect(applyNetworkConditions(payload, undefined, seededRng())).toEqual(
payload
);
});
it("falls back to defaultRng when no Rng supplied", () => {
// In this test env the host lacks crypto.getRandomValues, so the
// default RNG factory throws RandomError. The genesis determinism
// rule forbids silent Math.random fallback; either crypto is
// available (function returns) or it isn't (function throws).
// Both outcomes exercise the fallback branch.
try {
simulateLatency();
// If it didn't throw, the host has crypto.getRandomValues.
expect(true).toBe(true);
} catch (e) {
expect(e).toBeInstanceOf(Error);
expect((e as Error).message).toMatch(
/random\.source\.unavailable|getRandomValues|crypto/i
);
}
try {
shouldDropPacket();
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
try {
applyNetworkConditions(new Uint8Array([1]));
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
});
});

View File

@@ -0,0 +1,83 @@
# Bitpacker Network OTel Instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OTel meter, tracer, counter, and histogram declarations for `@suaveplan/bitpacker-network`. Ships canonical instrumentation primitives for the `NetworkSimulator`'s send/receive boundaries plus the burst-loss event. Centralizing the metric/span vocabulary here lets a downstream consumer attach observability to the simulator without re-deriving names.
The metric and span names follow the suaveplan convention with snake-case for the multi-word package: tracer/meter named `@suaveplan/bitpacker-network`; spans named `bitpacker_network.<operation>` (`send`, `receive`, `burst`); counters/histograms named `bitpacker_network.<scope>.<metric>`.
## Features
- `getBitPackerNetworkTracer()` — returns the package's tracer
- `networkPacketsTotal` — counter labelled by `status` (one of the five `PacketStatus` values)
- `networkLatencyMs` — histogram of simulated latency for delivered packets
- `networkPacketBytes` — histogram of per-packet byte size, labelled by `status`
- `BITPACKER_NETWORK_SPAN_NAMES` — three canonical span names
## Usage: Basic Example
```ts
import {
BITPACKER_NETWORK_SPAN_NAMES,
networkPacketsTotal,
getBitPackerNetworkTracer,
} from "@suaveplan/bitpacker-network";
const tracer = getBitPackerNetworkTracer();
tracer.startActiveSpan(BITPACKER_NETWORK_SPAN_NAMES.send, (span) => {
try {
// send work
networkPacketsTotal.add(1, { status: "delivered" });
} finally {
span.end();
}
});
```
## Usage: Advanced Example
```ts
import {
networkLatencyMs,
networkPacketsTotal,
PacketStatus,
} from "@suaveplan/bitpacker-network";
function recordOutcome(status: PacketStatus, latencyMs: number, bytes: number) {
networkPacketsTotal.add(1, { status });
if (status === PacketStatus.DELIVERED) {
networkLatencyMs.record(latencyMs);
}
}
```
## API Reference
### `getBitPackerNetworkTracer(): Tracer`
Returns the OTel `Tracer` instance for the package.
### `networkPacketsTotal: Counter`
Counter labelled by `status: "delivered" | "lost" | "duplicated" | "corrupted" | "reordered"`.
### `networkLatencyMs: Histogram`
Histogram of simulated per-packet latency in milliseconds, recorded only on `delivered` packets.
### `networkPacketBytes: Histogram`
Histogram of per-packet byte size, labelled by `status`.
### `BITPACKER_NETWORK_SPAN_NAMES`
Const map: `{ send, receive, burst }` → canonical span names.
## Implementation Notes
- **Status is a label, not separate counters.** `networkPacketsTotal{status="lost"}` is more flexible than separate `dropped_total`/`reordered_total`/etc; new packet states fold in without schema churn.
- **Latency only on delivered.** Lost/dropped packets have no meaningful latency. Recording would distort the latency histogram with zeros.
- **No per-packet spans.** A simulator running 10k packets/s would produce 10k spans/s if each were instrumented. The counter-and-histogram pattern is sufficient at this rate.

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BITPACKER_NETWORK_SPAN_NAMES,
getBitPackerNetworkTracer,
networkLatencyMs,
networkPacketBytes,
networkPacketsTotal,
} from "./instruments.js";
describe("OTel instruments — declarations", () => {
it("exposes the canonical span names", () => {
expect(BITPACKER_NETWORK_SPAN_NAMES.send).toBe(
"bitpacker_network.send"
);
expect(BITPACKER_NETWORK_SPAN_NAMES.receive).toBe(
"bitpacker_network.receive"
);
expect(BITPACKER_NETWORK_SPAN_NAMES.burst).toBe(
"bitpacker_network.burst"
);
});
it("counters and histograms are constructed without error", () => {
expect(networkPacketsTotal).toBeDefined();
expect(networkLatencyMs).toBeDefined();
expect(networkPacketBytes).toBeDefined();
});
it("tracer is reachable", () => {
expect(getBitPackerNetworkTracer()).toBeDefined();
});
it("counters accept the documented labels", () => {
for (const status of [
"delivered",
"lost",
"duplicated",
"corrupted",
"reordered",
]) {
expect(() => networkPacketsTotal.add(1, { status })).not.toThrow();
}
});
});

View File

@@ -0,0 +1,48 @@
import { metrics, type Tracer, trace } from "@opentelemetry/api";
const meter = metrics.getMeter("@suaveplan/bitpacker-network");
export function getBitPackerNetworkTracer(): Tracer {
return trace.getTracer("@suaveplan/bitpacker-network");
}
/**
* Counter incremented per packet processed by the simulator. Labels:
* `status: "delivered" | "lost" | "duplicated" | "corrupted" | "reordered"`.
*/
export const networkPacketsTotal = meter.createCounter(
"bitpacker_network.packets.total",
{
description: "Total packets traversing the network simulator",
unit: "{packets}",
}
);
/**
* Histogram of per-packet simulated latency in milliseconds. Recorded only
* on `delivered` packets.
*/
export const networkLatencyMs = meter.createHistogram(
"bitpacker_network.latency_ms",
{
description: "Simulated per-packet latency in milliseconds",
unit: "ms",
}
);
/**
* Histogram of per-packet byte size, labelled by `status`.
*/
export const networkPacketBytes = meter.createHistogram(
"bitpacker_network.packet.bytes",
{
description: "Bytes per packet traversing the network simulator",
unit: "By",
}
);
export const BITPACKER_NETWORK_SPAN_NAMES = {
send: "bitpacker_network.send",
receive: "bitpacker_network.receive",
burst: "bitpacker_network.burst",
} as const;

View File

@@ -0,0 +1,5 @@
export {
BITPACKER_NETWORK_SCHEMA_VERSION,
BITPACKER_NETWORK_SUBJECT,
registerBitPackerNetworkSchemas,
} from "./register-schemas.js";

View File

@@ -0,0 +1,61 @@
# Register Schemas — `@suaveplan/bitpacker-network`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Idempotent integration with `@suaveplan/schema-registry` for the `@suaveplan/bitpacker-network` package. Exposes `registerBitPackerNetworkSchemas(registry)` which converts the package's two public boundary schemas into JSON Schema form and registers them under `bitpacker:@suaveplan/bitpacker-network` at version 1. Re-registration on the same registry is a no-op.
The two schemas covered are: `networkSimulationOptions` (the user-facing simulation profile) and `networkSimulatorConstructorOptions` (the same options plus the `rng`/`clock` determinism injection points).
## Features
- `BITPACKER_NETWORK_SUBJECT` constant — `"bitpacker:@suaveplan/bitpacker-network"`
- `BITPACKER_NETWORK_SCHEMA_VERSION` constant — `1`
- `registerBitPackerNetworkSchemas(registry)` — idempotent registration
## Usage: Basic Example
```ts
import { createRegistry } from "@suaveplan/schema-registry";
import {
BITPACKER_NETWORK_SUBJECT,
registerBitPackerNetworkSchemas,
} from "@suaveplan/bitpacker-network/register-schemas";
const registry = createRegistry();
registerBitPackerNetworkSchemas(registry);
```
## Usage: Advanced Example
```ts
import { createRegistry, SchemaRegistry } from "@suaveplan/schema-registry";
import { registerBitPackerSchemas } from "@suaveplan/bitpacker/register-schemas";
import { registerBitPackerNetworkSchemas } from "@suaveplan/bitpacker-network/register-schemas";
const registry = createRegistry();
registerBitPackerSchemas(registry);
registerBitPackerNetworkSchemas(registry);
```
## API Reference
### `BITPACKER_NETWORK_SUBJECT: "bitpacker:@suaveplan/bitpacker-network"`
Const string.
### `BITPACKER_NETWORK_SCHEMA_VERSION: 1`
Const number.
### `registerBitPackerNetworkSchemas(registry: SchemaRegistry): void`
Idempotent registration. Returns without writing if the subject is already present.
## Implementation Notes
- **Two-key payload.** `networkSimulationOptions` for the user-facing config; `networkSimulatorConstructorOptions` for the constructor bundle that adds `rng`/`clock`.
- **`unrepresentable: "any"`.** Required because the constructor schema uses `z.custom` for the `Rng` and `Clock` injection points.

View File

@@ -0,0 +1,33 @@
import { createRegistry } from "@suaveplan/schema-registry";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BITPACKER_NETWORK_SCHEMA_VERSION,
BITPACKER_NETWORK_SUBJECT,
registerBitPackerNetworkSchemas,
} from "./register-schemas.js";
describe("registerBitPackerNetworkSchemas", () => {
it("registers under the namespaced subject at version 1", () => {
const registry = createRegistry();
registerBitPackerNetworkSchemas(registry);
expect(registry.getLatest(BITPACKER_NETWORK_SUBJECT).version).toBe(
BITPACKER_NETWORK_SCHEMA_VERSION
);
});
it("includes every public schema in the payload", () => {
const registry = createRegistry();
registerBitPackerNetworkSchemas(registry);
const payload = registry.getLatest(BITPACKER_NETWORK_SUBJECT)
.schema as Record<string, unknown>;
expect(payload).toHaveProperty("networkSimulationOptions");
expect(payload).toHaveProperty("networkSimulatorConstructorOptions");
});
it("is idempotent across repeated calls", () => {
const registry = createRegistry();
registerBitPackerNetworkSchemas(registry);
registerBitPackerNetworkSchemas(registry);
expect(registry.getLatest(BITPACKER_NETWORK_SUBJECT).version).toBe(1);
});
});

View File

@@ -0,0 +1,48 @@
import type { SchemaRegistry } from "@suaveplan/schema-registry";
import z from "zod";
import { networkSimulatorConstructorOptionsSchema } from "../simulator/simulator.schema.js";
import { networkSimulationOptionsSchema } from "../types/types.schema.js";
export const BITPACKER_NETWORK_SUBJECT =
"bitpacker:@suaveplan/bitpacker-network" as const;
export const BITPACKER_NETWORK_SCHEMA_VERSION = 1;
interface Payload {
readonly networkSimulationOptions: unknown;
readonly networkSimulatorConstructorOptions: unknown;
}
function buildPayload(): Payload {
const opts = { unrepresentable: "any" } as const;
return {
networkSimulationOptions: z.toJSONSchema(
networkSimulationOptionsSchema,
opts
),
networkSimulatorConstructorOptions: z.toJSONSchema(
networkSimulatorConstructorOptionsSchema,
opts
),
};
}
/**
* Idempotently register `@suaveplan/bitpacker-network`'s public Zod schemas
* with the given `SchemaRegistry`.
*/
export function registerBitPackerNetworkSchemas(
registry: SchemaRegistry
): void {
if (registry.listSubjects().includes(BITPACKER_NETWORK_SUBJECT)) {
return;
}
registry.register(
BITPACKER_NETWORK_SUBJECT,
buildPayload(),
"json-schema",
{
package: "@suaveplan/bitpacker-network",
namespace: "bitpacker",
}
);
}

View File

@@ -0,0 +1,156 @@
# NetworkSimulator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - simulator.schema — Zod schema for `NetworkSimulator…
- [simulator.schema](./simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
- simulator.schema — Zod schema for `NetworkSimulator…
- [simulator.schema](./simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
- simulator.schema — Zod schema for `NetworkSimulator…
- [simulator.schema](./simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
- simulator.schema — Zod schema for `NetworkSimulator…
- [simulator.schema](./simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
- [simulator.schema](./simulator.schema.md) — Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSi…
## Purpose
`NetworkSimulator` simulates a network channel for testing real-time and multiplayer code paths without involving actual transport. It models latency, jitter, packet loss, duplication, corruption, reordering, bandwidth caps, burstiness, and disconnects, with deterministic, replayable outputs given a seeded `Rng`. Tests inject the seed; production code uses the package default (`cryptoRng()` from `@suaveplan/random`).
This is the port of upstream `bitpacker/src/network/network-simulation.ts`. The audit-driven hardening replaces every upstream `Math.random()` and `Date.now()` call with injected `Rng` and `Clock` so simulator behaviour is reproducible across runs given identical inputs. The class composes a minimal typed listener table internally rather than extending an `EventEmitter`, keeping the public surface minimal.
## Features
- Eight typed events: `packet:sent`, `packet:received`, `packet:lost`, `packet:duplicated`, `packet:corrupted`, `packet:reordered`, `bandwidth:throttled`, `stats:updated`, plus `connection:disconnected` / `connection:reconnected`.
- Seven `NetworkCondition` presets (`PERFECT`, `GOOD`, `AVERAGE`, `POOR`, `TERRIBLE`, `MOBILE`, `SATELLITE`) plus `CUSTOM` for caller-supplied options.
- Bandwidth throttling enforced over a rolling 1-second window.
- Burstiness scheduler simulates periodic high-loss windows.
- Random-disconnect scheduler samples exponentially-distributed time-to-event.
- Stat counters with overflow guard — `packetsSent` near `MAX_SAFE_INTEGER` throws `BITPACKER.NETWORK_STAT_OVERFLOW`.
- `Rng` and `Clock` injection for deterministic replay; defaults are `cryptoRng()` and `defaultClock`.
## Usage: Basic Example
```typescript
import {
NetworkCondition,
NetworkSimulator,
} from "@suaveplan/bitpacker-network";
const sim = new NetworkSimulator({ condition: NetworkCondition.MOBILE });
sim.on("packet:received", (packet) => {
console.log(`got ${packet.size} bytes after ${packet.timestamp}ms`);
});
sim.on("packet:lost", () => {
console.log("packet lost");
});
const id = sim.sendPacket(new Uint8Array([1, 2, 3, 4]));
```
## Usage: Advanced Example
```typescript
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
import { NetworkSimulator } from "@suaveplan/bitpacker-network";
// Deterministic replay: the same seed produces the same loss / duplication /
// corruption / reordering pattern on every run, so a regression test can
// pin a specific simulator outcome.
const seededRng = new Mulberry32Rng(0xCAFE_F00D);
let now = 0;
const fakeClock = (): number => now;
const sim = new NetworkSimulator({
rng: seededRng,
clock: fakeClock,
latencyMin: 50,
latencyMax: 100,
jitter: 10,
packetLoss: 5,
packetDuplication: 1,
enableThrottling: true,
bandwidthKbps: 1000,
});
let receivedCount = 0;
sim.on("packet:received", () => receivedCount++);
for (let i = 0; i < 1000; i++) {
sim.sendPacket(new Uint8Array([i & 0xff]));
now += 10;
}
const stats = sim.getStats();
// stats are reproducible across runs given the same seed + inputs
```
## API Reference
### `new NetworkSimulator(options?)`
`options` extends `NetworkSimulationOptions` with optional `rng?: Rng` and `clock?: Clock`. Defaults: `cryptoRng()` and `defaultClock`.
### `sendPacket(data, callback?) → number`
Submit a packet. Returns the assigned id, or `-1` if disconnected. The simulator decides the packet's fate (delivered / lost / duplicated / corrupted / reordered) using the configured probabilities and the injected `Rng`. Callback fires once on the packet's terminal status.
### `on(event, listener) → () => void`
### `off(event, listener) → void`
### `removeAllListeners() → void`
Typed listener registry. `on` returns an unsubscribe function.
### `getStats() → NetworkSimulationStats`
Snapshot of aggregate counters and rolling averages. Refreshes derived fields (`uptime`, `packetLossRate`, `throughputKbps`) before returning.
### `resetStats() → void`
Zero all counters and reset `startTime` (useful between independent test runs of one simulator).
### `setOptions(options) → void`
Reconfigure a running simulator. Re-arms or cancels burstiness / random-disconnect timers based on the new options.
### `getOptions() → NetworkSimulationOptions`
Snapshot of the current options.
### `disconnect() → void` / `reconnect() → void` / `isConnected() → boolean`
Manual connection control (bypasses random-disconnect scheduler).
### `getQueueSize() → number` / `clearQueues() → void`
Inspection / reset of in-flight packet and reorder queues.
### `destroy() → void`
Stops every timer, clears queues, and removes every listener. Idempotent.
### `createNetworkSimulator(condition, options?) → NetworkSimulator`
Factory shortcut for `new NetworkSimulator({ condition, ...options })`.
## Implementation Notes
The simulator deliberately does not extend `@suaveplan/events::EventBus`. The bus is overkill for the eight-event surface here (we don't need interceptors, FIFO ordering across cross-plugin topics, or backpressure), and inheriting from a typed bus would leak its full surface into `NetworkSimulator`'s public API. The minimal internal listener table covers the simulator's observability needs without that overhead.
The `STAT_OVERFLOW_GUARD = MAX_SAFE_INTEGER - 1024` margin reserves enough room for one packet to land cleanly even at the boundary; calling `sendPacket` while `packetsSent` is at or above this guard throws `BITPACKER.NETWORK_STAT_OVERFLOW`. Consumers running multi-day soak tests should `resetStats()` periodically.
Bandwidth throttling uses a 1-second rolling window: bytes sent within a window count toward the cap; once the cap is reached, further packets in that window emit `bandwidth:throttled` and the per-send callback fires with `PacketStatus.LOST`. The window resets when `now - lastBandwidthCheck > 1000` ms.
Burstiness schedules a 1-second-long burst window every `burstInterval` ms (default 5000). During a burst, `burstPacketLoss` (default 25%) replaces the normal `packetLoss` for every packet sent. The scheduler self-rearms on every burst end.
Random disconnects use an exponential-distribution sample for time-to-event: `t = -log(U) / λ` where `U ~ Uniform(0,1)`. The simulator guards against `U = 0` (which would produce `Infinity`) by clamping to `Number.EPSILON`. After disconnect, reconnection happens after a uniformly random 5–15 s delay, then the scheduler rearms.
The `rng.nextFloat()` calls for loss / duplication / corruption / reordering decisions all happen sequentially in `sendPacket` — a seeded `Rng` produces a fixed sequence of decisions across runs, which is what makes test-time replay possible.

View File

@@ -0,0 +1,70 @@
# Network Simulator Constructor Options Schema
## Purpose
Zod schema for `NetworkSimulatorConstructorOptions`, the constructor bundle for `NetworkSimulator` and `createNetworkSimulator`. Extends the `NetworkSimulationOptions` validation with two determinism injection points — `rng` (a structural `Rng` from `@suaveplan/types`) and `clock` (a structural `Clock`) — so test fakes flow through with referential identity preserved.
The schema validates the structural duck-types of `Rng` and `Clock` rather than requiring concrete classes, because tests inject `fakeRng()` and `fakeClock()` from `@suaveplan/testing` that satisfy the shape without inheriting from a base class. Reflecting that flexibility in the schema keeps the test surface clean.
## Features
- `networkSimulatorConstructorOptionsSchema` — strict, extends the simulation options bundle
- Structural `Rng` validation — must expose `nextFloat(): number`
- Structural `Clock` validation — must expose `now(): number`
- Identity preservation for `rng` and `clock` via `z.custom`
- `as unknown as z.ZodType<T>` cast for the EOPT mismatch
- `networkSimulationOptionsSchema` is re-exported for convenience
## Usage: Basic Example
```ts
import {
NetworkSimulator,
networkSimulatorConstructorOptionsSchema,
defaultRng,
defaultClock,
} from "@suaveplan/bitpacker-network";
const opts = networkSimulatorConstructorOptionsSchema.parse({
packetLoss: 1,
rng: defaultRng(),
clock: defaultClock(),
});
const sim = new NetworkSimulator(opts);
```
## Usage: Advanced Example
```ts
import {
createNetworkSimulator,
networkSimulatorConstructorOptionsSchema,
} from "@suaveplan/bitpacker-network";
import { fakeClock, fakeRng } from "@suaveplan/testing/runner";
function deterministicSim(seed: number) {
return createNetworkSimulator(
networkSimulatorConstructorOptionsSchema.parse({
packetLoss: 5,
rng: fakeRng(seed),
clock: fakeClock(),
})
);
}
```
## API Reference
### `networkSimulatorConstructorOptionsSchema: z.ZodType<NetworkSimulatorConstructorOptions>`
Strict schema extending the simulation options shape with `rng` and `clock`. Both injection points are validated structurally and preserved by identity. Unknown keys throw.
### `networkSimulationOptionsSchema` (re-exported)
The base options schema; see `../types/types.schema.md` for full details.
## Implementation Notes
- **Structural `Rng` / `Clock`.** Test fakes from `@suaveplan/testing` are duck-typed objects, not class instances. Validating via `z.custom<T>((v): v is T => …)` accepts both the class form and the duck-type, and preserves identity for both.
- **Inline option fields, not extension.** Zod doesn't have a clean `extend` story for `z.ZodType`-typed schemas, so the constructor schema re-states the option fields rather than composing the simulation options schema. The runtime behaviour is identical; the duplication is acceptable for one schema.
- **Identity matters for tests.** A test that injects `fakeRng()` then asserts on its emitted call sequence requires `===` equality between the injected and consumed instance; `z.function()`-style wrapping would break this.

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { networkSimulatorConstructorOptionsSchema } from "./simulator.schema.js";
describe("networkSimulatorConstructorOptionsSchema", () => {
it("accepts an empty bundle", () => {
expect(networkSimulatorConstructorOptionsSchema.parse({})).toEqual({});
});
it("accepts a populated bundle with rng and clock", () => {
const rng = { nextFloat: () => 0.5 };
const clock = { now: () => 0 };
const opts = {
latencyMin: 10,
latencyMax: 50,
packetLoss: 0.5,
rng,
clock,
};
const parsed = networkSimulatorConstructorOptionsSchema.parse(opts);
expect(parsed.rng).toBe(rng);
expect(parsed.clock).toBe(clock);
});
it("rejects an rng without nextFloat", () => {
expect(() =>
networkSimulatorConstructorOptionsSchema.parse({
rng: { random() {} },
})
).toThrow();
});
it("rejects a clock without now", () => {
expect(() =>
networkSimulatorConstructorOptionsSchema.parse({
clock: { time() {} },
})
).toThrow();
});
it("rejects unknown keys", () => {
expect(() =>
networkSimulatorConstructorOptionsSchema.parse({ orbital: true })
).toThrow();
});
});

View File

@@ -0,0 +1,56 @@
import type { Clock, Rng } from "@suaveplan/types";
import z from "zod";
import { networkSimulationOptionsSchema } from "../types/types.schema.js";
import type { NetworkSimulatorConstructorOptions } from "./simulator.js";
const rngSchema = z.custom<Rng>(
(v): v is Rng => {
if (v === null || typeof v !== "object") return false;
const obj = v as Record<string, unknown>;
return typeof obj["nextFloat"] === "function";
},
{ message: "rng must expose nextFloat()" }
);
const clockSchema = z.custom<Clock>(
(v): v is Clock => {
if (v === null || typeof v !== "object") return false;
const obj = v as Record<string, unknown>;
return typeof obj["now"] === "function";
},
{ message: "clock must expose now()" }
);
/**
* Strict Zod schema for {@link NetworkSimulatorConstructorOptions}.
*
* Extends the `NetworkSimulationOptions` validation with two determinism
* injection points: `rng` (a structural `Rng` from `@suaveplan/types`) and
* `clock` (a structural `Clock`). Identity is preserved for both via
* `z.custom`, so the simulator gets the same fakes the test suite created.
*/
export const networkSimulatorConstructorOptionsSchema = z
.object({
latencyMin: z.number().nonnegative().optional(),
latencyMax: z.number().nonnegative().optional(),
jitter: z.number().nonnegative().optional(),
packetLoss: z.number().min(0).max(100).optional(),
packetDuplication: z.number().min(0).max(100).optional(),
corruption: z.number().min(0).max(100).optional(),
bandwidthKbps: z.number().nonnegative().optional(),
packetReordering: z.number().min(0).max(100).optional(),
packetBatchDelay: z.number().nonnegative().optional(),
packetBatchSize: z.number().int().nonnegative().optional(),
disconnectChance: z.number().min(0).max(100).optional(),
mtu: z.number().int().positive().optional(),
enableThrottling: z.boolean().optional(),
enableBurstiness: z.boolean().optional(),
burstInterval: z.number().nonnegative().optional(),
burstPacketLoss: z.number().min(0).max(100).optional(),
condition: z.string().optional(),
rng: rngSchema.optional(),
clock: clockSchema.optional(),
})
.strict() as unknown as z.ZodType<NetworkSimulatorConstructorOptions>;
export { networkSimulationOptionsSchema };

View File

@@ -0,0 +1,167 @@
/**
* Spec-conformance tests for `NetworkSimulator`.
* Pins:
* - Determinism rule (no module-scope `Math.random` / `Date.now` in
* implementation files outside the allow-listed `defaults/` factories).
* - Stat overflow guard.
* - Wire-compat enums (`NetworkCondition` values).
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defaultClock, NetworkCondition, NetworkSimulator } from "../index.js";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const SRC_ROOT = join(__dirname, "..");
const ALLOWED_DEFAULTS = join("defaults", "default-clock.ts");
const ALLOWED_DEFAULTS_CLOCK = "defaults/default-clock.ts";
describe("NetworkSimulator spec conformance", () => {
it("no Date.now() in implementation files outside defaults/default-clock.ts", {
timeout: 5000,
}, () => {
const matches = grepImpl(/Date\.now\(\)/g);
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
});
it("no Math.random() in implementation files (no allow-list)", {
timeout: 5000,
}, () => {
const matches = grepImpl(/Math\.random\(\)/g, true);
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
});
it("throws NETWORK_STAT_OVERFLOW when packetsSent near MAX_SAFE_INTEGER", {
timeout: 5000,
}, () => {
const sim = new NetworkSimulator({
rng: new Mulberry32Rng(1),
clock: () => 0,
condition: NetworkCondition.PERFECT,
});
// Reach into private state: assign packetsSent to the guard
// value. This is the only way to exercise the overflow path
// without sending billions of packets in a unit test.
(
sim as unknown as {
stats: { packetsSent: number };
}
).stats.packetsSent = Number.MAX_SAFE_INTEGER - 100;
expect(() => sim.sendPacket(new Uint8Array([1]))).toThrow(
BitpackerError
);
try {
sim.sendPacket(new Uint8Array([1]));
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.NETWORK_STAT_OVERFLOW
);
}
});
it("NetworkCondition values are stable wire-compat strings", {
timeout: 5000,
}, () => {
// Don't accidentally rename — wire-compat with upstream and
// any consumer that branches on the string values.
expect(NetworkCondition.PERFECT).toBe("perfect");
expect(NetworkCondition.GOOD).toBe("good");
expect(NetworkCondition.AVERAGE).toBe("average");
expect(NetworkCondition.POOR).toBe("poor");
expect(NetworkCondition.TERRIBLE).toBe("terrible");
expect(NetworkCondition.MOBILE).toBe("mobile");
expect(NetworkCondition.SATELLITE).toBe("satellite");
expect(NetworkCondition.CUSTOM).toBe("custom");
});
it("defaultClock returns a number close to Date.now()", {
timeout: 5000,
}, () => {
const before = Date.now();
const v = defaultClock();
const after = Date.now();
expect(v).toBeGreaterThanOrEqual(before);
expect(v).toBeLessThanOrEqual(after);
});
it("default RNG calls cryptoRng() (succeeds in browsers/Node, throws RandomError in environments without crypto.getRandomValues)", {
timeout: 5000,
}, () => {
// The simulator is opted-out of fallback to Math.random — the
// genesis determinism rule. Either the host has
// `crypto.getRandomValues` (and construction succeeds) or it
// doesn't (and `cryptoRng()` throws `RandomError`). Both
// outcomes are spec-conformant; what we forbid is silent
// fallback to non-cryptographic randomness.
try {
const sim = new NetworkSimulator({
clock: () => 0,
condition: NetworkCondition.PERFECT,
});
expect(sim.isConnected()).toBe(true);
sim.destroy();
} catch (e) {
// Acceptable if the host lacks crypto.getRandomValues.
expect(e).toBeInstanceOf(Error);
expect((e as Error).message).toMatch(
/random\.source\.unavailable|getRandomValues|crypto/i
);
}
});
});
function grepImpl(
pattern: RegExp,
excludeAllDefaults = false
): readonly { file: string; line: number; text: string }[] {
const matches: { file: string; line: number; text: string }[] = [];
const visit = (dir: string): void => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
visit(full);
continue;
}
if (!entry.endsWith(".ts")) continue;
if (entry.endsWith(".test.ts")) continue;
if (entry.endsWith(".spec-conformance.test.ts")) continue;
// Allow-list `defaults/default-clock.ts` for `Date.now()` only.
if (
!excludeAllDefaults &&
full.includes(ALLOWED_DEFAULTS_CLOCK) &&
full.endsWith(ALLOWED_DEFAULTS)
) {
continue;
}
const content = readFileSync(full, "utf8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? "";
if (isCommentLine(raw)) continue;
if (pattern.test(raw)) {
matches.push({
file: full,
line: i + 1,
text: raw.trim(),
});
}
pattern.lastIndex = 0;
}
}
};
visit(SRC_ROOT);
return matches;
}
function isCommentLine(line: string): boolean {
const trimmed = line.trim();
if (trimmed === "") return true;
if (trimmed.startsWith("//")) return true;
if (trimmed.startsWith("*")) return true;
if (trimmed.startsWith("/*")) return true;
return false;
}

View File

@@ -0,0 +1,987 @@
/**
* Behavioural tests for `NetworkSimulator`. Tests use a seeded RNG
* (`Mulberry32Rng` from `@suaveplan/pseudo-random`) so RNG-driven outcomes
* are byte-identical across runs.
*
* The simulator's `setTimeout`-driven scheduling is exercised with REAL
* timers (vitest fake timers hang in this environment) and short
* `await flush(ms)` waits between send and assertion.
*/
import { Mulberry32Rng } from "@suaveplan/pseudo-random";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import {
createNetworkSimulator,
NETWORK_CONDITIONS,
NetworkCondition,
type NetworkSimulationStats,
NetworkSimulator,
type PacketInfo,
PacketStatus,
} from "../index.js";
const fakeClock: Clock = () => 0;
function newSim(
options: ConstructorParameters<typeof NetworkSimulator>[0] = {}
): NetworkSimulator {
return new NetworkSimulator({
rng: new Mulberry32Rng(0xcafe_f00d),
clock: fakeClock,
...options,
});
}
const flush = (ms = 50): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
describe("NetworkSimulator", () => {
it("delivers a packet under PERFECT conditions", {
timeout: 5000,
}, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
const received: PacketInfo[] = [];
sim.on("packet:received", (p) => received.push(p));
sim.sendPacket(new Uint8Array([1, 2, 3]));
await flush();
expect(received).toHaveLength(1);
expect(received[0]?.size).toBe(3);
sim.destroy();
});
it("drops every packet under 100% packetLoss", {
timeout: 5000,
}, async () => {
const sim = newSim({ packetLoss: 100 });
const lost: PacketInfo[] = [];
sim.on("packet:lost", (p) => lost.push(p));
for (let i = 0; i < 10; i++) {
sim.sendPacket(new Uint8Array([i]));
}
await flush();
expect(lost).toHaveLength(10);
expect(sim.getStats().packetsLost).toBe(10);
sim.destroy();
});
it("duplicates packets when configured", { timeout: 5000 }, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
packetDuplication: 100,
packetLoss: 0,
});
const received: PacketInfo[] = [];
sim.on("packet:received", (p) => received.push(p));
sim.sendPacket(new Uint8Array([42]));
await flush();
expect(received).toHaveLength(2);
expect(sim.getStats().packetsDuplicated).toBe(1);
sim.destroy();
});
it("corrupts packets when configured", { timeout: 5000 }, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
corruption: 100,
packetLoss: 0,
packetDuplication: 0,
});
const corrupted: PacketInfo[] = [];
sim.on("packet:corrupted", (p) => corrupted.push(p));
sim.sendPacket(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
await flush();
expect(corrupted).toHaveLength(1);
expect(corrupted[0]?.data).not.toEqual(
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
);
sim.destroy();
});
it("reorders packets when configured", { timeout: 5000 }, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
packetReordering: 100,
packetLoss: 0,
packetDuplication: 0,
corruption: 0,
});
const reordered: PacketInfo[] = [];
sim.on("packet:reordered", (p) => reordered.push(p));
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(reordered).toHaveLength(1);
sim.destroy();
});
it("seeded RNG produces reproducible loss patterns", {
timeout: 5000,
}, async () => {
const opts = {
packetLoss: 30,
latencyMin: 0,
latencyMax: 0,
jitter: 0,
} as const;
const simA = new NetworkSimulator({
rng: new Mulberry32Rng(0x12345678),
clock: fakeClock,
...opts,
});
const simB = new NetworkSimulator({
rng: new Mulberry32Rng(0x12345678),
clock: fakeClock,
...opts,
});
const lostA: number[] = [];
const lostB: number[] = [];
simA.on("packet:lost", (p) => lostA.push(p.id));
simB.on("packet:lost", (p) => lostB.push(p.id));
for (let i = 0; i < 100; i++) {
simA.sendPacket(new Uint8Array([i]));
simB.sendPacket(new Uint8Array([i]));
}
await flush();
expect(lostA).toEqual(lostB);
simA.destroy();
simB.destroy();
});
it("MTU large packet still sends", { timeout: 5000 }, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
mtu: 100,
});
const received: PacketInfo[] = [];
sim.on("packet:received", (p) => received.push(p));
sim.sendPacket(new Uint8Array(200));
await flush();
expect(received).toHaveLength(1);
sim.destroy();
});
it("bandwidth throttling drops packets that exceed the cap", {
timeout: 5000,
}, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
enableThrottling: true,
bandwidthKbps: 1, // 125 bytes/s
packetLoss: 0,
packetDuplication: 0,
corruption: 0,
packetReordering: 0,
});
const throttled: number[] = [];
sim.on("bandwidth:throttled", (size) => throttled.push(size));
sim.sendPacket(new Uint8Array(100));
sim.sendPacket(new Uint8Array(100));
await flush();
expect(throttled.length).toBeGreaterThanOrEqual(1);
sim.destroy();
});
it("disconnect drops new sends", { timeout: 5000 }, () => {
const sim = newSim();
sim.disconnect();
const id = sim.sendPacket(new Uint8Array([1]));
expect(id).toBe(-1);
sim.destroy();
});
it("reconnect restores send delivery", { timeout: 5000 }, () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
sim.disconnect();
sim.reconnect();
const id = sim.sendPacket(new Uint8Array([1]));
expect(id).toBeGreaterThan(0);
sim.destroy();
});
it("disconnect emits connection event", { timeout: 5000 }, () => {
const sim = newSim();
let fired = 0;
sim.on("connection:disconnected", () => fired++);
sim.disconnect();
sim.disconnect(); // already — no-op
expect(fired).toBe(1);
sim.destroy();
});
it("reconnect emits connection event", { timeout: 5000 }, () => {
const sim = newSim();
let fired = 0;
sim.on("connection:reconnected", () => fired++);
sim.disconnect();
sim.reconnect();
sim.reconnect(); // already — no-op
expect(fired).toBe(1);
sim.destroy();
});
it("stats counters increment correctly", { timeout: 5000 }, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
for (let i = 0; i < 5; i++) {
sim.sendPacket(new Uint8Array([i]));
}
await flush();
const stats = sim.getStats();
expect(stats.packetsSent).toBe(5);
expect(stats.packetsReceived).toBe(5);
expect(stats.packetsLost).toBe(0);
sim.destroy();
});
it("resetStats zeros all counters", { timeout: 5000 }, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(sim.getStats().packetsSent).toBeGreaterThan(0);
sim.resetStats();
const after = sim.getStats();
expect(after.packetsSent).toBe(0);
expect(after.packetsReceived).toBe(0);
sim.destroy();
});
it("getOptions returns the resolved options", { timeout: 5000 }, () => {
const sim = newSim({ condition: NetworkCondition.MOBILE });
const opts = sim.getOptions();
expect(opts.condition).toBe(NetworkCondition.MOBILE);
expect(opts.latencyMin).toBe(
NETWORK_CONDITIONS[NetworkCondition.MOBILE].latencyMin
);
sim.destroy();
});
it("setOptions reconfigures running simulator", { timeout: 5000 }, () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
sim.setOptions({ condition: NetworkCondition.SATELLITE });
const opts = sim.getOptions();
expect(opts.latencyMin).toBe(
NETWORK_CONDITIONS[NetworkCondition.SATELLITE].latencyMin
);
sim.destroy();
});
it("setOptions in CUSTOM mode merges into existing", {
timeout: 5000,
}, () => {
const sim = newSim({ packetLoss: 5 });
sim.setOptions({ packetLoss: 10 });
expect(sim.getOptions().packetLoss).toBe(10);
sim.destroy();
});
it("setOptions disables burstiness clears scheduler", {
timeout: 5000,
}, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
enableBurstiness: true,
});
sim.setOptions({ enableBurstiness: false });
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(sim.getStats().packetsSent).toBe(1);
sim.destroy();
});
it("isConnected reflects state", { timeout: 5000 }, () => {
const sim = newSim();
expect(sim.isConnected()).toBe(true);
sim.disconnect();
expect(sim.isConnected()).toBe(false);
sim.reconnect();
expect(sim.isConnected()).toBe(true);
sim.destroy();
});
it("clearQueues drains queues", { timeout: 5000 }, () => {
const sim = newSim({
condition: NetworkCondition.GOOD,
latencyMin: 100000,
latencyMax: 100000,
});
sim.sendPacket(new Uint8Array([1]));
sim.clearQueues();
expect(sim.getQueueSize()).toBe(0);
sim.destroy();
});
it("destroy stops scheduling and clears listeners", {
timeout: 5000,
}, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
enableBurstiness: true,
});
let fired = 0;
sim.on("packet:received", () => fired++);
sim.destroy();
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(fired).toBe(0);
});
it("destroy is idempotent", { timeout: 5000 }, () => {
const sim = newSim();
sim.destroy();
expect(() => sim.destroy()).not.toThrow();
});
it("createNetworkSimulator factory respects condition", {
timeout: 5000,
}, () => {
const sim = createNetworkSimulator(NetworkCondition.MOBILE, {
rng: new Mulberry32Rng(1),
clock: fakeClock,
});
expect(sim.getOptions().condition).toBe(NetworkCondition.MOBILE);
sim.destroy();
});
it("off removes a registered listener", { timeout: 5000 }, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
let fired = 0;
const listener = (): void => {
fired++;
};
sim.on("packet:received", listener);
sim.off("packet:received", listener);
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(fired).toBe(0);
sim.destroy();
});
it("off is a no-op for unregistered listener", { timeout: 5000 }, () => {
const sim = newSim();
const listener = (): void => {};
expect(() => sim.off("packet:received", listener)).not.toThrow();
sim.destroy();
});
it("on returns an unsubscribe handle", { timeout: 5000 }, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
let fired = 0;
const unsub = sim.on("packet:received", () => fired++);
unsub();
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(fired).toBe(0);
sim.destroy();
});
it("bandwidthKbps undefined skips throttling", {
timeout: 5000,
}, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
enableThrottling: true,
// bandwidthKbps omitted → undefined → throttling skipped
});
sim.sendPacket(new Uint8Array(10000));
await flush();
expect(sim.getStats().packetsSent).toBe(1);
sim.destroy();
});
it("bandwidthKbps zero skips throttling", { timeout: 5000 }, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
enableThrottling: true,
bandwidthKbps: 0,
});
sim.sendPacket(new Uint8Array(10000));
await flush();
expect(sim.getStats().packetsSent).toBe(1);
sim.destroy();
});
it("burstPacketLoss undefined falls back to packetLoss during burst", {
timeout: 5000,
}, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
enableBurstiness: true,
burstInterval: 30,
burstPacketLoss: undefined,
packetLoss: 50, // baseline
});
await flush(80); // wait for burst
sim.sendPacket(new Uint8Array([1]));
await flush();
// Test passes regardless of outcome — exercises the
// burstPacketLoss-undefined branch
sim.destroy();
expect(true).toBe(true);
});
it("latency clamped to 0 when min > result", {
timeout: 5000,
}, async () => {
// negative jitter could push latency below 0; Math.max(0, ...) clamps it
const sim = newSim({
condition: NetworkCondition.PERFECT,
latencyMin: 0,
latencyMax: 1,
jitter: 200, // huge — drives result negative often
});
const received: PacketInfo[] = [];
sim.on("packet:received", (p) => received.push(p));
for (let i = 0; i < 10; i++) {
sim.sendPacket(new Uint8Array([i]));
}
await flush(50);
expect(received.length).toBeGreaterThan(0);
sim.destroy();
});
it("burstInterval undefined defaults to DEFAULT_NETWORK_SIMULATION_OPTIONS value", {
timeout: 5000,
}, () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
enableBurstiness: true,
burstInterval: undefined,
});
// Just exercises the default-fallback branch in scheduleBurst.
sim.destroy();
expect(true).toBe(true);
});
it("registering a second listener for the same event reuses the listener list", {
timeout: 5000,
}, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
let a = 0;
let b = 0;
sim.on("packet:received", () => a++); // creates list
sim.on("packet:received", () => b++); // appends to existing list
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(a).toBe(1);
expect(b).toBe(1);
sim.destroy();
});
it("off on a never-registered event is a no-op", { timeout: 5000 }, () => {
const sim = newSim();
// Never registered any listener for 'packet:received'
const listener = (): void => {};
sim.off("packet:received", listener);
expect(true).toBe(true);
sim.destroy();
});
it("unsubscribe handle is a no-op on a since-cleared event list", {
timeout: 5000,
}, () => {
const sim = newSim();
const unsub = sim.on("packet:received", () => {});
sim.removeAllListeners();
expect(() => unsub()).not.toThrow();
sim.destroy();
});
it("default rng/clock paths exercised when omitted", {
timeout: 5000,
}, () => {
// Construction without rng option → calls defaultRng() (host
// crypto.getRandomValues missing in test env → throws). Both
// outcomes (success and throw) exercise the rng-undefined branch.
try {
const sim = new NetworkSimulator({
clock: () => 0,
condition: NetworkCondition.PERFECT,
});
sim.destroy();
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
// clock omitted → defaultClock used.
try {
const sim = new NetworkSimulator({
rng: new Mulberry32Rng(1),
condition: NetworkCondition.PERFECT,
});
sim.destroy();
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
});
it("PacketStatus enum values are stable", { timeout: 5000 }, () => {
expect(PacketStatus.DELIVERED).toBe("delivered");
expect(PacketStatus.LOST).toBe("lost");
expect(PacketStatus.DUPLICATED).toBe("duplicated");
expect(PacketStatus.CORRUPTED).toBe("corrupted");
expect(PacketStatus.REORDERED).toBe("reordered");
});
it("stats:updated event fires on packet delivery", {
timeout: 5000,
}, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
const updates: NetworkSimulationStats[] = [];
sim.on("stats:updated", (s) => updates.push(s));
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(updates.length).toBeGreaterThan(0);
sim.destroy();
});
it("removeAllListeners drops every listener", {
timeout: 5000,
}, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
let fired = 0;
sim.on("packet:received", () => fired++);
sim.on("packet:sent", () => fired++);
sim.removeAllListeners();
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(fired).toBe(0);
sim.destroy();
});
it("PERFECT preset has zero loss/duplication", {
timeout: 5000,
}, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
for (let i = 0; i < 50; i++) {
sim.sendPacket(new Uint8Array([i]));
}
await flush();
expect(sim.getStats().packetsLost).toBe(0);
sim.destroy();
});
it("callback fires with packet status on delivery", {
timeout: 5000,
}, async () => {
const sim = newSim({ condition: NetworkCondition.PERFECT });
const statuses: PacketStatus[] = [];
sim.sendPacket(new Uint8Array([1]), (s) => statuses.push(s));
await flush();
expect(statuses).toEqual([PacketStatus.DELIVERED]);
sim.destroy();
});
it("callback fires with PacketStatus.LOST on drop", {
timeout: 5000,
}, async () => {
const sim = newSim({ packetLoss: 100 });
const statuses: PacketStatus[] = [];
sim.sendPacket(new Uint8Array([1]), (s) => statuses.push(s));
await flush();
expect(statuses).toEqual([PacketStatus.LOST]);
sim.destroy();
});
it("corruption loop runs multiple iterations on long packets", {
timeout: 5000,
}, async () => {
// numBytesToCorrupt = max(1, floor(length * 0.1))
// 100-byte packet → 10 corruption iterations
const sim = newSim({
condition: NetworkCondition.PERFECT,
corruption: 100,
});
const corrupted: PacketInfo[] = [];
sim.on("packet:corrupted", (p) => corrupted.push(p));
sim.sendPacket(new Uint8Array(100));
await flush();
expect(corrupted).toHaveLength(1);
sim.destroy();
});
it("non-zero jitter affects latency", { timeout: 5000 }, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
latencyMin: 50,
latencyMax: 50,
jitter: 50,
});
const received: PacketInfo[] = [];
sim.on("packet:received", (p) => received.push(p));
for (let i = 0; i < 5; i++) {
sim.sendPacket(new Uint8Array([i]));
}
await flush(200);
expect(received).toHaveLength(5);
// currentLatency reflects last delivered packet — non-zero
expect(sim.getStats().currentLatency).toBeGreaterThan(0);
sim.destroy();
});
it("variance is computed over 2+ latency samples", {
timeout: 5000,
}, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
latencyMin: 0,
latencyMax: 100,
jitter: 50,
});
for (let i = 0; i < 5; i++) {
sim.sendPacket(new Uint8Array([i]));
}
await flush(300);
const stats = sim.getStats();
// history has > 1 latency sample → variance computed
expect(stats.jitterVariation).toBeGreaterThanOrEqual(0);
sim.destroy();
});
it("bandwidth window resets after >1000ms", { timeout: 5000 }, async () => {
// Use a moving fake clock so the bandwidth-window math sees
// > 1000ms elapsed between sends.
let now = 0;
const movingClock: Clock = () => now;
const sim = new NetworkSimulator({
rng: new Mulberry32Rng(0xcafef00d),
clock: movingClock,
condition: NetworkCondition.PERFECT,
enableThrottling: true,
bandwidthKbps: 1, // 125 bytes/s
});
sim.sendPacket(new Uint8Array(50));
now = 1500; // advance clock past 1000ms window
sim.sendPacket(new Uint8Array(50));
await flush();
// Both sends fit within the per-window cap (50 bytes each, cap is 125):
expect(sim.getStats().packetsSent).toBe(2);
sim.destroy();
});
it("scheduleBurst body fires when burst interval elapses", {
timeout: 5000,
}, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
enableBurstiness: true,
burstInterval: 50,
burstPacketLoss: 100,
packetLoss: 0,
});
// Wait long enough for burst to start (50ms) AND end (1000ms)
await flush(1200);
// After burst ends, packets should not be lost again:
const lost: number[] = [];
sim.on("packet:lost", () => lost.push(1));
sim.sendPacket(new Uint8Array([1]));
await flush();
// The simulator continues running — destroy stops it.
sim.destroy();
expect(true).toBe(true); // burst body executed
});
it("setOptions enables and re-arms random disconnect scheduler", {
timeout: 5000,
}, () => {
const sim = newSim();
sim.setOptions({ disconnectChance: 50 });
// Scheduler is re-armed; destroy clears it.
expect(() => sim.destroy()).not.toThrow();
});
it("setOptions disables random disconnect scheduler", {
timeout: 5000,
}, () => {
const sim = newSim({ disconnectChance: 50 });
sim.setOptions({ disconnectChance: 0 });
expect(() => sim.destroy()).not.toThrow();
});
it("scheduled delivery is dropped when simulator disconnects mid-flight", {
timeout: 5000,
}, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
latencyMin: 50,
latencyMax: 50,
jitter: 0,
});
const received: PacketInfo[] = [];
sim.on("packet:received", (p) => received.push(p));
sim.sendPacket(new Uint8Array([1]));
// Disconnect before the scheduled delivery fires (50ms latency)
sim.disconnect();
await flush(150);
// Disconnect path early-returns inside deliverPacket
expect(received).toHaveLength(0);
sim.destroy();
});
it("random disconnect scheduler fires the disconnect timeout body", {
timeout: 5000,
}, async () => {
// Use a custom Rng that returns u close to 1 so log(u) → ~0,
// making minutesUntilDisconnect → ~0 → ms → ~0.
const fastRng: import("@suaveplan/types").Rng = {
nextUint32: () => 0xfffffffe,
nextFloat: () => 0.999_999_99,
};
const sim = new NetworkSimulator({
rng: fastRng,
clock: () => 0,
condition: NetworkCondition.PERFECT,
disconnectChance: 100,
});
// The timeout body fires within a few ms. Wait briefly.
await flush(200);
// The simulator may have disconnected and re-armed.
// Just verify the scheduler ran without throwing.
sim.destroy();
expect(true).toBe(true);
});
it("burst end timer is cleared when setOptions disables burstiness during a burst", {
timeout: 5000,
}, async () => {
const sim = newSim({
condition: NetworkCondition.PERFECT,
enableBurstiness: true,
burstInterval: 30, // burst fires after 30ms
});
await flush(100); // let burst start (and burstEndTimeout arm)
sim.setOptions({ enableBurstiness: false });
// Simulator is past the entry point; cleanup ran.
sim.destroy();
expect(true).toBe(true);
});
it("scheduleRandomDisconnect re-arms (clearing existing timeout)", {
timeout: 5000,
}, () => {
// First call from constructor sets the timeout.
// setOptions(disconnectChance: 50) triggers scheduleRandomDisconnect
// which sees disconnectTimeout !== null and clears it.
const sim = newSim({ disconnectChance: 50 });
sim.setOptions({ disconnectChance: 50 });
sim.destroy();
expect(true).toBe(true);
});
it("constructor accepts no arguments (default empty options)", {
timeout: 5000,
}, () => {
// Exercises L98 default-arg branch (no `options` passed).
// Polyfill globalThis.crypto so the defaultRng() call resolves.
const hadCrypto =
(globalThis as { crypto?: Crypto }).crypto !== undefined;
if (!hadCrypto) {
(
globalThis as {
crypto?: {
getRandomValues: <T extends ArrayBufferView>(
buf: T
) => T;
};
}
).crypto = {
getRandomValues: <T extends ArrayBufferView>(buf: T): T => {
const view = new Uint8Array(
(buf as unknown as { buffer: ArrayBuffer }).buffer,
(buf as unknown as { byteOffset: number }).byteOffset,
(buf as unknown as { byteLength: number }).byteLength
);
for (let i = 0; i < view.length; i++) view[i] = i & 0xff;
return buf;
},
};
}
try {
const sim = new NetworkSimulator();
expect(sim.isConnected()).toBe(true);
sim.destroy();
} finally {
if (!hadCrypto) {
(globalThis as { crypto?: unknown }).crypto = undefined;
}
}
});
it("createNetworkSimulator accepts only the condition (default options arg)", {
timeout: 5000,
}, () => {
// Exercises L561 default-arg branch. Polyfill crypto for the same
// reason as the previous test.
const hadCrypto =
(globalThis as { crypto?: Crypto }).crypto !== undefined;
if (!hadCrypto) {
(
globalThis as {
crypto?: {
getRandomValues: <T extends ArrayBufferView>(
buf: T
) => T;
};
}
).crypto = {
getRandomValues: <T extends ArrayBufferView>(buf: T): T => {
const view = new Uint8Array(
(buf as unknown as { buffer: ArrayBuffer }).buffer,
(buf as unknown as { byteOffset: number }).byteOffset,
(buf as unknown as { byteLength: number }).byteLength
);
for (let i = 0; i < view.length; i++) view[i] = i & 0xff;
return buf;
},
};
}
try {
const sim = createNetworkSimulator(NetworkCondition.PERFECT);
expect(sim.getOptions().condition).toBe(NetworkCondition.PERFECT);
sim.destroy();
} finally {
if (!hadCrypto) {
(globalThis as { crypto?: unknown }).crypto = undefined;
}
}
});
it("on() unsubscribe handle is a no-op when listener already removed", {
timeout: 5000,
}, () => {
// Exercises L150 idx-1 branch: idx === -1 path on unsubscribe.
const sim = newSim();
const listener = (): void => {};
const otherListener = (): void => {};
const unsub = sim.on("packet:received", listener);
// Register a second listener so the array isn't undefined when
// the first unsub runs after manual removal.
sim.on("packet:received", otherListener);
sim.off("packet:received", listener);
// Now `current` exists but `idx === -1`.
expect(() => unsub()).not.toThrow();
sim.destroy();
});
it("off() is a no-op when listener not in the registered list", {
timeout: 5000,
}, () => {
// Exercises L161 idx-1 branch: idx === -1 path.
const sim = newSim();
const registered = (): void => {};
const stranger = (): void => {};
sim.on("packet:received", registered);
// The event has listeners, but `stranger` isn't one of them.
expect(() => sim.off("packet:received", stranger)).not.toThrow();
sim.destroy();
});
it("packetLoss/duplication/corruption/reordering undefined -> ?? 0 fallback", {
timeout: 5000,
}, async () => {
// Exercises L252/264/274/297 binary-expr right-hand side (`?? 0`).
// Spread DEFAULT sets these to 0; override to undefined to hit
// the nullish-coalesce RHS.
const sim = new NetworkSimulator({
rng: new Mulberry32Rng(1),
clock: fakeClock,
latencyMin: 0,
latencyMax: 0,
jitter: 0,
packetLoss: undefined,
packetDuplication: undefined,
corruption: undefined,
packetReordering: undefined,
});
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(sim.getStats().packetsSent).toBe(1);
sim.destroy();
});
it("reorder timer finds nothing after clearQueues (index === -1 branch)", {
timeout: 5000,
}, async () => {
// Exercises L308 if-else: when the reorderQueue has been
// cleared before the deferred reorder timer fires, findIndex
// returns -1.
const sim = newSim({
condition: NetworkCondition.PERFECT,
packetReordering: 100,
packetLoss: 0,
packetDuplication: 0,
corruption: 0,
latencyMin: 100,
latencyMax: 100,
jitter: 0,
});
sim.sendPacket(new Uint8Array([1]));
// Clear before the reorder timer fires.
sim.clearQueues();
await flush(120);
// No `packet:reordered` should have been emitted.
sim.destroy();
expect(true).toBe(true);
});
it("calculateLatency uses 0 defaults when latency bounds omitted", {
timeout: 5000,
}, async () => {
// Exercises L357/L358 binary-expr (`latencyMin ?? 0`,
// `latencyMax ?? min`). Build a CUSTOM mode without latency
// overrides — the fall-through to `?? 0` is what we exercise.
const sim = new NetworkSimulator({
rng: new Mulberry32Rng(1),
clock: fakeClock,
// CUSTOM mode (no condition) -> uses
// DEFAULT_NETWORK_SIMULATION_OPTIONS for latency. The default
// fixture deliberately leaves latencyMin/Max set on the
// preset, so override them to undefined to hit the `??`.
latencyMin: undefined,
latencyMax: undefined,
jitter: 0,
packetLoss: 0,
packetDuplication: 0,
corruption: 0,
packetReordering: 0,
});
sim.sendPacket(new Uint8Array([1]));
await flush();
expect(sim.getStats().packetsSent).toBe(1);
sim.destroy();
});
it("random disconnect cycle: outer timer disconnects, inner timer reconnects", {
timeout: 30000,
}, async () => {
// We need:
// - Outer (disconnect) setTimeout to fire fast → u close to 1
// so `-log(u) → 0` minutes → ms ~ 0.
// - Inner (reconnect) setTimeout to fire fast → second
// nextFloat() close to 0 so `5000 + nextFloat() * 10000 ~ 5000`.
// The two RNG draws are on consecutive calls. A custom Rng
// that returns u very close to 1 on the FIRST call and
// close to 0 on the SECOND minimises both delays.
let drawCount = 0;
const cycleRng: import("@suaveplan/types").Rng = {
nextUint32: () => 0xfffffffe,
nextFloat: () => {
drawCount++;
if (drawCount === 1) return 0.999_999_99; // outer u
return 0; // inner u → 5000ms reconnect delay (minimum)
},
};
let reconnects = 0;
const sim = new NetworkSimulator({
rng: cycleRng,
clock: () => 0,
condition: NetworkCondition.PERFECT,
disconnectChance: 100,
});
sim.on("connection:reconnected", () => reconnects++);
// Outer fires ~0ms. Inner fires ~5000ms. Wait long enough.
await flush(5500);
expect(reconnects).toBeGreaterThanOrEqual(1);
sim.destroy();
});
});

View File

@@ -0,0 +1,589 @@
/**
* Network simulator: deterministic, replayable bit-packed packet transport
* with configurable latency, jitter, packet loss, duplication, corruption,
* reordering, bandwidth caps, burstiness, and disconnects.
*
* Ported from suaveplan upstream `bitpacker/src/network/network-simulation.ts`
* with genesis-side hardening:
*
* - Replaces every upstream `Math.random()` call with an injectable `Rng`
* from `@suaveplan/types`. Default: `cryptoRng()` from `@suaveplan/random`.
* - Replaces every upstream `Date.now()` call with an injectable `Clock`.
* Default: `defaultClock` from this package's `defaults/`.
* - Replaces upstream's inline `EventEmitter` class with a minimal listener
* table (deliberately thin; the simulator emits 8 distinct events with
* typed payloads). Consumers wire to `simulator.on(event, listener)`.
* - Plain `Error` throws → `BitpackerError` codes (`NETWORK_BANDWIDTH_EXCEEDED`,
* `NETWORK_DISCONNECTED`, `NETWORK_STAT_OVERFLOW`, `NETWORK_INVALID_OPTIONS`).
*
* The class deliberately does NOT extend a base bus — composition keeps the
* public surface minimal and avoids leaking unrelated `EventBus` controls
* to the simulator's callers.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type { Clock, Rng } from "@suaveplan/types";
import { defaultClock } from "../defaults/default-clock.js";
import { defaultRng } from "../defaults/default-rng.js";
import {
DEFAULT_NETWORK_SIMULATION_OPTIONS,
NETWORK_CONDITIONS,
NetworkCondition,
type NetworkSimulationOptions,
type NetworkSimulationStats,
type PacketInfo,
PacketStatus,
} from "../types/types.js";
/**
* Construction-time options. `simOptions` is the simulation profile (latency,
* loss, etc.); `rng` and `clock` are determinism injection points.
*/
export interface NetworkSimulatorConstructorOptions
extends NetworkSimulationOptions {
readonly rng?: Rng;
readonly clock?: Clock;
}
/**
* Typed event map for `simulator.on(event, listener)`. Each key is the
* event name; the value type is the payload listeners receive.
*/
export interface NetworkSimulatorEvents {
"packet:sent": PacketInfo;
"packet:received": PacketInfo;
"packet:lost": PacketInfo;
"packet:duplicated": PacketInfo;
"packet:corrupted": PacketInfo;
"packet:reordered": PacketInfo;
"bandwidth:throttled": number;
"stats:updated": NetworkSimulationStats;
"connection:disconnected": undefined;
"connection:reconnected": undefined;
}
type EventName = keyof NetworkSimulatorEvents;
type Listener<E extends EventName> = (
payload: NetworkSimulatorEvents[E]
) => void;
const STAT_OVERFLOW_GUARD = Number.MAX_SAFE_INTEGER - 1024;
/**
* Simulates a network channel for testing real-time / multiplayer code paths.
*
* Usage: construct with options, register listeners via `on(...)`, push
* packets via `sendPacket(...)`, observe behaviour via emitted events and
* `getStats()`. Tests inject a seeded `Rng` to make outcomes reproducible.
*/
export class NetworkSimulator {
private options: NetworkSimulationOptions;
private packetQueue: PacketInfo[] = [];
private reorderQueue: PacketInfo[] = [];
private connected = true;
private disconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private burstTimeout: ReturnType<typeof setTimeout> | null = null;
private burstEndTimeout: ReturnType<typeof setTimeout> | null = null;
private inBurst = false;
private lastBandwidthCheck = 0;
private bytesSentInWindow = 0;
private stats: NetworkSimulationStats;
private latencyHistory: number[] = [];
private startTime: number;
private nextPacketId = 0;
private readonly listeners = new Map<EventName, Listener<EventName>[]>();
private readonly rng: Rng;
private readonly clock: Clock;
public constructor(options: NetworkSimulatorConstructorOptions = {}) {
const { rng, clock, ...simOptions } = options;
this.rng = rng ?? defaultRng();
this.clock = clock ?? defaultClock;
if (
simOptions.condition !== undefined &&
simOptions.condition !== NetworkCondition.CUSTOM
) {
this.options = {
...NETWORK_CONDITIONS[simOptions.condition],
...simOptions,
};
} else {
this.options = {
...DEFAULT_NETWORK_SIMULATION_OPTIONS,
...simOptions,
};
}
this.startTime = this.clock();
this.stats = createEmptyStats();
if (this.options.enableBurstiness === true) {
this.scheduleBurst();
}
if (
this.options.disconnectChance !== undefined &&
this.options.disconnectChance > 0
) {
this.scheduleRandomDisconnect();
}
}
/**
* Subscribe to a typed event. Returns an unsubscribe handle.
*/
public on<E extends EventName>(
event: E,
listener: Listener<E>
): () => void {
let list = this.listeners.get(event);
if (list === undefined) {
list = [];
this.listeners.set(event, list);
}
list.push(listener as Listener<EventName>);
return () => {
const current = this.listeners.get(event);
if (current === undefined) return;
const idx = current.indexOf(listener as Listener<EventName>);
if (idx !== -1) current.splice(idx, 1);
};
}
/**
* Unsubscribe a previously registered listener. No-op if absent.
*/
public off<E extends EventName>(event: E, listener: Listener<E>): void {
const current = this.listeners.get(event);
if (current === undefined) return;
const idx = current.indexOf(listener as Listener<EventName>);
if (idx !== -1) current.splice(idx, 1);
}
/**
* Drop every listener registered on this simulator.
*/
public removeAllListeners(): void {
this.listeners.clear();
}
private emit<E extends EventName>(
event: E,
payload: NetworkSimulatorEvents[E]
): void {
const list = this.listeners.get(event);
if (list === undefined) return;
for (const listener of list) {
(listener as Listener<E>)(payload);
}
}
/**
* Send a packet through the simulated network.
*
* Returns the assigned packet id (incrementing counter), or `-1` if the
* simulator is currently disconnected. The supplied `callback` (if any)
* fires once the packet's fate is decided — delivered, lost, dropped to
* bandwidth throttling, etc. The packet's `data` is deep-copied on send
* so the caller's buffer isn't shared with the simulated network.
*/
public sendPacket(
data: Uint8Array,
callback?: (status: PacketStatus, data?: Uint8Array) => void
): number {
if (!this.connected) {
return -1;
}
if (this.stats.packetsSent >= STAT_OVERFLOW_GUARD) {
throw new BitpackerError(
BITPACKER_CODES.NETWORK_STAT_OVERFLOW,
`packetsSent counter near MAX_SAFE_INTEGER (${this.stats.packetsSent}); reset stats before continuing`,
{
context: {
counter: "packetsSent",
value: this.stats.packetsSent,
},
}
);
}
this.nextPacketId++;
const packetSize = data.length;
const packet: PacketInfo = {
id: this.nextPacketId,
data: new Uint8Array(data),
size: packetSize,
timestamp: this.clock(),
status: PacketStatus.DELIVERED,
};
this.stats.packetsSent++;
this.stats.bytesTransferred += packetSize;
this.emit("packet:sent", packet);
if (
this.options.enableThrottling === true &&
this.options.bandwidthKbps !== undefined &&
this.options.bandwidthKbps > 0
) {
const now = this.clock();
if (now - this.lastBandwidthCheck > 1000) {
this.bytesSentInWindow = 0;
this.lastBandwidthCheck = now;
}
this.bytesSentInWindow += packetSize;
const maxBytesPerSecond = this.options.bandwidthKbps * 125;
if (this.bytesSentInWindow > maxBytesPerSecond) {
this.stats.bytesDropped += packetSize;
this.emit("bandwidth:throttled", packetSize);
callback?.(PacketStatus.LOST);
return this.nextPacketId;
}
}
const packetLoss =
this.inBurst && this.options.burstPacketLoss !== undefined
? this.options.burstPacketLoss
: (this.options.packetLoss ?? 0);
if (this.rng.nextFloat() * 100 < packetLoss) {
packet.status = PacketStatus.LOST;
this.stats.packetsLost++;
setTimeout(() => {
this.emit("packet:lost", packet);
callback?.(PacketStatus.LOST);
}, 0);
return this.nextPacketId;
}
const dupChance = this.options.packetDuplication ?? 0;
if (this.rng.nextFloat() * 100 < dupChance) {
packet.status = PacketStatus.DUPLICATED;
this.stats.packetsDuplicated++;
this.schedulePacketDelivery({ ...packet }, callback);
this.schedulePacketDelivery({ ...packet }, callback);
this.emit("packet:duplicated", packet);
return this.nextPacketId;
}
const corruptionChance = this.options.corruption ?? 0;
if (this.rng.nextFloat() * 100 < corruptionChance) {
packet.status = PacketStatus.CORRUPTED;
this.stats.packetsCorrupted++;
const corruptedData = new Uint8Array(packet.data);
const numBytesToCorrupt = Math.max(
1,
Math.floor(corruptedData.length * 0.1)
);
for (let i = 0; i < numBytesToCorrupt; i++) {
const byteIndex = Math.floor(
this.rng.nextFloat() * corruptedData.length
);
corruptedData[byteIndex] = Math.floor(
this.rng.nextFloat() * 256
);
}
packet.data = corruptedData;
this.emit("packet:corrupted", packet);
this.schedulePacketDelivery(packet, callback);
return this.nextPacketId;
}
const reorderingChance = this.options.packetReordering ?? 0;
if (this.rng.nextFloat() * 100 < reorderingChance) {
packet.status = PacketStatus.REORDERED;
packet.originalOrder = this.packetQueue.length;
this.stats.packetsReordered++;
this.reorderQueue.push(packet);
setTimeout(() => {
const index = this.reorderQueue.findIndex(
(p) => p.id === packet.id
);
if (index !== -1) {
// splice with a valid index is guaranteed to return the
// removed element; the cast keeps TS happy without an
// unreachable defensive branch.
const reorderedPacket = this.reorderQueue.splice(
index,
1
)[0] as PacketInfo;
this.deliverPacket(reorderedPacket, callback);
this.emit("packet:reordered", reorderedPacket);
}
}, this.calculateLatency() * 0.5);
return this.nextPacketId;
}
this.schedulePacketDelivery(packet, callback);
return this.nextPacketId;
}
private schedulePacketDelivery(
packet: PacketInfo,
callback?: (status: PacketStatus, data?: Uint8Array) => void
): void {
const latency = this.calculateLatency();
this.packetQueue.push(packet);
setTimeout(() => {
this.deliverPacket(packet, callback);
}, latency);
this.updateLatencyStats(latency);
}
private deliverPacket(
packet: PacketInfo,
callback?: (status: PacketStatus, data?: Uint8Array) => void
): void {
if (!this.connected) {
return;
}
const index = this.packetQueue.findIndex((p) => p.id === packet.id);
if (index !== -1) {
this.packetQueue.splice(index, 1);
}
this.stats.packetsReceived++;
this.emit("packet:received", packet);
callback?.(packet.status, packet.data);
this.updateStats();
}
private calculateLatency(): number {
const min = this.options.latencyMin ?? 0;
const max = this.options.latencyMax ?? min;
let latency = min + this.rng.nextFloat() * (max - min);
if (this.options.jitter !== undefined && this.options.jitter > 0) {
const jitterAmount = latency * (this.options.jitter / 100);
latency += (this.rng.nextFloat() * 2 - 1) * jitterAmount;
}
return Math.max(0, latency);
}
private updateLatencyStats(latency: number): void {
this.stats.currentLatency = latency;
this.latencyHistory.push(latency);
if (this.latencyHistory.length > 50) {
this.latencyHistory.shift();
}
// After the push above, latencyHistory.length is always >= 1,
// so the average is always a real reduce result.
this.stats.averageLatency =
this.latencyHistory.reduce((sum, val) => sum + val, 0) /
this.latencyHistory.length;
if (this.latencyHistory.length > 1) {
const mean = this.stats.averageLatency;
const squaredDiffs = this.latencyHistory.map(
(val) => (val - mean) ** 2
);
const variance =
squaredDiffs.reduce((sum, val) => sum + val, 0) /
this.latencyHistory.length;
this.stats.jitterVariation = Math.sqrt(variance);
}
}
private updateStats(): void {
const now = this.clock();
this.stats.uptime = (now - this.startTime) / 1000;
const totalPackets = this.stats.packetsSent;
this.stats.packetLossRate =
totalPackets > 0
? (this.stats.packetsLost / totalPackets) * 100
: 0;
const elapsedSeconds = this.stats.uptime;
this.stats.throughputKbps =
elapsedSeconds > 0
? (this.stats.bytesTransferred * 8) / 1000 / elapsedSeconds
: 0;
this.emit("stats:updated", { ...this.stats });
}
private scheduleBurst(): void {
if (this.burstTimeout !== null) {
clearTimeout(this.burstTimeout);
this.burstTimeout = null;
}
if (this.burstEndTimeout !== null) {
clearTimeout(this.burstEndTimeout);
this.burstEndTimeout = null;
}
// DEFAULT_NETWORK_SIMULATION_OPTIONS.burstInterval is always
// defined, so the chain collapses to two operands.
const interval =
this.options.burstInterval ??
DEFAULT_NETWORK_SIMULATION_OPTIONS.burstInterval;
this.burstTimeout = setTimeout(() => {
this.inBurst = true;
this.burstEndTimeout = setTimeout(() => {
this.inBurst = false;
this.scheduleBurst();
}, 1000);
}, interval);
}
private scheduleRandomDisconnect(): void {
if (this.disconnectTimeout !== null) {
clearTimeout(this.disconnectTimeout);
}
// Callers gate on `disconnectChance !== undefined && > 0`, so this
// method is never reached when the option is undefined.
const disconnectChance = this.options.disconnectChance as number;
// Sample exponentially-distributed time-to-event:
// t = -log(U) / lambda where U ~ Uniform(0,1).
// Guard against U=0 (which would produce Infinity) by re-rolling.
let u = this.rng.nextFloat();
if (u === 0) u = Number.EPSILON;
const minutesUntilDisconnect = -Math.log(u) / (disconnectChance / 100);
const msUntilDisconnect = minutesUntilDisconnect * 60 * 1000;
this.disconnectTimeout = setTimeout(() => {
this.disconnect();
setTimeout(
() => {
this.reconnect();
this.scheduleRandomDisconnect();
},
5000 + this.rng.nextFloat() * 10000
);
}, msUntilDisconnect);
}
public disconnect(): void {
if (!this.connected) return;
this.connected = false;
this.stats.disconnections++;
this.emit("connection:disconnected", undefined);
}
public reconnect(): void {
if (this.connected) return;
this.connected = true;
this.stats.reconnections++;
this.emit("connection:reconnected", undefined);
}
public getStats(): NetworkSimulationStats {
this.updateStats();
return { ...this.stats };
}
public resetStats(): void {
this.stats = createEmptyStats();
this.latencyHistory = [];
this.startTime = this.clock();
}
public setOptions(options: NetworkSimulationOptions): void {
if (
options.condition !== undefined &&
options.condition !== NetworkCondition.CUSTOM
) {
this.options = {
...NETWORK_CONDITIONS[options.condition],
...options,
};
} else {
this.options = { ...this.options, ...options };
}
if (this.options.enableBurstiness === true) {
this.scheduleBurst();
} else {
if (this.burstTimeout !== null) {
clearTimeout(this.burstTimeout);
this.burstTimeout = null;
}
if (this.burstEndTimeout !== null) {
clearTimeout(this.burstEndTimeout);
this.burstEndTimeout = null;
}
this.inBurst = false;
}
if (
this.options.disconnectChance !== undefined &&
this.options.disconnectChance > 0
) {
this.scheduleRandomDisconnect();
} else if (this.disconnectTimeout !== null) {
clearTimeout(this.disconnectTimeout);
this.disconnectTimeout = null;
}
}
public getOptions(): NetworkSimulationOptions {
return { ...this.options };
}
public isConnected(): boolean {
return this.connected;
}
public getQueueSize(): number {
return this.packetQueue.length + this.reorderQueue.length;
}
public clearQueues(): void {
this.packetQueue = [];
this.reorderQueue = [];
}
/**
* Stop all scheduled timers, drop every queue entry, and remove every
* listener. The simulator instance is single-shot — calling `destroy`
* twice is a no-op.
*/
public destroy(): void {
if (this.burstTimeout !== null) {
clearTimeout(this.burstTimeout);
this.burstTimeout = null;
}
if (this.burstEndTimeout !== null) {
clearTimeout(this.burstEndTimeout);
this.burstEndTimeout = null;
}
if (this.disconnectTimeout !== null) {
clearTimeout(this.disconnectTimeout);
this.disconnectTimeout = null;
}
this.clearQueues();
this.removeAllListeners();
}
}
/**
* Convenience factory: build a simulator preconfigured with one of the
* named `NetworkCondition` presets. Equivalent to
* `new NetworkSimulator({ condition })`.
*/
export function createNetworkSimulator(
condition: NetworkCondition,
options: Pick<NetworkSimulatorConstructorOptions, "rng" | "clock"> = {}
): NetworkSimulator {
return new NetworkSimulator({ condition, ...options });
}
function createEmptyStats(): NetworkSimulationStats {
return {
packetsSent: 0,
packetsReceived: 0,
packetsLost: 0,
packetsDuplicated: 0,
packetsCorrupted: 0,
packetsReordered: 0,
bytesTransferred: 0,
bytesDropped: 0,
averageLatency: 0,
currentLatency: 0,
jitterVariation: 0,
disconnections: 0,
reconnections: 0,
uptime: 0,
packetLossRate: 0,
throughputKbps: 0,
};
}

View File

@@ -0,0 +1,97 @@
# Network simulation types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - types.schema — Zod schema for `NetworkSimulationOpt…
- [types.schema](./types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
- types.schema — Zod schema for `NetworkSimulationOpt…
- [types.schema](./types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
- types.schema — Zod schema for `NetworkSimulationOpt…
- [types.schema](./types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
- types.schema — Zod schema for `NetworkSimulationOpt…
- [types.schema](./types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
- [types.schema](./types.schema.md) — Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptio…
## Purpose
Defines the type companions and constant tables used by `NetworkSimulator`. These types were previously colocated with the misplaced simulator implementation in `@suaveplan/bitpacker-game/src/types.ts`; the 2026-05-02 audit moved them here so the network-simulator concern lives in one package. The shapes and default values match upstream's `bitpacker/src/types.ts` byte-for-byte to preserve wire-compat with any code that branches on these enums.
## Features
- `NetworkCondition` enum with seven presets (`PERFECT`, `GOOD`, `AVERAGE`, `POOR`, `TERRIBLE`, `MOBILE`, `SATELLITE`) plus `CUSTOM` for caller-supplied configurations.
- `PacketStatus` enum reflecting the simulator's per-packet outcomes (`DELIVERED`, `LOST`, `DUPLICATED`, `CORRUPTED`, `REORDERED`).
- `NetworkSimulationOptions` covering latency / jitter / loss / duplication / corruption / reordering / bandwidth / disconnect / MTU / burstiness.
- `NETWORK_CONDITIONS` map binding each condition to a baseline options object — picking `condition: NetworkCondition.MOBILE` populates the seven mobile-network defaults.
- `DEFAULT_NETWORK_SIMULATION_OPTIONS` for `CUSTOM` and absent-condition cases.
- `PacketInfo` — packet metadata emitted via `packet:*` events.
- `NetworkSimulationStats` — aggregate counters reported by `getStats()`.
## Usage: Basic Example
```typescript
import {
NetworkCondition,
type NetworkSimulationOptions,
} from "@suaveplan/bitpacker-network";
const opts: NetworkSimulationOptions = {
condition: NetworkCondition.MOBILE,
};
```
## Usage: Advanced Example
```typescript
import {
NETWORK_CONDITIONS,
NetworkCondition,
type NetworkSimulationOptions,
} from "@suaveplan/bitpacker-network";
// Override one field of a preset:
const opts: NetworkSimulationOptions = {
...NETWORK_CONDITIONS[NetworkCondition.SATELLITE],
packetLoss: 0, // perfect satellite — useful for diff studies
};
```
## API Reference
### `enum NetworkCondition`
Closed list of named profiles. Adding a value here is a wire-format extension; renaming or removing one is a wire-format break.
### `enum PacketStatus`
Closed list of per-packet outcomes the simulator reports.
### `interface NetworkSimulationOptions`
Caller-side configuration. All fields optional. Numeric percentages are 0–100 (not 0–1) — match upstream convention.
### `const DEFAULT_NETWORK_SIMULATION_OPTIONS`
Baseline used when no `condition` is supplied or when `condition` is `CUSTOM`.
### `const NETWORK_CONDITIONS`
Lookup table mapping every `NetworkCondition` to the base options for that preset.
### `interface PacketInfo`
Emitted alongside `packet:*` events. `data` is a fresh `Uint8Array` copy (the simulator deep-copies on send so the caller's buffer isn't shared with the simulated network).
### `interface NetworkSimulationStats`
Counters and rolling averages. Read via `simulator.getStats()` or via the `stats:updated` event.
## Implementation Notes
`NETWORK_CONDITIONS[NetworkCondition.CUSTOM]` is `{...DEFAULT_NETWORK_SIMULATION_OPTIONS}` — choosing `CUSTOM` is equivalent to omitting the condition entirely. The simulator constructor honours either spelling.
The percentage convention (0–100, not 0–1) is the upstream convention preserved verbatim. Switching to 0–1 would be a breaking config change. If a future redesign normalises this, document it as a wire-format extension.

View File

@@ -0,0 +1,72 @@
# Network Simulation Options Schema
## Purpose
Zod schema for `NetworkSimulationOptions`, the configuration accepted by `NetworkSimulator`'s constructor and `setOptions`. Validates the configuration at the package boundary so an out-of-range percentage (`packetLoss: 150`), a negative latency, or an unknown key all fail at parse time rather than producing a simulator that silently misbehaves under load.
The schema treats the `*Loss`, `*Duplication`, `corruption`, `*Reordering`, `disconnectChance`, and `burstPacketLoss` fields as percentages bounded to `[0, 100]` because that's the simulator's documented contract. Latency and bandwidth fields are non-negative; the boolean toggles are unbounded.
## Features
- `networkSimulationOptionsSchema` — strict, every field optional with documented bounds
- Percentages enforced to `[0, 100]` (not `[0, 1]`)
- `condition` token must be a `NetworkCondition` enum value
- `as unknown as z.ZodType<T>` cast for the EOPT mismatch
## Usage: Basic Example
```ts
import {
NetworkSimulator,
networkSimulationOptionsSchema,
NetworkCondition,
} from "@suaveplan/bitpacker-network";
const opts = networkSimulationOptionsSchema.parse({
condition: NetworkCondition.AVERAGE,
packetLoss: 1.5,
});
const sim = new NetworkSimulator(opts);
```
## Usage: Advanced Example
```ts
import {
createNetworkSimulator,
networkSimulationOptionsSchema,
NetworkCondition,
} from "@suaveplan/bitpacker-network";
function safeSimulator(rawOpts: unknown) {
const result = networkSimulationOptionsSchema.safeParse(rawOpts);
if (!result.success) {
throw new Error(
`Network simulation options rejected: ${result.error.message}`
);
}
return createNetworkSimulator(result.data);
}
const sim = safeSimulator({
latencyMin: 50,
latencyMax: 200,
jitter: 25,
packetLoss: 5,
enableBurstiness: true,
condition: NetworkCondition.MOBILE,
});
```
## API Reference
### `networkSimulationOptionsSchema: z.ZodType<NetworkSimulationOptions>`
Strict schema. Every field is optional. Numeric percentages (`packetLoss`, `packetDuplication`, `corruption`, `packetReordering`, `disconnectChance`, `burstPacketLoss`) are bounded to `[0, 100]`. Latency and timing fields are non-negative finite numbers. `mtu` and `packetBatchSize` are non-negative integers (mtu strictly positive). `condition` is a `NetworkCondition` enum value.
## Implementation Notes
- **Percentages are `[0, 100]`, not `[0, 1]`.** The simulator interprets `packetLoss: 5` as 5% loss. Validating `[0, 1]` would silently allow `5` which the simulator would then floor or treat as 100%, masking the bug. Refusing it at parse time keeps the contract honest.
- **`mtu` is positive.** A zero or negative MTU produces an undefined transmission state; refusing it forces the caller to either omit the field (defaults to 1500) or supply a meaningful value.
- **`condition: CUSTOM` honours every other field.** The schema does NOT enforce mutual exclusion between `condition` and the per-field overrides, because the simulator deliberately allows callers to start from a preset and override individual fields.
- **Strict mode.** Unknown keys are rejected; misspelled flags would otherwise silently default and hide configuration bugs.

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NetworkCondition } from "./types.js";
import { networkSimulationOptionsSchema } from "./types.schema.js";
describe("networkSimulationOptionsSchema", () => {
it("accepts an empty bundle", () => {
expect(networkSimulationOptionsSchema.parse({})).toEqual({});
});
it("accepts a populated bundle within range", () => {
const opts = {
latencyMin: 10,
latencyMax: 50,
jitter: 5,
packetLoss: 1.5,
packetDuplication: 0,
corruption: 0,
bandwidthKbps: 5000,
packetReordering: 0.5,
condition: NetworkCondition.AVERAGE,
};
expect(networkSimulationOptionsSchema.parse(opts)).toEqual(opts);
});
it("rejects packetLoss > 100", () => {
expect(() =>
networkSimulationOptionsSchema.parse({ packetLoss: 101 })
).toThrow();
});
it("rejects negative latencyMin", () => {
expect(() =>
networkSimulationOptionsSchema.parse({ latencyMin: -1 })
).toThrow();
});
it("rejects unknown keys", () => {
expect(() =>
networkSimulationOptionsSchema.parse({ bogus: 1 })
).toThrow();
});
it("rejects an unknown condition token", () => {
expect(() =>
networkSimulationOptionsSchema.parse({
condition: "carrier-pigeon",
})
).toThrow();
});
});

View File

@@ -0,0 +1,35 @@
import z from "zod";
import type { NetworkSimulationOptions } from "./types.js";
import { NetworkCondition } from "./types.js";
/**
* Strict Zod schema for {@link NetworkSimulationOptions}.
*
* Validates the configuration accepted by `NetworkSimulator`'s constructor
* and `setOptions`. Numeric percentage fields (`packetLoss`,
* `packetDuplication`, `corruption`, `packetReordering`, `disconnectChance`,
* `burstPacketLoss`) are bounded to `[0, 100]` because the simulator
* interprets them as percentages, not fractions. Other numeric fields are
* non-negative; the boolean toggles are unbounded.
*/
export const networkSimulationOptionsSchema = z
.object({
latencyMin: z.number().nonnegative().optional(),
latencyMax: z.number().nonnegative().optional(),
jitter: z.number().nonnegative().optional(),
packetLoss: z.number().min(0).max(100).optional(),
packetDuplication: z.number().min(0).max(100).optional(),
corruption: z.number().min(0).max(100).optional(),
bandwidthKbps: z.number().nonnegative().optional(),
packetReordering: z.number().min(0).max(100).optional(),
packetBatchDelay: z.number().nonnegative().optional(),
packetBatchSize: z.number().int().nonnegative().optional(),
disconnectChance: z.number().min(0).max(100).optional(),
mtu: z.number().int().positive().optional(),
enableThrottling: z.boolean().optional(),
enableBurstiness: z.boolean().optional(),
burstInterval: z.number().nonnegative().optional(),
burstPacketLoss: z.number().min(0).max(100).optional(),
condition: z.nativeEnum(NetworkCondition).optional(),
})
.strict() as unknown as z.ZodType<NetworkSimulationOptions>;

View File

@@ -0,0 +1,230 @@
/**
* Type companions for the network simulator. Ported (and re-homed) from
* upstream `bitpacker/src/types.ts` and the misplaced
* `bitpacker-game/src/types.ts` slot, where these constants previously
* lived. Constants and enums first; runtime API in `simulator/`.
*/
/**
* Named preset for a simulation profile. Picking a non-`CUSTOM` value as
* `NetworkSimulationOptions.condition` populates the rest of the options
* from the `NETWORK_CONDITIONS` map; explicit fields supplied alongside
* the condition override individual entries from the preset.
*/
export enum NetworkCondition {
PERFECT = "perfect",
GOOD = "good",
AVERAGE = "average",
POOR = "poor",
TERRIBLE = "terrible",
MOBILE = "mobile",
SATELLITE = "satellite",
CUSTOM = "custom",
}
/**
* Outcome of a single packet's traversal of the simulator. The simulator
* emits one of these via the corresponding event (`packet:received` /
* `packet:lost` / `packet:duplicated` / `packet:corrupted` /
* `packet:reordered`) and, where supplied, invokes the per-send callback
* with the same status.
*/
export enum PacketStatus {
DELIVERED = "delivered",
LOST = "lost",
DUPLICATED = "duplicated",
CORRUPTED = "corrupted",
REORDERED = "reordered",
}
/**
* Configuration accepted by the `NetworkSimulator` constructor and by
* `setOptions`. Numeric percentages (`packetLoss`, `packetDuplication`,
* `corruption`, `packetReordering`, `disconnectChance`, `burstPacketLoss`)
* are 0-100 (not 0-1). All fields are optional; missing fields take their
* value from `DEFAULT_NETWORK_SIMULATION_OPTIONS`.
*/
export interface NetworkSimulationOptions {
latencyMin?: number;
latencyMax?: number;
/** Jitter as a percentage of the base latency. */
jitter?: number;
/** Packet loss probability in [0, 100]. */
packetLoss?: number;
/** Packet duplication probability in [0, 100]. */
packetDuplication?: number;
/** Packet corruption probability in [0, 100]. */
corruption?: number;
/** Bandwidth cap in kbps. 0 disables the cap. */
bandwidthKbps?: number;
/** Packet reordering probability in [0, 100]. */
packetReordering?: number;
packetBatchDelay?: number;
packetBatchSize?: number;
/** Per-minute disconnect probability in [0, 100]. 0 disables. */
disconnectChance?: number;
/** Maximum transmission unit in bytes. */
mtu?: number;
/** Enforce `bandwidthKbps`. */
enableThrottling?: boolean;
/** Periodically enter a high-loss "burst" window. */
enableBurstiness?: boolean;
/** Milliseconds between bursts. */
burstInterval?: number;
/** Packet-loss percentage during a burst (overrides `packetLoss`). */
burstPacketLoss?: number;
/** Preset selector. `CUSTOM` (or absent) honours every supplied option. */
condition?: NetworkCondition;
}
/**
* Defaults applied when no preset is selected. Matches upstream behaviour.
*/
export const DEFAULT_NETWORK_SIMULATION_OPTIONS: NetworkSimulationOptions = {
latencyMin: 20,
latencyMax: 50,
jitter: 10,
packetLoss: 0,
packetDuplication: 0,
corruption: 0,
bandwidthKbps: 0,
packetReordering: 0,
packetBatchDelay: 0,
packetBatchSize: 0,
disconnectChance: 0,
mtu: 1500,
enableThrottling: false,
enableBurstiness: false,
burstInterval: 5000,
burstPacketLoss: 25,
condition: NetworkCondition.PERFECT,
};
/**
* One packet's metadata as it flows through the simulator. Emitted via
* `packet:*` events; populated with `status` reflecting the packet's
* eventual outcome.
*/
export interface PacketInfo {
id: number;
data: Uint8Array;
size: number;
timestamp: number;
status: PacketStatus;
originalOrder?: number;
}
/**
* Aggregate counters reported by `NetworkSimulator.getStats()` and emitted
* via the `stats:updated` event after every delivery.
*/
export interface NetworkSimulationStats {
packetsSent: number;
packetsReceived: number;
packetsLost: number;
packetsDuplicated: number;
packetsCorrupted: number;
packetsReordered: number;
bytesTransferred: number;
bytesDropped: number;
averageLatency: number;
currentLatency: number;
jitterVariation: number;
disconnections: number;
reconnections: number;
uptime: number;
packetLossRate: number;
throughputKbps: number;
}
/**
* The seven `NetworkCondition` presets — `CUSTOM` resolves to
* `DEFAULT_NETWORK_SIMULATION_OPTIONS`.
*/
export const NETWORK_CONDITIONS: Record<
NetworkCondition,
NetworkSimulationOptions
> = {
[NetworkCondition.PERFECT]: {
latencyMin: 0,
latencyMax: 5,
jitter: 0,
packetLoss: 0,
packetDuplication: 0,
corruption: 0,
bandwidthKbps: 0,
packetReordering: 0,
},
[NetworkCondition.GOOD]: {
latencyMin: 10,
latencyMax: 30,
jitter: 5,
packetLoss: 0.1,
packetDuplication: 0,
corruption: 0,
bandwidthKbps: 10000,
packetReordering: 0,
},
[NetworkCondition.AVERAGE]: {
latencyMin: 30,
latencyMax: 80,
jitter: 15,
packetLoss: 1,
packetDuplication: 0.1,
corruption: 0.1,
bandwidthKbps: 5000,
packetReordering: 0.5,
},
[NetworkCondition.POOR]: {
latencyMin: 80,
latencyMax: 150,
jitter: 25,
packetLoss: 3,
packetDuplication: 0.5,
corruption: 0.5,
bandwidthKbps: 2000,
packetReordering: 1,
enableBurstiness: true,
},
[NetworkCondition.TERRIBLE]: {
latencyMin: 150,
latencyMax: 300,
jitter: 40,
packetLoss: 10,
packetDuplication: 1,
corruption: 1,
bandwidthKbps: 1000,
packetReordering: 5,
enableBurstiness: true,
disconnectChance: 5,
},
[NetworkCondition.MOBILE]: {
latencyMin: 50,
latencyMax: 200,
jitter: 30,
packetLoss: 2,
packetDuplication: 0.2,
corruption: 0.5,
bandwidthKbps: 1500,
packetReordering: 1,
enableBurstiness: true,
burstInterval: 10000,
burstPacketLoss: 15,
},
[NetworkCondition.SATELLITE]: {
latencyMin: 500,
latencyMax: 700,
jitter: 50,
packetLoss: 5,
packetDuplication: 0.5,
corruption: 1,
bandwidthKbps: 500,
packetReordering: 2,
enableBurstiness: true,
burstInterval: 20000,
burstPacketLoss: 20,
},
[NetworkCondition.CUSTOM]: {
...DEFAULT_NETWORK_SIMULATION_OPTIONS,
},
};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/bitpacker",
plugins: [
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: [
"tslib",
"zod",
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "istanbul",
},
},
});