commit e93c1b026fa8ca1ac101b34a2bd6374d53bf7dc4 Author: Syncer Deploy Date: Fri Aug 28 10:46:42 2026 -0300 deploy Syncer to a2acloud Kubernetes diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..52178a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +**/node_modules +**/dist +.git +.DS_Store +npm-debug.log* +.env +.env.* diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..1f7d277 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,29 @@ +name: build + +on: + push: + branches: [main] + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Build production image in Kubernetes + run: | + set -euo pipefail + image=registry.a2acloud.io/syncer/syncer + docker build --platform linux/amd64 --pull \ + -t "$image:$GITHUB_SHA" \ + -t "$image:latest" \ + . + + - name: Push immutable and release tags + run: | + set -euo pipefail + image=registry.a2acloud.io/syncer/syncer + docker push "$image:$GITHUB_SHA" + docker push "$image:latest" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a7e323 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +**/node_modules/ +**/dist/ +.env +.env.* +.DS_Store +npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..89cc0ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/engine/package.json packages/engine/package.json +COPY packages/shared/package.json packages/shared/package.json +RUN npm ci + +COPY tsconfig.base.json ./ +COPY apps ./apps +COPY packages ./packages +RUN npm run build + +FROM node:22-bookworm-slim AS runtime + +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3001 \ + STATIC_ROOT=/app/apps/web/dist + +COPY package.json package-lock.json ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/engine/package.json packages/engine/package.json +COPY packages/shared/package.json packages/shared/package.json +RUN npm ci --omit=dev && npm cache clean --force + +COPY --from=build /app/apps/server/dist ./apps/server/dist +COPY --from=build /app/apps/web/dist ./apps/web/dist +COPY --from=build /app/packages/engine/dist ./packages/engine/dist +COPY --from=build /app/packages/shared/dist ./packages/shared/dist + +USER node +EXPOSE 3001 +CMD ["node", "apps/server/dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fc4f5f --- /dev/null +++ b/README.md @@ -0,0 +1,324 @@ +# Syncer + +Three playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, and bandwidth-budgeted interest management around game-supplied rules. + +## Structure + +```text +apps/ + server/ µWebSockets.js authoritative transport adapter + web/ React + Vite predicted client +packages/ + engine/ Generic clocks, authority, prediction, validation, and protocol + shared/ Game definitions: Sync Arena, Flux Relay, and Syncer Royale +``` + +## Play + +```bash +npm install +npm run dev +``` + +Open `http://localhost:5173`. Use the selector to switch between three games: + +- **Sync Arena** — click **Deploy**, then use `WASD`, `Shift`, mouse look, left click, `R`, and weapon keys `1`–`3`. +- **Flux Relay** — hold `Space` or the on-screen thruster to pull the shared core toward your team's gate while managing private energy. +- **Syncer Royale** — click **Drop In**, then use `WASD`, `Shift`, mouse look, left click, and `R`. Survive 31 server bots, loot automatically, and stay inside the shrinking circle on a streamed 2 km island. Open it directly at `http://localhost:5173/#royale`. + +The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay is intentionally unrelated to a shooter: it has teams, a shared core, balanced server bots, exact private energy, per-viewer events, and no weapons, visibility cones, or 3D physics. Royale proves the same engine can drive a much larger world: the map is generated client-side from a public seed, while players, private loot, combat, and the storm remain server authoritative. + +## Define a game + +`defineMultiplayerGame()` is the main developer surface. A contract names the five types that cross the trust boundary once; every rule callback is then inferred. Private authority and client-visible state remain different types, and internal events must be converted into per-player perceptions before transport: + +```ts +interface MyGame { + authority: AuthorityState; + client: ClientState; + input: Input; + authorityEvent: AuthorityEvent; + perceptionEvent: PerceptionEvent; +} + +export const game = defineMultiplayerGame({ + clock: { + ticksPerSecond: 60, + snapshotsPerSecond: 20, + }, + authority: { + createInitialState: () => createPrivateWorld(), + cloneState: clonePrivateWorld, + applyInput: applyAuthoritativeInput, + step: (state, context) => { + simulateAuthoritativePhysics(state, context.deltaSeconds); + context.emit(createInternalWorldEvent(state)); + }, + }, + prediction: { + createInitialState: () => createVisibleWorld(), + cloneState: cloneVisibleWorld, + applyInput: applyPredictedInput, + step: simulatePredictedPhysics, + mergeSnapshot: preserveLocalPresentationState, + applyEvent: applySensoryCue, + }, + visibility: { + createSnapshot: (authority, viewer) => + projectOnlyWhatPlayerMaySee(authority, viewer.playerId), + perceive: (authority, event, viewer) => + eventPlayerMaySense(authority, event, viewer.playerId), + validateClientState: validateReportedVisibleState, + // Optional: players with the same key share one encoded snapshot. + groupKey: (authority, viewer) => visibilityGroup(authority, viewer.playerId), + }, + input: { + validate: (input, context) => isAllowed(input, context), + }, + encoding: { + input: inputCodec, + clientState: visibleStateCodec, + perception: perceptionEventCodec, + }, +}); + +const server = game.createServer(); +const client = game.createClient(); +const protocol = game.protocol; +``` + +`createJsonCodec()` is included for prototypes and low-volume messages; games can replace it with custom packed binary codecs without changing any rules. `defineNetworkedGame()` remains available as the lower-level surface used internally and by advanced integrations. Every existing HOF—lag compensation, time travel, and replay transport—accepts the result of either definition API. + +Flux Relay in `packages/shared/src/flux-game.ts` is the compact reference implementation. Both games are mounted by the same `hostNetworkedGame()` transport adapter, demonstrating that the server loop has no shooter knowledge. + +## Scale a game with spatial replication + +`withSpatialReplication()` wraps any networked game with a deterministic grid index, per-viewer interest selection, priorities, and an explicit byte budget. The ordinary visibility callback still runs first, so this layer can reduce an already-safe projection but can never reveal private authority state: + +```ts +const largeWorldGame = withSpatialReplication(game, { + cellSize: 100, + bandwidthBudgetBytesPerSecond: 30_000, + reservedBytesPerSnapshot: 300, + viewerPosition: (authority, playerId) => + authority.players.get(playerId) ?? null, + sources: [ + { + category: "players", + maximumDistance: 360, + entities: (authority) => authority.players.values(), + estimatedBytes: 76, + priority: (_player, distance) => 1_000 - distance, + required: (player, playerId) => player.id === playerId, + }, + { + category: "loot", + maximumDistance: 165, + entities: (authority) => authority.loot.values(), + estimatedBytes: 44, + }, + ], + projectSnapshot: (snapshot, selection) => ({ + ...snapshot, + players: snapshot.players.filter((player) => + selection.has("players", player.id), + ), + loot: snapshot.loot.filter((item) => selection.has("loot", item.id)), + }), +}); +``` + +Syncer Royale in `packages/shared/src/royale-game.ts` is the large-world reference. The developer defines the categories, ranges, size estimates, priorities, required entities, and final projection; the HOF owns indexing and budget selection. + +## Add action-specific lag compensation + +`withLagCompensation()` owns one bounded private history ring for the game, but +each classified action chooses its own policy. The developer captures only the +authority data needed for validation—usually hitboxes rather than the entire +world: + +```ts +const compensatedGame = withLagCompensation(game, { + historySeconds: 2, + captureState: (authority) => capturePlayerHitboxes(authority), + cloneHistoricalState: cloneHitboxes, + classifyAction(input) { + if (input.fire) return { type: "hitscan", aim: input.aim }; + if (input.throw) return { type: "projectile", throw: input.throw }; + return null; + }, + cloneAction: (action) => structuredClone(action), + actions: { + hitscan: { + mode: "rewind", + maximumRewindMs: 250, + maximumFutureMs: 25, + outOfWindow: "clamp", + resolve: ({ currentState, historicalState, action, emit }) => + resolveHistoricalShot( + currentState, + historicalState, + action, + emit, + ), + }, + projectile: { + mode: "fast-forward", + maximumRewindMs: 150, + resolve: ({ historicalState, catchUpTicks, action }) => + spawnAndAdvanceProjectile(historicalState, action, catchUpTicks), + }, + }, +}); +``` + +Input packets carry separate `targetTick` and `observedTick` values. The former +schedules smooth authoritative input; the latter identifies the presentation +that produced an action. Because `observedTick` is client supplied, the server +clamps or rejects it using the selected action policy. Resolution receives a +cloned historical view and mutable current state: the live match itself is +never rewound. + +## Add deterministic time travel + +`withTimeTravel()` is a higher-order layer over any networked game. Existing +server calls keep working, but accepted inputs, joins, leaves, state hashes, and +periodic private authority checkpoints are recorded automatically: + +```ts +import { + defineNetworkedGame, + deterministicHash, + withLagCompensation, + withReplayTransport, + withTimeTravel, +} from "@syncer/engine"; + +const game = withTimeTravel( + defineNetworkedGame({ + // The normal generic server, client, replication, validation, and codecs. + }), + { + createSeed: () => crypto.getRandomValues(new Uint32Array(1))[0]!, + initializeState: (authority, seed) => { + authority.random = createSeededRandom(seed); + }, + hashState: deterministicHash, + checkpointIntervalTicks: 120, + verificationIntervalTicks: 1, + }, +); + +const server = game.createServer({ replaySeed: 42 }); +// Use addPlayer(), submitInput(), and step() normally. +const recording = server.exportRecording(); + +const replay = game.createReplay(recording); +replay.seek(8_420); + +// This passes through the game's normal privacy projection. +const playerSnapshot = replay.viewAs(playerId); +const playerPerceptions = replay.perceptionsAs(playerId); + +// Fork the authoritative timeline and continue with different inputs. +const branch = replay.branch(8_420); +branch.submitInput(playerId, alternateInputPacket); +branch.step(); +``` + +Replay recordings contain private authoritative checkpoints and must stay on +trusted infrastructure. A replay viewer should consume only `viewAs()` and +`perceptionsAs()`. Deterministic games must keep all randomness inside seeded, +cloned authority state; the replay verifier throws `ReplayDivergenceError` at +the first mismatching recorded tick. + +For long-running live matches, pass `recordingHistoryTicks` to keep a rolling +private log. The exported recording exposes its first seekable `startTick` and +remains deterministic from that checkpoint: + +```ts +const server = game.createServer({ + replaySeed: 42, + recordingHistoryTicks: 60 * 30, +}); +``` + +## Add secure replay transport + +`withReplayTransport()` is the client-delivery layer. It retains a bounded ring +of snapshots and events *after* the normal per-viewer projection has run. A +ticket can therefore expose an attacker camera without exposing server truth or +enemies that attacker could not see or identify: + +```ts +const liveGame = withReplayTransport(game, { + historySeconds: 30, + captureRateHz: 20, + listPerspectives: (authority) => authority.players.keys(), + createTickets(event, { tick, connectedPlayerIds }) { + if ( + event.type !== "elimination" || + !connectedPlayerIds.includes(event.victimId) + ) return null; + + return { + requesterId: event.victimId, + perspectiveId: event.killerId, + fromTick: Math.max(0, tick - 60 * 4), + toTick: tick, + playbackRate: 0.72, + }; + }, + authorizeReplay: ({ requesterId, perspectiveId, authorityState }) => + authorityState.players.has(requesterId) && + authorityState.players.has(perspectiveId), +}); +``` + +The transport emits server-issued `replay-start`, `replay-frame`, and +`replay-end` binary messages. There is intentionally no client message that can +request an arbitrary perspective. Private `ReplayRecording` objects remain on +the server; only codec-cloned `ClientState` and `PerceptionEvent` projections +enter replay frames. + +The shooter in `packages/shared/src/shooter-game.ts` is the large example. The server owns movement, collision, bot decisions, ammo, firing cadence, ray hits, damage, armor, eliminations, respawns, pickups, and scores. Clients predict only their permitted state. + +Replication is perception-aware. A client snapshot includes itself, public pickups and scores, plus only opponents currently inside the server-approved field of view with line of sight. Hidden footsteps and gunfire become anonymous bearing/intensity/distance cues with no source ID or world coordinates. + +## Engine responsibilities + +- Fixed-step server and client simulation clocks with bounded catch-up. +- Deterministic input ordering, sequence acknowledgement, rejection, and replay. +- Private authoritative snapshot history and developer-defined client-state validation. +- Client prediction and reconciliation from server snapshots. +- Ping/pong RTT, jitter, clock-offset estimation, and adaptive input lead. +- Per-viewer state projection and internal-event-to-perception filtering. +- Generic binary message framing around developer-provided input, visible-state, and perception codecs. +- Optional visibility grouping for sharing encoded snapshots without weakening privacy. +- Bounded WebSocket backpressure and per-player message batching. +- Higher-order deterministic recording, checkpoint seeking, state-hash verification, privacy-filtered playback, and alternate-timeline branching. +- Bounded, policy-authorized replay transport with projected historical frames and instant attacker-perspective killcams. +- Per-action current-state, rewind, and fast-forward policies with server-clamped observed ticks. + +The trust rule is simple: clients send inputs and optional state reports, never authoritative facts. The server validates inputs, advances private state, decides what each player can observe, and sends only that projection. A determined cheater can inspect anything delivered to their machine, so secret information must never enter `ClientState` or `PerceptionEvent` in the first place. + +## Development + +```bash +npm install +npm run dev +``` + +The SPA runs at `http://localhost:5173`; in development, gameplay connects directly to the authoritative WebSocket server at `http://localhost:3001` to keep the Vite proxy out of latency measurements. + +### Live multiplayer profiler + +Open `http://localhost:3001/api/profile` (or replace `localhost` with the host's LAN address) for a one-second rolling JSON profile. It reports Node CPU, memory, event-loop utilization/delay, and a separate server profile for each mounted game. Per-game and per-connection metrics include tick rate, simulation/projection/encoding/send cost, input and snapshot rates, bytes per second, buffered bytes, backpressured sends, and dropped sends. Profiling stays on the authoritative server and adds no browser render-loop instrumentation. + +## Commands + +- `npm run dev` starts the engine, shared rules, server, and SPA in watch mode. +- `npm run build` builds every workspace in dependency order. +- `npm run typecheck` checks every workspace. +- `npm test` runs engine tests plus deterministic backend-only shooter matches, codec checks, and privacy assertions. +- `npm start` runs the built authoritative server. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..d771b4b --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,23 @@ +{ + "name": "@syncer/server", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "start": "node dist/index.js" + }, + "dependencies": { + "@syncer/engine": "0.0.0", + "@syncer/shared": "0.0.0", + "uWebSockets.js": "https://github.com/uNetworking/uWebSockets.js/archive/refs/tags/v20.67.0.tar.gz" + }, + "devDependencies": { + "@types/node": "^26.4.0", + "tsx": "^4.23.12", + "typescript": "^7.0.2" + } +} diff --git a/apps/server/src/client-profile-store.ts b/apps/server/src/client-profile-store.ts new file mode 100644 index 0000000..beaee50 --- /dev/null +++ b/apps/server/src/client-profile-store.ts @@ -0,0 +1,192 @@ +export interface ClientFrameProfile { + fps: number; + averageMs: number; + p95Ms: number; + maximumMs: number; + slowFrames: number; + longTasks: number; + longTaskMs: number; +} + +export interface ClientNetworkProfile { + roundTripTimeMs: number; + jitterMs: number; + clockOffsetMs: number; + inputLeadTicks: number; + connection: string; + validation: string; +} + +export interface ClientRendererProfile { + drawCalls: number; + triangles: number; + points: number; + lines: number; + geometries: number; + textures: number; + gpuVendor: string; + gpuRenderer: string; +} + +export interface ClientPerformanceSample { + sessionId: string; + game: string; + playerId: number | null; + capturedAt: string; + frame: ClientFrameProfile; + network: ClientNetworkProfile; + renderer: ClientRendererProfile | null; + device: { + viewportWidth: number; + viewportHeight: number; + devicePixelRatio: number; + hardwareConcurrency: number; + deviceMemoryGb: number | null; + visibility: string; + }; + heap: { + usedBytes: number; + totalBytes: number; + limitBytes: number; + } | null; +} + +interface StoredSession { + remoteAddress: string; + lastSeenAt: number; + samples: ClientPerformanceSample[]; +} + +export interface ClientProfileSnapshot { + sessionId: string; + remoteAddress: string; + game: string; + playerId: number | null; + lastSeenSecondsAgo: number; + latest: ClientPerformanceSample; + recent: { + samples: number; + averageFps: number; + minimumFps: number; + averageFrameMs: number; + worstFrameMs: number; + p95FrameMs: number; + slowFrames: number; + longTasks: number; + longTaskMs: number; + averageRoundTripTimeMs: number; + maximumRoundTripTimeMs: number; + averageJitterMs: number; + }; +} + +const maximumSamples = 30; +const staleSessionMs = 30_000; + +export class ClientProfileStore { + private readonly sessions = new Map(); + + record(sample: unknown, remoteAddress: string, now = performance.now()): boolean { + if (!isClientSample(sample)) return false; + let session = this.sessions.get(sample.sessionId); + if (!session) { + session = { remoteAddress, lastSeenAt: now, samples: [] }; + this.sessions.set(sample.sessionId, session); + } + session.remoteAddress = remoteAddress; + session.lastSeenAt = now; + session.samples.push(sample); + if (session.samples.length > maximumSamples) session.samples.shift(); + return true; + } + + snapshot(now = performance.now()): ClientProfileSnapshot[] { + for (const [sessionId, session] of this.sessions) { + if (now - session.lastSeenAt > staleSessionMs) this.sessions.delete(sessionId); + } + return [...this.sessions.entries()] + .map(([sessionId, session]) => summarize(sessionId, session, now)) + .sort((left, right) => left.game.localeCompare(right.game)); + } +} + +function summarize( + sessionId: string, + session: StoredSession, + now: number, +): ClientProfileSnapshot { + const latest = session.samples[session.samples.length - 1]!; + const frames = session.samples.map((sample) => sample.frame); + const networks = session.samples.map((sample) => sample.network); + const sum = (values: number[]) => values.reduce((total, value) => total + value, 0); + const average = (values: number[]) => values.length === 0 ? 0 : sum(values) / values.length; + return { + sessionId, + remoteAddress: session.remoteAddress, + game: latest.game, + playerId: latest.playerId, + lastSeenSecondsAgo: round((now - session.lastSeenAt) / 1_000, 2), + latest, + recent: { + samples: session.samples.length, + averageFps: round(average(frames.map((frame) => frame.fps)), 1), + minimumFps: round(Math.min(...frames.map((frame) => frame.fps)), 1), + averageFrameMs: round(average(frames.map((frame) => frame.averageMs)), 2), + worstFrameMs: round(Math.max(...frames.map((frame) => frame.maximumMs)), 2), + p95FrameMs: round(average(frames.map((frame) => frame.p95Ms)), 2), + slowFrames: sum(frames.map((frame) => frame.slowFrames)), + longTasks: sum(frames.map((frame) => frame.longTasks)), + longTaskMs: round(sum(frames.map((frame) => frame.longTaskMs)), 2), + averageRoundTripTimeMs: round( + average(networks.map((network) => network.roundTripTimeMs)), + 2, + ), + maximumRoundTripTimeMs: round( + Math.max(...networks.map((network) => network.roundTripTimeMs)), + 2, + ), + averageJitterMs: round(average(networks.map((network) => network.jitterMs)), 2), + }, + }; +} + +function isClientSample(value: unknown): value is ClientPerformanceSample { + if (!value || typeof value !== "object") return false; + const sample = value as Partial; + return ( + typeof sample.sessionId === "string" && + sample.sessionId.length > 0 && + sample.sessionId.length <= 128 && + typeof sample.game === "string" && + sample.game.length <= 32 && + (sample.playerId === null || Number.isInteger(sample.playerId)) && + isFiniteRecord(sample.frame, [ + "fps", + "averageMs", + "p95Ms", + "maximumMs", + "slowFrames", + "longTasks", + "longTaskMs", + ]) && + isFiniteRecord(sample.network, [ + "roundTripTimeMs", + "jitterMs", + "clockOffsetMs", + "inputLeadTicks", + ]) && + typeof sample.device === "object" && + sample.device !== null + ); +} + +function isFiniteRecord(value: unknown, keys: string[]): boolean { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return keys.every((key) => typeof record[key] === "number" && Number.isFinite(record[key])); +} + +function round(value: number, digits: number): number { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} diff --git a/apps/server/src/game-profiler.ts b/apps/server/src/game-profiler.ts new file mode 100644 index 0000000..fba0d56 --- /dev/null +++ b/apps/server/src/game-profiler.ts @@ -0,0 +1,284 @@ +export type InboundMessageKind = "input" | "ping" | "state-report"; + +export interface StepTimingSample { + simulationMs: number; + perceptionMs: number; + snapshotMs: number; + replayMs: number; + sendMs: number; + totalMs: number; + authorityEvents: number; + snapshotDue: boolean; +} + +interface MetricWindow { + startedAt: number; + elapsedMs: number; + steps: number; + simulationMs: number; + perceptionMs: number; + snapshotMs: number; + replayMs: number; + sendMs: number; + totalStepMs: number; + maximumStepMs: number; + authorityEvents: number; + snapshots: number; + inputMessages: number; + pingMessages: number; + stateReports: number; + inboundBytes: number; + outboundFrames: number; + outboundBytes: number; + backpressuredSends: number; + droppedSends: number; + maximumBufferedBytes: number; +} + +interface ClientMetrics { + playerId: number; + remoteAddress: string; + connectedAt: number; + current: MetricWindow; + previous: MetricWindow; + bufferedBytes: number; +} + +export interface RateProfile { + sampleSeconds: number; + stepsPerSecond: number; + averageStepMs: number; + maximumStepMs: number; + simulationMsPerStep: number; + perceptionMsPerStep: number; + snapshotMsPerStep: number; + replayMsPerStep: number; + sendMsPerStep: number; + authorityEventsPerSecond: number; + snapshotsPerSecond: number; + inputsPerSecond: number; + pingsPerSecond: number; + stateReportsPerSecond: number; + inboundBytesPerSecond: number; + outboundFramesPerSecond: number; + outboundBytesPerSecond: number; + backpressuredSendsPerSecond: number; + droppedSendsPerSecond: number; + maximumBufferedBytes: number; +} + +export interface ClientProfileSnapshot { + playerId: number; + remoteAddress: string; + connectedSeconds: number; + bufferedBytes: number; + recent: RateProfile; +} + +export interface GameProfileSnapshot { + path: string; + configuredTickRateHz: number; + connectedPlayers: number; + recent: RateProfile; + clients: ClientProfileSnapshot[]; +} + +/** Low-overhead one-second rolling profiler for a hosted networked game. */ +export class GameServerProfiler { + private current: MetricWindow; + private previous: MetricWindow; + private readonly clients = new Map(); + + constructor( + readonly path: string, + readonly tickRateHz: number, + now = performance.now(), + ) { + this.current = createWindow(now); + this.previous = createWindow(now); + } + + openClient(playerId: number, remoteAddress: string, now = performance.now()): void { + this.roll(now); + this.clients.set(playerId, { + playerId, + remoteAddress, + connectedAt: now, + current: createWindow(now), + previous: createWindow(now), + bufferedBytes: 0, + }); + } + + closeClient(playerId: number): void { + this.clients.delete(playerId); + } + + recordInbound( + playerId: number, + kind: InboundMessageKind, + bytes: number, + now = performance.now(), + ): void { + this.roll(now); + recordInbound(this.current, kind, bytes); + const client = this.clients.get(playerId); + if (client) recordInbound(client.current, kind, bytes); + } + + recordOutbound( + playerId: number, + bytes: number, + sendResult: number, + bufferedBytes: number, + now = performance.now(), + ): void { + this.roll(now); + recordOutbound(this.current, bytes, sendResult, bufferedBytes); + const client = this.clients.get(playerId); + if (client) { + client.bufferedBytes = bufferedBytes; + recordOutbound(client.current, bytes, sendResult, bufferedBytes); + } + } + + recordStep(sample: StepTimingSample, now = performance.now()): void { + this.roll(now); + const window = this.current; + window.steps += 1; + window.simulationMs += sample.simulationMs; + window.perceptionMs += sample.perceptionMs; + window.snapshotMs += sample.snapshotMs; + window.replayMs += sample.replayMs; + window.sendMs += sample.sendMs; + window.totalStepMs += sample.totalMs; + window.maximumStepMs = Math.max(window.maximumStepMs, sample.totalMs); + window.authorityEvents += sample.authorityEvents; + if (sample.snapshotDue) window.snapshots += 1; + } + + snapshot(now = performance.now()): GameProfileSnapshot { + this.roll(now); + return { + path: this.path, + configuredTickRateHz: this.tickRateHz, + connectedPlayers: this.clients.size, + recent: rateProfile(selectWindow(this.current, this.previous, now)), + clients: [...this.clients.values()] + .sort((left, right) => left.playerId - right.playerId) + .map((client) => ({ + playerId: client.playerId, + remoteAddress: client.remoteAddress, + connectedSeconds: round((now - client.connectedAt) / 1_000, 2), + bufferedBytes: client.bufferedBytes, + recent: rateProfile(selectWindow(client.current, client.previous, now)), + })), + }; + } + + private roll(now: number): void { + if (now - this.current.startedAt >= 1_000) { + this.current.elapsedMs = Math.max(1, now - this.current.startedAt); + this.previous = this.current; + this.current = createWindow(now); + } + for (const client of this.clients.values()) { + if (now - client.current.startedAt < 1_000) continue; + client.current.elapsedMs = Math.max(1, now - client.current.startedAt); + client.previous = client.current; + client.current = createWindow(now); + } + } +} + +function createWindow(now: number): MetricWindow { + return { + startedAt: now, + elapsedMs: 0, + steps: 0, + simulationMs: 0, + perceptionMs: 0, + snapshotMs: 0, + replayMs: 0, + sendMs: 0, + totalStepMs: 0, + maximumStepMs: 0, + authorityEvents: 0, + snapshots: 0, + inputMessages: 0, + pingMessages: 0, + stateReports: 0, + inboundBytes: 0, + outboundFrames: 0, + outboundBytes: 0, + backpressuredSends: 0, + droppedSends: 0, + maximumBufferedBytes: 0, + }; +} + +function recordInbound(window: MetricWindow, kind: InboundMessageKind, bytes: number): void { + window.inboundBytes += bytes; + if (kind === "input") window.inputMessages += 1; + else if (kind === "ping") window.pingMessages += 1; + else window.stateReports += 1; +} + +function recordOutbound( + window: MetricWindow, + bytes: number, + sendResult: number, + bufferedBytes: number, +): void { + window.outboundFrames += 1; + window.outboundBytes += bytes; + if (sendResult === 0) window.backpressuredSends += 1; + if (sendResult === 2) window.droppedSends += 1; + window.maximumBufferedBytes = Math.max(window.maximumBufferedBytes, bufferedBytes); +} + +function selectWindow( + current: MetricWindow, + previous: MetricWindow, + now: number, +): MetricWindow { + const currentElapsed = now - current.startedAt; + if (currentElapsed >= 350 || previous.elapsedMs === 0) { + return { ...current, elapsedMs: Math.max(1, currentElapsed) }; + } + return previous; +} + +function rateProfile(window: MetricWindow): RateProfile { + const seconds = Math.max(0.001, window.elapsedMs / 1_000); + const perSecond = (value: number) => round(value / seconds, 2); + const perStep = (value: number) => + round(window.steps === 0 ? 0 : value / window.steps, 4); + return { + sampleSeconds: round(seconds, 3), + stepsPerSecond: perSecond(window.steps), + averageStepMs: perStep(window.totalStepMs), + maximumStepMs: round(window.maximumStepMs, 4), + simulationMsPerStep: perStep(window.simulationMs), + perceptionMsPerStep: perStep(window.perceptionMs), + snapshotMsPerStep: perStep(window.snapshotMs), + replayMsPerStep: perStep(window.replayMs), + sendMsPerStep: perStep(window.sendMs), + authorityEventsPerSecond: perSecond(window.authorityEvents), + snapshotsPerSecond: perSecond(window.snapshots), + inputsPerSecond: perSecond(window.inputMessages), + pingsPerSecond: perSecond(window.pingMessages), + stateReportsPerSecond: perSecond(window.stateReports), + inboundBytesPerSecond: perSecond(window.inboundBytes), + outboundFramesPerSecond: perSecond(window.outboundFrames), + outboundBytesPerSecond: perSecond(window.outboundBytes), + backpressuredSendsPerSecond: perSecond(window.backpressuredSends), + droppedSendsPerSecond: perSecond(window.droppedSends), + maximumBufferedBytes: window.maximumBufferedBytes, + }; +} + +function round(value: number, digits: number): number { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} diff --git a/apps/server/src/host-networked-game.ts b/apps/server/src/host-networked-game.ts new file mode 100644 index 0000000..ae26d41 --- /dev/null +++ b/apps/server/src/host-networked-game.ts @@ -0,0 +1,295 @@ +import { + FixedStepClock, + type BinaryProtocol, + type ClientStateReport, + type InputDecision, + type InputPacket, + type NetworkedServerStepResult, + type PlayerId, + type ReplayTicket, + type SnapshotBatch, + type StateSnapshot, + type ValidationResult, +} from "@syncer/engine"; +import { + DISABLED, + type TemplatedApp, + type WebSocket, +} from "uWebSockets.js"; +import { + GameServerProfiler, + type GameProfileSnapshot, + type InboundMessageKind, +} from "./game-profiler.js"; + +interface HostableEngine { + readonly playerIds: PlayerId[]; + addPlayer(playerId: PlayerId): void; + removePlayer(playerId: PlayerId): void; + submitInput(playerId: PlayerId, packet: InputPacket): InputDecision; + submitStateReport(playerId: PlayerId, report: ClientStateReport): ValidationResult | null; + step(): NetworkedServerStepResult; + createSnapshot(playerId: PlayerId, serverTime: number): StateSnapshot; + createSnapshotBatches(serverTime: number): SnapshotBatch[]; + createPerceptions(playerId: PlayerId, events: readonly AuthorityEvent[]): PerceptionEvent[]; + drainReplayTickets?(playerId: PlayerId): Array>; +} + +interface HostableGame { + tickRateHz: number; + protocol: BinaryProtocol; + createServer(): HostableEngine; +} + +export interface HostedGameLoop { + readonly path: string; + advance(now: number): void; + profile(now?: number): GameProfileSnapshot; +} + +/** Mounts any defined networked game on the shared WebSocket server. */ +export function hostNetworkedGame( + app: TemplatedApp, + path: string, + game: HostableGame, +): HostedGameLoop { + const engine = game.createServer(); + const protocol = game.protocol; + const clock = new FixedStepClock({ rateHz: game.tickRateHz, maxCatchUpSteps: 5 }); + const profiler = new GameServerProfiler(path, game.tickRateHz); + const socketPlayers = new Map, PlayerId>(); + const playerSockets = new Map>(); + let nextPlayerId = 1; + + app.ws(path, { + compression: DISABLED, + maxPayloadLength: 512 * 1_024, + maxBackpressure: 256 * 1_024, + closeOnBackpressureLimit: true, + idleTimeout: 32, + sendPingsAutomatically: true, + open(socket) { + const playerId = nextPlayerId; + nextPlayerId = (nextPlayerId + 1) >>> 0; + socketPlayers.set(socket, playerId); + playerSockets.set(playerId, socket); + profiler.openClient(playerId, readRemoteAddress(socket)); + engine.addPlayer(playerId); + sendFrame( + socket, + playerId, + protocol.encodeServer({ + kind: "welcome", + playerId, + snapshot: engine.createSnapshot(playerId, performance.now()), + }), + ); + }, + message(socket, payload) { + const playerId = socketPlayers.get(socket); + if (playerId === undefined) { + socket.end(1011, "Missing player session"); + return; + } + + try { + const message = protocol.decodeClient(payload); + profiler.recordInbound( + playerId, + message.kind as InboundMessageKind, + payload.byteLength, + ); + switch (message.kind) { + case "input": { + const decision = engine.submitInput(playerId, message.packet); + if (!decision.accepted) { + sendFrame( + socket, + playerId, + protocol.encodeServer({ kind: "reject-input", sequence: message.packet.sequence }), + ); + } + break; + } + case "ping": { + const serverReceivedAt = performance.now(); + sendFrame( + socket, + playerId, + protocol.encodeServer({ + kind: "pong", + pong: { ...message.ping, serverReceivedAt, serverSentAt: performance.now() }, + }), + ); + break; + } + case "state-report": { + const validation = engine.submitStateReport(playerId, message.report); + if (validation) sendValidation(validation); + break; + } + } + } catch { + socket.end(1003, "Invalid game message"); + } + }, + close(socket) { + const playerId = socketPlayers.get(socket); + if (playerId === undefined) return; + socketPlayers.delete(socket); + playerSockets.delete(playerId); + profiler.closeClient(playerId); + engine.removePlayer(playerId); + }, + }); + + function runStep(): void { + const startedAt = performance.now(); + const result = engine.step(); + const simulationEndedAt = performance.now(); + const outbound = new Map(); + + for (const acknowledgement of result.acknowledgements) { + queueFrame(outbound, acknowledgement.playerId, protocol.encodeServer({ + kind: "acknowledge", + sequence: acknowledgement.sequence, + })); + } + for (const validation of result.validations) { + queueFrame(outbound, validation.playerId, protocol.encodeServer({ + kind: "validation", + tick: validation.tick, + valid: validation.valid, + })); + } + for (const playerId of engine.playerIds) { + for (const event of engine.createPerceptions(playerId, result.events)) { + queueFrame(outbound, playerId, protocol.encodeServer({ kind: "event", tick: result.tick, event })); + } + } + const perceptionEndedAt = performance.now(); + + if (result.snapshotDue) { + const serverTime = performance.now(); + for (const batch of engine.createSnapshotBatches(serverTime)) { + const frame = protocol.encodeServer({ + kind: "snapshot", + snapshot: { tick: result.tick, serverTime, state: batch.state }, + }); + for (const playerId of batch.playerIds) queueFrame(outbound, playerId, frame); + } + } + const snapshotEndedAt = performance.now(); + + if (engine.drainReplayTickets) { + for (const playerId of engine.playerIds) { + for (const ticket of engine.drainReplayTickets(playerId)) queueReplay(outbound, playerId, ticket); + } + } + const replayEndedAt = performance.now(); + + for (const [playerId, frames] of outbound) { + const socket = playerSockets.get(playerId); + socket?.cork(() => { + for (const frame of frames) sendFrame(socket, playerId, frame); + }); + } + const endedAt = performance.now(); + profiler.recordStep({ + simulationMs: simulationEndedAt - startedAt, + perceptionMs: perceptionEndedAt - simulationEndedAt, + snapshotMs: snapshotEndedAt - perceptionEndedAt, + replayMs: replayEndedAt - snapshotEndedAt, + sendMs: endedAt - replayEndedAt, + totalMs: endedAt - startedAt, + authorityEvents: result.events.length, + snapshotDue: result.snapshotDue, + }, endedAt); + } + + function sendValidation(validation: ValidationResult): void { + const socket = playerSockets.get(validation.playerId); + if (!socket) return; + sendFrame( + socket, + validation.playerId, + protocol.encodeServer({ kind: "validation", tick: validation.tick, valid: validation.valid }), + ); + } + + function sendFrame( + socket: WebSocket, + playerId: PlayerId, + frame: ArrayBuffer, + ): void { + const result = socket.send(frame, true, false); + profiler.recordOutbound( + playerId, + frame.byteLength, + result, + socket.getBufferedAmount(), + ); + } + + function queueReplay( + outbound: Map, + playerId: PlayerId, + ticket: ReplayTicket, + ): void { + queueFrame(outbound, playerId, protocol.encodeServer({ + kind: "replay-start", + ticketId: ticket.ticketId, + perspectiveId: ticket.perspectiveId, + fromTick: ticket.fromTick, + toTick: ticket.toTick, + frameCount: ticket.frames.length, + playbackRate: ticket.playbackRate, + })); + for (const frame of ticket.frames) { + queueFrame(outbound, playerId, protocol.encodeServer({ + kind: "replay-frame", + ticketId: ticket.ticketId, + tick: frame.tick, + state: frame.state, + events: frame.events, + })); + } + queueFrame(outbound, playerId, protocol.encodeServer({ kind: "replay-end", ticketId: ticket.ticketId })); + } + + return { + path, + advance(now) { + clock.advance(now, runStep); + }, + profile(now) { + return profiler.snapshot(now); + }, + }; +} + +function readRemoteAddress(socket: WebSocket): string { + const port = socket.getRemotePort(); + const text = new TextDecoder().decode(socket.getRemoteAddressAsText()); + if (text) return `${text}:${port}`; + const bytes = new Uint8Array(socket.getRemoteAddress()); + if (bytes.length === 4) return `${[...bytes].join(".")}:${port}`; + if (bytes.length === 16) { + const groups: string[] = []; + for (let index = 0; index < bytes.length; index += 2) { + groups.push(((bytes[index] ?? 0) * 256 + (bytes[index + 1] ?? 0)).toString(16)); + } + return `[${groups.join(":")}]:${port}`; + } + return port > 0 ? `unknown:${port}` : "unknown"; +} + +function queueFrame( + outbound: Map, + playerId: PlayerId, + frame: ArrayBuffer, +): void { + const frames = outbound.get(playerId); + if (frames) frames.push(frame); + else outbound.set(playerId, [frame]); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..414e94d --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,183 @@ +import { + API_PATHS, + FLUX_SOCKET_PATH, + GAME_SOCKET_PATH, + ROYALE_SOCKET_PATH, + createShooterGame, + fluxGame, + royaleGame, + type ApiMessage, +} from "@syncer/shared"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { extname, join, relative } from "node:path"; +import { App, type HttpResponse } from "uWebSockets.js"; +import { ClientProfileStore } from "./client-profile-store.js"; +import { hostNetworkedGame } from "./host-networked-game.js"; +import { ServerProcessProfiler } from "./server-profiler.js"; + +const port = Number(process.env.PORT ?? 3001); +const app = App(); +const processProfiler = new ServerProcessProfiler(); +const clientProfiles = new ClientProfileStore(); +const staticSite = loadStaticSite(process.env.STATIC_ROOT); +const gameLoops = [ + hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })), + hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame), + hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame), +]; + +app + .post("/api/client-profile", (response) => { + const chunks: Buffer[] = []; + let bytes = 0; + let finished = false; + response.onAborted(() => { + finished = true; + }); + response.onData((chunk, isLast) => { + if (finished) return; + bytes += chunk.byteLength; + if (bytes > 64 * 1_024) { + finished = true; + response.writeStatus("413 Payload Too Large").end(); + return; + } + chunks.push(Buffer.from(new Uint8Array(chunk))); + if (!isLast) return; + finished = true; + try { + const sample: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!clientProfiles.record(sample, readRemoteAddress(response))) { + response.writeStatus("400 Bad Request").end("Invalid profile sample"); + return; + } + response + .writeStatus("204 No Content") + .writeHeader("Access-Control-Allow-Origin", "*") + .end(); + } catch { + response.writeStatus("400 Bad Request").end("Invalid JSON"); + } + }); + }) + .get("/api/profile", (response) => { + const now = performance.now(); + response + .writeHeader("Content-Type", "application/json; charset=utf-8") + .writeHeader("Access-Control-Allow-Origin", "*") + .end(JSON.stringify({ + timestamp: new Date().toISOString(), + process: processProfiler.snapshot(), + clients: clientProfiles.snapshot(now), + games: gameLoops.map((game) => game.profile(now)), + }, null, 2)); + }) + .get(API_PATHS.message, (response) => { + const body: ApiMessage = { + message: "Authoritative multiplayer simulations online", + timestamp: new Date().toISOString(), + }; + response + .writeHeader("Content-Type", "application/json; charset=utf-8") + .end(JSON.stringify(body)); + }) + .any("/*", (response, request) => { + const url = request.getUrl(); + const method = request.getMethod().toUpperCase(); + const asset = staticSite?.files.get(url); + const fallback = + staticSite && + (method === "GET" || method === "HEAD") && + !url.startsWith("/api/") && + !url.startsWith("/ws/") + ? staticSite.index + : undefined; + const file = asset ?? fallback; + if (!file || (method !== "GET" && method !== "HEAD")) { + response.writeStatus("404 Not Found").end("Not found"); + return; + } + response + .writeHeader("Content-Type", file.contentType) + .writeHeader( + "Cache-Control", + asset && url.startsWith("/assets/") + ? "public, max-age=31536000, immutable" + : "no-cache", + ); + response.end(method === "HEAD" ? undefined : file.body); + }) + .listen(port, (listenSocket) => { + if (!listenSocket) { + console.error(`Could not listen on port ${port}`); + process.exitCode = 1; + return; + } + + console.log( + `Authoritative server listening on http://localhost:${port} (${gameLoops + .map(({ path }) => path) + .join(", ")}); profiler: /api/profile`, + ); + const timer = setInterval(() => { + const now = performance.now(); + for (const game of gameLoops) game.advance(now); + }, 4); + timer.unref(); + }); + +function readRemoteAddress(response: HttpResponse): string { + const port = response.getRemotePort(); + const text = new TextDecoder().decode(response.getRemoteAddressAsText()); + return text ? `${text}:${port}` : port > 0 ? `unknown:${port}` : "unknown"; +} + +interface StaticAsset { + body: Buffer; + contentType: string; +} + +interface StaticSite { + files: Map; + index: StaticAsset; +} + +function loadStaticSite(root: string | undefined): StaticSite | null { + if (!root || !existsSync(root)) return null; + const files = new Map(); + for (const filePath of walkFiles(root)) { + const url = `/${relative(root, filePath).split("\\").join("/")}`; + files.set(url, { + body: readFileSync(filePath), + contentType: contentType(filePath), + }); + } + const index = files.get("/index.html"); + if (!index) throw new Error(`STATIC_ROOT has no index.html: ${root}`); + console.log(`Serving ${files.size} static files from ${root}`); + return { files, index }; +} + +function walkFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? walkFiles(path) : [path]; + }); +} + +function contentType(filePath: string): string { + switch (extname(filePath)) { + case ".html": return "text/html; charset=utf-8"; + case ".js": return "text/javascript; charset=utf-8"; + case ".css": return "text/css; charset=utf-8"; + case ".json": return "application/json; charset=utf-8"; + case ".svg": return "image/svg+xml"; + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + case ".webp": return "image/webp"; + case ".ico": return "image/x-icon"; + case ".woff2": return "font/woff2"; + default: return "application/octet-stream"; + } +} diff --git a/apps/server/src/server-profiler.ts b/apps/server/src/server-profiler.ts new file mode 100644 index 0000000..6ef7da2 --- /dev/null +++ b/apps/server/src/server-profiler.ts @@ -0,0 +1,101 @@ +import { + monitorEventLoopDelay, + performance as nodePerformance, + type EventLoopUtilization, +} from "node:perf_hooks"; + +export interface ServerProcessProfile { + uptimeSeconds: number; + cpuPercent: number; + eventLoopUtilization: number; + eventLoopDelayMs: { + mean: number; + maximum: number; + p95: number; + p99: number; + }; + memoryBytes: { + residentSet: number; + heapUsed: number; + heapTotal: number; + external: number; + arrayBuffers: number; + }; +} + +/** Samples Node process pressure independently of any particular game. */ +export class ServerProcessProfiler { + private readonly delay = monitorEventLoopDelay({ resolution: 1 }); + private previousCpu = process.cpuUsage(); + private previousSampleAt = nodePerformance.now(); + private previousUtilization: EventLoopUtilization = + nodePerformance.eventLoopUtilization(); + private recent: ServerProcessProfile; + + constructor() { + this.delay.enable(); + this.recent = this.capture(); + const timer = setInterval(() => { + this.recent = this.capture(); + }, 1_000); + timer.unref(); + } + + snapshot(): ServerProcessProfile { + return { + ...this.recent, + uptimeSeconds: round(process.uptime(), 2), + memoryBytes: readMemory(), + }; + } + + private capture(): ServerProcessProfile { + const now = nodePerformance.now(); + const elapsedMs = Math.max(1, now - this.previousSampleAt); + const cpu = process.cpuUsage(); + const cpuMicroseconds = + cpu.user - this.previousCpu.user + cpu.system - this.previousCpu.system; + const utilization = nodePerformance.eventLoopUtilization(); + const utilizationDelta = nodePerformance.eventLoopUtilization( + utilization, + this.previousUtilization, + ); + const profile: ServerProcessProfile = { + uptimeSeconds: round(process.uptime(), 2), + cpuPercent: round((cpuMicroseconds / (elapsedMs * 1_000)) * 100, 2), + eventLoopUtilization: round(utilizationDelta.utilization * 100, 2), + eventLoopDelayMs: { + mean: nanosecondsToMilliseconds(this.delay.mean), + maximum: nanosecondsToMilliseconds(this.delay.max), + p95: nanosecondsToMilliseconds(this.delay.percentile(95)), + p99: nanosecondsToMilliseconds(this.delay.percentile(99)), + }, + memoryBytes: readMemory(), + }; + this.previousCpu = cpu; + this.previousSampleAt = now; + this.previousUtilization = utilization; + this.delay.reset(); + return profile; + } +} + +function readMemory(): ServerProcessProfile["memoryBytes"] { + const memory = process.memoryUsage(); + return { + residentSet: memory.rss, + heapUsed: memory.heapUsed, + heapTotal: memory.heapTotal, + external: memory.external, + arrayBuffers: memory.arrayBuffers, + }; +} + +function nanosecondsToMilliseconds(value: number): number { + return round(Number.isFinite(value) ? value / 1_000_000 : 0, 3); +} + +function round(value: number, digits: number): number { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..27da6a1 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "sourceMap": true, + "types": ["node"] + }, + "include": ["src"] +} + diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..4e16f2d --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + + + Sync Arena + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..0a07aee --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@syncer/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@syncer/engine": "0.0.0", + "@syncer/shared": "0.0.0", + "@types/three": "^0.183.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.0", + "typescript": "^7.0.2", + "vite": "^8.2.2" + } +} diff --git a/apps/web/public/assets/arena-panels.png b/apps/web/public/assets/arena-panels.png new file mode 100644 index 0000000..c4e0a7c Binary files /dev/null and b/apps/web/public/assets/arena-panels.png differ diff --git a/apps/web/public/assets/arena-wall.png b/apps/web/public/assets/arena-wall.png new file mode 100644 index 0000000..b7362ee Binary files /dev/null and b/apps/web/public/assets/arena-wall.png differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..813b98b --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,241 @@ +import { + WEAPONS, + Weapon, + playerDisplayName, + type PlayerKind, +} from "@syncer/shared"; +import { useEffect, useState } from "react"; +import { Arena3D } from "./Arena3D.js"; +import { FluxGame } from "./FluxGame.js"; +import { RoyaleGame } from "./RoyaleGame.js"; +import { useGameClient } from "./useGameClient.js"; + +export function App() { + const [demo, setDemo] = useState<"arena" | "flux" | "royale">(() => + window.location.hash === "#royale" + ? "royale" + : window.location.hash === "#flux" + ? "flux" + : "arena", + ); + + useEffect(() => { + window.history.replaceState(null, "", `#${demo}`); + }, [demo]); + + return ( + <> + {demo === "arena" ? : demo === "flux" ? : } + + + ); +} + +function ShooterGame() { + const client = useGameClient(); + const viewedId = client.cameraPlayerId; + const local = viewedId === null ? undefined : client.world.players.get(viewedId); + const recentEntries = [...client.world.events].reverse(); + const damageEntry = recentEntries.find((entry) => entry.event.type === "damage"); + const damageEvent = damageEntry?.event.type === "damage" ? damageEntry.event : null; + const hitEntry = recentEntries.find( + (entry) => entry.event.type === "hit", + ); + const soundEntry = recentEntries.find((entry) => entry.event.type === "sound"); + const eliminationEntries = recentEntries + .filter((entry) => entry.event.type === "elimination") + .slice(0, 4); + const weapon = WEAPONS[local?.weapon ?? Weapon.PulseRifle]; + const ammo = local?.ammo[local.weapon] ?? { magazine: 0, reserve: 0 }; + const matchSeconds = Math.floor(client.world.match.elapsedTicks / 60); + const recentDamage = Boolean( + damageEntry && damageEvent && client.tick - damageEntry.receivedTick < 18, + ); + const recentHit = Boolean(hitEntry && client.tick - hitEntry.receivedTick < 12); + const recentSound = Boolean(soundEntry && client.tick - soundEntry.receivedTick < 32); + + return ( +
+ +
+ ); +} + +function Telemetry({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} + +function scoreFor( + id: number | null, + scoreboard: ReadonlyArray<{ id: number; kind: PlayerKind; kills: number; deaths: number }>, +) { + return id === null ? undefined : scoreboard.find((entry) => entry.id === id); +} + +function labelFor( + id: number, + scoreboard: ReadonlyArray<{ id: number; kind: PlayerKind }>, +): string { + const kind = scoreboard.find((entry) => entry.id === id)?.kind ?? "bot"; + return playerDisplayName(id, kind); +} + +function formatClock(totalSeconds: number): string { + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, "0"); + const seconds = (totalSeconds % 60).toString().padStart(2, "0"); + return `${minutes}:${seconds}`; +} diff --git a/apps/web/src/Arena3D.tsx b/apps/web/src/Arena3D.tsx new file mode 100644 index 0000000..e89378c --- /dev/null +++ b/apps/web/src/Arena3D.tsx @@ -0,0 +1,756 @@ +import { useEffect, useRef, useState, type MutableRefObject } from "react"; +import * as THREE from "three"; +import { + ARENA_BLOCKS, + ARENA_HALF_SIZE, + PLAYER_EYE_HEIGHT, + WEAPONS, + Weapon, + type PickupState, + type PlayerState, + type ShooterPerception, + type ShooterWorldState, +} from "@syncer/shared"; +import { ArenaAudio } from "./audio.js"; + +interface Arena3DProps { + world: ShooterWorldState; + playerId: number | null; + presentationKey: string; + interactive: boolean; +} + +interface TemporaryEffect { + object: THREE.Object3D; + expiresAt: number; + createdAt?: number; + floatDistance?: number; +} + +interface SceneRuntime { + renderer: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + players: Map; + pickups: Map; + effects: TemporaryEffect[]; + animated: THREE.Object3D[]; + weapon: THREE.Group; + weaponAccent: THREE.MeshStandardMaterial; + muzzle: THREE.PointLight; + muzzleCore: THREE.Mesh; + muzzleUntil: number; + lastLocalImpact: { x: number; y: number; z: number; at: number } | null; + audio: ArenaAudio; + resizeObserver: ResizeObserver; + animationFrame: number; +} + +export function Arena3D({ + world, + playerId, + presentationKey, + interactive, +}: Arena3DProps) { + const hostRef = useRef(null); + const worldRef = useRef(world); + const playerIdRef = useRef(playerId); + const lastEventIdRef = useRef(0); + const presentationKeyRef = useRef("live"); + const runtimeRef = useRef(null); + const [locked, setLocked] = useState(false); + + worldRef.current = world; + playerIdRef.current = playerId; + if (presentationKeyRef.current !== presentationKey) { + lastEventIdRef.current = presentationKey === "live" + ? world.events.reduce( + (latest, entry) => Math.max(latest, entry.event.id), + 0, + ) + : 0; + presentationKeyRef.current = presentationKey; + } + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + + const renderer = new THREE.WebGLRenderer({ + antialias: true, + powerPreference: "high-performance", + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.6)); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFShadowMap; + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.38; + host.append(renderer.domElement); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0b1822); + scene.fog = new THREE.FogExp2(0x10212c, 0.014); + const camera = new THREE.PerspectiveCamera(79, 1, 0.045, 100); + camera.rotation.order = "YXZ"; + scene.add(camera); + + buildLights(scene); + const animated = buildArena(scene); + const { group: weapon, muzzle, muzzleCore, accent } = buildWeapon(); + camera.add(weapon); + + const runtime: SceneRuntime = { + renderer, + scene, + camera, + players: new Map(), + pickups: new Map(), + effects: [], + animated, + weapon, + weaponAccent: accent, + muzzle, + muzzleCore, + muzzleUntil: 0, + lastLocalImpact: null, + audio: new ArenaAudio(), + resizeObserver: new ResizeObserver(() => resize(runtime, host)), + animationFrame: 0, + }; + runtimeRef.current = runtime; + runtime.resizeObserver.observe(host); + resize(runtime, host); + + const animate = (time: number) => { + updateRuntime( + runtime, + worldRef.current, + playerIdRef.current, + lastEventIdRef, + time, + ); + renderer.render(scene, camera); + runtime.animationFrame = window.requestAnimationFrame(animate); + }; + runtime.animationFrame = window.requestAnimationFrame(animate); + + const pointerLockChanged = () => + setLocked(document.pointerLockElement === renderer.domElement); + document.addEventListener("pointerlockchange", pointerLockChanged); + + return () => { + document.removeEventListener("pointerlockchange", pointerLockChanged); + runtime.resizeObserver.disconnect(); + window.cancelAnimationFrame(runtime.animationFrame); + runtime.audio.dispose(); + scene.traverse((object) => { + if (object instanceof THREE.Sprite) { + object.material.map?.dispose(); + object.material.dispose(); + return; + } + if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return; + object.geometry.dispose(); + const materials = Array.isArray(object.material) + ? object.material + : [object.material]; + for (const material of materials) material.dispose(); + }); + renderer.dispose(); + renderer.domElement.remove(); + runtimeRef.current = null; + }; + }, []); + + const deploy = () => { + if (!interactive) return; + const runtime = runtimeRef.current; + const canvas = hostRef.current?.querySelector("canvas"); + if (!runtime || !canvas || !canvas.isConnected) return; + runtime.audio.unlock(); + if (document.pointerLockElement) { + setLocked(true); + return; + } + void canvas.requestPointerLock().catch(() => setLocked(false)); + }; + + return ( +
+ {interactive && !locked && ( + + )} +
+ ); +} + +function buildLights(scene: THREE.Scene): void { + scene.add(new THREE.AmbientLight(0x9bc8dc, 0.72)); + scene.add(new THREE.HemisphereLight(0xa8ddf4, 0x3a3028, 2.35)); + + const sun = new THREE.DirectionalLight(0xe8f6ff, 3.2); + sun.position.set(-9, 19, 5); + sun.castShadow = true; + sun.shadow.mapSize.set(1_024, 1_024); + sun.shadow.camera.left = -22; + sun.shadow.camera.right = 22; + sun.shadow.camera.top = 22; + sun.shadow.camera.bottom = -22; + scene.add(sun); + + const fill = new THREE.DirectionalLight(0x78bcd6, 1.15); + fill.position.set(11, 8, -12); + scene.add(fill); + + for (const [x, z] of [ + [0, -11], + [0, 11], + [-11, 0], + [11, 0], + ] as const) { + const light = new THREE.PointLight(0xb8e8ff, 18, 17, 1.8); + light.position.set(x, 5.5, z); + scene.add(light); + } + + for (const [x, z, color] of [ + [-15.5, -15.5, 0x17cfff], + [15.5, 15.5, 0xff842b], + [15.5, -15.5, 0x17cfff], + [-15.5, 15.5, 0xff842b], + ] as const) { + const light = new THREE.PointLight(color, 22, 16, 2); + light.position.set(x, 2.2, z); + scene.add(light); + } +} + +function buildArena(scene: THREE.Scene): THREE.Object3D[] { + const animated: THREE.Object3D[] = []; + const textureLoader = new THREE.TextureLoader(); + const texture = textureLoader.load("/assets/arena-panels.png"); + texture.colorSpace = THREE.SRGBColorSpace; + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.anisotropy = 8; + texture.repeat.set(2.5, 2.5); + + const floorTexture = textureLoader.load("/assets/arena-panels.png"); + floorTexture.colorSpace = THREE.SRGBColorSpace; + floorTexture.wrapS = THREE.RepeatWrapping; + floorTexture.wrapT = THREE.RepeatWrapping; + floorTexture.anisotropy = 8; + floorTexture.repeat.set(5, 5); + const floorMaterial = new THREE.MeshStandardMaterial({ + map: floorTexture, + color: 0x6f7a80, + roughness: 0.72, + metalness: 0.48, + }); + const floor = new THREE.Mesh( + new THREE.PlaneGeometry(ARENA_HALF_SIZE * 2, ARENA_HALF_SIZE * 2), + floorMaterial, + ); + floor.rotation.x = -Math.PI / 2; + floor.receiveShadow = true; + scene.add(floor); + + const grid = new THREE.GridHelper( + ARENA_HALF_SIZE * 2, + 36, + 0x2d819a, + 0x18313b, + ); + grid.position.y = 0.015; + for (const material of Array.isArray(grid.material) ? grid.material : [grid.material]) { + material.transparent = true; + material.opacity = 0.3; + } + scene.add(grid); + + const panelMaterial = new THREE.MeshStandardMaterial({ + map: texture, + color: 0x97a4aa, + roughness: 0.58, + metalness: 0.54, + }); + const coverMaterial = new THREE.MeshStandardMaterial({ + color: 0x35424b, + roughness: 0.47, + metalness: 0.72, + }); + const reactorMaterial = new THREE.MeshStandardMaterial({ + color: 0x112933, + emissive: 0x007c93, + emissiveIntensity: 1.9, + roughness: 0.3, + metalness: 0.8, + }); + + for (const block of ARENA_BLOCKS) { + const material = + block.style === "reactor" + ? reactorMaterial + : block.style === "wall" + ? panelMaterial + : coverMaterial; + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(block.width, block.height, block.depth), + material, + ); + mesh.position.set(block.x, block.height / 2, block.z); + mesh.castShadow = true; + mesh.receiveShadow = true; + scene.add(mesh); + + if (block.style === "reactor") { + for (let index = 0; index < 3; index += 1) { + const ring = new THREE.Mesh( + new THREE.TorusGeometry(2.45 + index * 0.2, 0.035, 8, 36), + new THREE.MeshBasicMaterial({ + color: index % 2 ? 0xffa240 : 0x31ddff, + transparent: true, + opacity: 0.78, + }), + ); + ring.position.set(0, 1.05 + index * 1.05, 0); + ring.rotation.x = Math.PI / 2; + ring.userData.spin = (index % 2 ? -1 : 1) * (0.00018 + index * 0.00004); + scene.add(ring); + animated.push(ring); + } + } + } + + const boundaryGeometry = [ + [0, -ARENA_HALF_SIZE - 0.3, ARENA_HALF_SIZE * 2 + 1.2, 0.65], + [0, ARENA_HALF_SIZE + 0.3, ARENA_HALF_SIZE * 2 + 1.2, 0.65], + [-ARENA_HALF_SIZE - 0.3, 0, 0.65, ARENA_HALF_SIZE * 2 + 1.2], + [ARENA_HALF_SIZE + 0.3, 0, 0.65, ARENA_HALF_SIZE * 2 + 1.2], + ] as const; + for (const [x, z, width, depth] of boundaryGeometry) { + const wall = new THREE.Mesh( + new THREE.BoxGeometry(width, 3.6, depth), + panelMaterial, + ); + wall.position.set(x, 1.8, z); + wall.castShadow = true; + wall.receiveShadow = true; + scene.add(wall); + } + + const stripMaterial = new THREE.MeshStandardMaterial({ + color: 0xa1efff, + emissive: 0x00a8cd, + emissiveIntensity: 4, + roughness: 0.2, + }); + for (let index = -14; index <= 14; index += 7) { + for (const z of [-17.75, 17.75]) { + const strip = new THREE.Mesh( + new THREE.BoxGeometry(3.2, 0.05, 0.13), + stripMaterial, + ); + strip.position.set(index, 0.035, z); + scene.add(strip); + } + } + return animated; +} + +function buildWeapon(): { + group: THREE.Group; + muzzle: THREE.PointLight; + muzzleCore: THREE.Mesh; + accent: THREE.MeshStandardMaterial; +} { + const group = new THREE.Group(); + group.position.set(0.36, -0.3, -0.56); + const dark = new THREE.MeshStandardMaterial({ + color: 0x121920, + roughness: 0.3, + metalness: 0.9, + }); + const accent = new THREE.MeshStandardMaterial({ + color: 0x5de4c7, + emissive: 0x0aa68d, + emissiveIntensity: 3.8, + roughness: 0.2, + }); + const body = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.2, 0.62), dark); + body.position.z = -0.16; + group.add(body); + const housing = new THREE.Mesh( + new THREE.BoxGeometry(0.31, 0.12, 0.3), + dark, + ); + housing.position.set(0, -0.05, -0.33); + group.add(housing); + const barrel = new THREE.Mesh( + new THREE.CylinderGeometry(0.036, 0.047, 0.5, 10), + dark, + ); + barrel.rotation.x = Math.PI / 2; + barrel.position.set(0, 0.025, -0.57); + group.add(barrel); + const rail = new THREE.Mesh(new THREE.BoxGeometry(0.105, 0.045, 0.38), accent); + rail.position.set(0, 0.116, -0.18); + group.add(rail); + const muzzleCore = new THREE.Mesh( + new THREE.SphereGeometry(0.075, 10, 8), + new THREE.MeshBasicMaterial({ color: 0xc8ffff }), + ); + muzzleCore.position.set(0, 0.025, -0.83); + muzzleCore.visible = false; + group.add(muzzleCore); + const muzzle = new THREE.PointLight(0x8deeff, 0, 3.5); + muzzle.position.copy(muzzleCore.position); + group.add(muzzle); + return { group, muzzle, muzzleCore, accent }; +} + +function updateRuntime( + runtime: SceneRuntime, + world: ShooterWorldState, + playerId: number | null, + lastEventId: MutableRefObject, + time: number, +): void { + const local = playerId === null ? undefined : world.players.get(playerId); + if (local) { + runtime.camera.position.set(local.x, PLAYER_EYE_HEIGHT, local.z); + runtime.camera.rotation.set(local.pitch, -local.yaw, 0); + runtime.weapon.visible = local.alive; + const moving = Math.hypot(local.velocityX, local.velocityZ); + runtime.weapon.position.y = + -0.3 + Math.sin(time * 0.013) * Math.min(0.021, moving * 0.003); + runtime.weapon.position.x = + 0.36 + Math.cos(time * 0.0065) * Math.min(0.008, moving * 0.0012); + const accentColor = new THREE.Color(WEAPONS[local.weapon].accent); + runtime.weaponAccent.color.copy(accentColor); + runtime.weaponAccent.emissive.copy(accentColor).multiplyScalar(0.52); + + } + runtime.muzzle.intensity = time < runtime.muzzleUntil ? 14 : 0; + runtime.muzzleCore.visible = time < runtime.muzzleUntil; + + const seen = new Set(); + for (const player of world.players.values()) { + if (player.id === playerId || !player.alive) continue; + seen.add(player.id); + let model = runtime.players.get(player.id); + if (!model) { + model = createPlayerModel(player); + runtime.players.set(player.id, model); + runtime.scene.add(model); + } + model.visible = true; + model.position.x += (player.x - model.position.x) * 0.28; + model.position.z += (player.z - model.position.z) * 0.28; + model.rotation.y = -player.yaw; + updatePlayerHealthBar(model, player.health); + } + for (const [id, model] of runtime.players) { + if (!seen.has(id)) model.visible = false; + } + + updatePickupModels(runtime, world.pickups, time); + + for (const entry of world.events) { + if (entry.event.id <= lastEventId.current) continue; + handlePerception(runtime, entry.event, playerId, time); + lastEventId.current = Math.max(lastEventId.current, entry.event.id); + } + + for (const object of runtime.animated) { + object.rotation.z = time * Number(object.userData.spin ?? 0); + } + for (let index = runtime.effects.length - 1; index >= 0; index -= 1) { + const effect = runtime.effects[index]!; + if (effect.createdAt !== undefined && effect.floatDistance !== undefined) { + const progress = clamp01( + (time - effect.createdAt) / (effect.expiresAt - effect.createdAt), + ); + effect.object.position.y = + Number(effect.object.userData.baseY ?? effect.object.position.y) + + progress * effect.floatDistance; + const material = effect.object instanceof THREE.Sprite + ? effect.object.material + : null; + if (material) material.opacity = 1 - progress; + } + if (time < effect.expiresAt) continue; + runtime.scene.remove(effect.object); + effect.object.traverse((child) => { + if (!(child instanceof THREE.Mesh || child instanceof THREE.Line)) return; + child.geometry.dispose(); + const materials = Array.isArray(child.material) ? child.material : [child.material]; + for (const material of materials) { + if ("map" in material && material.map instanceof THREE.Texture) { + material.map.dispose(); + } + material.dispose(); + } + }); + if (effect.object instanceof THREE.Sprite) { + effect.object.material.map?.dispose(); + effect.object.material.dispose(); + } + runtime.effects.splice(index, 1); + } +} + +function updatePickupModels( + runtime: SceneRuntime, + pickups: ReadonlyMap, + time: number, +): void { + for (const pickup of pickups.values()) { + let model = runtime.pickups.get(pickup.id); + if (!model) { + model = createPickupModel(pickup); + runtime.pickups.set(pickup.id, model); + runtime.scene.add(model); + } + model.visible = pickup.active; + model.rotation.y = time * 0.0012 + pickup.id; + model.position.y = 0.55 + Math.sin(time * 0.003 + pickup.id) * 0.1; + } +} + +function handlePerception( + runtime: SceneRuntime, + event: ShooterPerception, + playerId: number | null, + time: number, +): void { + runtime.audio.play(event); + if (event.type === "hit") { + const impact = runtime.lastLocalImpact; + if (impact && time - impact.at < 500) { + const popup = createDamagePopup(event.amount, event.critical, event.eliminated); + popup.position.set(impact.x, Math.max(0.7, impact.y + 0.36), impact.z); + popup.userData.baseY = popup.position.y; + runtime.scene.add(popup); + runtime.effects.push({ + object: popup, + createdAt: time, + expiresAt: time + 850, + floatDistance: 0.72, + }); + } + return; + } + if (event.type !== "shot") return; + + const color = new THREE.Color(WEAPONS[event.weapon].accent); + const geometry = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(event.originX, event.originY, event.originZ), + new THREE.Vector3(event.endX, event.endY, event.endZ), + ]); + const tracer = new THREE.Line( + geometry, + new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: event.weapon === Weapon.RailRifle ? 0.95 : 0.72, + }), + ); + runtime.scene.add(tracer); + runtime.effects.push({ object: tracer, expiresAt: time + (event.weapon === Weapon.RailRifle ? 150 : 75) }); + + if (event.impact !== "miss") { + const impact = new THREE.Mesh( + new THREE.SphereGeometry(event.weapon === Weapon.Scattergun ? 0.09 : 0.065, 8, 6), + new THREE.MeshBasicMaterial({ color }), + ); + impact.position.set(event.endX, event.endY, event.endZ); + runtime.scene.add(impact); + runtime.effects.push({ object: impact, expiresAt: time + 140 }); + } + + if (event.sourceId === playerId) { + runtime.muzzleUntil = time + 52; + runtime.muzzle.color.copy(color); + runtime.lastLocalImpact = { + x: event.endX, + y: event.endY, + z: event.endZ, + at: time, + }; + } +} + +function createPlayerModel(player: PlayerState): THREE.Group { + const group = new THREE.Group(); + group.position.set(player.x, 0, player.z); + const primary = new THREE.MeshStandardMaterial({ + color: player.kind === "bot" ? 0xcb6229 : 0x2f9dbb, + roughness: 0.45, + metalness: 0.58, + }); + const dark = new THREE.MeshStandardMaterial({ + color: 0x10171d, + roughness: 0.36, + metalness: 0.76, + }); + const visor = new THREE.MeshStandardMaterial({ + color: player.kind === "bot" ? 0xffc078 : 0x9ef3ff, + emissive: player.kind === "bot" ? 0xb52d05 : 0x00a8d4, + emissiveIntensity: 3.4, + }); + + const torso = new THREE.Mesh( + new THREE.CylinderGeometry(0.35, 0.43, 0.88, 8), + primary, + ); + torso.position.y = 0.91; + torso.castShadow = true; + group.add(torso); + const chest = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.17, 0.16), dark); + chest.position.set(0, 1.03, -0.35); + group.add(chest); + const head = new THREE.Mesh(new THREE.SphereGeometry(0.27, 12, 8), dark); + head.position.y = 1.55; + head.castShadow = true; + group.add(head); + const face = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.1, 0.06), visor); + face.position.set(0, 1.57, -0.245); + group.add(face); + const weapon = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.13, 0.72), dark); + weapon.position.set(0.34, 1.08, -0.38); + group.add(weapon); + + const healthBackground = new THREE.Sprite( + new THREE.SpriteMaterial({ + color: 0x081015, + opacity: 0.86, + transparent: true, + depthWrite: false, + }), + ); + healthBackground.position.set(0, 2.04, 0); + healthBackground.scale.set(0.9, 0.12, 1); + healthBackground.renderOrder = 4; + group.add(healthBackground); + + const healthMaterial = new THREE.SpriteMaterial({ + color: 0x55ef9a, + transparent: true, + depthWrite: false, + }); + const healthFill = new THREE.Sprite(healthMaterial); + healthFill.position.set(0, 2.04, 0.002); + healthFill.scale.set(0.8, 0.065, 1); + healthFill.renderOrder = 5; + group.add(healthFill); + group.userData.healthFill = healthFill; + group.userData.healthMaterial = healthMaterial; + return group; +} + +function updatePlayerHealthBar(model: THREE.Group, health: number): void { + const fill = model.userData.healthFill as THREE.Sprite | undefined; + const material = model.userData.healthMaterial as + | THREE.SpriteMaterial + | undefined; + if (!fill || !material) return; + const ratio = clamp01(health / 100); + fill.scale.x = Math.max(0.012, 0.8 * ratio); + material.color.setHex( + ratio > 0.55 ? 0x55ef9a : ratio > 0.25 ? 0xffb04d : 0xff5c4f, + ); +} + +function createDamagePopup( + amount: number, + critical: boolean, + eliminated: boolean, +): THREE.Sprite { + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 112; + const context = canvas.getContext("2d"); + if (context) { + context.clearRect(0, 0, canvas.width, canvas.height); + context.textAlign = "center"; + context.textBaseline = "middle"; + context.font = critical + ? "800 52px IBM Plex Mono, monospace" + : "700 48px IBM Plex Mono, monospace"; + context.lineWidth = 10; + context.strokeStyle = "rgba(2, 7, 10, 0.92)"; + const label = eliminated + ? `${amount} ELIM` + : critical + ? `${amount} CRIT` + : `${amount}`; + context.strokeText(label, canvas.width / 2, canvas.height / 2); + context.fillStyle = eliminated + ? "#ff774f" + : critical + ? "#ffc05d" + : "#dffaff"; + context.fillText(label, canvas.width / 2, canvas.height / 2); + } + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, + depthWrite: false, + }), + ); + sprite.scale.set(1.75, 0.61, 1); + sprite.renderOrder = 20; + return sprite; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function createPickupModel(pickup: PickupState): THREE.Group { + const group = new THREE.Group(); + group.position.set(pickup.x, 0.55, pickup.z); + const color = + pickup.kind === "health" + ? 0x54ef9b + : pickup.kind === "ammo" + ? 0xffb04d + : new THREE.Color(WEAPONS[pickup.weapon].accent).getHex(); + const material = new THREE.MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 1.4, + roughness: 0.28, + metalness: 0.68, + }); + const core = new THREE.Mesh( + pickup.kind === "health" + ? new THREE.OctahedronGeometry(0.3) + : pickup.kind === "ammo" + ? new THREE.BoxGeometry(0.5, 0.28, 0.32) + : new THREE.TorusKnotGeometry(0.2, 0.065, 40, 7), + material, + ); + core.castShadow = true; + group.add(core); + const light = new THREE.PointLight(color, 3.5, 3); + group.add(light); + return group; +} + +function resize(runtime: SceneRuntime, host: HTMLDivElement): void { + const width = Math.max(1, host.clientWidth); + const height = Math.max(1, host.clientHeight); + runtime.renderer.setSize(width, height, false); + runtime.camera.aspect = width / height; + runtime.camera.updateProjectionMatrix(); +} diff --git a/apps/web/src/FluxGame.tsx b/apps/web/src/FluxGame.tsx new file mode 100644 index 0000000..8349734 --- /dev/null +++ b/apps/web/src/FluxGame.tsx @@ -0,0 +1,111 @@ +import type { FluxPlayerView, FluxTeam } from "@syncer/shared"; +import { useFluxClient } from "./useFluxClient.js"; + +export function FluxGame() { + const client = useFluxClient(); + const local = client.world.players.find((player) => player.id === client.playerId); + const recent = [...client.world.events].reverse()[0]; + const energy = local?.energy ?? 0; + const corePercent = (client.world.core + 1) * 50; + + return ( +
+
+ ); +} + +function TeamRoster({ + team, + players, + localId, +}: { + team: FluxTeam; + players: FluxPlayerView[]; + localId: number | null; +}) { + return ( +
+ {team} links + {players.filter((player) => player.team === team).map((player) => ( +
+ + {player.bot ? "BOT" : `P${player.id}`} + {player.energy === null ? "PRIVATE" : `${Math.round(player.energy)}%`} +
+ ))} +
+ ); +} diff --git a/apps/web/src/Royale3D.tsx b/apps/web/src/Royale3D.tsx new file mode 100644 index 0000000..62e73b8 --- /dev/null +++ b/apps/web/src/Royale3D.tsx @@ -0,0 +1,540 @@ +import { useEffect, useRef, useState, type MutableRefObject } from "react"; +import * as THREE from "three"; +import { + ROYALE_CHUNK_SIZE, + ROYALE_WORLD_RADIUS, + generateRoyaleChunk, + royaleChunkCoordinate, + type RoyaleClientState, + type RoyaleLootView, + type RoyalePlayerView, +} from "@syncer/shared"; + +interface Royale3DProps { + world: RoyaleClientState; + playerId: number | null; +} + +interface TemporaryEffect { + object: THREE.Object3D; + expiresAt: number; + startedAt?: number; + start?: THREE.Vector3; + end?: THREE.Vector3; +} + +interface RoyaleRuntime { + renderer: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + viewWeapon: THREE.Group; + viewMuzzle: THREE.PointLight; + viewMuzzleCore: THREE.Mesh; + muzzleUntil: number; + chunks: Map; + players: Map; + loot: Map; + storm: THREE.Mesh; + effects: TemporaryEffect[]; + resizeObserver: ResizeObserver; + animationFrame: number; +} + +export function Royale3D({ world, playerId }: Royale3DProps) { + const hostRef = useRef(null); + const worldRef = useRef(world); + const playerIdRef = useRef(playerId); + const runtimeRef = useRef(null); + const lastEventIdRef = useRef(0); + const [locked, setLocked] = useState(false); + worldRef.current = world; + playerIdRef.current = playerId; + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFShadowMap; + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.18; + host.append(renderer.domElement); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x8bb4c2); + scene.fog = new THREE.FogExp2(0x9cb9bc, 0.0022); + const camera = new THREE.PerspectiveCamera(78, 1, 0.045, 1_100); + camera.rotation.order = "YXZ"; + scene.add(camera); + + const { + group: viewWeapon, + muzzle: viewMuzzle, + muzzleCore: viewMuzzleCore, + } = buildViewWeapon(); + camera.add(viewWeapon); + + scene.add(new THREE.HemisphereLight(0xcbe8ed, 0x253426, 2.2)); + const sun = new THREE.DirectionalLight(0xfff0cf, 3.25); + sun.position.set(-180, 280, 90); + sun.castShadow = true; + sun.shadow.mapSize.set(1_024, 1_024); + sun.shadow.camera.left = -100; + sun.shadow.camera.right = 100; + sun.shadow.camera.top = 100; + sun.shadow.camera.bottom = -100; + scene.add(sun); + + const water = new THREE.Mesh( + new THREE.PlaneGeometry(2_500, 2_500), + new THREE.MeshStandardMaterial({ color: 0x1a6276, roughness: 0.32, metalness: 0.22 }), + ); + water.rotation.x = -Math.PI / 2; + water.position.y = -1.2; + scene.add(water); + + const storm = new THREE.Mesh( + new THREE.RingGeometry(0.994, 1, 160), + new THREE.MeshBasicMaterial({ color: 0x68d8ff, transparent: true, opacity: 0.84, side: THREE.DoubleSide }), + ); + storm.rotation.x = -Math.PI / 2; + storm.position.y = 0.24; + scene.add(storm); + + const runtime: RoyaleRuntime = { + renderer, + scene, + camera, + viewWeapon, + viewMuzzle, + viewMuzzleCore, + muzzleUntil: 0, + chunks: new Map(), + players: new Map(), + loot: new Map(), + storm, + effects: [], + resizeObserver: new ResizeObserver(() => resize(runtime, host)), + animationFrame: 0, + }; + runtimeRef.current = runtime; + runtime.resizeObserver.observe(host); + resize(runtime, host); + + const animate = (time: number) => { + updateRuntime(runtime, worldRef.current, playerIdRef.current, lastEventIdRef, time); + renderer.render(scene, camera); + runtime.animationFrame = window.requestAnimationFrame(animate); + }; + runtime.animationFrame = window.requestAnimationFrame(animate); + + const pointerLockChanged = () => setLocked(document.pointerLockElement === renderer.domElement); + document.addEventListener("pointerlockchange", pointerLockChanged); + return () => { + document.removeEventListener("pointerlockchange", pointerLockChanged); + runtime.resizeObserver.disconnect(); + window.cancelAnimationFrame(runtime.animationFrame); + scene.traverse((object) => disposeObject(object)); + renderer.dispose(); + renderer.domElement.remove(); + runtimeRef.current = null; + }; + }, []); + + const deploy = () => { + const canvas = runtimeRef.current?.renderer.domElement; + if (!canvas) return; + void canvas.requestPointerLock().catch(() => setLocked(false)); + }; + + return ( +
+ {!locked && ( + + )} +
+ ); +} + +function updateRuntime( + runtime: RoyaleRuntime, + world: RoyaleClientState, + playerId: number | null, + lastEventIdRef: MutableRefObject, + time: number, +): void { + const local = world.players.find((player) => player.id === playerId); + if (local) { + updateChunks(runtime, local.x, local.z); + runtime.camera.position.set(local.x, local.altitude + 1.55, local.z); + runtime.camera.rotation.set(local.pitch, Math.PI + local.yaw, 0); + runtime.viewWeapon.visible = local.alive; + const moving = Math.hypot(local.velocityX, local.velocityZ); + runtime.viewWeapon.position.x = + 0.36 + Math.cos(time * 0.0065) * Math.min(0.009, moving * 0.0012); + runtime.viewWeapon.position.y = + -0.31 + Math.sin(time * 0.013) * Math.min(0.024, moving * 0.003); + } else runtime.viewWeapon.visible = false; + runtime.viewMuzzle.intensity = time < runtime.muzzleUntil ? 14 : 0; + runtime.viewMuzzleCore.visible = time < runtime.muzzleUntil; + + runtime.storm.position.x = world.stormX; + runtime.storm.position.z = world.stormZ; + runtime.storm.scale.set(world.stormRadius, world.stormRadius, 1); + runtime.storm.material.opacity = 0.62 + Math.sin(time * 0.003) * 0.18; + + updatePlayers(runtime, world.players, playerId, time); + updateLoot(runtime, world.loot, time); + updateEffects(runtime, world, playerId, lastEventIdRef, time); +} + +function updateChunks(runtime: RoyaleRuntime, x: number, z: number): void { + const centerX = royaleChunkCoordinate(x); + const centerZ = royaleChunkCoordinate(z); + const wanted = new Set(); + const radius = 3; + for (let offsetX = -radius; offsetX <= radius; offsetX += 1) { + for (let offsetZ = -radius; offsetZ <= radius; offsetZ += 1) { + const chunkX = centerX + offsetX; + const chunkZ = centerZ + offsetZ; + const chunkCenterX = (chunkX + 0.5) * ROYALE_CHUNK_SIZE; + const chunkCenterZ = (chunkZ + 0.5) * ROYALE_CHUNK_SIZE; + if (Math.hypot(chunkCenterX, chunkCenterZ) > ROYALE_WORLD_RADIUS + 90) continue; + const key = `${chunkX}:${chunkZ}`; + wanted.add(key); + if (!runtime.chunks.has(key)) { + const group = buildChunk(chunkX, chunkZ); + runtime.chunks.set(key, group); + runtime.scene.add(group); + } + } + } + + for (const [key, group] of runtime.chunks) { + if (wanted.has(key)) continue; + runtime.chunks.delete(key); + runtime.scene.remove(group); + group.traverse((object) => disposeObject(object)); + } +} + +function buildChunk(chunkX: number, chunkZ: number): THREE.Group { + const chunk = generateRoyaleChunk(chunkX, chunkZ); + const group = new THREE.Group(); + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(ROYALE_CHUNK_SIZE, ROYALE_CHUNK_SIZE), + new THREE.MeshStandardMaterial({ color: chunk.tint, roughness: 0.93, metalness: 0.02 }), + ); + ground.rotation.x = -Math.PI / 2; + ground.position.set( + chunkX * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2, + 0, + chunkZ * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2, + ); + ground.receiveShadow = true; + group.add(ground); + + const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x4b514c, roughness: 0.88 }); + if (chunk.roadX) { + const road = new THREE.Mesh(new THREE.PlaneGeometry(ROYALE_CHUNK_SIZE, 18), roadMaterial); + road.rotation.x = -Math.PI / 2; + road.position.set(ground.position.x, 0.035, ground.position.z); + group.add(road); + } + if (chunk.roadZ) { + const road = new THREE.Mesh(new THREE.PlaneGeometry(18, ROYALE_CHUNK_SIZE), roadMaterial.clone()); + road.rotation.x = -Math.PI / 2; + road.position.set(ground.position.x, 0.04, ground.position.z); + group.add(road); + } + + for (const obstacle of chunk.obstacles) { + if (obstacle.kind === "building") { + const building = new THREE.Mesh( + new THREE.BoxGeometry(obstacle.width, obstacle.height, obstacle.depth), + new THREE.MeshStandardMaterial({ color: 0x707b72, roughness: 0.78, metalness: 0.12 }), + ); + building.position.set(obstacle.x, obstacle.height / 2, obstacle.z); + building.castShadow = true; + building.receiveShadow = true; + group.add(building); + const roof = new THREE.Mesh( + new THREE.BoxGeometry(obstacle.width + 0.6, 0.35, obstacle.depth + 0.6), + new THREE.MeshStandardMaterial({ color: 0x30383a, roughness: 0.7 }), + ); + roof.position.set(obstacle.x, obstacle.height + 0.18, obstacle.z); + group.add(roof); + } else if (obstacle.kind === "tree") { + const trunk = new THREE.Mesh( + new THREE.CylinderGeometry(0.65, 0.9, obstacle.height * 0.48, 7), + new THREE.MeshStandardMaterial({ color: 0x58442d, roughness: 1 }), + ); + trunk.position.set(obstacle.x, obstacle.height * 0.24, obstacle.z); + trunk.castShadow = true; + group.add(trunk); + const crown = new THREE.Mesh( + new THREE.ConeGeometry(obstacle.width * 1.25, obstacle.height * 0.72, 8), + new THREE.MeshStandardMaterial({ color: 0x244f34, roughness: 0.92 }), + ); + crown.position.set(obstacle.x, obstacle.height * 0.7, obstacle.z); + crown.castShadow = true; + group.add(crown); + } else { + const rock = new THREE.Mesh( + new THREE.DodecahedronGeometry(obstacle.width * 0.48, 0), + new THREE.MeshStandardMaterial({ color: 0x646a65, roughness: 0.96 }), + ); + rock.scale.y = obstacle.height / obstacle.width; + rock.position.set(obstacle.x, obstacle.height * 0.34, obstacle.z); + rock.rotation.set(0.2, obstacle.x * 0.1, -0.12); + rock.castShadow = true; + group.add(rock); + } + } + return group; +} + +function updatePlayers( + runtime: RoyaleRuntime, + players: RoyalePlayerView[], + playerId: number | null, + time: number, +): void { + const visible = new Set(players.map((player) => player.id)); + for (const player of players) { + let group = runtime.players.get(player.id); + if (!group) { + group = buildPlayer(player.id === playerId, player.bot); + runtime.players.set(player.id, group); + runtime.scene.add(group); + } + group.position.set(player.x, player.altitude, player.z); + group.rotation.y = player.yaw; + group.visible = player.alive && player.id !== playerId; + const canopy = group.getObjectByName("canopy"); + if (canopy) canopy.visible = player.altitude > 0; + const pulse = group.getObjectByName("pulse") as THREE.PointLight | undefined; + if (pulse) pulse.intensity = player.firing ? 15 : 2 + Math.sin(time * 0.005 + player.id) * 0.8; + } + for (const [id, group] of runtime.players) { + if (visible.has(id)) continue; + runtime.players.delete(id); + runtime.scene.remove(group); + group.traverse((object) => disposeObject(object)); + } +} + +function buildPlayer(local: boolean, bot: boolean): THREE.Group { + const group = new THREE.Group(); + const color = local ? 0x69efff : bot ? 0xff8257 : 0xf7e9bd; + const body = new THREE.Mesh( + new THREE.CapsuleGeometry(0.72, 1.35, 4, 8), + new THREE.MeshStandardMaterial({ color, roughness: 0.45, metalness: 0.28 }), + ); + body.position.y = 1.35; + body.castShadow = true; + group.add(body); + const weapon = new THREE.Mesh( + new THREE.BoxGeometry(0.18, 0.18, 1.55), + new THREE.MeshStandardMaterial({ color: 0x202a2d, metalness: 0.65, roughness: 0.35 }), + ); + weapon.position.set(0.42, 1.45, 0.55); + group.add(weapon); + const pulse = new THREE.PointLight(color, 3, 12, 2); + pulse.name = "pulse"; + pulse.position.y = 1.8; + group.add(pulse); + const canopy = new THREE.Group(); + canopy.name = "canopy"; + const wing = new THREE.Mesh( + new THREE.SphereGeometry(3.4, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.48), + new THREE.MeshStandardMaterial({ color: local ? 0x4ddff8 : 0x3c4747, side: THREE.DoubleSide, roughness: 0.66 }), + ); + wing.scale.z = 0.42; + wing.position.y = 5.2; + canopy.add(wing); + group.add(canopy); + return group; +} + +function buildViewWeapon(): { + group: THREE.Group; + muzzle: THREE.PointLight; + muzzleCore: THREE.Mesh; +} { + const group = new THREE.Group(); + group.position.set(0.36, -0.31, -0.62); + group.visible = false; + const body = new THREE.Mesh( + new THREE.BoxGeometry(0.18, 0.2, 0.9), + new THREE.MeshStandardMaterial({ + color: 0x263438, + metalness: 0.72, + roughness: 0.28, + }), + ); + body.position.z = -0.18; + group.add(body); + const rail = new THREE.Mesh( + new THREE.BoxGeometry(0.1, 0.06, 0.58), + new THREE.MeshStandardMaterial({ + color: 0x76e9ef, + emissive: 0x1b8792, + emissiveIntensity: 0.72, + metalness: 0.48, + roughness: 0.3, + }), + ); + rail.position.set(0, 0.12, -0.25); + group.add(rail); + const muzzleCore = new THREE.Mesh( + new THREE.SphereGeometry(0.07, 10, 8), + new THREE.MeshBasicMaterial({ color: 0xffe0a0 }), + ); + muzzleCore.position.set(0, 0, -0.72); + muzzleCore.visible = false; + group.add(muzzleCore); + const muzzle = new THREE.PointLight(0xffc66d, 0, 3.5, 2); + muzzle.position.copy(muzzleCore.position); + group.add(muzzle); + return { group, muzzle, muzzleCore }; +} + +function updateLoot(runtime: RoyaleRuntime, loot: RoyaleLootView[], time: number): void { + const visible = new Set(loot.map((item) => item.id)); + for (const item of loot) { + let group = runtime.loot.get(item.id); + if (!group) { + group = buildLoot(item); + runtime.loot.set(item.id, group); + runtime.scene.add(group); + } + group.position.set(item.x, 0.7 + Math.sin(time * 0.002 + item.id) * 0.18, item.z); + group.rotation.y = time * 0.0006 + item.id; + } + for (const [id, group] of runtime.loot) { + if (visible.has(id)) continue; + runtime.loot.delete(id); + runtime.scene.remove(group); + group.traverse((object) => disposeObject(object)); + } +} + +function buildLoot(item: RoyaleLootView): THREE.Group { + const group = new THREE.Group(); + const color = item.kind === "weapon" ? 0xffc75f : item.kind === "ammo" ? 0x8ceaff : item.kind === "armor" ? 0x9b8cff : 0x71ef9c; + const mesh = new THREE.Mesh( + item.kind === "weapon" ? new THREE.BoxGeometry(1.3, 0.28, 0.45) : new THREE.OctahedronGeometry(0.62), + new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.7, metalness: 0.45, roughness: 0.3 }), + ); + mesh.castShadow = true; + group.add(mesh); + group.add(new THREE.PointLight(color, 6, 9, 2)); + return group; +} + +function updateEffects( + runtime: RoyaleRuntime, + world: RoyaleClientState, + playerId: number | null, + lastEventIdRef: MutableRefObject, + time: number, +): void { + for (const entry of world.events) { + if (entry.event.id <= lastEventIdRef.current || entry.event.type !== "shot") continue; + const event = entry.event; + if (event.sourceId === playerId) runtime.muzzleUntil = time + 85; + const horizontal = Math.cos(event.pitch); + const direction = new THREE.Vector3( + Math.sin(event.yaw) * horizontal, + Math.sin(event.pitch), + Math.cos(event.yaw) * horizontal, + ).normalize(); + const start = new THREE.Vector3(event.x, event.y, event.z) + .addScaledVector(direction, 1.35); + const end = new THREE.Vector3( + event.x + direction.x * 86, + event.y + direction.y * 86, + event.z + direction.z * 86, + ); + const length = start.distanceTo(end); + const trail = new THREE.Mesh( + new THREE.CylinderGeometry(0.028, 0.055, length, 6), + new THREE.MeshBasicMaterial({ + color: 0xffd27a, + transparent: true, + opacity: 0.72, + depthWrite: false, + }), + ); + trail.position.copy(start).add(end).multiplyScalar(0.5); + trail.quaternion.setFromUnitVectors( + new THREE.Vector3(0, 1, 0), + direction, + ); + runtime.scene.add(trail); + runtime.effects.push({ object: trail, expiresAt: time + 165 }); + + const bullet = new THREE.Group(); + const core = new THREE.Mesh( + new THREE.SphereGeometry(0.13, 10, 8), + new THREE.MeshBasicMaterial({ color: 0xfff1bd }), + ); + bullet.add(core); + bullet.add(new THREE.PointLight(0xffbd5c, 10, 5, 2)); + bullet.position.copy(start); + runtime.scene.add(bullet); + runtime.effects.push({ + object: bullet, + startedAt: time, + expiresAt: time + 230, + start, + end, + }); + } + lastEventIdRef.current = world.events.reduce( + (latest, entry) => Math.max(latest, entry.event.id), + lastEventIdRef.current, + ); + runtime.effects = runtime.effects.filter((effect) => { + if ( + effect.startedAt !== undefined && + effect.start && + effect.end + ) { + const progress = clamp01( + (time - effect.startedAt) / (effect.expiresAt - effect.startedAt), + ); + effect.object.position.lerpVectors(effect.start, effect.end, progress); + } + if (effect.expiresAt > time) return true; + runtime.scene.remove(effect.object); + effect.object.traverse((object) => disposeObject(object)); + return false; + }); +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function resize(runtime: RoyaleRuntime, host: HTMLDivElement): void { + const width = Math.max(1, host.clientWidth); + const height = Math.max(1, host.clientHeight); + runtime.renderer.setSize(width, height, false); + runtime.camera.aspect = width / height; + runtime.camera.updateProjectionMatrix(); +} + +function disposeObject(object: THREE.Object3D): void { + if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return; + object.geometry.dispose(); + const materials = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of materials) material.dispose(); +} diff --git a/apps/web/src/RoyaleGame.tsx b/apps/web/src/RoyaleGame.tsx new file mode 100644 index 0000000..d00aab8 --- /dev/null +++ b/apps/web/src/RoyaleGame.tsx @@ -0,0 +1,179 @@ +import { + ROYALE_WEAPONS, + type RoyaleClientState, + type RoyalePlayerView, +} from "@syncer/shared"; +import { Royale3D } from "./Royale3D.js"; +import { useRoyaleClient } from "./useRoyaleClient.js"; + +export function RoyaleGame() { + const client = useRoyaleClient(); + const local = client.world.players.find((player) => player.id === client.playerId); + const recent = [...client.world.events].reverse(); + const damage = recent.find((entry) => entry.event.type === "damage"); + const hit = recent.find((entry) => entry.event.type === "hit"); + const pickup = recent.find((entry) => entry.event.type === "pickup"); + const eliminations = recent + .filter((entry) => entry.event.type === "elimination") + .slice(0, 4); + const weapon = ROYALE_WEAPONS[local?.weapon ?? "pistol"]; + const stormDistance = local + ? Math.hypot(local.x - client.world.stormX, local.z - client.world.stormZ) - + client.world.stormRadius + : 0; + const recentDamage = Boolean( + damage && client.tick - damage.receivedTick < 18, + ); + const recentHit = Boolean(hit && client.tick - hit.receivedTick < 12); + + return ( +
+ +
+ ); +} + +function Radar({ world, local }: { world: RoyaleClientState; local: RoyalePlayerView | undefined }) { + const scale = 150; + return ( +
+
+ + {local + ? world.players + .filter((player) => player.id !== local.id) + .map((player) => ( + + )) + : null} + {local + ? world.loot.map((item) => ( + + )) + : null} + 150 M LOCAL CELL +
+ ); +} + +function combatant(id: number): string { + return id === 0 ? "THE STORM" : id >= 30_000 ? `BOT ${id - 29_999}` : `PLAYER ${id}`; +} + +function formatBytes(bytes: number): string { + return bytes >= 1_000 ? `${(bytes / 1_000).toFixed(1)}K` : `${bytes}B`; +} diff --git a/apps/web/src/audio.ts b/apps/web/src/audio.ts new file mode 100644 index 0000000..eeadacc --- /dev/null +++ b/apps/web/src/audio.ts @@ -0,0 +1,142 @@ +import { Weapon, type ShooterPerception, type WeaponId } from "@syncer/shared"; + +export class ArenaAudio { + private context: AudioContext | null = null; + private master: GainNode | null = null; + + unlock(): void { + if (!this.context) { + this.context = new AudioContext(); + this.master = this.context.createGain(); + this.master.gain.value = 0.26; + this.master.connect(this.context.destination); + } + void this.context.resume(); + } + + play(event: ShooterPerception): void { + if (!this.context || !this.master || this.context.state !== "running") return; + switch (event.type) { + case "shot": + this.shot(event.weapon, 0, 0.48); + if (event.impact === "player") this.tone(760, 520, 0.07, "square"); + break; + case "sound": + if (event.sound === "footstep") this.footstep(event.bearingRadians, event.intensity); + else this.shot(event.weapon, event.bearingRadians, event.intensity); + break; + case "hit": + this.tone( + event.eliminated ? 520 : event.critical ? 920 : 760, + event.eliminated ? 180 : event.critical ? 620 : 520, + 0.08, + event.critical ? "square" : "triangle", + ); + break; + case "damage": + this.damage(event.bearingRadians); + break; + case "elimination": + this.tone(510, 180, 0.12, "triangle"); + break; + case "pickup": + this.tone(440, 860, 0.15, "sine"); + break; + case "respawn": + this.tone(220, 660, 0.28, "sine"); + break; + } + } + + dispose(): void { + void this.context?.close(); + this.context = null; + this.master = null; + } + + private footstep(bearing: number, intensity: number): void { + const context = this.context!; + const now = context.currentTime; + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(105, now); + oscillator.frequency.exponentialRampToValueAtTime(48, now + 0.09); + gain.gain.setValueAtTime(Math.max(0.012, intensity * 0.2), now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13); + oscillator.connect(gain).connect(this.panner(bearing)); + oscillator.start(now); + oscillator.stop(now + 0.14); + } + + private shot(weapon: WeaponId, bearing: number, intensity: number): void { + const context = this.context!; + const now = context.currentTime; + const panner = this.panner(bearing); + const duration = weapon === Weapon.RailRifle ? 0.23 : 0.16; + const noise = context.createBufferSource(); + const buffer = context.createBuffer(1, Math.floor(context.sampleRate * duration), context.sampleRate); + const samples = buffer.getChannelData(0); + for (let index = 0; index < samples.length; index += 1) { + samples[index] = (Math.random() * 2 - 1) * Math.exp(-index / (samples.length * 0.18)); + } + noise.buffer = buffer; + const filter = context.createBiquadFilter(); + filter.type = weapon === Weapon.PulseRifle ? "bandpass" : "lowpass"; + filter.frequency.value = weapon === Weapon.PulseRifle ? 1_500 : weapon === Weapon.Scattergun ? 760 : 2_200; + filter.Q.value = weapon === Weapon.PulseRifle ? 0.8 : 1.25; + const gain = context.createGain(); + gain.gain.setValueAtTime(Math.max(0.02, intensity * (weapon === Weapon.Scattergun ? 0.62 : 0.46)), now); + gain.gain.exponentialRampToValueAtTime(0.001, now + duration); + noise.connect(filter).connect(gain).connect(panner); + noise.start(now); + + const thump = context.createOscillator(); + const thumpGain = context.createGain(); + const base = weapon === Weapon.Scattergun ? 88 : weapon === Weapon.RailRifle ? 210 : 145; + thump.frequency.setValueAtTime(base, now); + thump.frequency.exponentialRampToValueAtTime(45, now + 0.11); + thumpGain.gain.setValueAtTime(intensity * 0.24, now); + thumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12); + thump.connect(thumpGain).connect(panner); + thump.start(now); + thump.stop(now + 0.13); + } + + private damage(bearing: number): void { + const context = this.context!; + const now = context.currentTime; + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.type = "sawtooth"; + oscillator.frequency.setValueAtTime(72, now); + oscillator.frequency.exponentialRampToValueAtTime(34, now + 0.2); + gain.gain.setValueAtTime(0.22, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22); + oscillator.connect(gain).connect(this.panner(bearing)); + oscillator.start(now); + oscillator.stop(now + 0.23); + } + + private tone(from: number, to: number, duration: number, type: OscillatorType): void { + const context = this.context!; + const now = context.currentTime; + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.type = type; + oscillator.frequency.setValueAtTime(from, now); + oscillator.frequency.exponentialRampToValueAtTime(to, now + duration); + gain.gain.setValueAtTime(0.09, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + duration); + oscillator.connect(gain).connect(this.master!); + oscillator.start(now); + oscillator.stop(now + duration + 0.01); + } + + private panner(bearing: number): StereoPannerNode { + const panner = this.context!.createStereoPanner(); + panner.pan.value = Math.max(-1, Math.min(1, Math.sin(bearing))); + panner.connect(this.master!); + return panner; + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..d7fcbc5 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; +import "./styles.css"; + +const root = document.querySelector("#root"); + +if (!root) { + throw new Error("Root element was not found"); +} + +createRoot(root).render( + + + , +); + diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..d51dd06 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,602 @@ +@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap"); + +:root { + font-family: "Barlow Condensed", system-ui, sans-serif; + color: #eaf7fb; + background: #05080c; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { box-sizing: border-box; } + +html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; } +button { font: inherit; } + +.game-switcher { + position: fixed; + z-index: 30; + top: 78px; + left: 50%; + display: flex; + padding: 3px; + border: 1px solid rgb(99 225 255 / 22%); + background: rgb(3 11 16 / 78%); + backdrop-filter: blur(10px); + transform: translateX(-50%); +} +.game-switcher button { + padding: 5px 10px; + border: 0; + color: #5f7f8a; + background: transparent; + font-family: "IBM Plex Mono", monospace; + font-size: .48rem; + letter-spacing: .12em; + cursor: pointer; +} +.game-switcher button:hover { color: #caeff7; } +.game-switcher .is-active { color: #b9f4ff; background: rgb(75 213 243 / 14%); } + +.game { + position: relative; + width: 100%; + height: 100%; + min-width: 320px; + overflow: hidden; + background: #05080c; + user-select: none; +} + +.viewport, .viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.viewport canvas { display: block; } + +.vignette { + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(180deg, rgb(3 7 11 / 52%), transparent 20%, transparent 78%, rgb(2 5 8 / 72%)), + radial-gradient(circle, transparent 48%, rgb(0 0 0 / 44%) 100%); +} + +.topbar { + position: absolute; + top: 0; + left: 0; + display: grid; + width: 100%; + grid-template-columns: 1fr auto 1fr; + align-items: start; + padding: 24px 28px; + pointer-events: none; +} + +.brand { display: flex; align-items: center; gap: 11px; } +.brand-mark { + display: grid; + width: 46px; + height: 40px; + place-items: center; + border: 1px solid rgb(104 225 255 / 55%); + color: #7ce8ff; + background: rgb(5 19 27 / 70%); + box-shadow: inset 0 0 20px rgb(0 185 230 / 12%); + font-family: "IBM Plex Mono", monospace; + font-size: 0.9rem; + font-weight: 600; + clip-path: polygon(0 0, 88% 0, 100% 24%, 100% 100%, 12% 100%, 0 76%); +} +.brand strong { display: block; font-size: 1rem; letter-spacing: .14em; } +.brand small { color: #72909b; font-family: "IBM Plex Mono", monospace; font-size: .56rem; letter-spacing: .12em; } + +.match-clock { min-width: 124px; padding: 6px 22px 9px; text-align: center; background: linear-gradient(90deg, transparent, rgb(7 20 28 / 78%) 18%, rgb(7 20 28 / 78%) 82%, transparent); } +.match-clock small { display: block; color: #7698a4; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .15em; } +.match-clock strong { color: #e8faff; font-family: "IBM Plex Mono", monospace; font-size: 1.32rem; letter-spacing: .12em; } + +.connection { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; padding: 8px 12px; border-right: 2px solid #62e0ff; background: linear-gradient(90deg, transparent, rgb(4 19 27 / 75%)); text-align: right; } +.connection i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #66e4ff; box-shadow: 0 0 12px #4bdcff; } +.connection span { color: #8eeaff; font-size: .66rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } +.connection b { color: #7a939c; font-family: "IBM Plex Mono", monospace; font-size: .53rem; font-weight: 500; } +.connection--connecting, .connection--reconnecting { border-color: #ffa84e; } +.connection--connecting i, .connection--reconnecting i { background: #ffa84e; box-shadow: 0 0 12px #ffa84e; } +.connection--connecting span, .connection--reconnecting span { color: #ffc17d; } + +.telemetry { position: absolute; top: 104px; left: 28px; display: flex; flex-direction: column; gap: 1px; pointer-events: none; } +.telemetry > div { display: grid; min-width: 112px; grid-template-columns: 45px 1fr; gap: 8px; padding: 5px 9px; border-left: 1px solid rgb(92 213 239 / 40%); background: linear-gradient(90deg, rgb(3 15 21 / 74%), transparent); } +.telemetry small { color: #63818c; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .1em; } +.telemetry strong { color: #a9c2ca; font-family: "IBM Plex Mono", monospace; font-size: .54rem; font-weight: 500; } + +.kill-feed { position: absolute; top: 105px; right: 28px; display: flex; align-items: flex-end; flex-direction: column; gap: 5px; pointer-events: none; } +.kill-feed div { display: flex; align-items: center; gap: 8px; padding: 5px 9px; border-right: 1px solid rgb(255 153 63 / 55%); background: linear-gradient(90deg, transparent, rgb(20 12 7 / 75%)); color: #afc1c7; font-family: "IBM Plex Mono", monospace; font-size: .58rem; } +.kill-feed b { color: #ff9c45; font-size: .48rem; } + +.leaderboard { + position: absolute; + top: 210px; + right: 28px; + width: 178px; + padding: 9px 0 5px; + border-top: 1px solid rgb(94 220 247 / 35%); + background: linear-gradient(90deg, transparent, rgb(3 14 20 / 58%)); + pointer-events: none; +} +.leaderboard > small { + display: block; + margin: 0 8px 6px; + color: #6b8a95; + font-family: "IBM Plex Mono", monospace; + font-size: .48rem; + letter-spacing: .14em; + text-align: right; +} +.leaderboard > div { + display: grid; + grid-template-columns: 17px 1fr 18px; + gap: 6px; + padding: 3px 8px; + color: #8ca4ad; + font-family: "IBM Plex Mono", monospace; + font-size: .52rem; +} +.leaderboard > div b { color: #506b75; font-weight: 500; } +.leaderboard > div strong { color: #d7eef5; text-align: right; } +.leaderboard .leaderboard__local { + color: #9aeeff; + background: linear-gradient(90deg, transparent, rgb(32 168 201 / 15%)); +} + +.crosshair { position: absolute; top: 50%; left: 50%; width: 40px; height: 40px; transform: translate(-50%, -50%); pointer-events: none; } +.crosshair::after { position: absolute; top: 18px; left: 18px; width: 4px; height: 4px; border: 1px solid rgb(225 248 255 / 90%); border-radius: 50%; content: ""; box-shadow: 0 0 8px rgb(80 223 255 / 80%); } +.crosshair i { position: absolute; display: block; background: rgb(224 249 255 / 88%); box-shadow: 0 0 4px rgb(46 214 255 / 60%); } +.crosshair i:nth-child(1), .crosshair i:nth-child(2) { top: 19px; width: 9px; height: 1px; } +.crosshair i:nth-child(1) { left: 2px; } +.crosshair i:nth-child(2) { right: 2px; } +.crosshair i:nth-child(3), .crosshair i:nth-child(4) { left: 19px; width: 1px; height: 9px; } +.crosshair i:nth-child(3) { top: 2px; } +.crosshair i:nth-child(4) { bottom: 2px; } +.crosshair--damage { animation: crosshair-hit .18s ease-out; } +@keyframes crosshair-hit { 50% { transform: translate(-50%, -50%) scale(1.5); filter: sepia(1) saturate(4); } } + +.hit-confirm { + position: absolute; + top: calc(50% + 32px); + left: 50%; + color: #d9f9ff; + font-family: "IBM Plex Mono", monospace; + font-size: .48rem; + font-weight: 600; + letter-spacing: .18em; + text-shadow: 0 0 9px #66e5ff; + transform: translateX(-50%); + pointer-events: none; +} + +.sound-indicator { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + width: 210px; + height: 210px; + border: 1px solid transparent; + border-radius: 50%; + pointer-events: none; +} +.sound-indicator i { + position: absolute; + top: -2px; + left: calc(50% - 10px); + width: 20px; + height: 2px; + background: #ffb761; + box-shadow: 0 0 10px #ff9e3e; +} + +.deploy { + position: absolute; + z-index: 8; + top: 50%; + left: 50%; + display: flex; + width: min(400px, calc(100% - 40px)); + flex-direction: column; + gap: 8px; + padding: 21px 28px; + border: 1px solid rgb(99 225 255 / 50%); + color: #dff9ff; + background: rgb(3 14 20 / 86%); + box-shadow: 0 0 80px rgb(0 178 221 / 12%), inset 0 0 28px rgb(0 178 221 / 6%); + clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px)); + cursor: crosshair; + transform: translate(-50%, -50%); +} +.deploy span { font-size: 1.25rem; font-weight: 800; letter-spacing: .2em; } +.deploy small { color: #6f939f; font-family: "IBM Plex Mono", monospace; font-size: .55rem; letter-spacing: .06em; } +.deploy:hover { border-color: #abf1ff; background: rgb(6 27 37 / 92%); } + +.respawn-panel { position: absolute; top: 50%; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 16px 38px; border-block: 1px solid rgb(255 118 70 / 55%); background: linear-gradient(90deg, transparent, rgb(31 8 5 / 75%), transparent); transform: translate(-50%, -50%); pointer-events: none; } +.respawn-panel small { color: #c06c53; font-family: "IBM Plex Mono", monospace; font-size: .58rem; letter-spacing: .18em; } +.respawn-panel strong { color: #ffbea6; font-size: 1.4rem; letter-spacing: .15em; } + +.killcam-panel { + position: absolute; + bottom: 126px; + left: 50%; + width: min(560px, calc(100% - 48px)); + padding: 12px 15px 10px; + border-top: 1px solid rgb(255 164 79 / 62%); + border-bottom: 1px solid rgb(100 222 247 / 28%); + color: #e8f8fc; + background: linear-gradient(90deg, transparent, rgb(7 17 23 / 90%) 11%, rgb(7 17 23 / 90%) 89%, transparent); + transform: translateX(-50%); + pointer-events: none; +} +.killcam-panel__title { display: flex; align-items: end; justify-content: space-between; gap: 18px; } +.killcam-panel__title small { color: #8b6f5b; font-family: "IBM Plex Mono", monospace; font-size: .47rem; letter-spacing: .13em; } +.killcam-panel__title strong { color: #ffc28c; font-size: .87rem; letter-spacing: .16em; text-align: right; text-shadow: 0 0 14px rgb(255 134 48 / 40%); } +.killcam-panel__timeline { overflow: hidden; height: 3px; margin: 8px 0 6px; background: rgb(113 151 162 / 22%); transform: skewX(-22deg); } +.killcam-panel__timeline i { display: block; height: 100%; background: linear-gradient(90deg, #ff8e48, #7be5ff); box-shadow: 0 0 12px rgb(99 223 255 / 55%); } +.killcam-panel__meta { display: flex; justify-content: space-between; gap: 15px; color: #68838d; font-family: "IBM Plex Mono", monospace; font-size: .46rem; letter-spacing: .09em; } + +.hud { position: absolute; right: 28px; bottom: 24px; left: 28px; display: grid; grid-template-columns: minmax(150px, .7fr) 1fr minmax(210px, .85fr) auto; align-items: end; gap: 30px; pointer-events: none; } +.health-block small, .weapon-block small { color: #77919a; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .15em; } +.health-block strong { display: block; margin-top: -2px; color: #d8f8ff; font-family: "IBM Plex Mono", monospace; font-size: 2.3rem; font-weight: 500; line-height: 1; } +.health-track { overflow: hidden; width: 150px; height: 3px; margin-top: 7px; background: rgb(117 150 160 / 28%); transform: skewX(-24deg); } +.health-track i { display: block; height: 100%; background: #65e2ff; box-shadow: 0 0 12px #42d8fb; transition: width .15s; } + +.mission-note { align-self: end; padding-bottom: 1px; color: #92abb3; text-align: center; } +.mission-note span { display: block; font-size: .72rem; font-weight: 700; letter-spacing: .16em; } +.mission-note small { color: #5f7881; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .08em; } + +.weapon-block { display: flex; align-items: end; justify-content: flex-end; gap: 18px; padding-right: 16px; border-right: 1px solid rgb(93 218 245 / 35%); text-align: right; } +.weapon-block div > span { display: block; margin-top: 4px; color: #5f7c86; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .08em; } +.weapon-block strong { color: #effcff; font-family: "IBM Plex Mono", monospace; font-size: 2.5rem; font-weight: 500; line-height: .9; } +.weapon-block strong i { color: #75909a; font-size: .72rem; font-style: normal; } +.score-block { display: flex; gap: 12px; } +.score-block span { display: flex; flex-direction: column; color: #d5edf3; font-family: "IBM Plex Mono", monospace; font-size: 1.2rem; } +.score-block small { color: #66838e; font-size: .5rem; } + +.damage-flash { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(circle, transparent 15%, rgb(158 22 7 / 28%)); animation: damage .3s ease-out forwards; } +@keyframes damage { from { opacity: 1; } to { opacity: 0; } } + +.flux-game { + --team: #66e6ff; + position: relative; + display: grid; + width: 100%; + height: 100%; + min-width: 320px; + grid-template-rows: auto 1fr auto; + overflow: hidden; + color: #e8faff; + background: + radial-gradient(circle at 50% 45%, rgb(40 137 161 / 16%), transparent 26%), + radial-gradient(circle at 8% 50%, rgb(35 198 234 / 10%), transparent 25%), + radial-gradient(circle at 92% 50%, rgb(255 128 48 / 11%), transparent 25%), + #04080c; + user-select: none; +} +.flux-game--orange { --team: #ff9a4e; } +.flux-grid { + position: absolute; + inset: 0; + opacity: .18; + background-image: + linear-gradient(rgb(102 226 255 / 16%) 1px, transparent 1px), + linear-gradient(90deg, rgb(102 226 255 / 16%) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(circle at center, black, transparent 75%); + pointer-events: none; +} +.flux-header { + position: relative; + z-index: 2; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: start; + padding: 26px 34px; +} +.flux-header > div:first-child small, +.flux-identity small, +.flux-energy small { + display: block; + color: #66818a; + font-family: "IBM Plex Mono", monospace; + font-size: .48rem; + letter-spacing: .14em; + text-transform: uppercase; +} +.flux-header > div:first-child strong { display: block; color: #dffbff; font-size: 1.32rem; letter-spacing: .2em; } +.flux-score { display: grid; grid-template-columns: 54px auto 54px; align-items: center; gap: 15px; text-align: center; } +.flux-score b { color: #72eaff; font-family: "IBM Plex Mono", monospace; font-size: 1.6rem; font-weight: 500; text-shadow: 0 0 18px rgb(90 225 255 / 50%); } +.flux-score b:last-child { color: #ffa05d; text-shadow: 0 0 18px rgb(255 126 54 / 50%); } +.flux-score span { padding: 6px 18px; border-inline: 1px solid rgb(119 210 229 / 24%); color: #7d99a2; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .13em; } +.flux-connection { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; text-align: right; } +.flux-connection i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #66e4ff; box-shadow: 0 0 12px #4bdcff; } +.flux-connection span { color: #99eefd; font-size: .65rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } +.flux-connection small { color: #627e87; font-family: "IBM Plex Mono", monospace; font-size: .48rem; } +.flux-connection--connecting i, .flux-connection--reconnecting i { background: #ff9c50; box-shadow: 0 0 12px #ff9c50; } + +.flux-arena { + position: relative; + z-index: 1; + display: grid; + width: min(1200px, calc(100% - 64px)); + grid-template-columns: 145px 1fr 145px; + align-items: center; + gap: 18px; + margin: auto; +} +.flux-field { display: grid; height: 230px; grid-template-columns: 80px 1fr 80px; align-items: center; } +.flux-lane { + position: relative; + height: 6px; + background: linear-gradient(90deg, #4be3ff, rgb(114 184 199 / 28%) 48%, rgb(114 184 199 / 28%) 52%, #ff8c42); + box-shadow: 0 0 24px rgb(75 224 255 / 10%); +} +.flux-lane::before, +.flux-lane::after { position: absolute; top: -42px; width: 1px; height: 90px; background: rgb(107 191 210 / 18%); content: ""; } +.flux-lane::before { left: 25%; } +.flux-lane::after { right: 25%; } +.flux-midline { position: absolute; top: -72px; left: 50%; width: 1px; height: 150px; background: linear-gradient(transparent, rgb(183 239 250 / 42%), transparent); } +.flux-core { + position: absolute; + top: 50%; + width: 74px; + height: 74px; + border: 1px solid #d6f9ff; + border-radius: 50%; + background: radial-gradient(circle, #e9fdff 0 8%, #6eeaff 10%, rgb(46 206 239 / 22%) 36%, transparent 38%); + box-shadow: 0 0 18px #62e6ff, 0 0 65px rgb(60 219 251 / 50%); + transform: translate(-50%, -50%); + transition: left 60ms linear; +} +.flux-core i { position: absolute; inset: -10px; border: 1px solid rgb(135 236 255 / 35%); border-radius: 50%; animation: flux-spin 4s linear infinite; } +.flux-core i:nth-child(2) { inset: -18px 8px; transform: rotate(64deg); animation-duration: 2.8s; } +.flux-core i:nth-child(3) { inset: 8px -18px; transform: rotate(-64deg); animation-duration: 3.4s; } +@keyframes flux-spin { to { transform: rotate(360deg); } } +.flux-gate { position: relative; height: 170px; border: 1px solid; opacity: .8; } +.flux-gate span { position: absolute; top: 50%; width: 110px; font-family: "IBM Plex Mono", monospace; font-size: .46rem; letter-spacing: .12em; text-align: center; transform: translateY(-50%) rotate(-90deg); } +.flux-gate--cyan { border-color: #53def9; border-right: 0; box-shadow: inset 18px 0 30px rgb(57 208 239 / 9%); } +.flux-gate--cyan span { left: -28px; color: #55dffb; } +.flux-gate--orange { border-color: #ff8c47; border-left: 0; box-shadow: inset -18px 0 30px rgb(255 125 48 / 9%); } +.flux-gate--orange span { right: -28px; color: #ff9554; } + +.flux-roster { display: flex; flex-direction: column; gap: 7px; } +.flux-roster > small { margin-bottom: 5px; color: #526e77; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .13em; text-transform: uppercase; } +.flux-roster > div { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 7px; padding: 7px 8px; border-left: 1px solid rgb(88 211 238 / 24%); color: #77949d; background: linear-gradient(90deg, rgb(34 135 158 / 9%), transparent); font-family: "IBM Plex Mono", monospace; font-size: .48rem; } +.flux-roster--orange { text-align: right; } +.flux-roster--orange > div { grid-template-columns: auto 1fr 9px; border-right: 1px solid rgb(255 142 75 / 24%); border-left: 0; background: linear-gradient(90deg, transparent, rgb(159 77 34 / 9%)); } +.flux-roster--orange > div i { grid-column: 3; grid-row: 1; background: #70462f; } +.flux-roster--orange > div span { grid-column: 2; grid-row: 1; } +.flux-roster--orange > div b { grid-column: 1; grid-row: 1; } +.flux-roster > div i { width: 6px; height: 6px; border-radius: 50%; background: #376977; } +.flux-roster > div b { color: #465f68; font-size: .4rem; font-weight: 500; } +.flux-roster > .flux-player--active { color: #baf7ff; } +.flux-roster > .flux-player--active i { background: #6ceaff; box-shadow: 0 0 10px #5ee5ff; } +.flux-roster--orange > .flux-player--active i { background: #ff9d5d; box-shadow: 0 0 10px #ff8c45; } +.flux-roster > .flux-player--local { border-color: var(--team); background: linear-gradient(90deg, rgb(64 208 239 / 18%), transparent); } + +.flux-event { position: absolute; z-index: 4; top: 26%; left: 50%; padding: 8px 24px; border-block: 1px solid; background: linear-gradient(90deg, transparent, rgb(4 17 23 / 88%) 18%, rgb(4 17 23 / 88%) 82%, transparent); font-family: "IBM Plex Mono", monospace; font-size: .58rem; letter-spacing: .13em; transform: translateX(-50%); pointer-events: none; } +.flux-event--cyan { border-color: rgb(84 223 250 / 50%); color: #8bedff; } +.flux-event--orange { border-color: rgb(255 144 79 / 50%); color: #ffad76; } + +.flux-controls { + position: relative; + z-index: 3; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 36px; + padding: 24px 34px 30px; + border-top: 1px solid rgb(109 205 225 / 12%); + background: linear-gradient(180deg, transparent, rgb(2 8 12 / 75%)); +} +.flux-identity strong { display: block; margin: 3px 0; color: var(--team); font-size: .88rem; letter-spacing: .13em; } +.flux-identity span { color: #536d76; font-family: "IBM Plex Mono", monospace; font-size: .46rem; } +.flux-thrust { min-width: 250px; padding: 13px 28px; border: 1px solid color-mix(in srgb, var(--team) 48%, transparent); color: #dffaff; background: rgb(6 23 30 / 84%); cursor: pointer; clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px), 0 10px); } +.flux-thrust small { display: block; color: #5e7d87; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .11em; } +.flux-thrust strong { display: block; margin-top: 3px; font-size: .92rem; letter-spacing: .16em; } +.flux-thrust:hover { border-color: var(--team); } +.flux-thrust--active { color: #fff; background: color-mix(in srgb, var(--team) 20%, rgb(5 21 28)); box-shadow: 0 0 28px color-mix(in srgb, var(--team) 25%, transparent), inset 0 0 24px color-mix(in srgb, var(--team) 14%, transparent); } +.flux-energy { justify-self: end; width: 245px; } +.flux-energy > div { display: flex; align-items: end; justify-content: space-between; } +.flux-energy strong { color: var(--team); font-family: "IBM Plex Mono", monospace; font-size: 1.1rem; font-weight: 500; } +.flux-energy > span { display: block; overflow: hidden; height: 4px; margin: 7px 0; background: rgb(119 155 165 / 20%); transform: skewX(-20deg); } +.flux-energy > span i { display: block; height: 100%; background: var(--team); box-shadow: 0 0 13px var(--team); transition: width 80ms linear; } +.flux-energy em { display: block; color: #4c6871; font-family: "IBM Plex Mono", monospace; font-size: .43rem; font-style: normal; letter-spacing: .08em; text-align: right; } + +.royale-game { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + color: #f1f5e9; + background: #17291f; + user-select: none; +} +.royale-viewport, +.royale-viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.royale-viewport canvas { display: block; } +.royale-grade { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgb(7 14 10 / 54%), transparent 24%, transparent 70%, rgb(4 9 7 / 70%)), + radial-gradient(circle, transparent 52%, rgb(3 7 5 / 44%)); + pointer-events: none; +} +.royale-deploy { + position: absolute; + z-index: 12; + top: 50%; + left: 50%; + display: flex; + width: min(430px, calc(100% - 36px)); + flex-direction: column; + gap: 7px; + padding: 24px 34px; + border: 1px solid rgb(198 231 161 / 55%); + color: #efffdc; + background: rgb(8 19 12 / 86%); + box-shadow: 0 0 80px rgb(99 177 67 / 17%), inset 0 0 34px rgb(124 202 82 / 7%); + clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px)); + cursor: crosshair; + transform: translate(-50%, -50%); +} +.royale-deploy small, +.royale-deploy span { color: #84977b; font-family: "IBM Plex Mono", monospace; font-size: .5rem; letter-spacing: .1em; } +.royale-deploy strong { font-size: 1.55rem; letter-spacing: .24em; } +.royale-deploy:hover { border-color: #e4ffc6; background: rgb(12 30 18 / 92%); } + +.royale-header { + position: absolute; + z-index: 4; + top: 0; + left: 0; + display: grid; + width: 100%; + grid-template-columns: 1fr auto 1fr; + align-items: start; + padding: 24px 30px; + pointer-events: none; +} +.royale-brand small { display: block; color: #899987; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .13em; } +.royale-brand strong { display: block; margin-top: 2px; color: #efffda; font-size: 1.13rem; letter-spacing: .2em; text-shadow: 0 0 20px rgb(151 214 98 / 24%); } +.royale-status { display: flex; align-items: center; gap: 1px; background: rgb(8 19 12 / 65%); } +.royale-status span { padding: 7px 14px; border-inline: 1px solid rgb(208 232 187 / 11%); color: #9dab96; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .1em; } +.royale-status b { color: #efffd8; font-size: .74rem; font-weight: 500; } +.royale-status .is-danger { color: #ff8a65; } +.royale-live { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; text-align: right; } +.royale-live i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #b7f27c; box-shadow: 0 0 12px #a7eb67; } +.royale-live span { color: #c9f4a4; font-size: .64rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } +.royale-live small { color: #758172; font-family: "IBM Plex Mono", monospace; font-size: .46rem; } +.royale-live--connecting i, +.royale-live--reconnecting i { background: #ffad68; box-shadow: 0 0 12px #ff9a4d; } + +.royale-stream { + position: absolute; + z-index: 4; + top: 108px; + left: 30px; + width: 174px; + padding: 9px 10px; + border-left: 1px solid rgb(187 226 153 / 35%); + background: linear-gradient(90deg, rgb(8 18 12 / 80%), transparent); + pointer-events: none; +} +.royale-stream > small { display: block; margin-bottom: 6px; color: #81917d; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .14em; } +.royale-stream div { display: grid; grid-template-columns: 58px 1fr; padding: 2px 0; font-family: "IBM Plex Mono", monospace; font-size: .45rem; } +.royale-stream span { color: #60705e; } +.royale-stream b { color: #b3c8a7; font-weight: 500; } + +.royale-radar { + position: absolute; + z-index: 4; + top: 106px; + right: 30px; + overflow: hidden; + width: 148px; + height: 148px; + border: 1px solid rgb(190 226 157 / 35%); + border-radius: 50%; + background: + linear-gradient(rgb(176 215 144 / 8%) 1px, transparent 1px), + linear-gradient(90deg, rgb(176 215 144 / 8%) 1px, transparent 1px), + rgb(5 15 9 / 74%); + background-size: 18px 18px; + box-shadow: inset 0 0 30px rgb(83 141 61 / 12%); + pointer-events: none; +} +.royale-radar__ring { position: absolute; inset: 24%; border: 1px solid rgb(194 231 164 / 15%); border-radius: 50%; } +.royale-radar i { position: absolute; display: block; transform: translate(-50%, -50%); } +.royale-radar__self { top: 50%; left: 50%; width: 7px; height: 7px; border: 1px solid #ecffd6; transform: translate(-50%, -50%) rotate(45deg) !important; box-shadow: 0 0 8px #beef8f; } +.royale-radar__enemy { width: 5px; height: 5px; border-radius: 50%; background: #ff7957; box-shadow: 0 0 7px #ff5d3d; } +.royale-radar__loot { width: 3px; height: 3px; background: #78dcff; box-shadow: 0 0 5px #68d7ff; } +.royale-radar small { position: absolute; right: 0; bottom: 11px; left: 0; color: #6f806c; font-family: "IBM Plex Mono", monospace; font-size: .39rem; letter-spacing: .11em; text-align: center; } + +.royale-feed { position: absolute; z-index: 4; top: 274px; right: 30px; display: flex; align-items: flex-end; flex-direction: column; gap: 4px; pointer-events: none; } +.royale-feed div { display: flex; gap: 7px; padding: 4px 8px; border-right: 1px solid rgb(255 137 92 / 35%); color: #b7c1b2; background: linear-gradient(90deg, transparent, rgb(12 17 12 / 68%)); font-family: "IBM Plex Mono", monospace; font-size: .43rem; } +.royale-feed b { color: #ff9068; font-size: .38rem; } + +.royale-crosshair { position: absolute; z-index: 3; top: 50%; left: 50%; width: 42px; height: 42px; transform: translate(-50%, -50%); pointer-events: none; } +.royale-crosshair::after { position: absolute; top: 19px; left: 19px; width: 4px; height: 4px; border: 1px solid #f4ffe5; border-radius: 50%; content: ""; } +.royale-crosshair i { position: absolute; background: rgb(241 255 224 / 88%); box-shadow: 0 0 5px rgb(177 239 113 / 65%); } +.royale-crosshair i:nth-child(1), .royale-crosshair i:nth-child(2) { top: 20px; width: 9px; height: 1px; } +.royale-crosshair i:nth-child(1) { left: 1px; } +.royale-crosshair i:nth-child(2) { right: 1px; } +.royale-crosshair i:nth-child(3), .royale-crosshair i:nth-child(4) { left: 20px; width: 1px; height: 9px; } +.royale-crosshair i:nth-child(3) { top: 1px; } +.royale-crosshair i:nth-child(4) { bottom: 1px; } +.royale-crosshair--hit { animation: royale-hit .16s ease-out; } +@keyframes royale-hit { 50% { transform: translate(-50%, -50%) scale(1.55); filter: sepia(1) saturate(5); } } +.royale-hit, +.royale-pickup { position: absolute; z-index: 4; left: 50%; color: #f0ffdf; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .15em; text-shadow: 0 0 9px #a9e877; transform: translateX(-50%); pointer-events: none; } +.royale-hit { top: calc(50% + 33px); } +.royale-pickup { bottom: 126px; color: #9fe7ff; text-shadow: 0 0 9px #55cfff; } + +.royale-drop { position: absolute; z-index: 4; top: 50%; right: 33px; display: grid; grid-template-columns: auto 5px; gap: 4px 12px; color: #dff8ff; transform: translateY(-50%); pointer-events: none; } +.royale-drop small { color: #7d989c; font-family: "IBM Plex Mono", monospace; font-size: .42rem; letter-spacing: .12em; } +.royale-drop strong { grid-column: 1; font-family: "IBM Plex Mono", monospace; font-size: 1.4rem; font-weight: 500; text-align: right; } +.royale-drop span { grid-column: 2; grid-row: 1 / span 2; position: relative; overflow: hidden; width: 4px; height: 60px; background: rgb(137 183 190 / 22%); } +.royale-drop i { position: absolute; right: 0; bottom: 0; left: 0; background: #9cecff; box-shadow: 0 0 9px #6edfff; } + +.royale-eliminated, +.royale-winner { position: absolute; z-index: 5; top: 50%; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 5px; width: min(600px, calc(100% - 40px)); padding: 18px; border-block: 1px solid rgb(255 116 76 / 55%); background: linear-gradient(90deg, transparent, rgb(30 11 7 / 83%), transparent); transform: translate(-50%, -50%); pointer-events: none; } +.royale-eliminated small, +.royale-winner small { color: #bd735e; font-family: "IBM Plex Mono", monospace; font-size: .5rem; letter-spacing: .17em; } +.royale-eliminated strong, +.royale-winner strong { color: #ffd0bc; font-size: 1.25rem; letter-spacing: .18em; } +.royale-winner { border-color: rgb(184 239 129 / 58%); background: linear-gradient(90deg, transparent, rgb(12 29 9 / 86%), transparent); } +.royale-winner small { color: #8cad72; } +.royale-winner strong { color: #e8ffd0; } +.royale-damage { position: absolute; inset: 0; z-index: 2; background: radial-gradient(circle, transparent 22%, rgb(145 22 10 / 32%)); animation: damage .32s ease-out forwards; pointer-events: none; } + +.royale-hud { position: absolute; z-index: 4; right: 30px; bottom: 24px; left: 30px; display: grid; grid-template-columns: 230px 1fr 260px; align-items: end; gap: 28px; pointer-events: none; } +.royale-vitals small, +.royale-weapon small { color: #83907d; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .13em; } +.royale-vitals strong { display: block; color: #efffde; font-family: "IBM Plex Mono", monospace; font-size: 2.15rem; font-weight: 500; line-height: 1; } +.royale-vitals > span { display: block; overflow: hidden; width: 180px; height: 4px; margin: 6px 0; background: rgb(155 174 144 / 24%); transform: skewX(-22deg); } +.royale-vitals > span i { display: block; height: 100%; background: #a8ed72; box-shadow: 0 0 12px #9ce362; } +.royale-vitals em { color: #7fa0ad; font-family: "IBM Plex Mono", monospace; font-size: .45rem; font-style: normal; } +.royale-mission { text-align: center; } +.royale-mission strong { display: block; color: #d4dfca; font-size: .7rem; letter-spacing: .17em; } +.royale-mission small { color: #71806d; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .08em; } +.royale-weapon { justify-self: end; min-width: 240px; padding-right: 14px; border-right: 1px solid rgb(194 225 166 / 30%); text-align: right; } +.royale-weapon strong { display: block; color: #f6ffec; font-family: "IBM Plex Mono", monospace; font-size: 2.35rem; font-weight: 500; line-height: .9; } +.royale-weapon strong i { color: #81907d; font-size: .72rem; font-style: normal; } +.royale-weapon span { color: #6e8069; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .1em; } + +@media (max-width: 760px) { + .game-switcher { top: 70px; } + .topbar { padding: 16px; grid-template-columns: 1fr auto; } + .match-clock { display: none; } + .brand small, .telemetry, .mission-note { display: none; } + .kill-feed { top: 82px; right: 16px; } + .leaderboard { display: none; } + .hud { right: 16px; bottom: 16px; left: 16px; grid-template-columns: 1fr 1fr; gap: 18px; } + .weapon-block { grid-column: 2; grid-row: 1; } + .score-block { display: none; } + .killcam-panel { bottom: 104px; } + .killcam-panel__title small, .killcam-panel__meta span:last-child { display: none; } + .flux-header { grid-template-columns: 1fr auto; padding: 18px; } + .flux-score { grid-column: 1 / -1; grid-row: 2; margin-top: 38px; } + .flux-arena { width: calc(100% - 24px); grid-template-columns: 1fr; } + .flux-roster { display: none; } + .flux-field { height: 180px; grid-template-columns: 45px 1fr 45px; } + .flux-gate { height: 125px; } + .flux-controls { grid-template-columns: 1fr; gap: 13px; padding: 16px 20px 20px; } + .flux-identity { display: none; } + .flux-energy { justify-self: stretch; width: auto; } + .flux-thrust { grid-row: 1; min-width: 0; } + .royale-header { padding: 16px; grid-template-columns: 1fr auto; } + .royale-status { grid-column: 1 / -1; grid-row: 2; justify-self: center; margin-top: 42px; } + .royale-stream { top: 112px; left: 16px; } + .royale-radar { top: 112px; right: 16px; width: 112px; height: 112px; } + .royale-feed { display: none; } + .royale-hud { right: 16px; bottom: 14px; left: 16px; grid-template-columns: 1fr 1fr; gap: 18px; } + .royale-mission { display: none; } + .royale-weapon { min-width: 0; } + .royale-drop { right: 16px; } +} diff --git a/apps/web/src/useFluxClient.ts b/apps/web/src/useFluxClient.ts new file mode 100644 index 0000000..da73b70 --- /dev/null +++ b/apps/web/src/useFluxClient.ts @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FixedStepClock, type NetworkStats } from "@syncer/engine"; +import { + FLUX_SOCKET_PATH, + fluxGame, + type FluxClientState, + type FluxInput, +} from "@syncer/shared"; +import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js"; + +export interface FluxClientView { + connection: ConnectionStatus; + validation: ValidationStatus; + playerId: number | null; + tick: number; + inputLeadTicks: number; + world: FluxClientState; + network: NetworkStats; + setThrust(active: boolean): void; +} + +const emptyNetworkStats: NetworkStats = { + roundTripTime: 0, + jitter: 0, + clockOffset: 0, + samples: 0, +}; + +function socketUrl(): string { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + if (window.location.protocol === "http:" && window.location.port === "5173") { + return `ws://${window.location.hostname}:3001${FLUX_SOCKET_PATH}`; + } + return `${protocol}//${window.location.host}${FLUX_SOCKET_PATH}`; +} + +export function useFluxClient(): FluxClientView { + const engine = useMemo(() => fluxGame.createClient(), []); + const protocol = fluxGame.protocol; + const clock = useMemo( + () => new FixedStepClock({ rateHz: fluxGame.tickRateHz, maxCatchUpSteps: 5 }), + [], + ); + const setThrustRef = useRef<(active: boolean) => void>(() => undefined); + const setThrust = useCallback((active: boolean) => setThrustRef.current(active), []); + const [view, setView] = useState>({ + connection: "connecting", + validation: "waiting", + playerId: null, + tick: 0, + inputLeadTicks: 1, + world: fluxGame.client.createInitialState(), + network: emptyNetworkStats, + }); + + useEffect(() => { + let active = true; + let socket: WebSocket | undefined; + let retryTimer: number | undefined; + let animationFrame: number | undefined; + let connection: ConnectionStatus = "connecting"; + let validation: ValidationStatus = "waiting"; + let input: FluxInput = { thrust: false }; + + const publish = () => { + if (!active) return; + setView({ + connection, + validation, + playerId: engine.localPlayerId, + tick: engine.tick, + inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(fluxGame.tickRateHz), + world: fluxGame.client.cloneState(engine.currentState as FluxClientState), + network: engine.networkClock.stats, + }); + }; + + const send = (frame: ArrayBuffer) => { + if (socket?.readyState === WebSocket.OPEN) socket.send(frame); + }; + + const sendInput = () => { + if (!engine.initialized) return; + send(protocol.encodeClient({ kind: "input", packet: engine.createInput(input) })); + }; + + const updateThrust = (thrust: boolean) => { + if (input.thrust === thrust) return; + input = { thrust }; + sendInput(); + publish(); + }; + setThrustRef.current = updateThrust; + + const connect = () => { + connection = engine.initialized ? "reconnecting" : "connecting"; + publish(); + socket = new WebSocket(socketUrl()); + socket.binaryType = "arraybuffer"; + socket.addEventListener("open", () => { + if (!active) return; + connection = "live"; + publish(); + }); + socket.addEventListener("message", (event: MessageEvent) => { + if (!active || !(event.data instanceof ArrayBuffer)) return; + try { + const message = protocol.decodeServer(event.data); + switch (message.kind) { + case "welcome": + engine.initialize(message.playerId, message.snapshot); + clock.reset(performance.now()); + input = { thrust: false }; + validation = "waiting"; + sendInput(); + break; + case "snapshot": + engine.reconcile(message.snapshot); + break; + case "acknowledge": + engine.acknowledge(message.sequence); + break; + case "pong": + engine.networkClock.receivePong(message.pong, performance.now()); + break; + case "validation": + validation = message.valid ? "valid" : "invalid"; + break; + case "reject-input": + engine.reject(message.sequence); + validation = "invalid"; + break; + case "event": + engine.receiveEvent(message.event, message.tick); + break; + case "replay-start": + case "replay-frame": + case "replay-end": + break; + } + publish(); + } catch { + socket?.close(1003, "Invalid game frame"); + } + }); + socket.addEventListener("close", () => { + if (!active) return; + input = { thrust: false }; + connection = "reconnecting"; + publish(); + retryTimer = window.setTimeout(connect, 1_000); + }); + socket.addEventListener("error", () => socket?.close()); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code !== "Space") return; + event.preventDefault(); + updateThrust(true); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.code !== "Space") return; + event.preventDefault(); + updateThrust(false); + }; + const release = () => updateThrust(false); + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", release); + + const pingTimer = window.setInterval(() => { + send(protocol.encodeClient({ + kind: "ping", + ping: engine.networkClock.createPing(performance.now()), + })); + }, 1_000); + const validationTimer = window.setInterval(() => { + if (engine.initialized) { + send(protocol.encodeClient({ + kind: "state-report", + report: engine.createStateReport(), + })); + } + }, 2_000); + const animate = (now: number) => { + clock.advance(now, () => engine.step()); + publish(); + animationFrame = window.requestAnimationFrame(animate); + }; + + connect(); + animationFrame = window.requestAnimationFrame(animate); + return () => { + active = false; + setThrustRef.current = () => undefined; + window.clearTimeout(retryTimer); + window.clearInterval(pingTimer); + window.clearInterval(validationTimer); + if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame); + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", release); + socket?.close(); + }; + }, [clock, engine, protocol]); + + return { ...view, setThrust }; +} diff --git a/apps/web/src/useGameClient.ts b/apps/web/src/useGameClient.ts new file mode 100644 index 0000000..7bf9046 --- /dev/null +++ b/apps/web/src/useGameClient.ts @@ -0,0 +1,487 @@ +import { useEffect, useMemo, useState } from "react"; +import { + FixedStepClock, + type NetworkStats, + type ProjectedReplayFrame, +} from "@syncer/engine"; +import { + GAME_SOCKET_PATH, + WEAPONS, + Weapon, + shooterGame, + type ShooterInput, + type ShooterPerception, + type ShooterWorldState, +} from "@syncer/shared"; + +export type ConnectionStatus = "connecting" | "live" | "reconnecting"; +export type ValidationStatus = "waiting" | "valid" | "invalid"; + +export interface ReplayPlaybackView { + ticketId: number; + perspectiveId: number; + fromTick: number; + toTick: number; + currentTick: number; + progress: number; + playbackRate: number; +} + +export interface GameClientView { + connection: ConnectionStatus; + validation: ValidationStatus; + playerId: number | null; + cameraPlayerId: number | null; + presentationKey: string; + tick: number; + inputLeadTicks: number; + world: ShooterWorldState; + network: NetworkStats; + replay: ReplayPlaybackView | null; +} + +interface IncomingReplay { + ticketId: number; + perspectiveId: number; + fromTick: number; + toTick: number; + frameCount: number; + playbackRate: number; + frames: Array>; +} + +interface ActiveReplay extends IncomingReplay { + startedAt: number; + durationMilliseconds: number; +} + +const emptyNetworkStats: NetworkStats = { + roundTripTime: 0, + jitter: 0, + clockOffset: 0, + samples: 0, +}; + +const neutralInput: ShooterInput = { + strafe: 0, + forward: 0, + yaw: 0, + pitch: 0, + fire: false, + sprint: false, + reload: false, + weapon: Weapon.PulseRifle, +}; + +function getSocketUrl(): string { + const socketProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + if (window.location.protocol === "http:" && window.location.port === "5173") { + return `ws://${window.location.hostname}:3001${GAME_SOCKET_PATH}`; + } + return `${socketProtocol}//${window.location.host}${GAME_SOCKET_PATH}`; +} + +export function useGameClient(): GameClientView { + const engine = useMemo(() => shooterGame.createClient(), []); + const protocol = shooterGame.protocol; + const simulationClock = useMemo( + () => new FixedStepClock({ rateHz: shooterGame.tickRateHz, maxCatchUpSteps: 5 }), + [], + ); + const [view, setView] = useState({ + connection: "connecting", + validation: "waiting", + playerId: null, + cameraPlayerId: null, + presentationKey: "live", + tick: 0, + inputLeadTicks: 1, + world: shooterGame.client.createInitialState(), + network: emptyNetworkStats, + replay: null, + }); + + useEffect(() => { + let active = true; + let socket: WebSocket | undefined; + let retryTimer: number | undefined; + let animationFrame: number | undefined; + let connection: ConnectionStatus = "connecting"; + let validation: ValidationStatus = "waiting"; + const pressedKeys = new Set(); + let input: ShooterInput = { ...neutralInput }; + let lastSentInput: ShooterInput | null = null; + let inputDirty = true; + const incomingReplays = new Map(); + let activeReplay: ActiveReplay | null = null; + + const publishView = () => { + if (!active) return; + const now = performance.now(); + let world = shooterGame.client.cloneState( + engine.currentState as ShooterWorldState, + ); + let tick = engine.tick; + let cameraPlayerId = engine.localPlayerId; + let replayView: ReplayPlaybackView | null = null; + + if (activeReplay) { + const progress = clamp( + (now - activeReplay.startedAt) / activeReplay.durationMilliseconds, + 0, + 1, + ); + if (progress >= 1) { + activeReplay = null; + } else { + const targetTick = + activeReplay.fromTick + + (activeReplay.toTick - activeReplay.fromTick) * progress; + const frame = frameAtOrBefore(activeReplay.frames, targetTick); + if (frame) { + world = shooterGame.client.cloneState(frame.state); + world.events = frame.events.map((event) => ({ + receivedTick: frame.tick, + event: { ...event }, + })); + tick = frame.tick; + cameraPlayerId = activeReplay.perspectiveId; + replayView = { + ticketId: activeReplay.ticketId, + perspectiveId: activeReplay.perspectiveId, + fromTick: activeReplay.fromTick, + toTick: activeReplay.toTick, + currentTick: frame.tick, + progress, + playbackRate: activeReplay.playbackRate, + }; + } + } + } + + setView({ + connection, + validation, + playerId: engine.localPlayerId, + cameraPlayerId, + presentationKey: replayView ? `replay-${replayView.ticketId}` : "live", + tick, + inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(shooterGame.tickRateHz), + world, + network: engine.networkClock.stats, + replay: replayView, + }); + }; + + const send = (message: ArrayBuffer) => { + if (socket?.readyState === WebSocket.OPEN) socket.send(message); + }; + + const sendInput = (force = false) => { + if (!engine.initialized || (!force && !inputDirty)) return; + if (!force && lastSentInput && inputsEqual(input, lastSentInput)) { + inputDirty = false; + return; + } + const packet = engine.createInput(input); + send(protocol.encodeClient({ kind: "input", packet })); + lastSentInput = { ...input }; + inputDirty = false; + }; + + const updateMovement = () => { + input = { + ...input, + strafe: Number(pressedKeys.has("d")) - Number(pressedKeys.has("a")), + forward: Number(pressedKeys.has("w")) - Number(pressedKeys.has("s")), + sprint: pressedKeys.has("shift"), + }; + inputDirty = true; + sendInput(); + }; + + const connect = () => { + connection = engine.initialized ? "reconnecting" : "connecting"; + publishView(); + socket = new WebSocket(getSocketUrl()); + socket.binaryType = "arraybuffer"; + + socket.addEventListener("open", () => { + if (!active) return; + connection = "live"; + publishView(); + }); + + socket.addEventListener("message", (event: MessageEvent) => { + if (!active || !(event.data instanceof ArrayBuffer)) return; + try { + const message = protocol.decodeServer(event.data); + switch (message.kind) { + case "welcome": { + activeReplay = null; + incomingReplays.clear(); + engine.initialize(message.playerId, message.snapshot); + simulationClock.reset(performance.now()); + const local = message.snapshot.state.players.get(message.playerId); + input = { + ...neutralInput, + yaw: local?.yaw ?? 0, + pitch: local?.pitch ?? 0, + weapon: local?.weapon ?? Weapon.PulseRifle, + }; + lastSentInput = null; + inputDirty = true; + validation = "waiting"; + sendInput(true); + break; + } + case "snapshot": + engine.reconcile(message.snapshot); + break; + case "acknowledge": + engine.acknowledge(message.sequence); + break; + case "pong": + engine.networkClock.receivePong(message.pong, performance.now()); + break; + case "validation": + validation = message.valid ? "valid" : "invalid"; + break; + case "reject-input": + engine.reject(message.sequence); + validation = "invalid"; + break; + case "event": + engine.receiveEvent(message.event, message.tick); + break; + case "replay-start": { + if (message.frameCount === 0 || message.frameCount > 4_096) break; + incomingReplays.set(message.ticketId, { + ticketId: message.ticketId, + perspectiveId: message.perspectiveId, + fromTick: message.fromTick, + toTick: message.toTick, + frameCount: message.frameCount, + playbackRate: message.playbackRate, + frames: [], + }); + break; + } + case "replay-frame": { + const replay = incomingReplays.get(message.ticketId); + if ( + !replay || + replay.frames.length >= replay.frameCount || + message.tick < replay.fromTick || + message.tick > replay.toTick + ) { + break; + } + replay.frames.push({ + tick: message.tick, + state: message.state, + events: message.events, + }); + break; + } + case "replay-end": { + const replay = incomingReplays.get(message.ticketId); + incomingReplays.delete(message.ticketId); + if (!replay || replay.frames.length !== replay.frameCount) break; + replay.frames.sort((left, right) => left.tick - right.tick); + const replayTicks = Math.max(1, replay.toTick - replay.fromTick); + activeReplay = { + ...replay, + startedAt: performance.now(), + durationMilliseconds: Math.max( + 1_200, + (replayTicks / shooterGame.tickRateHz / replay.playbackRate) * 1_000, + ), + }; + releaseControls(); + break; + } + } + publishView(); + } catch { + socket?.close(1003, "Invalid game frame"); + } + }); + + socket.addEventListener("close", () => { + if (!active) return; + activeReplay = null; + incomingReplays.clear(); + connection = "reconnecting"; + publishView(); + retryTimer = window.setTimeout(connect, 1_000); + }); + socket.addEventListener("error", () => socket?.close()); + }; + + const animate = (now: number) => { + simulationClock.advance(now, () => engine.step()); + publishView(); + animationFrame = window.requestAnimationFrame(animate); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if (["w", "a", "s", "d", "shift", "r", "1", "2", "3"].includes(key)) event.preventDefault(); + if (activeReplay) return; + if (["w", "a", "s", "d", "shift"].includes(key)) { + pressedKeys.add(key); + updateMovement(); + } else if (key === "r") { + input = { ...input, reload: true }; + inputDirty = true; + sendInput(); + } else if (key === "1" || key === "2" || key === "3") { + input = { + ...input, + weapon: + key === "1" + ? Weapon.PulseRifle + : key === "2" + ? Weapon.Scattergun + : Weapon.RailRifle, + }; + inputDirty = true; + sendInput(); + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if (activeReplay) return; + if (["w", "a", "s", "d", "shift"].includes(key)) { + pressedKeys.delete(key); + updateMovement(); + } else if (key === "r") { + input = { ...input, reload: false }; + inputDirty = true; + sendInput(); + } + }; + + const handleMouseMove = (event: MouseEvent) => { + if (activeReplay || !document.pointerLockElement) return; + input = { + ...input, + yaw: normalizeAngle(input.yaw + event.movementX * 0.00225), + pitch: clamp(input.pitch - event.movementY * 0.0019, -1.25, 1.25), + }; + inputDirty = true; + }; + + const handleMouseDown = (event: MouseEvent) => { + if (activeReplay || event.button !== 0 || !document.pointerLockElement) return; + input = { ...input, fire: true }; + inputDirty = true; + sendInput(); + }; + + const handleMouseUp = (event: MouseEvent) => { + if (event.button !== 0) return; + if (activeReplay) return; + input = { ...input, fire: false }; + inputDirty = true; + sendInput(); + }; + + const releaseControls = () => { + pressedKeys.clear(); + input = { ...input, strafe: 0, forward: 0, fire: false, sprint: false, reload: false }; + inputDirty = true; + sendInput(true); + }; + + const handlePointerLockChange = () => { + if (!document.pointerLockElement) releaseControls(); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mouseup", handleMouseUp); + window.addEventListener("blur", releaseControls); + document.addEventListener("pointerlockchange", handlePointerLockChange); + + const inputTimer = window.setInterval( + () => sendInput(input.fire && WEAPONS[input.weapon].automatic), + 1_000 / 30, + ); + const pingTimer = window.setInterval(() => { + send(protocol.encodeClient({ kind: "ping", ping: engine.networkClock.createPing(performance.now()) })); + }, 1_000); + const validationTimer = window.setInterval(() => { + if (engine.initialized) { + send(protocol.encodeClient({ kind: "state-report", report: engine.createStateReport() })); + } + }, 2_000); + + connect(); + animationFrame = window.requestAnimationFrame(animate); + + return () => { + active = false; + window.clearTimeout(retryTimer); + window.clearInterval(inputTimer); + window.clearInterval(pingTimer); + window.clearInterval(validationTimer); + if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame); + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("blur", releaseControls); + document.removeEventListener("pointerlockchange", handlePointerLockChange); + socket?.close(); + }; + }, [engine, protocol, simulationClock]); + + return view; +} + +function inputsEqual(left: ShooterInput, right: ShooterInput): boolean { + return ( + left.strafe === right.strafe && + left.forward === right.forward && + left.yaw === right.yaw && + left.pitch === right.pitch && + left.fire === right.fire && + left.sprint === right.sprint && + left.reload === right.reload && + left.weapon === right.weapon + ); +} + +function normalizeAngle(value: number): number { + return Math.atan2(Math.sin(value), Math.cos(value)); +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} + +function frameAtOrBefore( + frames: ReadonlyArray>, + targetTick: number, +): ProjectedReplayFrame | undefined { + let low = 0; + let high = frames.length - 1; + let selected: ProjectedReplayFrame | undefined; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const frame = frames[middle]!; + if (frame.tick <= targetTick) { + selected = frame; + low = middle + 1; + } else { + high = middle - 1; + } + } + return selected ?? frames[0]; +} diff --git a/apps/web/src/useRoyaleClient.ts b/apps/web/src/useRoyaleClient.ts new file mode 100644 index 0000000..ba2dbdf --- /dev/null +++ b/apps/web/src/useRoyaleClient.ts @@ -0,0 +1,323 @@ +import { useEffect, useMemo, useState } from "react"; +import { FixedStepClock, type NetworkStats } from "@syncer/engine"; +import { + ROYALE_SOCKET_PATH, + royaleGame, + type RoyaleClientState, + type RoyaleInput, +} from "@syncer/shared"; +import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js"; + +export interface RoyaleClientView { + connection: ConnectionStatus; + validation: ValidationStatus; + playerId: number | null; + tick: number; + inputLeadTicks: number; + world: RoyaleClientState; + network: NetworkStats; +} + +const emptyNetworkStats: NetworkStats = { + roundTripTime: 0, + jitter: 0, + clockOffset: 0, + samples: 0, +}; + +const neutralInput: RoyaleInput = { + forward: 0, + strafe: 0, + yaw: 0, + pitch: 0, + fire: false, + sprint: false, + reload: false, +}; + +function socketUrls(): string[] { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const proxied = `${protocol}//${window.location.host}${ROYALE_SOCKET_PATH}`; + if (window.location.protocol !== "http:" || window.location.port !== "5173") { + return [proxied]; + } + return [ + `ws://${window.location.hostname}:3001${ROYALE_SOCKET_PATH}`, + proxied, + ]; +} + +export function useRoyaleClient(): RoyaleClientView { + const engine = useMemo(() => royaleGame.createClient(), []); + const protocol = royaleGame.protocol; + const clock = useMemo( + () => new FixedStepClock({ rateHz: royaleGame.tickRateHz, maxCatchUpSteps: 5 }), + [], + ); + const [view, setView] = useState({ + connection: "connecting", + validation: "waiting", + playerId: null, + tick: 0, + inputLeadTicks: 1, + world: royaleGame.client.createInitialState(), + network: emptyNetworkStats, + }); + + useEffect(() => { + let active = true; + let socket: WebSocket | undefined; + let retryTimer: number | undefined; + let animationFrame: number | undefined; + let connection: ConnectionStatus = "connecting"; + let validation: ValidationStatus = "waiting"; + let input: RoyaleInput = { ...neutralInput }; + let lastSent: RoyaleInput | null = null; + let inputDirty = false; + let socketUrlIndex = 0; + const connectionUrls = socketUrls(); + const pressed = new Set(); + + const publish = () => { + if (!active) return; + setView({ + connection, + validation, + playerId: engine.localPlayerId, + tick: engine.tick, + inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(royaleGame.tickRateHz), + world: royaleGame.client.cloneState(engine.currentState as RoyaleClientState), + network: engine.networkClock.stats, + }); + }; + + const send = (frame: ArrayBuffer) => { + if (socket?.readyState === WebSocket.OPEN) socket.send(frame); + }; + const sendInput = (force = false) => { + if (!engine.initialized) return; + if (!force && !inputDirty) return; + if (!force && lastSent && inputsEqual(lastSent, input)) { + inputDirty = false; + return; + } + const packet = engine.createInput(input); + send(protocol.encodeClient({ kind: "input", packet })); + lastSent = { ...input }; + inputDirty = false; + }; + const updateMovement = () => { + input = { + ...input, + forward: Number(pressed.has("w")) - Number(pressed.has("s")), + strafe: Number(pressed.has("d")) - Number(pressed.has("a")), + sprint: pressed.has("shift"), + }; + inputDirty = true; + sendInput(); + }; + + const connect = () => { + let opened = false; + connection = engine.initialized ? "reconnecting" : "connecting"; + publish(); + socket = new WebSocket(connectionUrls[socketUrlIndex]!); + socket.binaryType = "arraybuffer"; + socket.addEventListener("open", () => { + if (!active) return; + opened = true; + connection = "live"; + publish(); + }); + socket.addEventListener("message", (event: MessageEvent) => { + if (!active || !(event.data instanceof ArrayBuffer)) return; + try { + const message = protocol.decodeServer(event.data); + switch (message.kind) { + case "welcome": { + engine.initialize(message.playerId, message.snapshot); + clock.reset(performance.now()); + const local = message.snapshot.state.players.find( + (player) => player.id === message.playerId, + ); + input = { + ...neutralInput, + yaw: local?.yaw ?? 0, + pitch: local?.pitch ?? 0, + }; + lastSent = null; + inputDirty = true; + validation = "waiting"; + sendInput(true); + break; + } + case "snapshot": + engine.reconcile(message.snapshot); + break; + case "acknowledge": + engine.acknowledge(message.sequence); + break; + case "pong": + engine.networkClock.receivePong(message.pong, performance.now()); + break; + case "validation": + validation = message.valid ? "valid" : "invalid"; + break; + case "reject-input": + engine.reject(message.sequence); + validation = "invalid"; + break; + case "event": + engine.receiveEvent(message.event, message.tick); + break; + case "replay-start": + case "replay-frame": + case "replay-end": + break; + } + publish(); + } catch { + socket?.close(1003, "Invalid game frame"); + } + }); + socket.addEventListener("close", () => { + if (!active) return; + if (!opened && connectionUrls.length > 1) { + socketUrlIndex = (socketUrlIndex + 1) % connectionUrls.length; + } else if (opened) { + socketUrlIndex = 0; + } + connection = "reconnecting"; + publish(); + retryTimer = window.setTimeout(connect, 1_000); + }); + socket.addEventListener("error", () => socket?.close()); + }; + + const keyDown = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if (["w", "a", "s", "d", "shift", "r"].includes(key)) event.preventDefault(); + if (["w", "a", "s", "d", "shift"].includes(key)) { + pressed.add(key); + updateMovement(); + } else if (key === "r") { + input = { ...input, reload: true }; + inputDirty = true; + sendInput(true); + } + }; + const keyUp = (event: KeyboardEvent) => { + const key = event.key.toLowerCase(); + if (["w", "a", "s", "d", "shift"].includes(key)) { + pressed.delete(key); + updateMovement(); + } else if (key === "r") { + input = { ...input, reload: false }; + inputDirty = true; + sendInput(true); + } + }; + const mouseMove = (event: MouseEvent) => { + if (!document.pointerLockElement) return; + input = { + ...input, + yaw: normalizeAngle(input.yaw - event.movementX * 0.0027), + pitch: clamp(input.pitch - event.movementY * 0.0022, -1.25, 1.25), + }; + inputDirty = true; + }; + const mouseDown = (event: MouseEvent) => { + if (event.button !== 0 || !document.pointerLockElement) return; + input = { ...input, fire: true }; + inputDirty = true; + sendInput(true); + }; + const mouseUp = (event: MouseEvent) => { + if (event.button !== 0) return; + input = { ...input, fire: false }; + inputDirty = true; + sendInput(true); + }; + const release = () => { + pressed.clear(); + input = { ...input, forward: 0, strafe: 0, sprint: false, fire: false, reload: false }; + inputDirty = true; + sendInput(true); + }; + const pointerLockChange = () => { + if (!document.pointerLockElement) release(); + }; + + window.addEventListener("keydown", keyDown); + window.addEventListener("keyup", keyUp); + window.addEventListener("mousemove", mouseMove); + window.addEventListener("mousedown", mouseDown); + window.addEventListener("mouseup", mouseUp); + window.addEventListener("blur", release); + document.addEventListener("pointerlockchange", pointerLockChange); + + const inputTimer = window.setInterval(() => { + sendInput(input.fire); + }, 1_000 / royaleGame.tickRateHz); + const pingTimer = window.setInterval(() => { + send(protocol.encodeClient({ + kind: "ping", + ping: engine.networkClock.createPing(performance.now()), + })); + }, 1_000); + const validationTimer = window.setInterval(() => { + if (engine.initialized) { + send(protocol.encodeClient({ + kind: "state-report", + report: engine.createStateReport(), + })); + } + }, 2_000); + const animate = (now: number) => { + clock.advance(now, () => engine.step()); + publish(); + animationFrame = window.requestAnimationFrame(animate); + }; + + connect(); + animationFrame = window.requestAnimationFrame(animate); + return () => { + active = false; + window.clearTimeout(retryTimer); + window.clearInterval(inputTimer); + window.clearInterval(pingTimer); + window.clearInterval(validationTimer); + if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame); + window.removeEventListener("keydown", keyDown); + window.removeEventListener("keyup", keyUp); + window.removeEventListener("mousemove", mouseMove); + window.removeEventListener("mousedown", mouseDown); + window.removeEventListener("mouseup", mouseUp); + window.removeEventListener("blur", release); + document.removeEventListener("pointerlockchange", pointerLockChange); + socket?.close(); + }; + }, [clock, engine, protocol]); + + return view; +} + +function inputsEqual(left: RoyaleInput, right: RoyaleInput): boolean { + return ( + left.forward === right.forward && + left.strafe === right.strafe && + left.yaw === right.yaw && + left.pitch === right.pitch && + left.fire === right.fire && + left.sprint === right.sprint && + left.reload === right.reload + ); +} + +function normalizeAngle(value: number): number { + return Math.atan2(Math.sin(value), Math.cos(value)); +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..ce759a8 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} + diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..797d900 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:3001", + "/ws": { + target: "ws://localhost:3001", + ws: true, + }, + }, + }, +}); diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml new file mode 100644 index 0000000..a5c074d --- /dev/null +++ b/deploy/kubernetes.yaml @@ -0,0 +1,89 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: syncer +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: syncer + namespace: syncer + labels: + app.kubernetes.io/name: syncer +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: syncer + template: + metadata: + labels: + app.kubernetes.io/name: syncer + spec: + containers: + - name: syncer + image: registry.a2acloud.io/syncer/syncer:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 3001 + readinessProbe: + httpGet: + path: /api/message + port: http + initialDelaySeconds: 2 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /api/message + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "2" + memory: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: syncer + namespace: syncer +spec: + selector: + app.kubernetes.io/name: syncer + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: syncer + namespace: syncer + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure +spec: + ingressClassName: traefik + tls: + - hosts: + - syncer.a2acloud.io + secretName: syncer-a2acloud-io-tls + rules: + - host: syncer.a2acloud.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: syncer + port: + name: http diff --git a/output/screenshots/sync-arena-duel.jpg b/output/screenshots/sync-arena-duel.jpg new file mode 100644 index 0000000..8cde3a9 Binary files /dev/null and b/output/screenshots/sync-arena-duel.jpg differ diff --git a/output/screenshots/sync-arena-firefight.jpg b/output/screenshots/sync-arena-firefight.jpg new file mode 100644 index 0000000..0f87f46 Binary files /dev/null and b/output/screenshots/sync-arena-firefight.jpg differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f56e50e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2205 @@ +{ + "name": "syncer", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "syncer", + "version": "0.0.0", + "workspaces": [ + "apps/*", + "packages/*" + ], + "devDependencies": { + "concurrently": "^10.0.5", + "typescript": "^7.0.2" + } + }, + "apps/server": { + "name": "@syncer/server", + "version": "0.0.0", + "dependencies": { + "@syncer/engine": "0.0.0", + "@syncer/shared": "0.0.0", + "uWebSockets.js": "https://github.com/uNetworking/uWebSockets.js/archive/refs/tags/v20.67.0.tar.gz" + }, + "devDependencies": { + "@types/node": "^26.4.0", + "tsx": "^4.23.12", + "typescript": "^7.0.2" + } + }, + "apps/web": { + "name": "@syncer/web", + "version": "0.0.0", + "dependencies": { + "@syncer/engine": "0.0.0", + "@syncer/shared": "0.0.0", + "@types/three": "^0.183.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.0", + "typescript": "^7.0.2", + "vite": "^8.2.2" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@syncer/engine": { + "resolved": "packages/engine", + "link": true + }, + "node_modules/@syncer/server": { + "resolved": "apps/server", + "link": true + }, + "node_modules/@syncer/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/@syncer/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", + "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.72", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz", + "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==", + "license": "BSD-3-Clause" + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uWebSockets.js": { + "version": "20.67.0", + "resolved": "https://github.com/uNetworking/uWebSockets.js/archive/refs/tags/v20.67.0.tar.gz", + "integrity": "sha512-DGXOmh66IZ4sLX2YE5brCl6Z3I/teCjxsgTQMPnTbbraFUBC9o2jiYWuiDKUWlniIizX+qD6YJizjLZ54cjDPQ==", + "license": "Apache-2.0" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "packages/engine": { + "name": "@syncer/engine", + "version": "0.0.0", + "devDependencies": { + "typescript": "^7.0.2" + } + }, + "packages/shared": { + "name": "@syncer/shared", + "version": "0.0.0", + "dependencies": { + "@syncer/engine": "0.0.0" + }, + "devDependencies": { + "typescript": "^7.0.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3fc56a3 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "syncer", + "version": "0.0.0", + "private": true, + "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "predev": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared", + "dev": "concurrently --kill-others-on-fail --names engine,shared,server,web --prefix-colors blue,magenta,cyan,green \"npm run dev --workspace @syncer/engine\" \"npm run dev --workspace @syncer/shared\" \"npm run dev --workspace @syncer/server\" \"npm run dev --workspace @syncer/web\"", + "build": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared && npm run build --workspace @syncer/server && npm run build --workspace @syncer/web", + "pretypecheck": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared", + "typecheck": "npm run typecheck --workspace @syncer/engine && npm run typecheck --workspace @syncer/shared && npm run typecheck --workspace @syncer/server && npm run typecheck --workspace @syncer/web", + "test": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared && node --test packages/engine/test/*.test.mjs packages/shared/test/*.test.mjs", + "start": "npm run start --workspace @syncer/server" + }, + "devDependencies": { + "concurrently": "^10.0.5", + "typescript": "^7.0.2" + } +} diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 0000000..2273b51 --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,25 @@ +{ + "name": "@syncer/engine", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "dev": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^7.0.2" + } +} diff --git a/packages/engine/src/client.ts b/packages/engine/src/client.ts new file mode 100644 index 0000000..27d3262 --- /dev/null +++ b/packages/engine/src/client.ts @@ -0,0 +1,185 @@ +import { NetworkClock } from "./network-clock.js"; +import type { + ClientStateReport, + GameDefinition, + InputContext, + InputPacket, + PlayerId, + StateSnapshot, +} from "./types.js"; + +interface PredictedInput { + packet: InputPacket; + applied: boolean; +} + +export class PredictedEngine { + readonly game: GameDefinition; + readonly networkClock = new NetworkClock(); + private state: State; + private playerId: PlayerId | null = null; + private currentTick = 0; + private nextInputSequence = 1; + private acknowledgedSequence = 0; + private pendingInputs: PredictedInput[] = []; + + constructor(game: GameDefinition) { + this.game = game; + this.state = game.createInitialState(); + } + + get tick(): number { + return this.currentTick; + } + + get localPlayerId(): PlayerId | null { + return this.playerId; + } + + get currentState(): Readonly { + return this.state; + } + + get initialized(): boolean { + return this.playerId !== null; + } + + initialize(playerId: PlayerId, snapshot: StateSnapshot): void { + this.playerId = playerId; + this.currentTick = snapshot.tick; + this.state = this.game.cloneState(snapshot.state); + this.pendingInputs = []; + this.acknowledgedSequence = 0; + } + + createInput(input: Input, leadTicks?: number): InputPacket { + if (this.playerId === null) { + throw new Error("Client engine has not received a welcome snapshot"); + } + + const targetTick = + this.currentTick + + (leadTicks ?? + this.networkClock.recommendedInputLeadTicks(this.game.tickRateHz)); + const packet: InputPacket = { + sequence: this.nextInputSequence, + targetTick, + observedTick: this.currentTick, + input, + }; + const context = this.inputContext(packet); + + if (!this.game.validateInput(input, context)) { + throw new Error("Refusing to send invalid local input"); + } + + this.nextInputSequence = (this.nextInputSequence + 1) >>> 0; + this.pendingInputs.push({ packet, applied: false }); + return packet; + } + + acknowledge(sequence: number): void { + this.acknowledgedSequence = Math.max(this.acknowledgedSequence, sequence); + this.pendingInputs = this.pendingInputs.filter( + (pending) => pending.packet.sequence > this.acknowledgedSequence, + ); + } + + reject(sequence: number): void { + this.pendingInputs = this.pendingInputs.filter( + (pending) => pending.packet.sequence !== sequence, + ); + } + + step(): void { + if (this.playerId === null) { + return; + } + + this.currentTick += 1; + this.applyDueInputs(); + this.game.step(this.state, { + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }); + this.assertValidState(); + } + + reconcile(snapshot: StateSnapshot): void { + if ( + this.playerId === null || + snapshot.tick < this.currentTick - this.game.tickRateHz * 2 + ) { + return; + } + + const predictedTick = Math.max(this.currentTick, snapshot.tick); + this.state = this.game.cloneState(snapshot.state); + this.currentTick = snapshot.tick; + + for (const pending of this.pendingInputs) { + pending.applied = false; + } + + this.applyDueInputs(); + while (this.currentTick < predictedTick) { + this.currentTick += 1; + this.applyDueInputs(); + this.game.step(this.state, { + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }); + } + + this.assertValidState(); + } + + createStateReport(): ClientStateReport { + return { + tick: this.currentTick, + state: this.game.cloneState(this.state), + }; + } + + private applyDueInputs(): void { + for (const pending of this.pendingInputs) { + if (!pending.applied && pending.packet.targetTick <= this.currentTick) { + this.game.applyInput( + this.state, + pending.packet.input, + this.inputContext(pending.packet), + ); + pending.applied = true; + } + } + } + + private inputContext(packet: InputPacket): InputContext { + if (this.playerId === null) { + throw new Error("Client engine has not been initialized"); + } + + return { + playerId: this.playerId, + sequence: packet.sequence, + targetTick: packet.targetTick, + observedTick: packet.observedTick ?? packet.targetTick, + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }; + } + + private assertValidState(): void { + if ( + this.game.validateState && + !this.game.validateState(this.state, { + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }) + ) { + throw new Error( + `Client prediction produced invalid state at tick ${this.currentTick}`, + ); + } + } +} diff --git a/packages/engine/src/clock.ts b/packages/engine/src/clock.ts new file mode 100644 index 0000000..056262b --- /dev/null +++ b/packages/engine/src/clock.ts @@ -0,0 +1,55 @@ +export interface FixedStepClockOptions { + rateHz: number; + maxCatchUpSteps?: number; +} + +export class FixedStepClock { + readonly stepMilliseconds: number; + private readonly maxCatchUpSteps: number; + private previousTime: number | null = null; + private accumulator = 0; + + constructor(options: FixedStepClockOptions) { + if (!Number.isFinite(options.rateHz) || options.rateHz <= 0) { + throw new RangeError("Clock rate must be a positive finite number"); + } + + this.stepMilliseconds = 1_000 / options.rateHz; + this.maxCatchUpSteps = options.maxCatchUpSteps ?? 5; + } + + advance(now: number, step: () => void): number { + if (this.previousTime === null) { + this.previousTime = now; + return 0; + } + + const elapsed = Math.max(0, now - this.previousTime); + this.previousTime = now; + this.accumulator += Math.min( + elapsed, + this.stepMilliseconds * this.maxCatchUpSteps, + ); + + let steps = 0; + while ( + this.accumulator >= this.stepMilliseconds && + steps < this.maxCatchUpSteps + ) { + step(); + this.accumulator -= this.stepMilliseconds; + steps += 1; + } + + return steps; + } + + get interpolationAlpha(): number { + return this.accumulator / this.stepMilliseconds; + } + + reset(now?: number): void { + this.previousTime = now ?? null; + this.accumulator = 0; + } +} diff --git a/packages/engine/src/define-game.ts b/packages/engine/src/define-game.ts new file mode 100644 index 0000000..8e61241 --- /dev/null +++ b/packages/engine/src/define-game.ts @@ -0,0 +1,38 @@ +import { PredictedEngine } from "./client.js"; +import { AuthoritativeEngine, type ServerEngineOptions } from "./server.js"; +import type { GameDefinition } from "./types.js"; + +export interface DefinedGame + extends GameDefinition { + createServer(options?: ServerEngineOptions): AuthoritativeEngine; + createClient(): PredictedEngine; +} + +export function defineGame( + definition: GameDefinition, +): DefinedGame { + if (!Number.isInteger(definition.tickRateHz) || definition.tickRateHz <= 0) { + throw new RangeError("tickRateHz must be a positive integer"); + } + + if ( + !Number.isInteger(definition.snapshotRateHz) || + definition.snapshotRateHz <= 0 || + definition.snapshotRateHz > definition.tickRateHz || + definition.tickRateHz % definition.snapshotRateHz !== 0 + ) { + throw new RangeError( + "snapshotRateHz must be a positive divisor of tickRateHz", + ); + } + + return Object.freeze({ + ...definition, + createServer(options?: ServerEngineOptions) { + return new AuthoritativeEngine(definition, options); + }, + createClient() { + return new PredictedEngine(definition); + }, + }); +} diff --git a/packages/engine/src/define-multiplayer-game.ts b/packages/engine/src/define-multiplayer-game.ts new file mode 100644 index 0000000..28ac2fd --- /dev/null +++ b/packages/engine/src/define-multiplayer-game.ts @@ -0,0 +1,90 @@ +import { + defineNetworkedGame, + type DefinedNetworkedGame, +} from "./define-networked-game.js"; +import type { + ClientSimulationRules, + ReplicationRules, + ServerSimulationRules, +} from "./networked-types.js"; +import type { BinaryCodec, InputContext } from "./types.js"; + +/** + * Names the five types that cross a multiplayer game's trust boundaries. + * Define this once, then every callback in defineMultiplayerGame is inferred. + */ +export interface MultiplayerGameContract { + authority: unknown; + client: unknown; + input: unknown; + authorityEvent: unknown; + perceptionEvent: unknown; +} + +export interface MultiplayerGameConfiguration< + Contract extends MultiplayerGameContract, +> { + clock: { + ticksPerSecond: number; + snapshotsPerSecond: number; + }; + /** Private rules that run only on trusted authority. */ + authority: ServerSimulationRules< + Contract["authority"], + Contract["input"], + Contract["authorityEvent"] + >; + /** Rules the client may run speculatively between snapshots. */ + prediction: ClientSimulationRules< + Contract["client"], + Contract["input"], + Contract["perceptionEvent"] + >; + /** The only authority data and events a particular player may receive. */ + visibility: ReplicationRules< + Contract["authority"], + Contract["client"], + Contract["authorityEvent"], + Contract["perceptionEvent"] + >; + input: { + validate(input: Contract["input"], context: InputContext): boolean; + }; + encoding: { + input: BinaryCodec; + clientState: BinaryCodec; + perception?: BinaryCodec; + }; +} + +/** + * Developer-facing game-definition API. It compiles the readable game sections + * into the same low-level definition consumed by every engine HOF. + */ +export function defineMultiplayerGame< + Contract extends MultiplayerGameContract, +>( + configuration: MultiplayerGameConfiguration, +): DefinedNetworkedGame< + Contract["authority"], + Contract["client"], + Contract["input"], + Contract["authorityEvent"], + Contract["perceptionEvent"] +> { + return defineNetworkedGame({ + tickRateHz: configuration.clock.ticksPerSecond, + snapshotRateHz: configuration.clock.snapshotsPerSecond, + server: configuration.authority, + client: configuration.prediction, + replication: configuration.visibility, + validateInput: configuration.input.validate, + codecs: { + input: configuration.encoding.input, + state: configuration.encoding.clientState, + ...(configuration.encoding.perception + ? { event: configuration.encoding.perception } + : {}), + }, + }); +} diff --git a/packages/engine/src/define-networked-game.ts b/packages/engine/src/define-networked-game.ts new file mode 100644 index 0000000..0c1f167 --- /dev/null +++ b/packages/engine/src/define-networked-game.ts @@ -0,0 +1,93 @@ +import { NetworkedPredictedEngine } from "./networked-client.js"; +import { NetworkedAuthoritativeEngine } from "./networked-server.js"; +import type { NetworkedGameDefinition } from "./networked-types.js"; +import { createBinaryProtocol, type BinaryProtocol } from "./protocol.js"; +import type { ServerEngineOptions } from "./server.js"; + +export interface DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent = never, + PerceptionEvent = never, +> extends NetworkedGameDefinition< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + > { + protocol: BinaryProtocol; + createServer( + options?: ServerEngineOptions, + ): NetworkedAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >; + createClient(): NetworkedPredictedEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >; +} + +export function defineNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent = never, + PerceptionEvent = never, +>( + definition: NetworkedGameDefinition< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, +): DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent +> { + validateRates(definition.tickRateHz, definition.snapshotRateHz); + + const protocol = createBinaryProtocol( + definition.codecs, + ); + + return Object.freeze({ + ...definition, + protocol, + createServer(options?: ServerEngineOptions) { + return new NetworkedAuthoritativeEngine(definition, options); + }, + createClient() { + return new NetworkedPredictedEngine(definition); + }, + }); +} + +function validateRates(tickRateHz: number, snapshotRateHz: number): void { + if (!Number.isInteger(tickRateHz) || tickRateHz <= 0) { + throw new RangeError("tickRateHz must be a positive integer"); + } + + if ( + !Number.isInteger(snapshotRateHz) || + snapshotRateHz <= 0 || + snapshotRateHz > tickRateHz || + tickRateHz % snapshotRateHz !== 0 + ) { + throw new RangeError( + "snapshotRateHz must be a positive divisor of tickRateHz", + ); + } +} diff --git a/packages/engine/src/deterministic-hash.ts b/packages/engine/src/deterministic-hash.ts new file mode 100644 index 0000000..8a32ad6 --- /dev/null +++ b/packages/engine/src/deterministic-hash.ts @@ -0,0 +1,113 @@ +const fnvOffset = 0xcbf29ce484222325n; +const fnvPrime = 0x100000001b3n; +const uint64Mask = 0xffffffffffffffffn; + +/** + * Produces a stable 64-bit hash for deterministic simulation state. + * Maps, sets, and object keys are sorted so insertion order does not affect it. + * Cyclic structures and executable values are rejected deliberately. + */ +export function deterministicHash(value: unknown): string { + const canonical = canonicalize(value, new WeakSet()); + let hash = fnvOffset; + + for (let index = 0; index < canonical.length; index += 1) { + const code = canonical.charCodeAt(index); + hash ^= BigInt(code & 0xff); + hash = (hash * fnvPrime) & uint64Mask; + hash ^= BigInt(code >>> 8); + hash = (hash * fnvPrime) & uint64Mask; + } + + return hash.toString(16).padStart(16, "0"); +} + +function canonicalize(value: unknown, ancestors: WeakSet): string { + if (value === null) return "null"; + + switch (typeof value) { + case "undefined": + return "undefined"; + case "boolean": + return value ? "boolean:true" : "boolean:false"; + case "number": + if (Number.isNaN(value)) return "number:NaN"; + if (value === Infinity) return "number:+Infinity"; + if (value === -Infinity) return "number:-Infinity"; + if (Object.is(value, -0)) return "number:-0"; + return `number:${value}`; + case "bigint": + return `bigint:${value}`; + case "string": + return `string:${JSON.stringify(value)}`; + case "symbol": + case "function": + throw new TypeError(`Cannot hash simulation value of type ${typeof value}`); + case "object": + return canonicalizeObject(value, ancestors); + } + + throw new TypeError("Unsupported simulation value"); +} + +function canonicalizeObject(value: object, ancestors: WeakSet): string { + if (ancestors.has(value)) { + throw new TypeError("Cannot hash cyclic simulation state"); + } + ancestors.add(value); + + try { + if (Array.isArray(value)) { + return `array:[${value.map((entry) => canonicalize(entry, ancestors)).join(",")}]`; + } + + if (value instanceof Date) { + return `date:${value.toISOString()}`; + } + + if (value instanceof Map) { + const entries = [...value].map(([key, entryValue]) => [ + canonicalize(key, ancestors), + canonicalize(entryValue, ancestors), + ] as const); + entries.sort(([left], [right]) => compareText(left, right)); + return `map:{${entries + .map(([key, entryValue]) => `${key}=>${entryValue}`) + .join(",")}}`; + } + + if (value instanceof Set) { + const entries = [...value].map((entry) => canonicalize(entry, ancestors)); + entries.sort(compareText); + return `set:{${entries.join(",")}}`; + } + + if (value instanceof ArrayBuffer) { + return `bytes:${bytesToHex(new Uint8Array(value))}`; + } + + if (ArrayBuffer.isView(value)) { + return `${value.constructor.name}:${bytesToHex( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength), + )}`; + } + + const record = value as Record; + const keys = Object.keys(record).sort(compareText); + return `object:{${keys + .map((key) => `${JSON.stringify(key)}:${canonicalize(record[key], ancestors)}`) + .join(",")}}`; + } finally { + ancestors.delete(value); + } +} + +function bytesToHex(bytes: Uint8Array): string { + let result = ""; + for (const byte of bytes) result += byte.toString(16).padStart(2, "0"); + return result; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts new file mode 100644 index 0000000..2a6eb3f --- /dev/null +++ b/packages/engine/src/index.ts @@ -0,0 +1,113 @@ +export { FixedStepClock, type FixedStepClockOptions } from "./clock.js"; +export { deterministicHash } from "./deterministic-hash.js"; +export { PredictedEngine } from "./client.js"; +export { + defineGame, + type DefinedGame, +} from "./define-game.js"; +export { + defineNetworkedGame, + type DefinedNetworkedGame, +} from "./define-networked-game.js"; +export { + defineMultiplayerGame, + type MultiplayerGameConfiguration, + type MultiplayerGameContract, +} from "./define-multiplayer-game.js"; +export { + createJsonCodec, + type JsonCodecOptions, +} from "./json-codec.js"; +export { + withLagCompensation, + type LagCompensatedAction, + type LagCompensationActionPolicy, + type LagCompensationDefinition, + type LagCompensationMode, + type LagCompensationPolicies, + type LagCompensationResolutionContext, +} from "./lag-compensation.js"; +export { NetworkedPredictedEngine } from "./networked-client.js"; +export { + NetworkedAuthoritativeEngine, + type NetworkedServerStepResult, + type NetworkedSimulationCheckpoint, +} from "./networked-server.js"; +export type { + ClientEventContext, + ClientSimulationRules, + EmittingInputContext, + EmittingPlayerContext, + EmittingTickContext, + NetworkedGameDefinition, + ReplicationRules, + ServerSimulationRules, + SnapshotBatch, +} from "./networked-types.js"; +export { NetworkClock, type NetworkStats } from "./network-clock.js"; +export { + createBinaryProtocol, + type BinaryProtocol, + type ClientWireMessage, + type ServerWireMessage, +} from "./protocol.js"; +export { + ReplayTransportAuthoritativeEngine, + withReplayTransport, + type ProjectedReplayFrame, + type ReplayAuthorizationContext, + type ReplayTicket, + type ReplayTicketPlan, + type ReplayTransportDefinition, + type ReplayTransportNetworkedGame, + type ReplayTriggerContext, +} from "./replay-transport.js"; +export { + AuthoritativeEngine, + type Acknowledgement, + type InputDecision, + type ServerEngineOptions, + type ServerStepResult, +} from "./server.js"; +export { + SpatialGridIndex, + withSpatialReplication, + type SpatialPoint, + type SpatialReplicationController, + type SpatialReplicationDefinition, + type SpatialReplicationEntity, + type SpatialReplicationSelection, + type SpatialReplicationSource, + type SpatialReplicationViewerContext, + type SpatiallyReplicatedNetworkedGame, +} from "./spatial-replication.js"; +export { + ReplayDivergenceError, + ReplaySession, + TimeTravelAuthoritativeEngine, + withTimeTravel, + type ReplayCheckpoint, + type ReplayCommand, + type ReplayFrame, + type ReplayRecording, + type ReplayStateHash, + type ReplayVerification, + type TimeTravelDefinition, + type TimeTravelNetworkedGame, + type TimeTravelServerOptions, +} from "./time-travel.js"; +export type { + BinaryCodec, + ClientStateContext, + ClientStateReport, + GameDefinition, + InputContext, + InputPacket, + PingPacket, + PlayerContext, + PlayerId, + PongPacket, + StateSnapshot, + TickContext, + ValidationResult, +} from "./types.js"; diff --git a/packages/engine/src/json-codec.ts b/packages/engine/src/json-codec.ts new file mode 100644 index 0000000..eea602f --- /dev/null +++ b/packages/engine/src/json-codec.ts @@ -0,0 +1,45 @@ +import type { BinaryCodec } from "./types.js"; + +export interface JsonCodecOptions { + serialize?(value: Value): unknown; + deserialize?(value: unknown): Value; +} + +interface Utf8Encoder { + encode(value: string): Uint8Array; +} + +interface Utf8Decoder { + decode(value: Uint8Array): string; +} + +type Utf8Globals = typeof globalThis & { + TextEncoder: new () => Utf8Encoder; + TextDecoder: new ( + label?: string, + options?: { fatal?: boolean }, + ) => Utf8Decoder; +}; + +/** Creates a UTF-8 JSON codec for prototypes and low-volume game messages. */ +export function createJsonCodec( + options: JsonCodecOptions = {}, +): BinaryCodec { + const utf8 = globalThis as Utf8Globals; + const encoder = new utf8.TextEncoder(); + const decoder = new utf8.TextDecoder("utf-8", { fatal: true }); + + return { + encode(value) { + const json = JSON.stringify(options.serialize?.(value) ?? value); + if (json === undefined) { + throw new TypeError("The JSON codec cannot encode undefined"); + } + return encoder.encode(json); + }, + decode(payload) { + const value: unknown = JSON.parse(decoder.decode(payload)); + return options.deserialize ? options.deserialize(value) : (value as Value); + }, + }; +} diff --git a/packages/engine/src/lag-compensation.ts b/packages/engine/src/lag-compensation.ts new file mode 100644 index 0000000..08a2c7c --- /dev/null +++ b/packages/engine/src/lag-compensation.ts @@ -0,0 +1,425 @@ +import { + defineNetworkedGame, + type DefinedNetworkedGame, +} from "./define-networked-game.js"; +import type { EmittingInputContext } from "./networked-types.js"; +import type { PlayerId, TickContext } from "./types.js"; + +export type LagCompensationMode = "current" | "rewind" | "fast-forward"; + +export interface LagCompensatedAction { + type: string; +} + +export interface LagCompensationResolutionContext< + AuthorityState, + HistoricalState, + Action, + AuthorityEvent, +> { + currentState: AuthorityState; + historicalState: Readonly; + action: Readonly; + playerId: PlayerId; + sequence: number; + requestedTick: number; + resolvedTick: number; + currentTick: number; + rewindTicks: number; + catchUpTicks: number; + clamped: boolean; + emit(event: AuthorityEvent): void; +} + +export interface LagCompensationActionPolicy< + AuthorityState, + HistoricalState, + Action, + AuthorityEvent, +> { + mode: LagCompensationMode; + maximumRewindMs?: number; + maximumFutureMs?: number; + outOfWindow?: "clamp" | "reject"; + validate?( + context: LagCompensationResolutionContext< + AuthorityState, + HistoricalState, + Action, + AuthorityEvent + >, + ): boolean; + resolve( + context: LagCompensationResolutionContext< + AuthorityState, + HistoricalState, + Action, + AuthorityEvent + >, + ): void; +} + +export type LagCompensationPolicies< + AuthorityState, + HistoricalState, + Action extends LagCompensatedAction, + AuthorityEvent, +> = { + [Kind in Action["type"]]: LagCompensationActionPolicy< + AuthorityState, + HistoricalState, + Extract, + AuthorityEvent + >; +}; + +export interface LagCompensationDefinition< + AuthorityState, + Input, + AuthorityEvent, + HistoricalState, + Action extends LagCompensatedAction, +> { + historySeconds: number; + captureState( + state: Readonly, + context: TickContext, + ): HistoricalState; + cloneHistoricalState(state: Readonly): HistoricalState; + classifyAction( + input: Readonly, + context: EmittingInputContext, + ): Action | null; + cloneAction(action: Readonly): Action; + actions: LagCompensationPolicies< + AuthorityState, + HistoricalState, + Action, + AuthorityEvent + >; +} + +interface HistoricalFrame { + tick: number; + state: HistoricalState; +} + +interface QueuedAction { + action: Action; + playerId: PlayerId; + sequence: number; + requestedTick: number; +} + +interface LagRuntime { + frames: Array>; + queuedActions: Array>; +} + +/** + * Adds one private authority-history ring to a game and resolves only classified + * actions against it. The live world is never rewound or exposed to clients. + */ +export function withLagCompensation< + AuthorityState extends object, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + HistoricalState, + Action extends LagCompensatedAction, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + definition: LagCompensationDefinition< + AuthorityState, + Input, + AuthorityEvent, + HistoricalState, + Action + >, +): DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent +> { + const historyTicks = normalizeHistoryTicks( + game.tickRateHz, + definition.historySeconds, + ); + validatePolicies(definition.actions); + const runtimes = new WeakMap< + AuthorityState, + LagRuntime + >(); + + const ensureRuntime = ( + state: AuthorityState, + ): LagRuntime => { + const existing = runtimes.get(state); + if (existing) return existing; + const created = { frames: [], queuedActions: [] }; + runtimes.set(state, created); + return created; + }; + + const capture = (state: AuthorityState, context: TickContext): void => { + const runtime = ensureRuntime(state); + const frame = { + tick: context.tick, + state: definition.cloneHistoricalState( + definition.captureState(state, context), + ), + }; + if (runtime.frames.at(-1)?.tick === context.tick) { + runtime.frames[runtime.frames.length - 1] = frame; + } else { + runtime.frames.push(frame); + } + const earliestTick = context.tick - historyTicks; + while (runtime.frames[0] && runtime.frames[0].tick < earliestTick) { + runtime.frames.shift(); + } + }; + + return defineNetworkedGame({ + ...game, + server: { + ...game.server, + createInitialState() { + const state = game.server.createInitialState(); + ensureRuntime(state); + return state; + }, + initializeState(state, context) { + game.server.initializeState?.(state, context); + capture(state, context); + }, + cloneState(state) { + const cloned = game.server.cloneState(state); + const source = ensureRuntime(state); + runtimes.set(cloned, { + frames: source.frames.map((frame) => ({ + tick: frame.tick, + state: definition.cloneHistoricalState(frame.state), + })), + queuedActions: source.queuedActions.map((queued) => ({ + ...queued, + action: definition.cloneAction(queued.action), + })), + }); + return cloned; + }, + cloneStateForHistory(state) { + return game.server.cloneStateForHistory?.(state) ?? + game.server.cloneState(state); + }, + addPlayer(state, context) { + game.server.addPlayer?.(state, context); + capture(state, { + tick: context.tick, + deltaSeconds: 1 / game.tickRateHz, + }); + }, + removePlayer(state, context) { + game.server.removePlayer?.(state, context); + capture(state, { + tick: context.tick, + deltaSeconds: 1 / game.tickRateHz, + }); + }, + applyInput(state, input, context) { + game.server.applyInput(state, input, context); + const action = definition.classifyAction(input, context); + if (!action) return; + const policy = definition.actions[action.type as Action["type"]] as + | LagCompensationActionPolicy< + AuthorityState, + HistoricalState, + Action, + AuthorityEvent + > + | undefined; + if (!policy) return; + ensureRuntime(state).queuedActions.push({ + action: definition.cloneAction(action), + playerId: context.playerId, + sequence: context.sequence, + requestedTick: context.observedTick, + }); + }, + step(state, context) { + game.server.step(state, context); + const runtime = ensureRuntime(state); + const queuedActions = runtime.queuedActions; + runtime.queuedActions = []; + + for (const queued of queuedActions) { + resolveQueuedAction( + state, + context, + runtime.frames, + queued, + game.tickRateHz, + historyTicks, + definition, + ); + } + capture(state, context); + }, + }, + }); +} + +function resolveQueuedAction< + AuthorityState, + Input, + AuthorityEvent, + HistoricalState, + Action extends LagCompensatedAction, +>( + state: AuthorityState, + tickContext: TickContext & { emit(event: AuthorityEvent): void }, + frames: readonly HistoricalFrame[], + queued: QueuedAction, + tickRateHz: number, + historyTicks: number, + definition: LagCompensationDefinition< + AuthorityState, + Input, + AuthorityEvent, + HistoricalState, + Action + >, +): void { + const policy = definition.actions[queued.action.type as Action["type"]] as + | LagCompensationActionPolicy< + AuthorityState, + HistoricalState, + Action, + AuthorityEvent + > + | undefined; + if (!policy) return; + + const maximumRewindTicks = Math.min( + historyTicks, + millisecondsToTicks( + policy.maximumRewindMs ?? (historyTicks / tickRateHz) * 1_000, + tickRateHz, + ), + ); + const maximumFutureTicks = millisecondsToTicks( + policy.maximumFutureMs ?? 0, + tickRateHz, + ); + const minimumTick = Math.max(0, tickContext.tick - maximumRewindTicks); + const maximumTick = tickContext.tick + maximumFutureTicks; + const outsideWindow = + queued.requestedTick < minimumTick || queued.requestedTick > maximumTick; + if (outsideWindow && policy.outOfWindow === "reject") return; + + const boundedRequestedTick = clamp( + queued.requestedTick, + minimumTick, + tickContext.tick, + ); + let resolvedTick = tickContext.tick; + let historicalState: HistoricalState; + + if (policy.mode === "current") { + historicalState = definition.cloneHistoricalState( + definition.captureState(state, tickContext), + ); + } else { + const frame = frameAtOrBefore(frames, boundedRequestedTick); + if (!frame) return; + resolvedTick = frame.tick; + historicalState = definition.cloneHistoricalState(frame.state); + } + + const resolutionContext: LagCompensationResolutionContext< + AuthorityState, + HistoricalState, + Action, + AuthorityEvent + > = { + currentState: state, + historicalState, + action: queued.action, + playerId: queued.playerId, + sequence: queued.sequence, + requestedTick: queued.requestedTick, + resolvedTick, + currentTick: tickContext.tick, + rewindTicks: Math.max(0, tickContext.tick - resolvedTick), + catchUpTicks: + policy.mode === "fast-forward" + ? Math.max(0, tickContext.tick - resolvedTick) + : 0, + clamped: resolvedTick !== queued.requestedTick, + emit: tickContext.emit, + }; + if (policy.validate && !policy.validate(resolutionContext)) return; + policy.resolve(resolutionContext); +} + +function frameAtOrBefore( + frames: readonly HistoricalFrame[], + tick: number, +): HistoricalFrame | undefined { + let low = 0; + let high = frames.length - 1; + let selected: HistoricalFrame | undefined; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const frame = frames[middle]!; + if (frame.tick <= tick) { + selected = frame; + low = middle + 1; + } else { + high = middle - 1; + } + } + return selected ?? frames[0]; +} + +function normalizeHistoryTicks(tickRateHz: number, seconds: number): number { + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new RangeError("historySeconds must be positive"); + } + return Math.max(1, Math.ceil(seconds * tickRateHz)); +} + +function validatePolicies( + policies: Record, +): void { + for (const [kind, policy] of Object.entries(policies)) { + if (!["current", "rewind", "fast-forward"].includes(policy.mode)) { + throw new RangeError(`Unknown lag compensation mode for ${kind}`); + } + for (const [name, value] of [ + ["maximumRewindMs", policy.maximumRewindMs], + ["maximumFutureMs", policy.maximumFutureMs], + ] as const) { + if (value !== undefined && (!Number.isFinite(value) || value < 0)) { + throw new RangeError(`${name} for ${kind} must be non-negative`); + } + } + } +} + +function millisecondsToTicks(milliseconds: number, tickRateHz: number): number { + return Math.max(0, Math.floor((milliseconds / 1_000) * tickRateHz)); +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/packages/engine/src/network-clock.ts b/packages/engine/src/network-clock.ts new file mode 100644 index 0000000..c8db19c --- /dev/null +++ b/packages/engine/src/network-clock.ts @@ -0,0 +1,92 @@ +import type { PingPacket, PongPacket } from "./types.js"; + +export interface NetworkStats { + roundTripTime: number; + jitter: number; + clockOffset: number; + samples: number; +} + +interface ClockSample { + roundTripTime: number; + offset: number; +} + +export class NetworkClock { + private nextPingId = 1; + private readonly recentSamples: ClockSample[] = []; + private smoothedRtt = 0; + private smoothedJitter = 0; + private offset = 0; + + createPing(clientNow: number): PingPacket { + const ping = { + id: this.nextPingId, + clientSentAt: clientNow, + }; + + this.nextPingId = (this.nextPingId + 1) >>> 0; + return ping; + } + + receivePong(pong: PongPacket, clientReceivedAt: number): NetworkStats { + const serverProcessing = Math.max( + 0, + pong.serverSentAt - pong.serverReceivedAt, + ); + const roundTripTime = Math.max( + 0, + clientReceivedAt - pong.clientSentAt - serverProcessing, + ); + const offset = + ((pong.serverReceivedAt - pong.clientSentAt) + + (pong.serverSentAt - clientReceivedAt)) / + 2; + + const previousRtt = this.smoothedRtt; + this.smoothedRtt = + this.recentSamples.length === 0 + ? roundTripTime + : previousRtt * 0.8 + roundTripTime * 0.2; + this.smoothedJitter = + this.recentSamples.length === 0 + ? 0 + : this.smoothedJitter * 0.8 + + Math.abs(roundTripTime - previousRtt) * 0.2; + + this.recentSamples.push({ roundTripTime, offset }); + if (this.recentSamples.length > 10) { + this.recentSamples.shift(); + } + + const bestSample = this.recentSamples.reduce((best, sample) => + sample.roundTripTime < best.roundTripTime ? sample : best, + ); + this.offset = bestSample.offset; + + return this.stats; + } + + toServerTime(clientNow: number): number { + return clientNow + this.offset; + } + + recommendedInputLeadTicks(tickRateHz: number, maximum = 8): number { + const tickMilliseconds = 1_000 / tickRateHz; + const latencyBudget = this.smoothedRtt / 2 + this.smoothedJitter * 2; + + return Math.max( + 1, + Math.min(maximum, Math.ceil(latencyBudget / tickMilliseconds) + 1), + ); + } + + get stats(): NetworkStats { + return { + roundTripTime: this.smoothedRtt, + jitter: this.smoothedJitter, + clockOffset: this.offset, + samples: this.recentSamples.length, + }; + } +} diff --git a/packages/engine/src/networked-client.ts b/packages/engine/src/networked-client.ts new file mode 100644 index 0000000..9736232 --- /dev/null +++ b/packages/engine/src/networked-client.ts @@ -0,0 +1,214 @@ +import { NetworkClock } from "./network-clock.js"; +import type { + ClientStateReport, + InputContext, + InputPacket, + PlayerId, + StateSnapshot, +} from "./types.js"; +import type { NetworkedGameDefinition } from "./networked-types.js"; + +interface PredictedInput { + packet: InputPacket; + applied: boolean; +} + +export class NetworkedPredictedEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, +> { + readonly game: NetworkedGameDefinition< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >; + readonly networkClock = new NetworkClock(); + private state: ClientState; + private playerId: PlayerId | null = null; + private currentTick = 0; + private nextInputSequence = 1; + private acknowledgedSequence = 0; + private pendingInputs: PredictedInput[] = []; + + constructor( + game: NetworkedGameDefinition< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + ) { + this.game = game; + this.state = game.client.createInitialState(); + } + + get tick(): number { + return this.currentTick; + } + + get localPlayerId(): PlayerId | null { + return this.playerId; + } + + get currentState(): Readonly { + return this.state; + } + + get initialized(): boolean { + return this.playerId !== null; + } + + initialize(playerId: PlayerId, snapshot: StateSnapshot): void { + this.playerId = playerId; + this.currentTick = snapshot.tick; + this.state = this.game.client.cloneState(snapshot.state); + this.pendingInputs = []; + this.acknowledgedSequence = 0; + } + + createInput(input: Input, leadTicks?: number): InputPacket { + if (this.playerId === null) { + throw new Error("Client engine has not received a welcome snapshot"); + } + + const targetTick = + this.currentTick + + (leadTicks ?? + this.networkClock.recommendedInputLeadTicks(this.game.tickRateHz)); + const packet = { + sequence: this.nextInputSequence, + targetTick, + observedTick: this.currentTick, + input, + }; + + if (!this.game.validateInput(input, this.inputContext(packet))) { + throw new Error("Refusing to send invalid local input"); + } + + this.nextInputSequence = (this.nextInputSequence + 1) >>> 0; + this.pendingInputs.push({ packet, applied: false }); + return packet; + } + + acknowledge(sequence: number): void { + this.acknowledgedSequence = Math.max(this.acknowledgedSequence, sequence); + this.pendingInputs = this.pendingInputs.filter( + (pending) => pending.packet.sequence > this.acknowledgedSequence, + ); + } + + reject(sequence: number): void { + this.pendingInputs = this.pendingInputs.filter( + (pending) => pending.packet.sequence !== sequence, + ); + } + + step(): void { + if (this.playerId === null) { + return; + } + + this.currentTick += 1; + this.applyDueInputs(); + this.game.client.step(this.state, { + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }); + this.assertValidState(); + } + + reconcile(snapshot: StateSnapshot): void { + if ( + this.playerId === null || + snapshot.tick < this.currentTick - this.game.tickRateHz * 2 + ) { + return; + } + + const predictedTick = Math.max(this.currentTick, snapshot.tick); + const context = { + tick: snapshot.tick, + deltaSeconds: 1 / this.game.tickRateHz, + }; + this.state = this.game.client.mergeSnapshot + ? this.game.client.mergeSnapshot(this.state, snapshot.state, context) + : this.game.client.cloneState(snapshot.state); + this.currentTick = snapshot.tick; + + for (const pending of this.pendingInputs) { + pending.applied = false; + } + + this.applyDueInputs(); + while (this.currentTick < predictedTick) { + this.currentTick += 1; + this.applyDueInputs(); + this.game.client.step(this.state, { + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }); + } + this.assertValidState(); + } + + receiveEvent(event: PerceptionEvent, tick: number): void { + this.game.client.applyEvent?.(this.state, event, { tick }); + this.assertValidState(); + } + + createStateReport(): ClientStateReport { + return { + tick: this.currentTick, + state: this.game.client.cloneState(this.state), + }; + } + + private applyDueInputs(): void { + for (const pending of this.pendingInputs) { + if (!pending.applied && pending.packet.targetTick <= this.currentTick) { + this.game.client.applyInput( + this.state, + pending.packet.input, + this.inputContext(pending.packet), + ); + pending.applied = true; + } + } + } + + private inputContext(packet: InputPacket): InputContext { + if (this.playerId === null) { + throw new Error("Client engine has not been initialized"); + } + + return { + playerId: this.playerId, + sequence: packet.sequence, + targetTick: packet.targetTick, + observedTick: packet.observedTick ?? packet.targetTick, + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }; + } + + private assertValidState(): void { + if ( + this.game.client.validateState && + !this.game.client.validateState(this.state, { + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }) + ) { + throw new Error( + `Client prediction produced invalid state at tick ${this.currentTick}`, + ); + } + } +} diff --git a/packages/engine/src/networked-server.ts b/packages/engine/src/networked-server.ts new file mode 100644 index 0000000..bea577c --- /dev/null +++ b/packages/engine/src/networked-server.ts @@ -0,0 +1,472 @@ +import type { + ClientStateReport, + InputContext, + InputPacket, + PlayerId, + StateSnapshot, + ValidationResult, +} from "./types.js"; +import type { + NetworkedGameDefinition, + SnapshotBatch, +} from "./networked-types.js"; +import type { + Acknowledgement, + InputDecision, + ServerEngineOptions, +} from "./server.js"; + +export interface NetworkedServerStepResult { + tick: number; + acknowledgements: Acknowledgement[]; + validations: ValidationResult[]; + events: AuthorityEvent[]; + snapshotDue: boolean; +} + +export interface NetworkedSimulationCheckpoint< + AuthorityState, + Input, + AuthorityEvent, +> { + tick: number; + state: AuthorityState; + playerIds: PlayerId[]; + lastReceivedSequences: Array; + acknowledgedSequences: Array; + queuedInputs: Array<{ + playerId: PlayerId; + packet: InputPacket; + }>; + pendingEvents: AuthorityEvent[]; +} + +interface QueuedInput { + playerId: PlayerId; + packet: InputPacket; +} + +interface QueuedReport { + playerId: PlayerId; + report: ClientStateReport; +} + +export class NetworkedAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, +> { + readonly game: NetworkedGameDefinition< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >; + private readonly historyTicks: number; + private readonly maxPastTicks: number; + private readonly maxFutureTicks: number; + private readonly snapshotEveryTicks: number; + private readonly players = new Set(); + private readonly lastReceivedSequence = new Map(); + private readonly acknowledgedSequence = new Map(); + private readonly history = new Map(); + private queuedInputs: QueuedInput[] = []; + private queuedReports: QueuedReport[] = []; + private pendingEvents: AuthorityEvent[] = []; + private state: AuthorityState; + private currentTick = 0; + + constructor( + game: NetworkedGameDefinition< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + options: ServerEngineOptions = {}, + ) { + this.game = game; + this.historyTicks = options.historyTicks ?? game.tickRateHz * 2; + this.maxPastTicks = options.maxPastTicks ?? 2; + this.maxFutureTicks = options.maxFutureTicks ?? game.tickRateHz; + this.snapshotEveryTicks = game.tickRateHz / game.snapshotRateHz; + this.state = game.server.createInitialState(); + game.server.initializeState?.(this.state, { + tick: 0, + deltaSeconds: 1 / game.tickRateHz, + }); + this.assertValidState(); + this.storeHistory(); + } + + get tick(): number { + return this.currentTick; + } + + get currentState(): Readonly { + return this.state; + } + + get playerIds(): PlayerId[] { + return [...this.players]; + } + + addPlayer(playerId: PlayerId): void { + if (this.players.has(playerId)) { + return; + } + + this.players.add(playerId); + this.game.server.addPlayer?.(this.state, { + playerId, + tick: this.currentTick, + emit: (event) => this.pendingEvents.push(event), + }); + this.assertValidState(); + this.storeHistory(); + } + + removePlayer(playerId: PlayerId): void { + if (!this.players.delete(playerId)) { + return; + } + + this.game.server.removePlayer?.(this.state, { + playerId, + tick: this.currentTick, + emit: (event) => this.pendingEvents.push(event), + }); + this.lastReceivedSequence.delete(playerId); + this.acknowledgedSequence.delete(playerId); + this.queuedInputs = this.queuedInputs.filter( + (queued) => queued.playerId !== playerId, + ); + this.queuedReports = this.queuedReports.filter( + (queued) => queued.playerId !== playerId, + ); + this.assertValidState(); + this.storeHistory(); + } + + submitInput(playerId: PlayerId, packet: InputPacket): InputDecision { + if (!this.players.has(playerId)) { + return { accepted: false, reason: "unknown-player" }; + } + + const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0; + if (packet.sequence <= previousSequence) { + return { accepted: false, reason: "duplicate" }; + } + if (packet.targetTick < this.currentTick - this.maxPastTicks) { + return { accepted: false, reason: "past" }; + } + if (packet.targetTick > this.currentTick + this.maxFutureTicks) { + return { accepted: false, reason: "future" }; + } + + const context = this.inputContext(playerId, packet); + if (!this.game.validateInput(packet.input, context)) { + return { accepted: false, reason: "invalid" }; + } + + this.lastReceivedSequence.set(playerId, packet.sequence); + this.queuedInputs.push({ playerId, packet }); + return { accepted: true }; + } + + submitStateReport( + playerId: PlayerId, + report: ClientStateReport, + ): ValidationResult | null { + if (report.tick > this.currentTick + this.maxFutureTicks) { + return { + playerId, + tick: report.tick, + valid: false, + reason: "too-far-ahead", + }; + } + + if (report.tick > this.currentTick) { + this.queuedReports = this.queuedReports.filter( + (queued) => queued.playerId !== playerId, + ); + this.queuedReports.push({ playerId, report }); + return null; + } + + return this.validateReport(playerId, report); + } + + step(): NetworkedServerStepResult { + this.currentTick += 1; + const deltaSeconds = 1 / this.game.tickRateHz; + const events = this.pendingEvents; + this.pendingEvents = []; + const emit = (event: AuthorityEvent) => events.push(event); + const acknowledgementByPlayer = new Map(); + const dueInputs: QueuedInput[] = []; + const futureInputs: QueuedInput[] = []; + + for (const queued of this.queuedInputs) { + (queued.packet.targetTick <= this.currentTick + ? dueInputs + : futureInputs + ).push(queued); + } + this.queuedInputs = futureInputs; + + dueInputs.sort( + (left, right) => + left.packet.targetTick - right.packet.targetTick || + left.playerId - right.playerId || + left.packet.sequence - right.packet.sequence, + ); + + for (const queued of dueInputs) { + const { playerId, packet } = queued; + this.game.server.applyInput(this.state, packet.input, { + ...this.inputContext(playerId, packet), + emit, + }); + + const previousAck = this.acknowledgedSequence.get(playerId) ?? 0; + if (packet.sequence > previousAck) { + this.acknowledgedSequence.set(playerId, packet.sequence); + acknowledgementByPlayer.set(playerId, packet.sequence); + } + } + + this.game.server.step(this.state, { + tick: this.currentTick, + deltaSeconds, + emit, + }); + this.assertValidState(); + this.storeHistory(); + + const validations: ValidationResult[] = []; + const futureReports: QueuedReport[] = []; + for (const queued of this.queuedReports) { + if (queued.report.tick <= this.currentTick) { + validations.push( + this.validateReport(queued.playerId, queued.report), + ); + } else { + futureReports.push(queued); + } + } + this.queuedReports = futureReports; + + return { + tick: this.currentTick, + acknowledgements: [...acknowledgementByPlayer].map( + ([playerId, sequence]) => ({ playerId, sequence }), + ), + validations, + events, + snapshotDue: this.currentTick % this.snapshotEveryTicks === 0, + }; + } + + createSnapshot( + playerId: PlayerId, + serverTime: number, + ): StateSnapshot { + return { + tick: this.currentTick, + serverTime, + state: this.game.replication.createSnapshot(this.state, { + playerId, + tick: this.currentTick, + }), + }; + } + + createSnapshotBatches(serverTime: number): SnapshotBatch[] { + const groups = new Map(); + for (const playerId of this.players) { + const context = { playerId, tick: this.currentTick }; + const key = + this.game.replication.groupKey?.(this.state, context) ?? playerId; + const group = groups.get(key); + if (group) { + group.push(playerId); + } else { + groups.set(key, [playerId]); + } + } + + return [...groups.values()].map((playerIds) => ({ + playerIds, + state: this.createSnapshot(playerIds[0]!, serverTime).state, + })); + } + + createPerceptions( + playerId: PlayerId, + events: readonly AuthorityEvent[], + ): PerceptionEvent[] { + if (!this.game.replication.perceive) { + return []; + } + + const perceptions: PerceptionEvent[] = []; + for (const event of events) { + const perception = this.game.replication.perceive( + this.state, + event, + { playerId, tick: this.currentTick }, + ); + if (perception !== null) { + perceptions.push(perception); + } + } + return perceptions; + } + + createSimulationCheckpoint(): NetworkedSimulationCheckpoint< + AuthorityState, + Input, + AuthorityEvent + > { + return { + tick: this.currentTick, + state: this.game.server.cloneState(this.state), + playerIds: [...this.players], + lastReceivedSequences: [...this.lastReceivedSequence], + acknowledgedSequences: [...this.acknowledgedSequence], + queuedInputs: this.queuedInputs.map(({ playerId, packet }) => ({ + playerId, + packet: this.cloneInputPacket(packet), + })), + pendingEvents: [...this.pendingEvents], + }; + } + + restoreSimulationCheckpoint( + checkpoint: NetworkedSimulationCheckpoint< + AuthorityState, + Input, + AuthorityEvent + >, + ): void { + if (!Number.isInteger(checkpoint.tick) || checkpoint.tick < 0) { + throw new RangeError("checkpoint tick must be a non-negative integer"); + } + + this.currentTick = checkpoint.tick; + this.state = this.game.server.cloneState(checkpoint.state); + + this.players.clear(); + for (const playerId of checkpoint.playerIds) this.players.add(playerId); + + this.lastReceivedSequence.clear(); + for (const [playerId, sequence] of checkpoint.lastReceivedSequences) { + this.lastReceivedSequence.set(playerId, sequence); + } + + this.acknowledgedSequence.clear(); + for (const [playerId, sequence] of checkpoint.acknowledgedSequences) { + this.acknowledgedSequence.set(playerId, sequence); + } + + this.queuedInputs = checkpoint.queuedInputs.map(({ playerId, packet }) => ({ + playerId, + packet: this.cloneInputPacket(packet), + })); + this.queuedReports = []; + this.pendingEvents = [...checkpoint.pendingEvents]; + this.history.clear(); + this.assertValidState(); + this.storeHistory(); + } + + private inputContext( + playerId: PlayerId, + packet: InputPacket, + ): InputContext { + return { + playerId, + sequence: packet.sequence, + targetTick: packet.targetTick, + observedTick: packet.observedTick ?? packet.targetTick, + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }; + } + + private cloneInputPacket(packet: InputPacket): InputPacket { + const encoded = this.game.codecs.input.encode(packet.input); + return { + sequence: packet.sequence, + targetTick: packet.targetTick, + observedTick: packet.observedTick ?? packet.targetTick, + input: this.game.codecs.input.decode(encoded), + }; + } + + private validateReport( + playerId: PlayerId, + report: ClientStateReport, + ): ValidationResult { + const authoritative = this.history.get(report.tick); + if (!authoritative) { + return { + playerId, + tick: report.tick, + valid: false, + reason: "outside-history", + }; + } + + const valid = this.game.replication.validateClientState + ? this.game.replication.validateClientState( + authoritative, + report.state, + { playerId, tick: report.tick }, + ) + : (this.game.client.validateState?.(report.state, { + tick: report.tick, + deltaSeconds: 1 / this.game.tickRateHz, + }) ?? true); + + return { + playerId, + tick: report.tick, + valid, + ...(valid ? {} : { reason: "mismatch" as const }), + }; + } + + private assertValidState(): void { + if ( + this.game.server.validateState && + !this.game.server.validateState(this.state, { + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }) + ) { + throw new Error(`Game produced invalid state at tick ${this.currentTick}`); + } + } + + private storeHistory(): void { + this.history.set( + this.currentTick, + this.game.server.cloneStateForHistory?.(this.state) ?? + this.game.server.cloneState(this.state), + ); + + const oldestTick = this.currentTick - this.historyTicks; + for (const tick of this.history.keys()) { + if (tick < oldestTick) { + this.history.delete(tick); + } + } + } +} diff --git a/packages/engine/src/networked-types.ts b/packages/engine/src/networked-types.ts new file mode 100644 index 0000000..c1176f2 --- /dev/null +++ b/packages/engine/src/networked-types.ts @@ -0,0 +1,125 @@ +import type { + BinaryCodec, + ClientStateContext, + InputContext, + PlayerContext, + PlayerId, + TickContext, +} from "./types.js"; + +export interface EmittingTickContext extends TickContext { + emit(event: Event): void; +} + +export interface EmittingInputContext extends InputContext { + emit(event: Event): void; +} + +export interface EmittingPlayerContext extends PlayerContext { + emit(event: Event): void; +} + +export interface ClientEventContext { + tick: number; +} + +export interface ServerSimulationRules { + createInitialState(): AuthorityState; + /** Runs after all higher-order initializers have finished creating state. */ + initializeState?(state: AuthorityState, context: TickContext): void; + cloneState(state: AuthorityState): AuthorityState; + /** Optional cheaper clone used only for client-state validation history. */ + cloneStateForHistory?(state: AuthorityState): AuthorityState; + step( + state: AuthorityState, + context: EmittingTickContext, + ): void; + applyInput( + state: AuthorityState, + input: Input, + context: EmittingInputContext, + ): void; + validateState?(state: AuthorityState, context: TickContext): boolean; + addPlayer?( + state: AuthorityState, + context: EmittingPlayerContext, + ): void; + removePlayer?( + state: AuthorityState, + context: EmittingPlayerContext, + ): void; +} + +export interface ClientSimulationRules { + createInitialState(): ClientState; + cloneState(state: ClientState): ClientState; + step(state: ClientState, context: TickContext): void; + applyInput(state: ClientState, input: Input, context: InputContext): void; + validateState?(state: ClientState, context: TickContext): boolean; + mergeSnapshot?( + predicted: ClientState, + snapshot: ClientState, + context: TickContext, + ): ClientState; + applyEvent?( + state: ClientState, + event: PerceptionEvent, + context: ClientEventContext, + ): void; +} + +export interface ReplicationRules< + AuthorityState, + ClientState, + AuthorityEvent, + PerceptionEvent, +> { + createSnapshot( + authoritative: AuthorityState, + context: PlayerContext, + ): ClientState; + validateClientState?( + authoritative: AuthorityState, + candidate: ClientState, + context: ClientStateContext, + ): boolean; + perceive?( + authoritative: AuthorityState, + event: AuthorityEvent, + context: PlayerContext, + ): PerceptionEvent | null; + groupKey?( + authoritative: AuthorityState, + context: PlayerContext, + ): string | number; +} + +export interface NetworkedGameDefinition< + AuthorityState, + ClientState, + Input, + AuthorityEvent = never, + PerceptionEvent = never, +> { + tickRateHz: number; + snapshotRateHz: number; + server: ServerSimulationRules; + client: ClientSimulationRules; + replication: ReplicationRules< + AuthorityState, + ClientState, + AuthorityEvent, + PerceptionEvent + >; + validateInput(input: Input, context: InputContext): boolean; + codecs: { + input: BinaryCodec; + state: BinaryCodec; + event?: BinaryCodec; + }; +} + +export interface SnapshotBatch { + playerIds: PlayerId[]; + state: ClientState; +} diff --git a/packages/engine/src/protocol.ts b/packages/engine/src/protocol.ts new file mode 100644 index 0000000..ba10eef --- /dev/null +++ b/packages/engine/src/protocol.ts @@ -0,0 +1,401 @@ +import type { + BinaryCodec, + ClientStateReport, + InputPacket, + PingPacket, + PlayerId, + PongPacket, + StateSnapshot, +} from "./types.js"; + +const enum ServerOpcode { + Welcome = 1, + Snapshot = 2, + Acknowledge = 3, + Pong = 4, + Validation = 5, + RejectInput = 6, + Event = 7, + ReplayStart = 8, + ReplayFrame = 9, + ReplayEnd = 10, +} + +const enum ClientOpcode { + Input = 16, + Ping = 17, + StateReport = 18, +} + +export type ClientWireMessage = + | { kind: "input"; packet: InputPacket } + | { kind: "ping"; ping: PingPacket } + | { kind: "state-report"; report: ClientStateReport }; + +export type ServerWireMessage = + | { kind: "welcome"; playerId: PlayerId; snapshot: StateSnapshot } + | { kind: "snapshot"; snapshot: StateSnapshot } + | { kind: "acknowledge"; sequence: number } + | { kind: "pong"; pong: PongPacket } + | { kind: "validation"; tick: number; valid: boolean } + | { kind: "reject-input"; sequence: number } + | { kind: "event"; tick: number; event: Event } + | { + kind: "replay-start"; + ticketId: number; + perspectiveId: PlayerId; + fromTick: number; + toTick: number; + frameCount: number; + playbackRate: number; + } + | { + kind: "replay-frame"; + ticketId: number; + tick: number; + state: State; + events: Event[]; + } + | { kind: "replay-end"; ticketId: number }; + +export interface BinaryProtocol { + encodeClient(message: ClientWireMessage): ArrayBuffer; + decodeClient(payload: ArrayBuffer): ClientWireMessage; + encodeServer(message: ServerWireMessage): ArrayBuffer; + decodeServer(payload: ArrayBuffer): ServerWireMessage; +} + +export function createBinaryProtocol(codecs: { + input: BinaryCodec; + state: BinaryCodec; + event?: BinaryCodec; +}): BinaryProtocol { + return { + encodeClient(message) { + switch (message.kind) { + case "input": { + const input = codecs.input.encode(message.packet.input); + const payload = createFrame(ClientOpcode.Input, 13, input); + const view = new DataView(payload); + view.setUint32(1, message.packet.sequence, true); + view.setUint32(5, message.packet.targetTick, true); + view.setUint32( + 9, + message.packet.observedTick ?? message.packet.targetTick, + true, + ); + return payload; + } + case "ping": { + const payload = createFrame(ClientOpcode.Ping, 13); + const view = new DataView(payload); + view.setUint32(1, message.ping.id, true); + view.setFloat64(5, message.ping.clientSentAt, true); + return payload; + } + case "state-report": { + const state = codecs.state.encode(message.report.state); + const payload = createFrame(ClientOpcode.StateReport, 5, state); + new DataView(payload).setUint32(1, message.report.tick, true); + return payload; + } + } + }, + decodeClient(payload) { + const view = new DataView(payload); + switch (view.getUint8(0)) { + case ClientOpcode.Input: + assertLength(payload, 13); + return { + kind: "input", + packet: { + sequence: view.getUint32(1, true), + targetTick: view.getUint32(5, true), + observedTick: view.getUint32(9, true), + input: codecs.input.decode(new Uint8Array(payload, 13)), + }, + }; + case ClientOpcode.Ping: + assertExactLength(payload, 13); + return { + kind: "ping", + ping: { + id: view.getUint32(1, true), + clientSentAt: view.getFloat64(5, true), + }, + }; + case ClientOpcode.StateReport: + assertLength(payload, 5); + return { + kind: "state-report", + report: { + tick: view.getUint32(1, true), + state: codecs.state.decode(new Uint8Array(payload, 5)), + }, + }; + default: + throw new RangeError("Unknown client message opcode"); + } + }, + encodeServer(message) { + switch (message.kind) { + case "welcome": { + const state = codecs.state.encode(message.snapshot.state); + const payload = createFrame(ServerOpcode.Welcome, 17, state); + const view = new DataView(payload); + view.setUint32(1, message.playerId, true); + view.setUint32(5, message.snapshot.tick, true); + view.setFloat64(9, message.snapshot.serverTime, true); + return payload; + } + case "snapshot": { + const state = codecs.state.encode(message.snapshot.state); + const payload = createFrame(ServerOpcode.Snapshot, 13, state); + const view = new DataView(payload); + view.setUint32(1, message.snapshot.tick, true); + view.setFloat64(5, message.snapshot.serverTime, true); + return payload; + } + case "acknowledge": { + const payload = createFrame(ServerOpcode.Acknowledge, 5); + new DataView(payload).setUint32(1, message.sequence, true); + return payload; + } + case "pong": { + const payload = createFrame(ServerOpcode.Pong, 29); + const view = new DataView(payload); + view.setUint32(1, message.pong.id, true); + view.setFloat64(5, message.pong.clientSentAt, true); + view.setFloat64(13, message.pong.serverReceivedAt, true); + view.setFloat64(21, message.pong.serverSentAt, true); + return payload; + } + case "validation": { + const payload = createFrame(ServerOpcode.Validation, 6); + const view = new DataView(payload); + view.setUint32(1, message.tick, true); + view.setUint8(5, message.valid ? 1 : 0); + return payload; + } + case "reject-input": { + const payload = createFrame(ServerOpcode.RejectInput, 5); + new DataView(payload).setUint32(1, message.sequence, true); + return payload; + } + case "event": { + if (!codecs.event) { + throw new Error("This protocol has no event codec"); + } + const event = codecs.event.encode(message.event); + const payload = createFrame(ServerOpcode.Event, 5, event); + new DataView(payload).setUint32(1, message.tick, true); + return payload; + } + case "replay-start": { + if (message.frameCount > 65_535) { + throw new RangeError("Replay frame count exceeds protocol limit"); + } + const payload = createFrame(ServerOpcode.ReplayStart, 23); + const view = new DataView(payload); + view.setUint32(1, message.ticketId, true); + view.setUint32(5, message.perspectiveId, true); + view.setUint32(9, message.fromTick, true); + view.setUint32(13, message.toTick, true); + view.setUint16(17, message.frameCount, true); + view.setFloat32(19, message.playbackRate, true); + return payload; + } + case "replay-frame": { + if (message.events.length > 65_535) { + throw new RangeError("Replay event count exceeds protocol limit"); + } + if (message.events.length > 0 && !codecs.event) { + throw new Error("This protocol has no event codec"); + } + const state = codecs.state.encode(message.state); + const events = message.events.map((event) => codecs.event!.encode(event)); + const bodyLength = + state.byteLength + + events.reduce((total, event) => total + 4 + event.byteLength, 0); + const payload = createFrame(ServerOpcode.ReplayFrame, 15 + bodyLength); + const view = new DataView(payload); + view.setUint32(1, message.ticketId, true); + view.setUint32(5, message.tick, true); + view.setUint16(9, events.length, true); + view.setUint32(11, state.byteLength, true); + const bytes = new Uint8Array(payload); + let offset = 15; + bytes.set(state, offset); + offset += state.byteLength; + for (const event of events) { + view.setUint32(offset, event.byteLength, true); + offset += 4; + bytes.set(event, offset); + offset += event.byteLength; + } + return payload; + } + case "replay-end": { + const payload = createFrame(ServerOpcode.ReplayEnd, 5); + new DataView(payload).setUint32(1, message.ticketId, true); + return payload; + } + } + }, + decodeServer(payload) { + const view = new DataView(payload); + switch (view.getUint8(0)) { + case ServerOpcode.Welcome: + assertLength(payload, 17); + return { + kind: "welcome", + playerId: view.getUint32(1, true), + snapshot: { + tick: view.getUint32(5, true), + serverTime: view.getFloat64(9, true), + state: codecs.state.decode(new Uint8Array(payload, 17)), + }, + }; + case ServerOpcode.Snapshot: + assertLength(payload, 13); + return { + kind: "snapshot", + snapshot: { + tick: view.getUint32(1, true), + serverTime: view.getFloat64(5, true), + state: codecs.state.decode(new Uint8Array(payload, 13)), + }, + }; + case ServerOpcode.Acknowledge: + assertExactLength(payload, 5); + return { + kind: "acknowledge", + sequence: view.getUint32(1, true), + }; + case ServerOpcode.Pong: + assertExactLength(payload, 29); + return { + kind: "pong", + pong: { + id: view.getUint32(1, true), + clientSentAt: view.getFloat64(5, true), + serverReceivedAt: view.getFloat64(13, true), + serverSentAt: view.getFloat64(21, true), + }, + }; + case ServerOpcode.Validation: + assertExactLength(payload, 6); + return { + kind: "validation", + tick: view.getUint32(1, true), + valid: view.getUint8(5) === 1, + }; + case ServerOpcode.RejectInput: + assertExactLength(payload, 5); + return { + kind: "reject-input", + sequence: view.getUint32(1, true), + }; + case ServerOpcode.Event: + assertLength(payload, 5); + if (!codecs.event) { + throw new Error("This protocol has no event codec"); + } + return { + kind: "event", + tick: view.getUint32(1, true), + event: codecs.event.decode(new Uint8Array(payload, 5)), + }; + case ServerOpcode.ReplayStart: + assertExactLength(payload, 23); + return { + kind: "replay-start", + ticketId: view.getUint32(1, true), + perspectiveId: view.getUint32(5, true), + fromTick: view.getUint32(9, true), + toTick: view.getUint32(13, true), + frameCount: view.getUint16(17, true), + playbackRate: view.getFloat32(19, true), + }; + case ServerOpcode.ReplayFrame: { + assertLength(payload, 15); + const ticketId = view.getUint32(1, true); + const tick = view.getUint32(5, true); + const eventCount = view.getUint16(9, true); + const stateLength = view.getUint32(11, true); + assertAvailable(payload, 15, stateLength); + const state = codecs.state.decode( + new Uint8Array(payload, 15, stateLength), + ); + let offset = 15 + stateLength; + const events: Event[] = []; + for (let index = 0; index < eventCount; index += 1) { + if (!codecs.event) throw new Error("This protocol has no event codec"); + assertAvailable(payload, offset, 4); + const eventLength = view.getUint32(offset, true); + offset += 4; + assertAvailable(payload, offset, eventLength); + events.push( + codecs.event.decode(new Uint8Array(payload, offset, eventLength)), + ); + offset += eventLength; + } + if (offset !== payload.byteLength) { + throw new RangeError("Replay frame has trailing bytes"); + } + return { kind: "replay-frame", ticketId, tick, state, events }; + } + case ServerOpcode.ReplayEnd: + assertExactLength(payload, 5); + return { + kind: "replay-end", + ticketId: view.getUint32(1, true), + }; + default: + throw new RangeError("Unknown server message opcode"); + } + }, + }; +} + +function createFrame( + opcode: number, + headerLength: number, + body?: Uint8Array, +): ArrayBuffer { + const payload = new ArrayBuffer(headerLength + (body?.byteLength ?? 0)); + const bytes = new Uint8Array(payload); + bytes[0] = opcode; + if (body) { + bytes.set(body, headerLength); + } + return payload; +} + +function assertLength(payload: ArrayBuffer, minimum: number): void { + if (payload.byteLength < minimum) { + throw new RangeError(`Message must be at least ${minimum} bytes`); + } +} + +function assertExactLength(payload: ArrayBuffer, expected: number): void { + if (payload.byteLength !== expected) { + throw new RangeError(`Message must be exactly ${expected} bytes`); + } +} + +function assertAvailable( + payload: ArrayBuffer, + offset: number, + length: number, +): void { + if ( + !Number.isInteger(offset) || + !Number.isInteger(length) || + offset < 0 || + length < 0 || + offset > payload.byteLength - length + ) { + throw new RangeError("Message body is truncated"); + } +} diff --git a/packages/engine/src/replay-transport.ts b/packages/engine/src/replay-transport.ts new file mode 100644 index 0000000..bf177ca --- /dev/null +++ b/packages/engine/src/replay-transport.ts @@ -0,0 +1,482 @@ +import type { DefinedNetworkedGame } from "./define-networked-game.js"; +import type { NetworkedServerStepResult } from "./networked-server.js"; +import type { SnapshotBatch } from "./networked-types.js"; +import type { InputDecision } from "./server.js"; +import { + type ReplayRecording, + type ReplayStateHash, + type TimeTravelAuthoritativeEngine, + type TimeTravelNetworkedGame, + type TimeTravelServerOptions, +} from "./time-travel.js"; +import type { + ClientStateReport, + InputPacket, + PlayerId, + StateSnapshot, + ValidationResult, +} from "./types.js"; + +export interface ProjectedReplayFrame { + tick: number; + state: ClientState; + events: PerceptionEvent[]; +} + +export interface ReplayTicket { + ticketId: number; + requesterId: PlayerId; + perspectiveId: PlayerId; + issuedAtTick: number; + fromTick: number; + toTick: number; + playbackRate: number; + frames: Array>; +} + +export interface ReplayTicketPlan { + requesterId: PlayerId; + perspectiveId: PlayerId; + fromTick: number; + toTick: number; + playbackRate?: number; +} + +export interface ReplayTriggerContext { + tick: number; + authorityState: Readonly; + connectedPlayerIds: readonly PlayerId[]; +} + +export interface ReplayAuthorizationContext + extends ReplayTicketPlan { + currentTick: number; + authorityState: Readonly; + connectedPlayerIds: readonly PlayerId[]; +} + +export interface ReplayTransportDefinition { + /** Maximum age of the projected frame ring and private deterministic log. */ + historySeconds: number; + /** Baseline frame rate. Event ticks are captured even between baseline frames. */ + captureRateHz?: number; + /** Perspectives worth retaining. Defaults to connected network players. */ + listPerspectives?( + authorityState: Readonly, + connectedPlayerIds: readonly PlayerId[], + ): Iterable; + /** Trusted server hook that can create automatic tickets from authority events. */ + createTickets?( + event: Readonly, + context: ReplayTriggerContext, + ): ReplayTicketPlan | readonly ReplayTicketPlan[] | null; + /** Every automatic or manual ticket passes through this policy. */ + authorizeReplay?( + context: ReplayAuthorizationContext, + ): boolean; + maxPendingTicketsPerPlayer?: number; +} + +interface NormalizedReplayTransportDefinition + extends ReplayTransportDefinition { + historyTicks: number; + captureEveryTicks: number; + maxPendingTicketsPerPlayer: number; +} + +export type ReplayTransportNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +> = Omit< + TimeTravelNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash + >, + "createServer" +> & { + createServer( + options?: TimeTravelServerOptions, + ): ReplayTransportAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash + >; +}; + +/** + * Keeps client-safe historical projections around a private authoritative + * engine. Raw authority checkpoints never cross this boundary. + */ +export class ReplayTransportAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +> { + readonly game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >; + private readonly engine: TimeTravelAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash + >; + private readonly replay: NormalizedReplayTransportDefinition< + AuthorityState, + AuthorityEvent + >; + private readonly history = new Map< + PlayerId, + Array> + >(); + private readonly pendingTickets = new Map< + PlayerId, + Array> + >(); + private nextTicketId = 1; + + constructor( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + engine: TimeTravelAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash + >, + replay: NormalizedReplayTransportDefinition< + AuthorityState, + AuthorityEvent + >, + ) { + this.game = game; + this.engine = engine; + this.replay = replay; + } + + get tick(): number { + return this.engine.tick; + } + + get currentState(): Readonly { + return this.engine.currentState; + } + + get playerIds(): PlayerId[] { + return this.engine.playerIds; + } + + addPlayer(playerId: PlayerId): void { + this.engine.addPlayer(playerId); + this.capture([], true); + } + + removePlayer(playerId: PlayerId): void { + this.engine.removePlayer(playerId); + this.pendingTickets.delete(playerId); + this.capture([], true); + } + + submitInput(playerId: PlayerId, packet: InputPacket): InputDecision { + return this.engine.submitInput(playerId, packet); + } + + submitStateReport( + playerId: PlayerId, + report: ClientStateReport, + ): ValidationResult | null { + return this.engine.submitStateReport(playerId, report); + } + + step(): NetworkedServerStepResult { + const result = this.engine.step(); + const shouldCapture = + result.tick % this.replay.captureEveryTicks === 0 || + result.events.length > 0; + if (shouldCapture) this.capture(result.events, true); + this.pruneHistory(); + + if (this.replay.createTickets) { + const context: ReplayTriggerContext = { + tick: result.tick, + authorityState: this.currentState, + connectedPlayerIds: this.playerIds, + }; + for (const event of result.events) { + const plans = this.replay.createTickets(event, context); + if (!plans) continue; + for (const plan of Array.isArray(plans) ? plans : [plans]) { + this.issueReplay(plan); + } + } + } + + return result; + } + + createSnapshot( + playerId: PlayerId, + serverTime: number, + ): StateSnapshot { + return this.engine.createSnapshot(playerId, serverTime); + } + + createSnapshotBatches(serverTime: number): SnapshotBatch[] { + return this.engine.createSnapshotBatches(serverTime); + } + + createPerceptions( + playerId: PlayerId, + events: readonly AuthorityEvent[], + ): PerceptionEvent[] { + return this.engine.createPerceptions(playerId, events); + } + + exportRecording(): ReplayRecording< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash + > { + return this.engine.exportRecording(); + } + + /** Trusted-server API. The configured authorization policy still applies. */ + issueReplay( + plan: ReplayTicketPlan, + ): ReplayTicket | null { + if (!this.playerIds.includes(plan.requesterId)) return null; + if (!validTick(plan.fromTick) || !validTick(plan.toTick)) return null; + if (plan.toTick < plan.fromTick || plan.toTick > this.tick) return null; + + const authorization: ReplayAuthorizationContext = { + ...plan, + currentTick: this.tick, + authorityState: this.currentState, + connectedPlayerIds: this.playerIds, + }; + const authorized = this.replay.authorizeReplay + ? this.replay.authorizeReplay(authorization) + : plan.requesterId === plan.perspectiveId; + if (!authorized) return null; + + const perspectiveHistory = this.history.get(plan.perspectiveId); + if (!perspectiveHistory) return null; + const selected = perspectiveHistory.filter( + ({ tick }) => tick >= plan.fromTick && tick <= plan.toTick, + ); + if (selected.length === 0) return null; + + const first = selected[0]!; + const last = selected[selected.length - 1]!; + const ticket: ReplayTicket = { + ticketId: this.allocateTicketId(), + requesterId: plan.requesterId, + perspectiveId: plan.perspectiveId, + issuedAtTick: this.tick, + fromTick: first.tick, + toTick: last.tick, + playbackRate: clamp(plan.playbackRate ?? 1, 0.1, 4), + frames: selected.map((frame) => this.cloneFrame(frame)), + }; + + const queue = this.pendingTickets.get(plan.requesterId) ?? []; + queue.push(ticket); + while (queue.length > this.replay.maxPendingTicketsPerPlayer) queue.shift(); + this.pendingTickets.set(plan.requesterId, queue); + return ticket; + } + + drainReplayTickets( + playerId: PlayerId, + ): Array> { + const tickets = this.pendingTickets.get(playerId) ?? []; + this.pendingTickets.delete(playerId); + return tickets; + } + + private capture(events: readonly AuthorityEvent[], replaceSameTick: boolean): void { + const connected = this.playerIds; + const perspectives = this.replay.listPerspectives + ? this.replay.listPerspectives(this.currentState, connected) + : connected; + const uniquePerspectives = new Set(perspectives); + + for (const perspectiveId of uniquePerspectives) { + if (!validPlayerId(perspectiveId)) continue; + const snapshot = this.engine.createSnapshot(perspectiveId, 0); + const perceptions = this.engine.createPerceptions(perspectiveId, events); + const frame: ProjectedReplayFrame = { + tick: this.tick, + state: this.cloneState(snapshot.state), + events: perceptions.map((event) => this.cloneEvent(event)), + }; + const frames = this.history.get(perspectiveId) ?? []; + if (replaceSameTick && frames.at(-1)?.tick === this.tick) { + frames[frames.length - 1] = frame; + } else { + frames.push(frame); + } + this.history.set(perspectiveId, frames); + } + } + + private pruneHistory(): void { + const earliestTick = Math.max(0, this.tick - this.replay.historyTicks); + for (const [perspectiveId, frames] of this.history) { + const firstRetained = frames.findIndex(({ tick }) => tick >= earliestTick); + if (firstRetained === -1) { + this.history.delete(perspectiveId); + continue; + } + if (firstRetained > 0) frames.splice(0, firstRetained); + } + } + + private cloneFrame( + frame: ProjectedReplayFrame, + ): ProjectedReplayFrame { + return { + tick: frame.tick, + state: this.cloneState(frame.state), + events: frame.events.map((event) => this.cloneEvent(event)), + }; + } + + private cloneState(state: ClientState): ClientState { + return this.game.codecs.state.decode(this.game.codecs.state.encode(state)); + } + + private cloneEvent(event: PerceptionEvent): PerceptionEvent { + const codec = this.game.codecs.event; + if (!codec) { + throw new Error("Replay perceptions require an event codec"); + } + return codec.decode(codec.encode(event)); + } + + private allocateTicketId(): number { + const ticketId = this.nextTicketId; + this.nextTicketId = this.nextTicketId === 0xffff_ffff + ? 1 + : this.nextTicketId + 1; + return ticketId; + } +} + +export function withReplayTransport< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +>( + game: TimeTravelNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash + >, + definition: ReplayTransportDefinition, +): ReplayTransportNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash +> { + const replay = normalizeReplayDefinition(game, definition); + + return Object.freeze({ + ...game, + createServer(options: TimeTravelServerOptions = {}) { + const engine = game.createServer({ + ...options, + recordingHistoryTicks: + options.recordingHistoryTicks ?? replay.historyTicks, + }); + return new ReplayTransportAuthoritativeEngine(engine.game, engine, replay); + }, + }); +} + +function normalizeReplayDefinition( + game: { tickRateHz: number; snapshotRateHz: number }, + definition: ReplayTransportDefinition, +): NormalizedReplayTransportDefinition { + if (!Number.isFinite(definition.historySeconds) || definition.historySeconds <= 0) { + throw new RangeError("historySeconds must be positive"); + } + const captureRateHz = definition.captureRateHz ?? game.snapshotRateHz; + if ( + !Number.isInteger(captureRateHz) || + captureRateHz <= 0 || + game.tickRateHz % captureRateHz !== 0 + ) { + throw new RangeError("captureRateHz must divide tickRateHz evenly"); + } + const maxPendingTicketsPerPlayer = + definition.maxPendingTicketsPerPlayer ?? 2; + if (!Number.isInteger(maxPendingTicketsPerPlayer) || maxPendingTicketsPerPlayer <= 0) { + throw new RangeError("maxPendingTicketsPerPlayer must be a positive integer"); + } + return { + ...definition, + historyTicks: Math.ceil(definition.historySeconds * game.tickRateHz), + captureEveryTicks: game.tickRateHz / captureRateHz, + maxPendingTicketsPerPlayer, + }; +} + +function validPlayerId(value: number): boolean { + return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff; +} + +function validTick(value: number): boolean { + return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/packages/engine/src/server.ts b/packages/engine/src/server.ts new file mode 100644 index 0000000..a21d87d --- /dev/null +++ b/packages/engine/src/server.ts @@ -0,0 +1,306 @@ +import type { + ClientStateContext, + ClientStateReport, + GameDefinition, + InputContext, + InputPacket, + PlayerId, + StateSnapshot, + ValidationResult, +} from "./types.js"; + +export interface ServerEngineOptions { + historyTicks?: number; + maxPastTicks?: number; + maxFutureTicks?: number; +} + +export interface InputDecision { + accepted: boolean; + reason?: "unknown-player" | "duplicate" | "invalid" | "past" | "future"; +} + +export interface Acknowledgement { + playerId: PlayerId; + sequence: number; +} + +export interface ServerStepResult { + tick: number; + acknowledgements: Acknowledgement[]; + validations: ValidationResult[]; + snapshotDue: boolean; +} + +interface QueuedInput { + playerId: PlayerId; + packet: InputPacket; +} + +interface QueuedReport { + playerId: PlayerId; + report: ClientStateReport; +} + +export class AuthoritativeEngine { + readonly game: GameDefinition; + private readonly historyTicks: number; + private readonly maxPastTicks: number; + private readonly maxFutureTicks: number; + private readonly snapshotEveryTicks: number; + private readonly players = new Set(); + private readonly lastReceivedSequence = new Map(); + private readonly acknowledgedSequence = new Map(); + private readonly history = new Map(); + private queuedInputs: QueuedInput[] = []; + private queuedReports: QueuedReport[] = []; + private state: State; + private currentTick = 0; + + constructor( + game: GameDefinition, + options: ServerEngineOptions = {}, + ) { + this.game = game; + this.historyTicks = options.historyTicks ?? game.tickRateHz * 2; + this.maxPastTicks = options.maxPastTicks ?? 2; + this.maxFutureTicks = options.maxFutureTicks ?? game.tickRateHz; + this.snapshotEveryTicks = game.tickRateHz / game.snapshotRateHz; + this.state = game.createInitialState(); + this.storeHistory(); + } + + get tick(): number { + return this.currentTick; + } + + get currentState(): Readonly { + return this.state; + } + + addPlayer(playerId: PlayerId): void { + if (this.players.has(playerId)) { + return; + } + + this.players.add(playerId); + this.game.addPlayer?.(this.state, { playerId, tick: this.currentTick }); + this.assertValidState(); + this.storeHistory(); + } + + removePlayer(playerId: PlayerId): void { + if (!this.players.delete(playerId)) { + return; + } + + this.game.removePlayer?.(this.state, { + playerId, + tick: this.currentTick, + }); + this.lastReceivedSequence.delete(playerId); + this.acknowledgedSequence.delete(playerId); + this.queuedInputs = this.queuedInputs.filter( + (queued) => queued.playerId !== playerId, + ); + this.queuedReports = this.queuedReports.filter( + (queued) => queued.playerId !== playerId, + ); + this.assertValidState(); + this.storeHistory(); + } + + submitInput(playerId: PlayerId, packet: InputPacket): InputDecision { + if (!this.players.has(playerId)) { + return { accepted: false, reason: "unknown-player" }; + } + + const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0; + if (packet.sequence <= previousSequence) { + return { accepted: false, reason: "duplicate" }; + } + + if (packet.targetTick < this.currentTick - this.maxPastTicks) { + return { accepted: false, reason: "past" }; + } + + if (packet.targetTick > this.currentTick + this.maxFutureTicks) { + return { accepted: false, reason: "future" }; + } + + const context = this.inputContext(playerId, packet); + if (!this.game.validateInput(packet.input, context)) { + return { accepted: false, reason: "invalid" }; + } + + this.lastReceivedSequence.set(playerId, packet.sequence); + this.queuedInputs.push({ playerId, packet }); + return { accepted: true }; + } + + submitStateReport( + playerId: PlayerId, + report: ClientStateReport, + ): ValidationResult | null { + if (report.tick > this.currentTick + this.maxFutureTicks) { + return { + playerId, + tick: report.tick, + valid: false, + reason: "too-far-ahead", + }; + } + + if (report.tick > this.currentTick) { + this.queuedReports = this.queuedReports.filter( + (queued) => queued.playerId !== playerId, + ); + this.queuedReports.push({ playerId, report }); + return null; + } + + return this.validateReport(playerId, report); + } + + step(): ServerStepResult { + this.currentTick += 1; + const deltaSeconds = 1 / this.game.tickRateHz; + const acknowledgementByPlayer = new Map(); + const dueInputs: QueuedInput[] = []; + const futureInputs: QueuedInput[] = []; + + for (const queued of this.queuedInputs) { + (queued.packet.targetTick <= this.currentTick + ? dueInputs + : futureInputs + ).push(queued); + } + this.queuedInputs = futureInputs; + + dueInputs.sort( + (left, right) => + left.packet.targetTick - right.packet.targetTick || + left.playerId - right.playerId || + left.packet.sequence - right.packet.sequence, + ); + + for (const queued of dueInputs) { + const { playerId, packet } = queued; + this.game.applyInput( + this.state, + packet.input, + this.inputContext(playerId, packet), + ); + + const previousAck = this.acknowledgedSequence.get(playerId) ?? 0; + if (packet.sequence > previousAck) { + this.acknowledgedSequence.set(playerId, packet.sequence); + acknowledgementByPlayer.set(playerId, packet.sequence); + } + } + + this.game.step(this.state, { + tick: this.currentTick, + deltaSeconds, + }); + this.assertValidState(); + this.storeHistory(); + + const validations: ValidationResult[] = []; + const futureReports: QueuedReport[] = []; + for (const queued of this.queuedReports) { + if (queued.report.tick <= this.currentTick) { + validations.push( + this.validateReport(queued.playerId, queued.report), + ); + } else { + futureReports.push(queued); + } + } + this.queuedReports = futureReports; + + return { + tick: this.currentTick, + acknowledgements: [...acknowledgementByPlayer].map( + ([playerId, sequence]) => ({ playerId, sequence }), + ), + validations, + snapshotDue: this.currentTick % this.snapshotEveryTicks === 0, + }; + } + + createSnapshot(serverTime: number): StateSnapshot { + return { + tick: this.currentTick, + serverTime, + state: this.game.cloneState(this.state), + }; + } + + private inputContext( + playerId: PlayerId, + packet: InputPacket, + ): InputContext { + return { + playerId, + sequence: packet.sequence, + targetTick: packet.targetTick, + observedTick: packet.observedTick ?? packet.targetTick, + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }; + } + + private validateReport( + playerId: PlayerId, + report: ClientStateReport, + ): ValidationResult { + const authoritative = this.history.get(report.tick); + if (!authoritative) { + return { + playerId, + tick: report.tick, + valid: false, + reason: "outside-history", + }; + } + + const context: ClientStateContext = { playerId, tick: report.tick }; + const valid = this.game.validateClientState + ? this.game.validateClientState(authoritative, report.state, context) + : (this.game.validateState?.(report.state, { + tick: report.tick, + deltaSeconds: 1 / this.game.tickRateHz, + }) ?? true); + + return { + playerId, + tick: report.tick, + valid, + ...(valid ? {} : { reason: "mismatch" as const }), + }; + } + + private assertValidState(): void { + if ( + this.game.validateState && + !this.game.validateState(this.state, { + tick: this.currentTick, + deltaSeconds: 1 / this.game.tickRateHz, + }) + ) { + throw new Error(`Game produced invalid state at tick ${this.currentTick}`); + } + } + + private storeHistory(): void { + this.history.set(this.currentTick, this.game.cloneState(this.state)); + + const oldestTick = this.currentTick - this.historyTicks; + for (const tick of this.history.keys()) { + if (tick < oldestTick) { + this.history.delete(tick); + } + } + } +} diff --git a/packages/engine/src/spatial-replication.ts b/packages/engine/src/spatial-replication.ts new file mode 100644 index 0000000..9cb8b7e --- /dev/null +++ b/packages/engine/src/spatial-replication.ts @@ -0,0 +1,474 @@ +import { + defineNetworkedGame, + type DefinedNetworkedGame, +} from "./define-networked-game.js"; +import type { ReplicationRules } from "./networked-types.js"; +import type { PlayerContext, PlayerId } from "./types.js"; + +export interface SpatialPoint { + x: number; + z: number; +} + +export interface SpatialReplicationEntity extends SpatialPoint { + id: string | number; +} + +export interface SpatialReplicationViewerContext extends PlayerContext { + position: SpatialPoint; +} + +export interface SpatialReplicationSource { + category: string; + maximumDistance: number; + entities( + authority: Readonly, + ): Iterable; + estimatedBytes: + | number + | (( + entity: SpatialReplicationEntity, + context: SpatialReplicationViewerContext, + ) => number); + priority?: + | number + | (( + entity: SpatialReplicationEntity, + distance: number, + context: SpatialReplicationViewerContext, + ) => number); + required?(entity: SpatialReplicationEntity, playerId: PlayerId): boolean; +} + +export interface SpatialReplicationSelection { + readonly playerId: PlayerId; + readonly tick: number; + readonly position: SpatialPoint; + readonly budgetBytes: number; + readonly usedBytes: number; + readonly candidateCount: number; + readonly droppedCount: number; + has(category: string, id: string | number): boolean; + ids(category: string): ReadonlySet; +} + +export interface SpatialReplicationDefinition< + AuthorityState, + ClientState, + AuthorityEvent, + PerceptionEvent, +> { + cellSize: number; + bandwidthBudgetBytesPerSecond: number; + /** Bytes reserved for non-entity match state in every snapshot. */ + reservedBytesPerSnapshot?: number; + viewerPosition( + authority: Readonly, + playerId: PlayerId, + ): SpatialPoint | null; + sources: readonly SpatialReplicationSource[]; + projectSnapshot( + snapshot: ClientState, + selection: SpatialReplicationSelection, + context: PlayerContext, + ): ClientState; + perceive?( + authority: Readonly, + event: Readonly, + perception: PerceptionEvent, + selection: SpatialReplicationSelection, + context: PlayerContext, + ): PerceptionEvent | null; +} + +export interface SpatialReplicationController { + select( + authority: Readonly, + context: PlayerContext, + ): SpatialReplicationSelection; +} + +export type SpatiallyReplicatedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, +> = DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent +> & { + spatialReplication: SpatialReplicationController; +}; + +interface IndexedEntity extends SpatialPoint { + entity: SpatialReplicationEntity; + source: SpatialReplicationSource; +} + +interface Candidate extends IndexedEntity { + distance: number; + estimatedBytes: number; + priority: number; + required: boolean; +} + +/** A deterministic two-dimensional uniform grid used by spatial replication. */ +export class SpatialGridIndex { + private readonly cells = new Map(); + + constructor(readonly cellSize: number) { + if (!Number.isFinite(cellSize) || cellSize <= 0) { + throw new RangeError("cellSize must be a positive finite number"); + } + } + + insert(value: Value): void { + if (!Number.isFinite(value.x) || !Number.isFinite(value.z)) { + throw new RangeError("spatial coordinates must be finite"); + } + const key = this.key(this.coordinate(value.x), this.coordinate(value.z)); + const cell = this.cells.get(key); + if (cell) cell.push(value); + else this.cells.set(key, [value]); + } + + query(position: SpatialPoint, radius: number): Value[] { + if (!Number.isFinite(radius) || radius < 0) { + throw new RangeError("query radius must be a non-negative finite number"); + } + const minimumX = this.coordinate(position.x - radius); + const maximumX = this.coordinate(position.x + radius); + const minimumZ = this.coordinate(position.z - radius); + const maximumZ = this.coordinate(position.z + radius); + const radiusSquared = radius * radius; + const values: Value[] = []; + + for (let cellX = minimumX; cellX <= maximumX; cellX += 1) { + for (let cellZ = minimumZ; cellZ <= maximumZ; cellZ += 1) { + for (const value of this.cells.get(this.key(cellX, cellZ)) ?? []) { + const dx = value.x - position.x; + const dz = value.z - position.z; + if (dx * dx + dz * dz <= radiusSquared) values.push(value); + } + } + } + return values; + } + + private coordinate(value: number): number { + return Math.floor(value / this.cellSize); + } + + private key(x: number, z: number): string { + return `${x}:${z}`; + } +} + +/** Adds deterministic interest management and bandwidth budgeting to a game. */ +export function withSpatialReplication< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + definition: SpatialReplicationDefinition< + AuthorityState, + ClientState, + AuthorityEvent, + PerceptionEvent + >, +): SpatiallyReplicatedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent +> { + validateDefinition(game.snapshotRateHz, definition); + + let cachedAuthority: Readonly | null = null; + let cachedTick = -1; + let cachedIndex = new SpatialGridIndex(definition.cellSize); + const maximumDistance = Math.max( + 0, + ...definition.sources.map((source) => source.maximumDistance), + ); + const budgetBytes = Math.max( + 0, + Math.floor( + definition.bandwidthBudgetBytesPerSecond / game.snapshotRateHz - + (definition.reservedBytesPerSnapshot ?? 0), + ), + ); + + const controller: SpatialReplicationController = { + select(authority, context) { + const position = definition.viewerPosition(authority, context.playerId); + if (!position) { + return createSelection( + context, + { x: 0, z: 0 }, + budgetBytes, + [], + new Map(), + 0, + ); + } + + if (authority !== cachedAuthority || context.tick !== cachedTick) { + cachedAuthority = authority; + cachedTick = context.tick; + cachedIndex = buildIndex(authority, definition); + } + + const viewerContext: SpatialReplicationViewerContext = { + ...context, + position, + }; + const candidates = cachedIndex + .query(position, maximumDistance) + .map((indexed): Candidate | null => { + const source = indexed.source as SpatialReplicationSource; + const distance = Math.hypot( + indexed.entity.x - position.x, + indexed.entity.z - position.z, + ); + if (distance > source.maximumDistance) return null; + const estimatedBytes = normalizeNonNegativeInteger( + typeof source.estimatedBytes === "function" + ? source.estimatedBytes(indexed.entity, viewerContext) + : source.estimatedBytes, + `${source.category}.estimatedBytes`, + ); + const priorityValue = + typeof source.priority === "function" + ? source.priority(indexed.entity, distance, viewerContext) + : (source.priority ?? 0); + return { + ...indexed, + distance, + estimatedBytes, + priority: Number.isFinite(priorityValue) ? priorityValue : 0, + required: source.required?.(indexed.entity, context.playerId) ?? false, + }; + }) + .filter((candidate): candidate is Candidate => candidate !== null) + .sort(compareCandidates); + + const selected = new Map>(); + let usedBytes = 0; + let droppedCount = 0; + for (const candidate of candidates) { + if (!candidate.required && usedBytes + candidate.estimatedBytes > budgetBytes) { + droppedCount += 1; + continue; + } + let ids = selected.get(candidate.source.category); + if (!ids) { + ids = new Set(); + selected.set(candidate.source.category, ids); + } + if (ids.has(candidate.entity.id)) continue; + ids.add(candidate.entity.id); + usedBytes += candidate.estimatedBytes; + } + + return createSelection( + context, + position, + budgetBytes, + candidates, + selected, + droppedCount, + usedBytes, + ); + }, + }; + + const replication: ReplicationRules< + AuthorityState, + ClientState, + AuthorityEvent, + PerceptionEvent + > = { + ...game.replication, + createSnapshot(authority, context) { + const snapshot = game.replication.createSnapshot(authority, context); + return definition.projectSnapshot( + snapshot, + controller.select(authority, context), + context, + ); + }, + ...(game.replication.perceive + ? { + perceive(authority, event, context) { + const perception = game.replication.perceive!( + authority, + event, + context, + ); + if (perception === null) return null; + return definition.perceive + ? definition.perceive( + authority, + event, + perception, + controller.select(authority, context), + context, + ) + : perception; + }, + } + : {}), + // Spatial selections are viewer-specific even when a base game groups views. + groupKey: (_authority, { playerId }) => playerId, + }; + + const wrapped = defineNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >({ + tickRateHz: game.tickRateHz, + snapshotRateHz: game.snapshotRateHz, + server: game.server, + client: game.client, + validateInput: game.validateInput, + codecs: game.codecs, + replication, + }); + + return Object.freeze({ ...wrapped, spatialReplication: controller }); +} + +function buildIndex( + authority: Readonly, + definition: SpatialReplicationDefinition< + AuthorityState, + ClientState, + AuthorityEvent, + PerceptionEvent + >, +): SpatialGridIndex { + const index = new SpatialGridIndex(definition.cellSize); + for (const source of definition.sources) { + for (const entity of source.entities(authority)) { + index.insert({ + x: entity.x, + z: entity.z, + entity, + source: source as SpatialReplicationSource, + }); + } + } + return index; +} + +function createSelection( + context: PlayerContext, + position: SpatialPoint, + budgetBytes: number, + candidates: readonly Candidate[], + selected: ReadonlyMap>, + droppedCount: number, + usedBytes = 0, +): SpatialReplicationSelection { + const empty = new Set(); + return Object.freeze({ + playerId: context.playerId, + tick: context.tick, + position: { ...position }, + budgetBytes, + usedBytes, + candidateCount: candidates.length, + droppedCount, + has(category: string, id: string | number) { + return selected.get(category)?.has(id) ?? false; + }, + ids(category: string) { + return selected.get(category) ?? empty; + }, + }); +} + +function compareCandidates(left: Candidate, right: Candidate): number { + return ( + Number(right.required) - Number(left.required) || + right.priority - left.priority || + left.distance - right.distance || + left.source.category.localeCompare(right.source.category) || + stableId(left.entity.id).localeCompare(stableId(right.entity.id)) + ); +} + +function stableId(id: string | number): string { + return `${typeof id}:${String(id)}`; +} + +function validateDefinition( + snapshotRateHz: number, + definition: SpatialReplicationDefinition< + AuthorityState, + ClientState, + AuthorityEvent, + PerceptionEvent + >, +): void { + if (!Number.isFinite(definition.cellSize) || definition.cellSize <= 0) { + throw new RangeError("cellSize must be a positive finite number"); + } + if ( + !Number.isFinite(definition.bandwidthBudgetBytesPerSecond) || + definition.bandwidthBudgetBytesPerSecond <= 0 + ) { + throw new RangeError("bandwidthBudgetBytesPerSecond must be positive"); + } + if ( + definition.reservedBytesPerSnapshot !== undefined && + (!Number.isFinite(definition.reservedBytesPerSnapshot) || + definition.reservedBytesPerSnapshot < 0) + ) { + throw new RangeError("reservedBytesPerSnapshot must be non-negative"); + } + const categories = new Set(); + for (const source of definition.sources) { + if (!source.category || categories.has(source.category)) { + throw new Error(`spatial source categories must be unique: ${source.category}`); + } + categories.add(source.category); + if (!Number.isFinite(source.maximumDistance) || source.maximumDistance < 0) { + throw new RangeError(`${source.category}.maximumDistance must be non-negative`); + } + if (typeof source.estimatedBytes === "number") { + normalizeNonNegativeInteger( + source.estimatedBytes, + `${source.category}.estimatedBytes`, + ); + } + } + if (!Number.isInteger(snapshotRateHz) || snapshotRateHz <= 0) { + throw new RangeError("snapshotRateHz must be positive"); + } +} + +function normalizeNonNegativeInteger(value: number, name: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError(`${name} must be non-negative and finite`); + } + return Math.ceil(value); +} diff --git a/packages/engine/src/time-travel.ts b/packages/engine/src/time-travel.ts new file mode 100644 index 0000000..46c8d70 --- /dev/null +++ b/packages/engine/src/time-travel.ts @@ -0,0 +1,1124 @@ +import type { DefinedNetworkedGame } from "./define-networked-game.js"; +import { + NetworkedAuthoritativeEngine, + type NetworkedServerStepResult, + type NetworkedSimulationCheckpoint, +} from "./networked-server.js"; +import type { NetworkedGameDefinition, SnapshotBatch } from "./networked-types.js"; +import type { + ClientStateReport, + InputPacket, + PlayerId, + StateSnapshot, + ValidationResult, +} from "./types.js"; +import type { InputDecision, ServerEngineOptions } from "./server.js"; + +export type ReplayStateHash = string | number; + +export type ReplayCommand = + | { + kind: "add-player"; + atTick: number; + order: number; + playerId: PlayerId; + } + | { + kind: "remove-player"; + atTick: number; + order: number; + playerId: PlayerId; + } + | { + kind: "input"; + atTick: number; + order: number; + playerId: PlayerId; + packet: InputPacket; + }; + +export interface ReplayVerification { + tick: number; + hash: StateHash; +} + +export interface ReplayCheckpoint< + AuthorityState, + Input, + AuthorityEvent, + StateHash extends ReplayStateHash, +> { + tick: number; + commandCount: number; + hash: StateHash; + simulation: NetworkedSimulationCheckpoint< + AuthorityState, + Input, + AuthorityEvent + >; + authorityEvents: AuthorityEvent[]; +} + +/** + * A recording contains private authority checkpoints and must remain on trusted + * infrastructure. Use ReplaySession.viewAs() for a privacy-filtered client view. + */ +export interface ReplayRecording< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash extends ReplayStateHash, +> { + version: 1; + seed: Seed; + tickRateHz: number; + snapshotRateHz: number; + /** First seekable tick. Rolling recordings may start after tick zero. */ + startTick: number; + durationTicks: number; + serverOptions: ServerEngineOptions; + commands: ReplayCommand[]; + verifications: ReplayVerification[]; + checkpoints: Array< + ReplayCheckpoint + >; +} + +export interface TimeTravelDefinition< + AuthorityState, + Seed, + StateHash extends ReplayStateHash, +> { + createSeed(): Seed; + initializeState?(state: AuthorityState, seed: Seed): void; + cloneSeed?(seed: Seed): Seed; + hashState(state: Readonly): StateHash; + checkpointIntervalTicks?: number; + verificationIntervalTicks?: number; +} + +export interface TimeTravelServerOptions extends ServerEngineOptions { + replaySeed?: Seed; + /** + * Retain only this many recent ticks in the private authority recording. + * The actual window can be up to one checkpoint interval larger. + */ + recordingHistoryTicks?: number; +} + +export interface ReplayFrame< + AuthorityState, + AuthorityEvent, + StateHash extends ReplayStateHash, +> { + tick: number; + state: AuthorityState; + authorityEvents: AuthorityEvent[]; + hash: StateHash; +} + +export class ReplayDivergenceError< + StateHash extends ReplayStateHash, +> extends Error { + readonly tick: number; + readonly expected: StateHash; + readonly actual: StateHash; + + constructor(tick: number, expected: StateHash, actual: StateHash) { + super( + `Replay diverged at tick ${tick}: expected ${String(expected)}, received ${String(actual)}`, + ); + this.name = "ReplayDivergenceError"; + this.tick = tick; + this.expected = expected; + this.actual = actual; + } +} + +interface NormalizedTimeTravelDefinition< + AuthorityState, + Seed, + StateHash extends ReplayStateHash, +> extends TimeTravelDefinition { + checkpointIntervalTicks: number; + verificationIntervalTicks: number; +} + +interface ReplayTimeline< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash extends ReplayStateHash, +> { + seed: Seed; + serverOptions: ServerEngineOptions; + commands: ReplayCommand[]; + verifications: ReplayVerification[]; + checkpoints: Array< + ReplayCheckpoint + >; +} + +export type TimeTravelNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +> = Omit< + DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + "createServer" +> & { + createServer( + options?: TimeTravelServerOptions, + ): TimeTravelAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash + >; + createReplay( + recording: ReplayRecording< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash + >, + ): ReplaySession< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash + >; +}; + +export class TimeTravelAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +> { + readonly game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >; + private readonly engine: NetworkedAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >; + private readonly timeTravel: NormalizedTimeTravelDefinition< + AuthorityState, + Seed, + StateHash + >; + private readonly seed: Seed; + private readonly serverOptions: ServerEngineOptions; + private readonly recordingHistoryTicks: number | null; + private commands: ReplayCommand[]; + private verifications: ReplayVerification[]; + private checkpoints: Array< + ReplayCheckpoint + >; + private nextOrder: number; + + constructor( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + engine: NetworkedAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + timeTravel: NormalizedTimeTravelDefinition< + AuthorityState, + Seed, + StateHash + >, + seed: Seed, + serverOptions: ServerEngineOptions, + recordingHistoryTicks: number | null, + timeline?: ReplayTimeline< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash + >, + ) { + this.game = game; + this.engine = engine; + this.timeTravel = timeTravel; + this.seed = cloneSeed(timeTravel, seed); + this.serverOptions = { ...serverOptions }; + this.recordingHistoryTicks = recordingHistoryTicks; + + if (timeline) { + this.commands = timeline.commands.map((command) => + cloneCommand(game, command), + ); + this.verifications = timeline.verifications.map((entry) => ({ ...entry })); + this.checkpoints = timeline.checkpoints.map((checkpoint) => + cloneReplayCheckpoint(game, checkpoint), + ); + this.nextOrder = + this.commands.reduce((maximum, command) => Math.max(maximum, command.order), -1) + 1; + } else { + this.commands = []; + const hash = timeTravel.hashState(engine.currentState); + this.verifications = [{ tick: engine.tick, hash }]; + this.checkpoints = [ + { + tick: engine.tick, + commandCount: 0, + hash, + simulation: engine.createSimulationCheckpoint(), + authorityEvents: [], + }, + ]; + this.nextOrder = 0; + } + } + + get tick(): number { + return this.engine.tick; + } + + get currentState(): Readonly { + return this.engine.currentState; + } + + get playerIds(): PlayerId[] { + return this.engine.playerIds; + } + + addPlayer(playerId: PlayerId): void { + const existed = this.engine.playerIds.includes(playerId); + this.engine.addPlayer(playerId); + if (!existed && this.engine.playerIds.includes(playerId)) { + this.recordCommand({ + kind: "add-player", + atTick: this.tick, + order: this.nextOrder, + playerId, + }); + } + } + + removePlayer(playerId: PlayerId): void { + const existed = this.engine.playerIds.includes(playerId); + this.engine.removePlayer(playerId); + if (existed && !this.engine.playerIds.includes(playerId)) { + this.recordCommand({ + kind: "remove-player", + atTick: this.tick, + order: this.nextOrder, + playerId, + }); + } + } + + submitInput(playerId: PlayerId, packet: InputPacket): InputDecision { + const decision = this.engine.submitInput(playerId, packet); + if (decision.accepted) { + this.recordCommand({ + kind: "input", + atTick: this.tick, + order: this.nextOrder, + playerId, + packet: cloneInputPacket(this.game, packet), + }); + } + return decision; + } + + submitStateReport( + playerId: PlayerId, + report: ClientStateReport, + ): ValidationResult | null { + return this.engine.submitStateReport(playerId, report); + } + + step(): NetworkedServerStepResult { + const result = this.engine.step(); + const hash = this.timeTravel.hashState(this.engine.currentState); + + if (result.tick % this.timeTravel.verificationIntervalTicks === 0) { + this.verifications.push({ tick: result.tick, hash }); + } + + if (result.tick % this.timeTravel.checkpointIntervalTicks === 0) { + this.checkpoints.push({ + tick: result.tick, + commandCount: this.commands.length, + hash, + simulation: this.engine.createSimulationCheckpoint(), + authorityEvents: [...result.events], + }); + } + + this.pruneRecording(); + + return result; + } + + createSnapshot( + playerId: PlayerId, + serverTime: number, + ): StateSnapshot { + return this.engine.createSnapshot(playerId, serverTime); + } + + createSnapshotBatches(serverTime: number): SnapshotBatch[] { + return this.engine.createSnapshotBatches(serverTime); + } + + createPerceptions( + playerId: PlayerId, + events: readonly AuthorityEvent[], + ): PerceptionEvent[] { + return this.engine.createPerceptions(playerId, events); + } + + exportRecording(): ReplayRecording< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash + > { + return cloneRecording(this.game, this.timeTravel, { + version: 1, + seed: this.seed, + tickRateHz: this.game.tickRateHz, + snapshotRateHz: this.game.snapshotRateHz, + startTick: this.checkpoints[0]?.tick ?? this.tick, + durationTicks: this.tick, + serverOptions: this.serverOptions, + commands: this.commands, + verifications: this.verifications, + checkpoints: this.checkpoints, + }); + } + + private recordCommand(command: ReplayCommand): void { + this.commands.push(cloneCommand(this.game, command)); + this.nextOrder += 1; + } + + private pruneRecording(): void { + if (this.recordingHistoryTicks === null) return; + const cutoff = this.tick - this.recordingHistoryTicks; + if (cutoff <= 0 || this.checkpoints.length < 2) return; + + let baselineIndex = 0; + for (let index = 1; index < this.checkpoints.length; index += 1) { + const checkpoint = this.checkpoints[index]!; + if (checkpoint.tick > cutoff) break; + baselineIndex = index; + } + if (baselineIndex === 0) return; + + const baseline = this.checkpoints[baselineIndex]!; + const removedCommandCount = baseline.commandCount; + this.commands = this.commands.slice(removedCommandCount); + this.verifications = this.verifications.filter( + ({ tick }) => tick >= baseline.tick, + ); + this.checkpoints = this.checkpoints.slice(baselineIndex).map( + (checkpoint) => ({ + ...checkpoint, + commandCount: checkpoint.commandCount - removedCommandCount, + }), + ); + } +} + +export class ReplaySession< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +> { + private readonly game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >; + private readonly timeTravel: NormalizedTimeTravelDefinition< + AuthorityState, + Seed, + StateHash + >; + private readonly recording: ReplayRecording< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash + >; + private readonly verifications: Map; + private engine: NetworkedAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >; + private commandIndex = 0; + private latestAuthorityEvents: AuthorityEvent[] = []; + + constructor( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + timeTravel: NormalizedTimeTravelDefinition< + AuthorityState, + Seed, + StateHash + >, + recording: ReplayRecording< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash + >, + ) { + validateRecording(game, recording); + this.game = game; + this.timeTravel = timeTravel; + this.recording = cloneRecording(game, timeTravel, recording); + this.verifications = new Map( + this.recording.verifications.map(({ tick, hash }) => [tick, hash]), + ); + this.engine = createSeededEngine( + game, + timeTravel, + this.recording.seed, + this.recording.serverOptions, + ); + this.seek(this.recording.startTick); + } + + get tick(): number { + return this.engine.tick; + } + + get durationTicks(): number { + return this.recording.durationTicks; + } + + get startTick(): number { + return this.recording.startTick; + } + + get currentState(): Readonly { + return this.engine.currentState; + } + + seek( + targetTick: number, + ): ReplayFrame { + if ( + !Number.isInteger(targetTick) || + targetTick < this.recording.startTick || + targetTick > this.recording.durationTicks + ) { + throw new RangeError( + `target tick must be between ${this.recording.startTick} and ${this.recording.durationTicks}`, + ); + } + + const checkpoint = this.findCheckpoint(targetTick); + this.engine = createSeededEngine( + this.game, + this.timeTravel, + this.recording.seed, + this.recording.serverOptions, + ); + this.engine.restoreSimulationCheckpoint(checkpoint.simulation); + this.commandIndex = checkpoint.commandCount; + this.latestAuthorityEvents = [...checkpoint.authorityEvents]; + this.assertHash(checkpoint.tick, checkpoint.hash); + this.applyCommandsAtCurrentTick(); + + while (this.engine.tick < targetTick) { + const result = this.engine.step(); + this.latestAuthorityEvents = [...result.events]; + const expected = this.verifications.get(result.tick); + if (expected !== undefined) this.assertHash(result.tick, expected); + this.applyCommandsAtCurrentTick(); + } + + return this.frame(); + } + + frame(): ReplayFrame { + return { + tick: this.engine.tick, + state: this.game.server.cloneState(this.engine.currentState), + authorityEvents: [...this.latestAuthorityEvents], + hash: this.timeTravel.hashState(this.engine.currentState), + }; + } + + viewAs( + playerId: PlayerId, + serverTime = 0, + ): StateSnapshot { + if (!this.engine.playerIds.includes(playerId)) { + throw new RangeError(`player ${playerId} does not exist at tick ${this.tick}`); + } + return this.engine.createSnapshot(playerId, serverTime); + } + + perceptionsAs(playerId: PlayerId): PerceptionEvent[] { + if (!this.engine.playerIds.includes(playerId)) { + throw new RangeError(`player ${playerId} does not exist at tick ${this.tick}`); + } + return this.engine.createPerceptions(playerId, this.latestAuthorityEvents); + } + + branch( + atTick = this.tick, + ): TimeTravelAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash + > { + this.seek(atTick); + const branchCheckpoint: ReplayCheckpoint< + AuthorityState, + Input, + AuthorityEvent, + StateHash + > = { + tick: atTick, + commandCount: this.commandIndex, + hash: this.timeTravel.hashState(this.engine.currentState), + simulation: this.engine.createSimulationCheckpoint(), + authorityEvents: [...this.latestAuthorityEvents], + }; + const branchEngine = createSeededEngine( + this.game, + this.timeTravel, + this.recording.seed, + this.recording.serverOptions, + ); + branchEngine.restoreSimulationCheckpoint(branchCheckpoint.simulation); + + return new TimeTravelAuthoritativeEngine( + this.game, + branchEngine, + this.timeTravel, + this.recording.seed, + this.recording.serverOptions, + null, + { + seed: this.recording.seed, + serverOptions: this.recording.serverOptions, + commands: this.recording.commands.slice(0, this.commandIndex), + verifications: this.recording.verifications.filter( + ({ tick }) => tick <= atTick, + ), + checkpoints: [ + ...this.recording.checkpoints.filter(({ tick }) => tick < atTick), + branchCheckpoint, + ], + }, + ); + } + + private findCheckpoint( + targetTick: number, + ): ReplayCheckpoint { + let selected = this.recording.checkpoints[0]; + if (!selected) throw new Error("Replay has no initial checkpoint"); + + for (const checkpoint of this.recording.checkpoints) { + if (checkpoint.tick <= targetTick && checkpoint.tick >= selected.tick) { + selected = checkpoint; + } + } + return selected; + } + + private applyCommandsAtCurrentTick(): void { + while (this.commandIndex < this.recording.commands.length) { + const command = this.recording.commands[this.commandIndex]!; + if (command.atTick > this.engine.tick) return; + if (command.atTick < this.engine.tick) { + throw new Error( + `Replay command ${command.order} at tick ${command.atTick} is behind playback tick ${this.engine.tick}`, + ); + } + + switch (command.kind) { + case "add-player": + this.engine.addPlayer(command.playerId); + break; + case "remove-player": + this.engine.removePlayer(command.playerId); + break; + case "input": { + const decision = this.engine.submitInput(command.playerId, command.packet); + if (!decision.accepted) { + throw new Error( + `Recorded input ${command.packet.sequence} was rejected during replay: ${decision.reason ?? "unknown"}`, + ); + } + break; + } + } + this.commandIndex += 1; + } + } + + private assertHash(tick: number, expected: StateHash): void { + const actual = this.timeTravel.hashState(this.engine.currentState); + if (!Object.is(expected, actual)) { + throw new ReplayDivergenceError(tick, expected, actual); + } + } +} + +export function withTimeTravel< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + definition: TimeTravelDefinition, +): TimeTravelNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash +> { + const timeTravel = normalizeDefinition(game.tickRateHz, definition); + + return Object.freeze({ + ...game, + createServer(options: TimeTravelServerOptions = {}) { + const { + replaySeed, + recordingHistoryTicks: requestedRecordingHistoryTicks, + ...serverOptions + } = options; + const recordingHistoryTicks = normalizeRecordingHistoryTicks( + requestedRecordingHistoryTicks, + ); + const seed = replaySeed === undefined + ? timeTravel.createSeed() + : replaySeed; + const engine = createSeededEngine( + game, + timeTravel, + seed, + serverOptions, + ); + return new TimeTravelAuthoritativeEngine( + game, + engine, + timeTravel, + seed, + serverOptions, + recordingHistoryTicks, + ); + }, + createReplay( + recording: ReplayRecording< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash + >, + ) { + return new ReplaySession(game, timeTravel, recording); + }, + }); +} + +function normalizeDefinition( + tickRateHz: number, + definition: TimeTravelDefinition, +): NormalizedTimeTravelDefinition { + const checkpointIntervalTicks = + definition.checkpointIntervalTicks ?? tickRateHz * 2; + const verificationIntervalTicks = definition.verificationIntervalTicks ?? 1; + + for (const [name, value] of [ + ["checkpointIntervalTicks", checkpointIntervalTicks], + ["verificationIntervalTicks", verificationIntervalTicks], + ] as const) { + if (!Number.isInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive integer`); + } + } + + return { + ...definition, + checkpointIntervalTicks, + verificationIntervalTicks, + }; +} + +function normalizeRecordingHistoryTicks(value: number | undefined): number | null { + if (value === undefined) return null; + if (!Number.isInteger(value) || value <= 0) { + throw new RangeError("recordingHistoryTicks must be a positive integer"); + } + return value; +} + +function createSeededEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + timeTravel: NormalizedTimeTravelDefinition, + seed: Seed, + serverOptions: ServerEngineOptions, +): NetworkedAuthoritativeEngine< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent +> { + const definition: NetworkedGameDefinition< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + > = { + tickRateHz: game.tickRateHz, + snapshotRateHz: game.snapshotRateHz, + server: { + ...game.server, + createInitialState() { + const state = game.server.createInitialState(); + timeTravel.initializeState?.(state, cloneSeed(timeTravel, seed)); + return state; + }, + }, + client: game.client, + replication: game.replication, + validateInput: game.validateInput, + codecs: game.codecs, + }; + return new NetworkedAuthoritativeEngine(definition, serverOptions); +} + +function cloneSeed( + timeTravel: TimeTravelDefinition, + seed: Seed, +): Seed { + return timeTravel.cloneSeed ? timeTravel.cloneSeed(seed) : seed; +} + +function cloneInputPacket< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + packet: InputPacket, +): InputPacket { + const encoded = game.codecs.input.encode(packet.input); + return { + sequence: packet.sequence, + targetTick: packet.targetTick, + observedTick: packet.observedTick ?? packet.targetTick, + input: game.codecs.input.decode(encoded), + }; +} + +function cloneCommand< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + command: ReplayCommand, +): ReplayCommand { + return command.kind === "input" + ? { ...command, packet: cloneInputPacket(game, command.packet) } + : { ...command }; +} + +function cloneSimulationCheckpoint< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + checkpoint: NetworkedSimulationCheckpoint< + AuthorityState, + Input, + AuthorityEvent + >, +): NetworkedSimulationCheckpoint { + return { + tick: checkpoint.tick, + state: game.server.cloneState(checkpoint.state), + playerIds: [...checkpoint.playerIds], + lastReceivedSequences: checkpoint.lastReceivedSequences.map( + ([playerId, sequence]) => [playerId, sequence] as const, + ), + acknowledgedSequences: checkpoint.acknowledgedSequences.map( + ([playerId, sequence]) => [playerId, sequence] as const, + ), + queuedInputs: checkpoint.queuedInputs.map(({ playerId, packet }) => ({ + playerId, + packet: cloneInputPacket(game, packet), + })), + pendingEvents: [...checkpoint.pendingEvents], + }; +} + +function cloneReplayCheckpoint< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + StateHash extends ReplayStateHash, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + checkpoint: ReplayCheckpoint< + AuthorityState, + Input, + AuthorityEvent, + StateHash + >, +): ReplayCheckpoint { + return { + tick: checkpoint.tick, + commandCount: checkpoint.commandCount, + hash: checkpoint.hash, + simulation: cloneSimulationCheckpoint(game, checkpoint.simulation), + authorityEvents: [...checkpoint.authorityEvents], + }; +} + +function cloneRecording< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + timeTravel: TimeTravelDefinition, + recording: ReplayRecording< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash + >, +): ReplayRecording { + return { + version: 1, + seed: cloneSeed(timeTravel, recording.seed), + tickRateHz: recording.tickRateHz, + snapshotRateHz: recording.snapshotRateHz, + startTick: recording.startTick, + durationTicks: recording.durationTicks, + serverOptions: { ...recording.serverOptions }, + commands: recording.commands.map((command) => cloneCommand(game, command)), + verifications: recording.verifications.map((entry) => ({ ...entry })), + checkpoints: recording.checkpoints.map((checkpoint) => + cloneReplayCheckpoint(game, checkpoint), + ), + }; +} + +function validateRecording< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent, + Seed, + StateHash extends ReplayStateHash, +>( + game: DefinedNetworkedGame< + AuthorityState, + ClientState, + Input, + AuthorityEvent, + PerceptionEvent + >, + recording: ReplayRecording< + AuthorityState, + Input, + AuthorityEvent, + Seed, + StateHash + >, +): void { + if (recording.version !== 1) throw new RangeError("Unsupported replay version"); + if ( + recording.tickRateHz !== game.tickRateHz || + recording.snapshotRateHz !== game.snapshotRateHz + ) { + throw new RangeError("Replay tick rates do not match this game"); + } + if (!Number.isInteger(recording.durationTicks) || recording.durationTicks < 0) { + throw new RangeError("Replay duration must be a non-negative integer"); + } + if ( + !Number.isInteger(recording.startTick) || + recording.startTick < 0 || + recording.startTick > recording.durationTicks + ) { + throw new RangeError("Replay start tick must be in the recorded range"); + } + if ( + recording.checkpoints.length === 0 || + recording.checkpoints[0]?.tick !== recording.startTick + ) { + throw new RangeError("Replay must begin with a checkpoint at startTick"); + } + + let previousOrder = -1; + let previousTick = recording.startTick; + for (const command of recording.commands) { + if ( + !Number.isInteger(command.atTick) || + command.atTick < previousTick || + command.atTick < recording.startTick || + command.atTick > recording.durationTicks || + !Number.isInteger(command.order) || + command.order <= previousOrder + ) { + throw new RangeError("Replay commands are not in canonical order"); + } + previousTick = command.atTick; + previousOrder = command.order; + } + + for (const checkpoint of recording.checkpoints) { + if ( + checkpoint.tick !== checkpoint.simulation.tick || + checkpoint.tick < 0 || + checkpoint.tick > recording.durationTicks || + checkpoint.commandCount < 0 || + checkpoint.commandCount > recording.commands.length + ) { + throw new RangeError("Replay contains an invalid checkpoint"); + } + } +} diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts new file mode 100644 index 0000000..414772f --- /dev/null +++ b/packages/engine/src/types.ts @@ -0,0 +1,80 @@ +export type PlayerId = number; + +export interface TickContext { + tick: number; + deltaSeconds: number; +} + +export interface InputContext extends TickContext { + playerId: PlayerId; + sequence: number; + targetTick: number; + /** Client simulation tick whose world presentation produced this input. */ + observedTick: number; +} + +export interface PlayerContext { + playerId: PlayerId; + tick: number; +} + +export interface ClientStateContext extends PlayerContext {} + +export interface GameDefinition { + tickRateHz: number; + snapshotRateHz: number; + createInitialState(): State; + cloneState(state: State): State; + step(state: State, context: TickContext): void; + applyInput(state: State, input: Input, context: InputContext): void; + validateInput(input: Input, context: InputContext): boolean; + validateState?(state: State, context: TickContext): boolean; + validateClientState?( + authoritative: State, + candidate: State, + context: ClientStateContext, + ): boolean; + addPlayer?(state: State, context: PlayerContext): void; + removePlayer?(state: State, context: PlayerContext): void; +} + +export interface InputPacket { + sequence: number; + targetTick: number; + /** Optional for backwards compatibility; servers fall back to targetTick. */ + observedTick?: number; + input: Input; +} + +export interface StateSnapshot { + tick: number; + serverTime: number; + state: State; +} + +export interface ClientStateReport { + tick: number; + state: State; +} + +export interface PingPacket { + id: number; + clientSentAt: number; +} + +export interface PongPacket extends PingPacket { + serverReceivedAt: number; + serverSentAt: number; +} + +export interface BinaryCodec { + encode(value: Value): Uint8Array; + decode(payload: Uint8Array): Value; +} + +export interface ValidationResult { + playerId: PlayerId; + tick: number; + valid: boolean; + reason?: "mismatch" | "outside-history" | "too-far-ahead"; +} diff --git a/packages/engine/test/engine.test.mjs b/packages/engine/test/engine.test.mjs new file mode 100644 index 0000000..2532658 --- /dev/null +++ b/packages/engine/test/engine.test.mjs @@ -0,0 +1,874 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + FixedStepClock, + NetworkClock, + ReplayDivergenceError, + SpatialGridIndex, + createBinaryProtocol, + createJsonCodec, + deterministicHash, + defineGame, + defineMultiplayerGame, + defineNetworkedGame, + withLagCompensation, + withReplayTransport, + withSpatialReplication, + withTimeTravel, +} from "../dist/index.js"; + +test("spatial replication indexes, prioritizes, budgets, and projects per viewer", () => { + const grid = new SpatialGridIndex(10); + grid.insert({ id: "negative", x: -4, z: -7 }); + grid.insert({ id: "near", x: 6, z: 2 }); + grid.insert({ id: "far", x: 40, z: 40 }); + assert.deepEqual( + grid.query({ x: 0, z: 0 }, 10).map(({ id }) => id).sort(), + ["near", "negative"], + ); + + const codec = createJsonCodec(); + const base = defineMultiplayerGame({ + clock: { ticksPerSecond: 10, snapshotsPerSecond: 10 }, + authority: { + createInitialState: () => ({ + entities: [ + { id: 1, x: 0, z: 0 }, + { id: 2, x: 10, z: 0 }, + { id: 3, x: 20, z: 0 }, + { id: 4, x: 200, z: 0 }, + ], + }), + cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })) }), + applyInput() {}, + step() {}, + }, + prediction: { + createInitialState: () => ({ entities: [], events: [] }), + cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })), events: [...state.events] }), + applyInput() {}, + step() {}, + applyEvent(state, event) { + state.events.push(event); + }, + }, + visibility: { + createSnapshot: (authority) => ({ + entities: authority.entities.map((entity) => ({ ...entity })), + events: [], + }), + perceive: (_authority, event) => event, + }, + input: { validate: () => true }, + encoding: { input: codec, clientState: codec, perception: codec }, + }); + const game = withSpatialReplication(base, { + cellSize: 16, + bandwidthBudgetBytesPerSecond: 1_000, + viewerPosition: (authority, playerId) => authority.entities.find(({ id }) => id === playerId) ?? null, + sources: [ + { + category: "entities", + maximumDistance: 100, + entities: (authority) => authority.entities, + estimatedBytes: 40, + required: (entity, playerId) => entity.id === playerId, + }, + ], + projectSnapshot(snapshot, selection) { + return { + ...snapshot, + entities: snapshot.entities.filter((entity) => selection.has("entities", entity.id)), + }; + }, + perceive(_authority, event, perception, selection) { + return selection.has("entities", event.sourceId) ? perception : null; + }, + }); + + const server = game.createServer(); + server.addPlayer(1); + assert.deepEqual( + server.createSnapshot(1, 0).state.entities.map(({ id }) => id), + [1, 2], + ); + const selection = game.spatialReplication.select(server.currentState, { + playerId: 1, + tick: server.tick, + }); + assert.equal(selection.budgetBytes, 100); + assert.equal(selection.usedBytes, 80); + assert.equal(selection.candidateCount, 3); + assert.equal(selection.droppedCount, 1); + assert.deepEqual(server.createPerceptions(1, [{ sourceId: 2 }]), [{ sourceId: 2 }]); + assert.deepEqual(server.createPerceptions(1, [{ sourceId: 3 }]), []); +}); + +test("the developer-facing API compiles named sections into a networked game", () => { + const codec = createJsonCodec(); + const game = defineMultiplayerGame({ + clock: { ticksPerSecond: 12, snapshotsPerSecond: 4 }, + authority: { + createInitialState: () => ({ value: 0 }), + cloneState: (state) => ({ ...state }), + applyInput(state, input) { + state.value += input.amount; + }, + step() {}, + }, + prediction: { + createInitialState: () => ({ value: 0 }), + cloneState: (state) => ({ ...state }), + applyInput(state, input) { + state.value += input.amount; + }, + step() {}, + }, + visibility: { + createSnapshot: (authority) => ({ value: authority.value }), + }, + input: { + validate: (input) => Number.isFinite(input.amount), + }, + encoding: { + input: codec, + clientState: codec, + }, + }); + + assert.equal(game.tickRateHz, 12); + assert.equal(game.snapshotRateHz, 4); + const server = game.createServer(); + server.addPlayer(1); + assert.equal( + server.submitInput(1, { + sequence: 1, + targetTick: 1, + observedTick: 0, + input: { amount: 3 }, + }).accepted, + true, + ); + server.step(); + assert.equal(server.currentState.value, 3); + assert.deepEqual(codec.decode(codec.encode({ unicode: "flux ⚡" })), { + unicode: "flux ⚡", + }); +}); + +const testGame = defineGame({ + tickRateHz: 10, + snapshotRateHz: 5, + createInitialState: () => ({ position: 0, velocity: 0 }), + cloneState: (state) => ({ ...state }), + validateInput: (input) => + Number.isFinite(input.axis) && Math.abs(input.axis) <= 1, + applyInput: (state, input) => { + state.velocity = input.axis; + }, + step: (state, { deltaSeconds }) => { + state.position += state.velocity * deltaSeconds; + }, + validateState: (state) => + Number.isFinite(state.position) && Number.isFinite(state.velocity), + validateClientState: (authoritative, candidate) => + Math.abs(authoritative.position - candidate.position) < 0.000_001, +}); + +test("defineGame creates matching authoritative and predicted runtimes", () => { + const server = testGame.createServer(); + const client = testGame.createClient(); + + server.addPlayer(7); + client.initialize(7, server.createSnapshot(0)); + + const packet = client.createInput({ axis: 1 }, 1); + assert.deepEqual(server.submitInput(7, packet), { accepted: true }); + + client.step(); + const result = server.step(); + + assert.equal(result.snapshotDue, false); + assert.deepEqual(result.acknowledgements, [ + { playerId: 7, sequence: packet.sequence }, + ]); + + client.acknowledge(packet.sequence); + client.reconcile(server.createSnapshot(100)); + assert.equal(client.currentState.position, server.currentState.position); + + const valid = server.submitStateReport(7, client.createStateReport()); + assert.equal(valid?.valid, true); + + const invalid = server.submitStateReport(7, { + tick: server.tick, + state: { position: 99, velocity: 0 }, + }); + assert.equal(invalid?.valid, false); + + assert.deepEqual( + server.submitInput(7, { + sequence: packet.sequence + 1, + targetTick: server.tick + 1, + input: { axis: 2 }, + }), + { accepted: false, reason: "invalid" }, + ); +}); + +test("fixed-step clocks cap catch-up work", () => { + const clock = new FixedStepClock({ rateHz: 10, maxCatchUpSteps: 3 }); + let steps = 0; + + assert.equal(clock.advance(0, () => steps++), 0); + assert.equal(clock.advance(100, () => steps++), 1); + assert.equal(clock.advance(1_000, () => steps++), 3); + assert.equal(steps, 4); +}); + +test("network clock estimates RTT, offset, and input lead", () => { + const clock = new NetworkClock(); + const ping = clock.createPing(100); + const stats = clock.receivePong( + { + ...ping, + serverReceivedAt: 160, + serverSentAt: 162, + }, + 202, + ); + + assert.equal(stats.roundTripTime, 100); + assert.equal(stats.clockOffset, 10); + assert.equal(clock.toServerTime(300), 310); + assert.equal(clock.recommendedInputLeadTicks(60), 4); +}); + +test("binary protocol round-trips generic inputs, state, and control frames", () => { + const numberCodec = { + encode(value) { + const payload = new Uint8Array(8); + new DataView(payload.buffer).setFloat64(0, value, true); + return payload; + }, + decode(payload) { + return new DataView( + payload.buffer, + payload.byteOffset, + payload.byteLength, + ).getFloat64(0, true); + }, + }; + const protocol = createBinaryProtocol({ + input: numberCodec, + state: numberCodec, + event: numberCodec, + }); + + const input = { + kind: "input", + packet: { + sequence: 4, + targetTick: 20, + observedTick: 16, + input: 0.75, + }, + }; + assert.deepEqual(protocol.decodeClient(protocol.encodeClient(input)), input); + + const welcome = { + kind: "welcome", + playerId: 9, + snapshot: { tick: 18, serverTime: 42.5, state: 3.25 }, + }; + assert.deepEqual(protocol.decodeServer(protocol.encodeServer(welcome)), welcome); + + const rejection = { kind: "reject-input", sequence: 44 }; + assert.deepEqual( + protocol.decodeServer(protocol.encodeServer(rejection)), + rejection, + ); + + const replayStart = { + kind: "replay-start", + ticketId: 12, + perspectiveId: 9, + fromTick: 100, + toTick: 160, + frameCount: 2, + playbackRate: 0.75, + }; + assert.deepEqual( + protocol.decodeServer(protocol.encodeServer(replayStart)), + replayStart, + ); + + const replayFrame = { + kind: "replay-frame", + ticketId: 12, + tick: 120, + state: 3.5, + events: [4.5, 5.5], + }; + assert.deepEqual( + protocol.decodeServer(protocol.encodeServer(replayFrame)), + replayFrame, + ); + const truncatedReplayFrame = protocol.encodeServer(replayFrame).slice(0, -1); + assert.throws(() => protocol.decodeServer(truncatedReplayFrame), RangeError); + + const replayEnd = { kind: "replay-end", ticketId: 12 }; + assert.deepEqual( + protocol.decodeServer(protocol.encodeServer(replayEnd)), + replayEnd, + ); +}); + +test("networked games keep authority private and derive viewer perceptions", () => { + const jsonCodec = { + encode: (value) => new TextEncoder().encode(JSON.stringify(value)), + decode: (payload) => JSON.parse(new TextDecoder().decode(payload)), + }; + const privateGame = defineNetworkedGame({ + tickRateHz: 10, + snapshotRateHz: 5, + server: { + createInitialState: () => ({ + publicValues: new Map(), + secrets: new Map(), + }), + cloneState: (state) => ({ + publicValues: new Map(state.publicValues), + secrets: new Map(state.secrets), + }), + addPlayer(state, { playerId }) { + state.publicValues.set(playerId, 0); + state.secrets.set(playerId, `secret-${playerId}`); + }, + applyInput(state, input, { playerId, emit }) { + state.publicValues.set(playerId, input); + emit({ source: playerId, privateValue: state.secrets.get(playerId) }); + }, + step() {}, + }, + client: { + createInitialState: () => ({ ownValue: 0, cues: [] }), + cloneState: (state) => ({ ...state, cues: [...state.cues] }), + applyInput(state, input) { + state.ownValue = input; + }, + step() {}, + applyEvent(state, event) { + state.cues.push(event); + }, + }, + replication: { + createSnapshot(authority, { playerId }) { + return { + ownValue: authority.publicValues.get(playerId) ?? 0, + cues: [], + }; + }, + validateClientState(authority, candidate, { playerId }) { + return candidate.ownValue === authority.publicValues.get(playerId); + }, + perceive(_authority, event, { playerId }) { + return event.source === playerId ? null : "heard-an-event"; + }, + }, + validateInput: (input) => Number.isFinite(input), + codecs: { input: jsonCodec, state: jsonCodec, event: jsonCodec }, + }); + + const server = privateGame.createServer(); + server.addPlayer(1); + server.addPlayer(2); + + const snapshot = server.createSnapshot(1, 0); + assert.deepEqual(snapshot.state, { ownValue: 0, cues: [] }); + assert.equal("secrets" in snapshot.state, false); + + assert.equal( + server.submitInput(2, { sequence: 1, targetTick: 1, input: 5 }).accepted, + true, + ); + const result = server.step(); + assert.deepEqual(server.createPerceptions(1, result.events), [ + "heard-an-event", + ]); + assert.deepEqual(server.createPerceptions(2, result.events), []); + + const client = privateGame.createClient(); + client.initialize(1, snapshot); + client.receiveEvent("heard-an-event", result.tick); + assert.deepEqual(client.currentState.cues, ["heard-an-event"]); + + const eventFrame = privateGame.protocol.encodeServer({ + kind: "event", + tick: result.tick, + event: "heard-an-event", + }); + assert.deepEqual(privateGame.protocol.decodeServer(eventFrame), { + kind: "event", + tick: result.tick, + event: "heard-an-event", + }); +}); + +const lagJsonCodec = { + encode: (value) => new TextEncoder().encode(JSON.stringify(value)), + decode: (payload) => JSON.parse(new TextDecoder().decode(payload)), +}; + +const lagBaseGame = defineNetworkedGame({ + tickRateHz: 10, + snapshotRateHz: 5, + server: { + createInitialState: () => ({ positions: {}, resolutions: [] }), + cloneState: (state) => ({ + positions: { ...state.positions }, + resolutions: state.resolutions.map((entry) => ({ ...entry })), + }), + addPlayer(state, { playerId }) { + state.positions[playerId] = 0; + }, + applyInput(state, input, { playerId }) { + if (input.moveTo !== undefined) state.positions[playerId] = input.moveTo; + }, + step() {}, + }, + client: { + createInitialState: () => ({ positions: {}, resolutions: [] }), + cloneState: (state) => structuredClone(state), + applyInput() {}, + step() {}, + }, + replication: { + createSnapshot: (state) => structuredClone(state), + }, + validateInput: (input) => + (input.moveTo === undefined || Number.isFinite(input.moveTo)) && + (input.action === undefined || + ["rewind", "current", "projectile", "strict"].includes(input.action)), + codecs: { input: lagJsonCodec, state: lagJsonCodec, event: lagJsonCodec }, +}); + +const lagPolicyGame = withLagCompensation(lagBaseGame, { + historySeconds: 2, + captureState: (state) => ({ positions: { ...state.positions } }), + cloneHistoricalState: (state) => ({ positions: { ...state.positions } }), + classifyAction: (input) => + input.action ? { type: input.action, targetId: input.targetId ?? 2 } : null, + cloneAction: (action) => ({ ...action }), + actions: { + rewind: { + mode: "rewind", + maximumRewindMs: 1_000, + resolve: recordLagResolution, + }, + current: { + mode: "current", + resolve: recordLagResolution, + }, + projectile: { + mode: "fast-forward", + maximumRewindMs: 1_000, + resolve: recordLagResolution, + }, + strict: { + mode: "rewind", + maximumRewindMs: 100, + outOfWindow: "reject", + resolve: recordLagResolution, + }, + }, +}); + +const lagReplayGame = withTimeTravel(lagPolicyGame, { + createSeed: () => 0, + hashState: deterministicHash, + checkpointIntervalTicks: 2, + verificationIntervalTicks: 1, +}); + +function recordLagResolution(context) { + context.currentState.resolutions.push({ + type: context.action.type, + requestedTick: context.requestedTick, + resolvedTick: context.resolvedTick, + currentTick: context.currentTick, + rewindTicks: context.rewindTicks, + catchUpTicks: context.catchUpTicks, + historicalPosition: + context.historicalState.positions[context.action.targetId], + currentPosition: context.currentState.positions[context.action.targetId], + }); +} + +test("lag-compensation HOF applies an independent policy per action", () => { + const server = lagPolicyGame.createServer(); + server.addPlayer(1); + server.addPlayer(2); + + server.submitInput(2, { + sequence: 1, + targetTick: 1, + observedTick: 0, + input: { moveTo: 5 }, + }); + server.step(); + server.step(); + + server.submitInput(1, { + sequence: 1, + targetTick: 3, + observedTick: 0, + input: { action: "rewind", targetId: 2 }, + }); + server.step(); + server.submitInput(1, { + sequence: 2, + targetTick: 4, + observedTick: 0, + input: { action: "current", targetId: 2 }, + }); + server.step(); + server.submitInput(1, { + sequence: 3, + targetTick: 5, + observedTick: 2, + input: { action: "projectile", targetId: 2 }, + }); + server.step(); + server.submitInput(1, { + sequence: 4, + targetTick: 6, + observedTick: 0, + input: { action: "strict", targetId: 2 }, + }); + server.step(); + + assert.equal(server.currentState.positions[2], 5); + assert.deepEqual(server.currentState.resolutions, [ + { + type: "rewind", + requestedTick: 0, + resolvedTick: 0, + currentTick: 3, + rewindTicks: 3, + catchUpTicks: 0, + historicalPosition: 0, + currentPosition: 5, + }, + { + type: "current", + requestedTick: 0, + resolvedTick: 4, + currentTick: 4, + rewindTicks: 0, + catchUpTicks: 0, + historicalPosition: 5, + currentPosition: 5, + }, + { + type: "projectile", + requestedTick: 2, + resolvedTick: 2, + currentTick: 5, + rewindTicks: 3, + catchUpTicks: 3, + historicalPosition: 5, + currentPosition: 5, + }, + ]); +}); + +test("lag-compensated actions replay deterministically through checkpoints", () => { + const server = lagReplayGame.createServer(); + server.addPlayer(1); + server.addPlayer(2); + server.submitInput(2, { + sequence: 1, + targetTick: 1, + observedTick: 0, + input: { moveTo: 5 }, + }); + server.step(); + server.step(); + server.submitInput(1, { + sequence: 1, + targetTick: 3, + observedTick: 0, + input: { action: "rewind", targetId: 2 }, + }); + for (let tick = 2; tick < 6; tick += 1) server.step(); + + const replay = lagReplayGame.createReplay(server.exportRecording()); + assert.deepEqual(replay.seek(6).state, server.currentState); + assert.equal(replay.currentState.resolutions[0].historicalPosition, 0); +}); + +const replayCodec = { + encode: (value) => new TextEncoder().encode(JSON.stringify(value)), + decode: (payload) => JSON.parse(new TextDecoder().decode(payload)), +}; + +const replayDefinition = defineNetworkedGame({ + tickRateHz: 10, + snapshotRateHz: 5, + server: { + createInitialState: () => ({ + seed: 0, + secret: "uninitialized", + positions: new Map(), + velocities: new Map(), + }), + cloneState: (state) => ({ + ...state, + positions: new Map(state.positions), + velocities: new Map(state.velocities), + }), + addPlayer(state, { playerId, emit }) { + state.positions.set(playerId, state.seed); + state.velocities.set(playerId, 0); + emit({ type: "joined", playerId }); + }, + removePlayer(state, { playerId }) { + state.positions.delete(playerId); + state.velocities.delete(playerId); + }, + applyInput(state, input, { playerId, emit }) { + state.velocities.set(playerId, input.axis); + emit({ type: "moved", playerId }); + }, + step(state, { deltaSeconds }) { + for (const [playerId, velocity] of state.velocities) { + state.positions.set( + playerId, + (state.positions.get(playerId) ?? 0) + velocity * deltaSeconds, + ); + } + }, + }, + client: { + createInitialState: () => ({ ownPosition: 0, cues: [] }), + cloneState: (state) => ({ ...state, cues: [...state.cues] }), + applyInput(state, input) { + state.ownPosition += input.axis / 10; + }, + step() {}, + applyEvent(state, event) { + state.cues.push(event); + }, + }, + replication: { + createSnapshot(authority, { playerId }) { + return { + ownPosition: authority.positions.get(playerId) ?? 0, + cues: [], + }; + }, + perceive(_authority, event, { playerId }) { + return event.playerId === playerId ? null : `heard-${event.type}`; + }, + }, + validateInput: (input) => + Number.isFinite(input.axis) && Math.abs(input.axis) <= 1, + codecs: { + input: replayCodec, + state: replayCodec, + event: replayCodec, + }, +}); + +const replayGame = withTimeTravel(replayDefinition, { + createSeed: () => 1, + initializeState(state, seed) { + state.seed = seed; + state.secret = `secret-${seed}`; + }, + hashState: deterministicHash, + checkpointIntervalTicks: 2, + verificationIntervalTicks: 1, +}); + +const replayTransportGame = withReplayTransport(replayGame, { + historySeconds: 0.6, + captureRateHz: 5, + createTickets(event, { tick }) { + return event.type === "joined" && event.playerId === 2 + ? { + requesterId: 1, + perspectiveId: 2, + fromTick: 0, + toTick: tick, + } + : null; + }, + authorizeReplay: ({ requesterId, perspectiveId }) => + requesterId === 1 && perspectiveId === 2, +}); + +test("time-travel HOF records, seeks, verifies, filters, and branches", () => { + const server = replayGame.createServer({ replaySeed: 7 }); + server.addPlayer(1); + server.addPlayer(2); + assert.equal( + server.submitInput(1, { + sequence: 1, + targetTick: 1, + input: { axis: 1 }, + }).accepted, + true, + ); + assert.equal( + server.submitInput(2, { + sequence: 1, + targetTick: 1, + input: { axis: -0.5 }, + }).accepted, + true, + ); + + for (let tick = 0; tick < 3; tick += 1) server.step(); + assert.equal( + server.submitInput(1, { + sequence: 2, + targetTick: 5, + input: { axis: 0.25 }, + }).accepted, + true, + ); + for (let tick = 3; tick < 6; tick += 1) server.step(); + + const recording = server.exportRecording(); + assert.equal(recording.seed, 7); + assert.equal(recording.durationTicks, 6); + assert.deepEqual( + recording.checkpoints.map(({ tick }) => tick), + [0, 2, 4, 6], + ); + + const replay = replayGame.createReplay(recording); + const finalFrame = replay.seek(6); + assert.deepEqual(finalFrame.state, server.currentState); + assert.equal(finalFrame.hash, deterministicHash(server.currentState)); + + const privateView = replay.viewAs(1, 123); + assert.equal(privateView.tick, 6); + assert.equal("secret" in privateView.state, false); + assert.deepEqual(Object.keys(privateView.state).sort(), ["cues", "ownPosition"]); + + replay.seek(1); + assert.deepEqual(replay.perceptionsAs(1), ["heard-joined", "heard-moved"]); + const rewindPosition = replay.currentState.positions.get(1); + replay.seek(6); + assert.notEqual(replay.currentState.positions.get(1), rewindPosition); + + const branch = replay.branch(2); + assert.equal( + branch.submitInput(1, { + sequence: 2, + targetTick: 3, + input: { axis: -1 }, + }).accepted, + true, + ); + while (branch.tick < 6) branch.step(); + assert.notEqual( + deterministicHash(branch.currentState), + deterministicHash(server.currentState), + ); + + const branchReplay = replayGame.createReplay(branch.exportRecording()); + assert.deepEqual(branchReplay.seek(6).state, branch.currentState); + assert.deepEqual(replay.seek(6).state, server.currentState); +}); + +test("time-travel replay pinpoints deterministic divergence", () => { + const server = replayGame.createServer({ replaySeed: 4 }); + server.addPlayer(1); + server.submitInput(1, { + sequence: 1, + targetTick: 1, + input: { axis: 1 }, + }); + for (let tick = 0; tick < 3; tick += 1) server.step(); + + const recording = server.exportRecording(); + const corrupted = { + ...recording, + verifications: recording.verifications.map((entry) => + entry.tick === 3 ? { ...entry, hash: "corrupted" } : entry, + ), + }; + const replay = replayGame.createReplay(corrupted); + assert.throws(() => replay.seek(3), ReplayDivergenceError); +}); + +test("rolling authority recordings stay bounded and remain seekable", () => { + const server = replayGame.createServer({ + replaySeed: 8, + recordingHistoryTicks: 4, + }); + server.addPlayer(1); + for (let tick = 0; tick < 10; tick += 1) server.step(); + + const recording = server.exportRecording(); + assert.equal(recording.startTick, 6); + assert.equal(recording.durationTicks, 10); + assert.ok(recording.verifications.every(({ tick }) => tick >= 6)); + assert.ok(recording.checkpoints.every(({ tick }) => tick >= 6)); + + const replay = replayGame.createReplay(recording); + assert.equal(replay.startTick, 6); + assert.deepEqual(replay.seek(10).state, server.currentState); + assert.throws(() => replay.seek(5), RangeError); +}); + +test("replay transport issues only authorized projected frames from a bounded ring", () => { + const server = replayTransportGame.createServer({ replaySeed: 11 }); + server.addPlayer(1); + server.addPlayer(2); + for (let tick = 0; tick < 12; tick += 1) server.step(); + + const automaticTickets = server.drainReplayTickets(1); + assert.equal(automaticTickets.length, 1); + assert.equal(automaticTickets[0].perspectiveId, 2); + + assert.equal( + server.issueReplay({ + requesterId: 1, + perspectiveId: 1, + fromTick: 0, + toTick: 12, + }), + null, + ); + + const ticket = server.issueReplay({ + requesterId: 1, + perspectiveId: 2, + fromTick: 0, + toTick: 12, + playbackRate: 0.5, + }); + assert.ok(ticket); + assert.equal(ticket.requesterId, 1); + assert.equal(ticket.perspectiveId, 2); + assert.equal(ticket.fromTick, 6); + assert.equal(ticket.toTick, 12); + assert.equal(ticket.playbackRate, 0.5); + assert.ok(ticket.frames.length > 1); + assert.ok( + ticket.frames.every( + ({ state }) => + !Object.hasOwn(state, "secret") && + Object.keys(state).sort().join(",") === "cues,ownPosition", + ), + ); + + assert.deepEqual(server.drainReplayTickets(1), [ticket]); + assert.deepEqual(server.drainReplayTickets(1), []); + assert.equal(server.exportRecording().startTick, 6); +}); diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json new file mode 100644 index 0000000..275a60e --- /dev/null +++ b/packages/engine/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "lib": ["ES2022"] + }, + "include": ["src"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..a2cc2d5 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,28 @@ +{ + "name": "@syncer/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "dependencies": { + "@syncer/engine": "0.0.0" + }, + "scripts": { + "dev": "tsc --watch --preserveWatchOutput", + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^7.0.2" + } +} diff --git a/packages/shared/src/arena.ts b/packages/shared/src/arena.ts new file mode 100644 index 0000000..e13fdf9 --- /dev/null +++ b/packages/shared/src/arena.ts @@ -0,0 +1,231 @@ +export interface ArenaBlock { + id: string; + x: number; + z: number; + width: number; + depth: number; + height: number; + style: "reactor" | "wall" | "crate" | "column"; +} + +export interface ArenaSpawn { + x: number; + z: number; + yaw: number; +} + +export interface ArenaPoint { + x: number; + z: number; +} + +export const ARENA_HALF_SIZE = 18; +export const PLAYER_RADIUS = 0.44; +export const PLAYER_EYE_HEIGHT = 1.58; +export const PLAYER_HEIGHT = 1.8; + +export const ARENA_BLOCKS: readonly ArenaBlock[] = [ + { id: "reactor", x: 0, z: 0, width: 4.2, depth: 4.2, height: 4.8, style: "reactor" }, + { id: "north-spine", x: 0, z: -10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" }, + { id: "south-spine", x: 0, z: 10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" }, + { id: "west-spine", x: -10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" }, + { id: "east-spine", x: 10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" }, + { id: "nw-cover-a", x: -7, z: -7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" }, + { id: "nw-cover-b", x: -7, z: -4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" }, + { id: "se-cover-a", x: 7, z: 7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" }, + { id: "se-cover-b", x: 7, z: 4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" }, + { id: "ne-cover-a", x: 7, z: -7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" }, + { id: "sw-cover-a", x: -7, z: 7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" }, + { id: "northwest-column", x: -14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" }, + { id: "northeast-column", x: 14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" }, + { id: "southwest-column", x: -14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" }, + { id: "southeast-column", x: 14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" }, +] as const; + +export const ARENA_SPAWNS: readonly ArenaSpawn[] = [ + { x: -14.5, z: -9.5, yaw: 2.35 }, + { x: 14.5, z: 9.5, yaw: -0.78 }, + { x: 14.5, z: -9.5, yaw: -2.35 }, + { x: -14.5, z: 9.5, yaw: 0.78 }, + { x: 0, z: -15, yaw: Math.PI }, + { x: 0, z: 15, yaw: 0 }, + { x: -15, z: 0, yaw: Math.PI / 2 }, + { x: 15, z: 0, yaw: -Math.PI / 2 }, +] as const; + +export const BOT_PATROL_POINTS: readonly ArenaPoint[] = [ + { x: -13, z: -8 }, + { x: -5.5, z: -12.5 }, + { x: 5.5, z: -12.5 }, + { x: 13, z: -8 }, + { x: 12.5, z: 4 }, + { x: 8, z: 12.5 }, + { x: -3.5, z: 13.5 }, + { x: -12.5, z: 8 }, + { x: -13.5, z: -3.5 }, + { x: -5.8, z: -4.8 }, + { x: 5.8, z: 4.8 }, + { x: 5.5, z: -5.5 }, +] as const; + +export function hasLineOfSight( + fromX: number, + fromZ: number, + toX: number, + toZ: number, +): boolean { + for (const block of ARENA_BLOCKS) { + if (segmentIntersectsExpandedBlock(fromX, fromZ, toX, toZ, block, 0.08)) { + return false; + } + } + return true; +} + +export function collidesWithArena(x: number, z: number, radius = PLAYER_RADIUS): boolean { + if ( + x - radius < -ARENA_HALF_SIZE || + x + radius > ARENA_HALF_SIZE || + z - radius < -ARENA_HALF_SIZE || + z + radius > ARENA_HALF_SIZE + ) { + return true; + } + + return ARENA_BLOCKS.some((block) => + circleIntersectsBlock(x, z, radius, block), + ); +} + +export function rayBlockDistance( + originX: number, + originY: number, + originZ: number, + directionX: number, + directionY: number, + directionZ: number, + maximumDistance: number, +): number | null { + let nearest: number | null = null; + + for (const block of ARENA_BLOCKS) { + const distance = rayAabbDistance( + originX, + originY, + originZ, + directionX, + directionY, + directionZ, + block.x - block.width / 2, + 0, + block.z - block.depth / 2, + block.x + block.width / 2, + block.height, + block.z + block.depth / 2, + ); + + if ( + distance !== null && + distance >= 0 && + distance <= maximumDistance && + (nearest === null || distance < nearest) + ) { + nearest = distance; + } + } + + return nearest; +} + +function circleIntersectsBlock( + x: number, + z: number, + radius: number, + block: ArenaBlock, +): boolean { + const halfWidth = block.width / 2; + const halfDepth = block.depth / 2; + const nearestX = Math.max(block.x - halfWidth, Math.min(x, block.x + halfWidth)); + const nearestZ = Math.max(block.z - halfDepth, Math.min(z, block.z + halfDepth)); + const dx = x - nearestX; + const dz = z - nearestZ; + return dx * dx + dz * dz < radius * radius; +} + +function segmentIntersectsExpandedBlock( + fromX: number, + fromZ: number, + toX: number, + toZ: number, + block: ArenaBlock, + padding: number, +): boolean { + const directionX = toX - fromX; + const directionZ = toZ - fromZ; + const distance = Math.hypot(directionX, directionZ); + if (distance < 0.0001) { + return false; + } + + const hit = rayAabbDistance( + fromX, + 1.2, + fromZ, + directionX / distance, + 0, + directionZ / distance, + block.x - block.width / 2 - padding, + 0, + block.z - block.depth / 2 - padding, + block.x + block.width / 2 + padding, + block.height, + block.z + block.depth / 2 + padding, + ); + return hit !== null && hit > 0.05 && hit < distance - 0.05; +} + +function rayAabbDistance( + originX: number, + originY: number, + originZ: number, + directionX: number, + directionY: number, + directionZ: number, + minimumX: number, + minimumY: number, + minimumZ: number, + maximumX: number, + maximumY: number, + maximumZ: number, +): number | null { + let minimumDistance = -Infinity; + let maximumDistance = Infinity; + + for (const [origin, direction, minimum, maximum] of [ + [originX, directionX, minimumX, maximumX], + [originY, directionY, minimumY, maximumY], + [originZ, directionZ, minimumZ, maximumZ], + ] as const) { + if (Math.abs(direction) < 0.000001) { + if (origin < minimum || origin > maximum) { + return null; + } + continue; + } + + const inverse = 1 / direction; + let near = (minimum - origin) * inverse; + let far = (maximum - origin) * inverse; + if (near > far) { + [near, far] = [far, near]; + } + minimumDistance = Math.max(minimumDistance, near); + maximumDistance = Math.min(maximumDistance, far); + if (maximumDistance < minimumDistance) { + return null; + } + } + + return maximumDistance >= 0 ? Math.max(0, minimumDistance) : null; +} + diff --git a/packages/shared/src/flux-game.ts b/packages/shared/src/flux-game.ts new file mode 100644 index 0000000..b055e44 --- /dev/null +++ b/packages/shared/src/flux-game.ts @@ -0,0 +1,364 @@ +import { + createJsonCodec, + defineMultiplayerGame, +} from "@syncer/engine"; +import type { + FluxAuthorityEvent, + FluxAuthorityPlayer, + FluxAuthorityState, + FluxClientState, + FluxGameContract, + FluxInput, + FluxPerception, + FluxPlayerView, + FluxTeam, +} from "./flux-types.js"; + +export const FLUX_SOCKET_PATH = "/ws/flux"; +export const FLUX_TICK_RATE = 30; +export const FLUX_SNAPSHOT_RATE = 10; +export const FLUX_ROUND_RESET_TICKS = 60; +export const FLUX_BOT_IDS = [20_001, 20_002] as const; + +const maximumEnergy = 100; +const energyDrainPerSecond = 31; +const energyRecoveryPerSecond = 20; +const coreAcceleration = 0.92; +const coreDragPerSecond = 0.86; +const eventLifetimeTicks = FLUX_TICK_RATE * 4; + +const inputCodec = createJsonCodec(); +const stateCodec = createJsonCodec(); +const perceptionCodec = createJsonCodec(); + +/** + * A complete second game built through the public authoring API. Flux Relay is + * a team tug-of-war: hold thrust, manage private energy, and pull the shared + * core through your team's gate. + */ +export const fluxGame = defineMultiplayerGame({ + clock: { + ticksPerSecond: FLUX_TICK_RATE, + snapshotsPerSecond: FLUX_SNAPSHOT_RATE, + }, + + authority: { + createInitialState: createAuthorityState, + cloneState: cloneAuthorityState, + addPlayer(state, { playerId }) { + state.players.push(createPlayer(playerId, leastPopulatedTeam(state), false)); + sortPlayers(state.players); + }, + removePlayer(state, { playerId }) { + state.players = state.players.filter((player) => player.id !== playerId); + }, + applyInput(state, input, { playerId }) { + const player = state.players.find((candidate) => candidate.id === playerId); + if (!player || player.bot) return; + player.thrust = input.thrust && !player.overheated; + }, + step(state, { tick, deltaSeconds, emit }) { + state.elapsedTicks += 1; + + if (state.resetTicks > 0) { + state.resetTicks -= 1; + state.core *= 0.88; + state.velocity = 0; + if (state.resetTicks === 0) state.core = 0; + updatePlayers(state, tick, deltaSeconds, emit, false); + return; + } + + updatePlayers(state, tick, deltaSeconds, emit, true); + const cyanForce = activePlayerCount(state, "cyan"); + const orangeForce = activePlayerCount(state, "orange"); + const signedForce = orangeForce - cyanForce; + state.velocity += signedForce * coreAcceleration * deltaSeconds; + state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds); + state.core += state.velocity * deltaSeconds; + + if (Math.abs(state.core) >= 1) { + const team: FluxTeam = state.core < 0 ? "cyan" : "orange"; + state.core = team === "cyan" ? -1 : 1; + state.velocity = 0; + state.resetTicks = FLUX_ROUND_RESET_TICKS; + state.round += 1; + if (team === "cyan") state.cyanScore += 1; + else state.orangeScore += 1; + emit({ + id: state.nextEventId++, + type: "round-won", + team, + round: state.round, + }); + } + }, + validateState(state) { + return ( + Number.isFinite(state.core) && + Number.isFinite(state.velocity) && + Math.abs(state.core) <= 1.000_001 && + state.players.every( + (player) => + Number.isFinite(player.energy) && + player.energy >= 0 && + player.energy <= maximumEnergy, + ) + ); + }, + }, + + prediction: { + createInitialState: createClientState, + cloneState: cloneClientState, + applyInput(state, input, { playerId }) { + const player = state.players.find((candidate) => candidate.id === playerId); + if (player && player.energy !== null) { + player.thrust = input.thrust && player.energy > 0; + } + }, + step(state, { tick, deltaSeconds }) { + state.elapsedTicks += 1; + state.events = state.events.filter( + (entry) => entry.receivedTick >= tick - eventLifetimeTicks, + ); + + const ownPlayer = state.players.find((player) => player.energy !== null); + if (ownPlayer) updateVisibleEnergy(ownPlayer, deltaSeconds); + + if (state.resetTicks > 0) { + state.resetTicks -= 1; + state.core *= 0.88; + state.velocity = 0; + if (state.resetTicks === 0) state.core = 0; + return; + } + + const cyanForce = visibleActiveCount(state, "cyan"); + const orangeForce = visibleActiveCount(state, "orange"); + state.velocity += (orangeForce - cyanForce) * coreAcceleration * deltaSeconds; + state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds); + state.core = clamp(state.core + state.velocity * deltaSeconds, -1, 1); + }, + mergeSnapshot(predicted, snapshot, { tick }) { + const merged = cloneClientState(snapshot); + merged.events = predicted.events + .filter((entry) => entry.receivedTick >= tick - eventLifetimeTicks) + .map((entry) => ({ + receivedTick: entry.receivedTick, + event: { ...entry.event }, + })); + return merged; + }, + applyEvent(state, event, { tick }) { + if (state.events.some((entry) => entry.event.id === event.id)) return; + state.events.push({ receivedTick: tick, event: { ...event } }); + }, + validateState(state) { + const ownEnergy = state.players.find((player) => player.energy !== null)?.energy; + return ( + Number.isFinite(state.core) && + Math.abs(state.core) <= 1.000_001 && + (ownEnergy === undefined || + (ownEnergy !== null && ownEnergy >= 0 && ownEnergy <= maximumEnergy)) + ); + }, + }, + + visibility: { + createSnapshot(authority, { playerId }) { + return { + core: authority.core, + velocity: authority.velocity, + round: authority.round, + cyanScore: authority.cyanScore, + orangeScore: authority.orangeScore, + resetTicks: authority.resetTicks, + elapsedTicks: authority.elapsedTicks, + players: authority.players.map((player) => ({ + id: player.id, + team: player.team, + bot: player.bot, + thrust: player.thrust, + energy: player.id === playerId ? player.energy : null, + })), + events: [], + }; + }, + validateClientState(authority, candidate, { playerId }) { + const expected = authority.players.find((player) => player.id === playerId); + const reported = candidate.players.find((player) => player.id === playerId); + return Boolean( + expected && + reported && + reported.energy !== null && + Math.abs(reported.energy - expected.energy) <= 12 && + Math.abs(candidate.core - authority.core) <= 0.3, + ); + }, + perceive(_authority, event, { playerId }) { + if (event.type === "round-won") return { ...event }; + return { + id: event.id, + type: "overheated", + team: event.team, + scope: event.playerId === playerId ? "self" : "field", + }; + }, + }, + + input: { + validate(input) { + return typeof input === "object" && input !== null && typeof input.thrust === "boolean"; + }, + }, + + encoding: { + input: inputCodec, + clientState: stateCodec, + perception: perceptionCodec, + }, +}); + +function createAuthorityState(): FluxAuthorityState { + return { + core: 0, + velocity: 0, + round: 1, + cyanScore: 0, + orangeScore: 0, + resetTicks: 0, + elapsedTicks: 0, + nextEventId: 1, + players: [ + createPlayer(FLUX_BOT_IDS[0], "cyan", true), + createPlayer(FLUX_BOT_IDS[1], "orange", true), + ], + }; +} + +function createClientState(): FluxClientState { + return { + core: 0, + velocity: 0, + round: 1, + cyanScore: 0, + orangeScore: 0, + resetTicks: 0, + elapsedTicks: 0, + players: [], + events: [], + }; +} + +function createPlayer( + id: number, + team: FluxTeam, + bot: boolean, +): FluxAuthorityPlayer { + return { + id, + team, + bot, + thrust: false, + energy: maximumEnergy, + overheated: false, + }; +} + +function updatePlayers( + state: FluxAuthorityState, + tick: number, + deltaSeconds: number, + emit: (event: FluxAuthorityEvent) => void, + allowThrust: boolean, +): void { + for (const player of state.players) { + if (player.bot) { + // Both bots use the same deterministic cadence, so they animate the field + // without biasing a solo player's match. + player.thrust = allowThrust && tick % 150 < 92 && !player.overheated; + } + + if (player.thrust && allowThrust && !player.overheated) { + player.energy = clamp( + player.energy - energyDrainPerSecond * deltaSeconds, + 0, + maximumEnergy, + ); + if (player.energy === 0) { + player.thrust = false; + player.overheated = true; + emit({ + id: state.nextEventId++, + type: "overheated", + playerId: player.id, + team: player.team, + }); + } + } else { + player.energy = clamp( + player.energy + energyRecoveryPerSecond * deltaSeconds, + 0, + maximumEnergy, + ); + if (player.overheated && player.energy >= maximumEnergy * 0.34) { + player.overheated = false; + } + } + } +} + +function updateVisibleEnergy(player: FluxPlayerView, deltaSeconds: number): void { + if (player.energy === null) return; + player.energy = clamp( + player.energy + + (player.thrust ? -energyDrainPerSecond : energyRecoveryPerSecond) * deltaSeconds, + 0, + maximumEnergy, + ); + if (player.energy === 0) player.thrust = false; +} + +function activePlayerCount(state: FluxAuthorityState, team: FluxTeam): number { + return state.players.filter( + (player) => player.team === team && player.thrust && !player.overheated, + ).length; +} + +function visibleActiveCount(state: FluxClientState, team: FluxTeam): number { + return state.players.filter((player) => player.team === team && player.thrust).length; +} + +function leastPopulatedTeam(state: FluxAuthorityState): FluxTeam { + const humans = state.players.filter((player) => !player.bot); + const cyan = humans.filter((player) => player.team === "cyan").length; + const orange = humans.length - cyan; + return cyan <= orange ? "cyan" : "orange"; +} + +function cloneAuthorityState(state: FluxAuthorityState): FluxAuthorityState { + return { + ...state, + players: state.players.map((player) => ({ ...player })), + }; +} + +function cloneClientState(state: FluxClientState): FluxClientState { + return { + ...state, + players: state.players.map((player) => ({ ...player })), + events: state.events.map((entry) => ({ + receivedTick: entry.receivedTick, + event: { ...entry.event }, + })), + }; +} + +function sortPlayers(players: FluxAuthorityPlayer[]): void { + players.sort((left, right) => left.id - right.id); +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/packages/shared/src/flux-types.ts b/packages/shared/src/flux-types.ts new file mode 100644 index 0000000..d5f5600 --- /dev/null +++ b/packages/shared/src/flux-types.ts @@ -0,0 +1,88 @@ +export type FluxTeam = "cyan" | "orange"; + +export interface FluxInput { + thrust: boolean; +} + +export interface FluxAuthorityPlayer { + id: number; + team: FluxTeam; + bot: boolean; + thrust: boolean; + energy: number; + overheated: boolean; +} + +export interface FluxAuthorityState { + core: number; + velocity: number; + round: number; + cyanScore: number; + orangeScore: number; + resetTicks: number; + elapsedTicks: number; + nextEventId: number; + players: FluxAuthorityPlayer[]; +} + +export interface FluxPlayerView { + id: number; + team: FluxTeam; + bot: boolean; + thrust: boolean; + /** Exact energy is private: null for every player except the viewer. */ + energy: number | null; +} + +export type FluxPerception = + | { + id: number; + type: "round-won"; + team: FluxTeam; + round: number; + } + | { + id: number; + type: "overheated"; + team: FluxTeam; + scope: "self" | "field"; + }; + +export interface FluxPresentationEvent { + receivedTick: number; + event: FluxPerception; +} + +export interface FluxClientState { + core: number; + velocity: number; + round: number; + cyanScore: number; + orangeScore: number; + resetTicks: number; + elapsedTicks: number; + players: FluxPlayerView[]; + events: FluxPresentationEvent[]; +} + +export type FluxAuthorityEvent = + | { + id: number; + type: "round-won"; + team: FluxTeam; + round: number; + } + | { + id: number; + type: "overheated"; + playerId: number; + team: FluxTeam; + }; + +export interface FluxGameContract { + authority: FluxAuthorityState; + client: FluxClientState; + input: FluxInput; + authorityEvent: FluxAuthorityEvent; + perceptionEvent: FluxPerception; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..9fa6062 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,16 @@ +export const API_PATHS = { message: "/api/message" } as const; +export const GAME_SOCKET_PATH = "/ws/game"; + +export interface ApiMessage { + message: string; + timestamp: string; +} + +export * from "./arena.js"; +export * from "./flux-types.js"; +export * from "./flux-game.js"; +export * from "./royale-types.js"; +export * from "./royale-map.js"; +export * from "./royale-game.js"; +export * from "./shooter-types.js"; +export * from "./shooter-game.js"; diff --git a/packages/shared/src/royale-game.ts b/packages/shared/src/royale-game.ts new file mode 100644 index 0000000..1d68522 --- /dev/null +++ b/packages/shared/src/royale-game.ts @@ -0,0 +1,834 @@ +import { + createJsonCodec, + defineMultiplayerGame, + withSpatialReplication, +} from "@syncer/engine"; +import { + ROYALE_CHUNK_SIZE, + ROYALE_MAP_SEED, + ROYALE_WORLD_RADIUS, + ROYALE_WORLD_SIZE, + collidesWithRoyaleMap, + hasRoyaleLineOfSight, + royaleChunkCoordinate, + royaleRandom, +} from "./royale-map.js"; +import type { + RoyaleAuthorityEvent, + RoyaleAuthorityLoot, + RoyaleAuthorityPlayer, + RoyaleAuthorityState, + RoyaleClientState, + RoyaleGameContract, + RoyaleInput, + RoyaleLootKind, + RoyalePerception, + RoyalePlayerView, + RoyaleWeapon, +} from "./royale-types.js"; + +export const ROYALE_SOCKET_PATH = "/ws/royale"; +export const ROYALE_TICK_RATE = 30; +export const ROYALE_SNAPSHOT_RATE = 10; +export const ROYALE_BOT_COUNT = 31; +export const ROYALE_BOT_ID_BASE = 30_000; + +export const ROYALE_WEAPONS: Record< + RoyaleWeapon, + { + name: string; + damage: number; + range: number; + cooldownTicks: number; + magazine: number; + reserve: number; + reloadTicks: number; + } +> = { + pistol: { name: "Kestrel P9", damage: 19, range: 125, cooldownTicks: 8, magazine: 12, reserve: 48, reloadTicks: 38 }, + rifle: { name: "VX-4 Carbine", damage: 15, range: 205, cooldownTicks: 4, magazine: 30, reserve: 120, reloadTicks: 52 }, + marksman: { name: "Longwatch DMR", damage: 48, range: 340, cooldownTicks: 17, magazine: 8, reserve: 32, reloadTicks: 64 }, +}; + +const movementAcceleration = 52; +const movementFriction = 10; +const walkSpeed = 11.5; +const sprintSpeed = 16.5; +const parachuteSpeed = 22; +const parachuteDescentPerSecond = 18; +const playerEyeHeight = 1.55; +const playerHitRadius = 1.25; +const presentationEventLifetime = ROYALE_TICK_RATE * 6; +const initialStormRadius = 900; +const finalStormRadius = 78; +const stormShrinkPerTick = 0.085; +const lootCount = 260; + +const inputCodec = createJsonCodec(); +const stateCodec = createJsonCodec(); +const eventCodec = createJsonCodec(); + +const baseRoyaleGame = defineMultiplayerGame({ + clock: { + ticksPerSecond: ROYALE_TICK_RATE, + snapshotsPerSecond: ROYALE_SNAPSHOT_RATE, + }, + + authority: { + createInitialState: createAuthorityState, + cloneState: cloneAuthorityState, + addPlayer(state, { playerId }) { + state.players.push(createPlayer(state, playerId, false, 110)); + state.players.sort((left, right) => left.id - right.id); + }, + removePlayer(state, { playerId }) { + state.players = state.players.filter((player) => player.id !== playerId); + }, + applyInput(state, input, { playerId }) { + const player = findPlayer(state, playerId); + if (!player || player.bot || !player.alive) return; + applyCommand(player, input); + if (input.reload) beginReload(player); + }, + step(state, { tick, deltaSeconds, emit }) { + if (state.resetTicks > 0) { + state.resetTicks -= 1; + if (state.resetTicks === 0) resetMatch(state); + return; + } + + state.elapsedTicks += 1; + state.stormRadius = Math.max( + finalStormRadius, + initialStormRadius - state.elapsedTicks * stormShrinkPerTick, + ); + + for (const player of state.players) { + if (!player.alive) continue; + updateTimers(player); + if (player.botState) updateBot(state, player, tick); + const landed = simulatePlayer(player, deltaSeconds); + if (landed) { + emit({ id: state.nextEventId++, type: "landed", playerId: player.id }); + } + if (player.altitude <= 0) { + collectLoot(state, player, emit); + if (player.firing) tryFire(state, player, emit); + applyStormDamage(state, player, tick, emit); + } + } + + const alive = state.players.filter((player) => player.alive); + if (state.elapsedTicks > ROYALE_TICK_RATE * 8 && alive.length <= 1 && state.players.length > 1) { + const winner = alive[0] ?? null; + state.winnerId = winner?.id ?? null; + state.resetTicks = ROYALE_TICK_RATE * 5; + if (winner) { + emit({ + id: state.nextEventId++, + type: "winner", + playerId: winner.id, + round: state.round, + }); + } + } + }, + validateState(state) { + return ( + Number.isFinite(state.stormRadius) && + state.stormRadius >= finalStormRadius && + state.players.every( + (player) => + Number.isFinite(player.x + player.z + player.health + player.altitude) && + Number.isFinite(player.yaw + player.pitch) && + player.health >= 0 && + player.health <= 100 && + player.armor >= 0 && + player.armor <= 100, + ) + ); + }, + }, + + prediction: { + createInitialState: createClientState, + cloneState: cloneClientState, + applyInput(state, input, { playerId }) { + const player = state.players.find((candidate) => candidate.id === playerId); + if (!player || !player.alive) return; + player.inputForward = input.forward; + player.inputStrafe = input.strafe; + player.yaw = normalizeAngle(input.yaw); + player.pitch = clamp(input.pitch, -1.25, 1.25); + player.firing = input.fire; + player.sprinting = input.sprint; + if (input.reload && player.reloadTicks === 0) { + player.reloadTicks = ROYALE_WEAPONS[player.weapon].reloadTicks; + } + }, + step(state, { tick, deltaSeconds }) { + state.elapsedTicks += 1; + state.stormRadius = Math.max( + finalStormRadius, + initialStormRadius - state.elapsedTicks * stormShrinkPerTick, + ); + for (const player of state.players) { + if (!player.alive) continue; + if (player.cooldownTicks > 0) player.cooldownTicks -= 1; + if (player.reloadTicks > 0) player.reloadTicks -= 1; + simulateVisiblePlayer(player, deltaSeconds); + } + state.events = state.events.filter( + (entry) => entry.receivedTick >= tick - presentationEventLifetime, + ); + }, + mergeSnapshot(predicted, snapshot, { tick }) { + const merged = cloneClientState(snapshot); + merged.events = predicted.events + .filter((entry) => entry.receivedTick >= tick - presentationEventLifetime) + .map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })); + return merged; + }, + applyEvent(state, event, { tick }) { + if (state.events.some((entry) => entry.event.id === event.id)) return; + state.events.push({ receivedTick: tick, event: { ...event } }); + if (state.events.length > 64) state.events.shift(); + }, + validateState(state) { + return ( + Number.isFinite(state.stormRadius) && + state.players.every( + (player) => + Number.isFinite(player.x + player.z + player.altitude) && + Number.isFinite(player.yaw + player.pitch) && + (player.health === null || (player.health >= 0 && player.health <= 100)), + ) + ); + }, + }, + + visibility: { + createSnapshot(authority, { playerId }) { + const viewer = findPlayer(authority, playerId); + const players = viewer + ? authority.players + .filter((target) => target.id === playerId || canSeePlayer(viewer, target)) + .map((player) => clonePlayerView(player, player.id === playerId)) + : []; + const loot = viewer + ? authority.loot + .filter( + (item) => + item.active && Math.hypot(item.x - viewer.x, item.z - viewer.z) <= 155, + ) + .map(({ id, kind, weapon, amount, x, z }) => ({ id, kind, weapon, amount, x, z })) + : []; + return { + mapSeed: ROYALE_MAP_SEED, + worldSize: ROYALE_WORLD_SIZE, + chunkSize: ROYALE_CHUNK_SIZE, + players, + loot, + elapsedTicks: authority.elapsedTicks, + round: authority.round, + aliveCount: authority.players.filter((player) => player.alive).length, + stormX: authority.stormX, + stormZ: authority.stormZ, + stormRadius: authority.stormRadius, + resetTicks: authority.resetTicks, + winnerId: authority.winnerId, + stream: { + candidateCount: players.length + loot.length, + droppedCount: 0, + usedBytes: 0, + budgetBytes: 0, + chunkX: viewer ? royaleChunkCoordinate(viewer.x) : 0, + chunkZ: viewer ? royaleChunkCoordinate(viewer.z) : 0, + }, + events: [], + }; + }, + validateClientState(authority, candidate, { playerId }) { + const expected = findPlayer(authority, playerId); + const reported = candidate.players.find((player) => player.id === playerId); + return Boolean( + expected && + reported && + reported.health === expected.health && + Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 5, + ); + }, + perceive(authority, event, { playerId }) { + return perceiveEvent(authority, event, playerId); + }, + }, + + input: { + validate(input) { + return ( + typeof input === "object" && + input !== null && + Number.isFinite(input.forward) && + Number.isFinite(input.strafe) && + Number.isFinite(input.yaw) && + Number.isFinite(input.pitch) && + Math.abs(input.forward) <= 1 && + Math.abs(input.strafe) <= 1 && + input.pitch >= -1.35 && + input.pitch <= 1.35 && + typeof input.fire === "boolean" && + typeof input.sprint === "boolean" && + typeof input.reload === "boolean" + ); + }, + }, + + encoding: { + input: inputCodec, + clientState: stateCodec, + perception: eventCodec, + }, +}); + +export const royaleGame = withSpatialReplication(baseRoyaleGame, { + cellSize: ROYALE_CHUNK_SIZE, + bandwidthBudgetBytesPerSecond: 30_000, + reservedBytesPerSnapshot: 300, + viewerPosition(authority, playerId) { + const viewer = findPlayer(authority, playerId); + return viewer ? { x: viewer.x, z: viewer.z } : null; + }, + sources: [ + { + category: "players", + maximumDistance: 360, + entities: (authority) => authority.players.filter((player) => player.alive), + estimatedBytes: 76, + priority: (_entity, distance) => 1_000 - distance, + required: (entity, playerId) => entity.id === playerId, + }, + { + category: "loot", + maximumDistance: 165, + entities: (authority) => authority.loot.filter((item) => item.active), + estimatedBytes: 44, + priority: (_entity, distance) => 300 - distance, + }, + ], + projectSnapshot(snapshot, selection) { + const visibleCandidates = snapshot.players.length + snapshot.loot.length; + const players = snapshot.players.filter((player) => selection.has("players", player.id)); + const loot = snapshot.loot.filter((item) => selection.has("loot", item.id)); + return { + ...snapshot, + players, + loot, + stream: { + candidateCount: visibleCandidates, + droppedCount: visibleCandidates - players.length - loot.length, + usedBytes: players.length * 76 + loot.length * 44, + budgetBytes: selection.budgetBytes, + chunkX: royaleChunkCoordinate(selection.position.x), + chunkZ: royaleChunkCoordinate(selection.position.z), + }, + }; + }, + perceive(_authority, _event, perception, selection) { + if (perception.type === "shot" && !selection.has("players", perception.sourceId)) { + return null; + } + return perception; + }, +}); + +function createAuthorityState(): RoyaleAuthorityState { + const secretSeed = randomSeed(); + const state: RoyaleAuthorityState = { + players: [], + loot: [], + elapsedTicks: 0, + round: 1, + stormX: (royaleRandom(secretSeed, 901) - 0.5) * 180, + stormZ: (royaleRandom(secretSeed, 902) - 0.5) * 180, + stormRadius: initialStormRadius, + resetTicks: 0, + winnerId: null, + nextEventId: 1, + secretSeed, + }; + state.loot = createLoot(secretSeed); + for (let index = 0; index < ROYALE_BOT_COUNT; index += 1) { + const id = ROYALE_BOT_ID_BASE + index; + state.players.push(createPlayer(state, id, true, 0)); + } + return state; +} + +function createClientState(): RoyaleClientState { + return { + mapSeed: ROYALE_MAP_SEED, + worldSize: ROYALE_WORLD_SIZE, + chunkSize: ROYALE_CHUNK_SIZE, + players: [], + loot: [], + elapsedTicks: 0, + round: 1, + aliveCount: 0, + stormX: 0, + stormZ: 0, + stormRadius: initialStormRadius, + resetTicks: 0, + winnerId: null, + stream: { candidateCount: 0, droppedCount: 0, usedBytes: 0, budgetBytes: 0, chunkX: 0, chunkZ: 0 }, + events: [], + }; +} + +function createPlayer( + state: RoyaleAuthorityState, + id: number, + bot: boolean, + altitude: number, +): RoyaleAuthorityPlayer { + const spawn = findSpawn(state.secretSeed, id + state.round * 101); + const weapon = bot && id % 5 === 0 ? "rifle" : "pistol"; + const config = ROYALE_WEAPONS[weapon]; + return { + id, + bot, + alive: true, + x: spawn.x, + z: spawn.z, + velocityX: 0, + velocityZ: 0, + yaw: spawn.yaw, + pitch: 0, + altitude, + health: 100, + armor: bot ? 20 : 0, + weapon, + magazine: config.magazine, + reserve: config.reserve, + cooldownTicks: 0, + reloadTicks: 0, + kills: 0, + inputForward: 0, + inputStrafe: 0, + firing: false, + sprinting: false, + botState: bot ? { phase: id % 360, targetId: null } : null, + }; +} + +function applyCommand(player: RoyaleAuthorityPlayer, input: RoyaleInput): void { + player.inputForward = input.forward; + player.inputStrafe = input.strafe; + player.yaw = normalizeAngle(input.yaw); + player.pitch = clamp(input.pitch, -1.25, 1.25); + player.firing = input.fire; + player.sprinting = input.sprint; +} + +function updateBot(state: RoyaleAuthorityState, bot: RoyaleAuthorityPlayer, tick: number): void { + const target = nearestTarget(state, bot, 280); + bot.botState!.targetId = target?.id ?? null; + let targetX = state.stormX; + let targetZ = state.stormZ; + if (target) { + targetX = target.x; + targetZ = target.z; + } else { + const angle = (bot.botState!.phase + tick * 0.18) * (Math.PI / 180); + targetX += Math.cos(angle) * Math.min(190, state.stormRadius * 0.45); + targetZ += Math.sin(angle) * Math.min(190, state.stormRadius * 0.45); + } + const distance = Math.hypot(targetX - bot.x, targetZ - bot.z); + bot.yaw = Math.atan2(targetX - bot.x, targetZ - bot.z) + Math.sin(tick * 0.17 + bot.id) * 0.035; + bot.pitch = target + ? clamp(Math.atan2(target.altitude - bot.altitude, Math.max(0.01, distance)), -1.25, 1.25) + : 0; + bot.inputForward = distance > 45 ? 1 : 0.24; + bot.inputStrafe = target ? Math.sin(tick * 0.08 + bot.id) * 0.7 : 0; + bot.sprinting = distance > 90; + bot.firing = Boolean( + target && + distance <= ROYALE_WEAPONS[bot.weapon].range && + hasRoyaleLineOfSight(bot.x, bot.z, target.x, target.z), + ); + if (bot.magazine === 0) beginReload(bot); +} + +function simulatePlayer(player: RoyaleAuthorityPlayer, deltaSeconds: number): boolean { + const wasAirborne = player.altitude > 0; + const speed = + player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed; + const sin = Math.sin(player.yaw); + const cos = Math.cos(player.yaw); + let directionX = sin * player.inputForward - cos * player.inputStrafe; + let directionZ = cos * player.inputForward + sin * player.inputStrafe; + const magnitude = Math.hypot(directionX, directionZ); + if (magnitude > 1) { + directionX /= magnitude; + directionZ /= magnitude; + } + player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds); + player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds); + if (magnitude < 0.01) { + player.velocityX *= Math.max(0, 1 - movementFriction * deltaSeconds); + player.velocityZ *= Math.max(0, 1 - movementFriction * deltaSeconds); + } + const nextX = player.x + player.velocityX * deltaSeconds; + const nextZ = player.z + player.velocityZ * deltaSeconds; + if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX; + else player.velocityX = 0; + if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ; + else player.velocityZ = 0; + if (player.altitude > 0) { + player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds); + } + return wasAirborne && player.altitude === 0; +} + +function simulateVisiblePlayer(player: RoyalePlayerView & { inputForward?: number; inputStrafe?: number }, deltaSeconds: number): void { + const inputForward = player.inputForward ?? 0; + const inputStrafe = player.inputStrafe ?? 0; + const speed = player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed; + const sin = Math.sin(player.yaw); + const cos = Math.cos(player.yaw); + let directionX = sin * inputForward - cos * inputStrafe; + let directionZ = cos * inputForward + sin * inputStrafe; + const magnitude = Math.hypot(directionX, directionZ); + if (magnitude > 1) { + directionX /= magnitude; + directionZ /= magnitude; + } + player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds); + player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds); + const nextX = player.x + player.velocityX * deltaSeconds; + const nextZ = player.z + player.velocityZ * deltaSeconds; + if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX; + if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ; + if (player.altitude > 0) player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds); +} + +function tryFire( + state: RoyaleAuthorityState, + shooter: RoyaleAuthorityPlayer, + emit: (event: RoyaleAuthorityEvent) => void, +): void { + if (shooter.cooldownTicks > 0 || shooter.reloadTicks > 0 || shooter.magazine <= 0) return; + const weapon = ROYALE_WEAPONS[shooter.weapon]; + shooter.magazine -= 1; + shooter.cooldownTicks = weapon.cooldownTicks; + emit({ + id: state.nextEventId++, + type: "shot", + sourceId: shooter.id, + x: shooter.x, + y: shooter.altitude + playerEyeHeight, + z: shooter.z, + yaw: shooter.yaw, + pitch: shooter.pitch, + }); + + const horizontal = Math.cos(shooter.pitch); + const directionX = Math.sin(shooter.yaw) * horizontal; + const directionY = Math.sin(shooter.pitch); + const directionZ = Math.cos(shooter.yaw) * horizontal; + const originY = shooter.altitude + playerEyeHeight; + let nearest: RoyaleAuthorityPlayer | null = null; + let nearestDistance = weapon.range; + for (const target of state.players) { + if (!target.alive || target.id === shooter.id) continue; + const offsetX = target.x - shooter.x; + const offsetY = target.altitude + playerEyeHeight - originY; + const offsetZ = target.z - shooter.z; + const forward = + offsetX * directionX + offsetY * directionY + offsetZ * directionZ; + if (forward <= 0 || forward >= nearestDistance) continue; + const offsetSquared = offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ; + const missDistance = Math.sqrt(Math.max(0, offsetSquared - forward * forward)); + if ( + missDistance > playerHitRadius || + !hasRoyaleLineOfSight(shooter.x, shooter.z, target.x, target.z) + ) continue; + nearest = target; + nearestDistance = forward; + } + if (nearest) damagePlayer(state, shooter.id, nearest, weapon.damage, emit); + if (shooter.magazine === 0) beginReload(shooter); +} + +function damagePlayer( + state: RoyaleAuthorityState, + sourceId: number, + target: RoyaleAuthorityPlayer, + amount: number, + emit: (event: RoyaleAuthorityEvent) => void, +): void { + const absorbed = Math.min(target.armor, Math.ceil(amount * 0.55)); + target.armor -= absorbed; + const healthDamage = Math.min(target.health, amount - absorbed); + target.health -= healthDamage; + emit({ + id: state.nextEventId++, + type: "damage", + sourceId, + targetId: target.id, + amount: healthDamage, + health: target.health, + }); + if (target.health > 0) return; + target.alive = false; + target.firing = false; + target.velocityX = 0; + target.velocityZ = 0; + const killer = findPlayer(state, sourceId); + if (killer) killer.kills += 1; + emit({ id: state.nextEventId++, type: "elimination", killerId: sourceId, victimId: target.id }); +} + +function applyStormDamage( + state: RoyaleAuthorityState, + player: RoyaleAuthorityPlayer, + tick: number, + emit: (event: RoyaleAuthorityEvent) => void, +): void { + if (tick % 15 !== 0) return; + if (Math.hypot(player.x - state.stormX, player.z - state.stormZ) <= state.stormRadius) return; + damagePlayer(state, 0, player, 3, emit); +} + +function collectLoot( + state: RoyaleAuthorityState, + player: RoyaleAuthorityPlayer, + emit: (event: RoyaleAuthorityEvent) => void, +): void { + const item = state.loot.find( + (candidate) => candidate.active && Math.hypot(candidate.x - player.x, candidate.z - player.z) < 2.4, + ); + if (!item) return; + item.active = false; + if (item.kind === "weapon") { + player.weapon = item.weapon; + player.magazine = ROYALE_WEAPONS[item.weapon].magazine; + player.reserve = Math.max(player.reserve, ROYALE_WEAPONS[item.weapon].reserve / 2); + } else if (item.kind === "ammo") { + player.reserve = Math.min(240, player.reserve + item.amount); + } else if (item.kind === "armor") { + player.armor = Math.min(100, player.armor + item.amount); + } else { + player.health = Math.min(100, player.health + item.amount); + } + emit({ + id: state.nextEventId++, + type: "pickup", + playerId: player.id, + kind: item.kind, + weapon: item.weapon, + amount: item.amount, + }); +} + +function perceiveEvent( + authority: RoyaleAuthorityState, + event: RoyaleAuthorityEvent, + playerId: number, +): RoyalePerception | null { + const viewer = findPlayer(authority, playerId); + if (!viewer) return null; + switch (event.type) { + case "shot": { + const source = findPlayer(authority, event.sourceId); + if (source && (source.id === viewer.id || canSeePlayer(viewer, source))) { + return { ...event }; + } + const dx = event.x - viewer.x; + const dz = event.z - viewer.z; + const distance = Math.hypot(dx, dz); + if (distance > 560) return null; + return { + id: event.id, + type: "gunfire", + bearing: normalizeAngle(Math.atan2(dx, dz) - viewer.yaw), + intensity: clamp(1 - distance / 560, 0.08, 1), + }; + } + case "damage": + if (event.targetId === playerId) { + return { id: event.id, type: "damage", amount: event.amount, health: event.health }; + } + if (event.sourceId === playerId) { + return { id: event.id, type: "hit", amount: event.amount, eliminated: event.health === 0 }; + } + return null; + case "elimination": + return { ...event }; + case "pickup": + return event.playerId === playerId + ? { id: event.id, type: "pickup", kind: event.kind, weapon: event.weapon, amount: event.amount } + : null; + case "landed": + return event.playerId === playerId ? { id: event.id, type: "landed" } : null; + case "winner": + return { ...event }; + } +} + +function canSeePlayer(viewer: RoyaleAuthorityPlayer, target: RoyaleAuthorityPlayer): boolean { + if (!target.alive || target.id === viewer.id) return false; + const distance = Math.hypot(target.x - viewer.x, target.z - viewer.z); + return distance <= 225 && hasRoyaleLineOfSight(viewer.x, viewer.z, target.x, target.z); +} + +function clonePlayerView(player: RoyaleAuthorityPlayer, owner: boolean): RoyalePlayerView { + return { + id: player.id, + bot: player.bot, + alive: player.alive, + x: player.x, + z: player.z, + velocityX: player.velocityX, + velocityZ: player.velocityZ, + yaw: player.yaw, + pitch: player.pitch, + altitude: player.altitude, + health: owner ? player.health : null, + armor: owner ? player.armor : null, + weapon: player.weapon, + magazine: owner ? player.magazine : null, + reserve: owner ? player.reserve : null, + cooldownTicks: player.cooldownTicks, + reloadTicks: player.reloadTicks, + kills: player.kills, + firing: player.firing, + sprinting: player.sprinting, + inputForward: owner ? player.inputForward : 0, + inputStrafe: owner ? player.inputStrafe : 0, + }; +} + +function createLoot(seed: number): RoyaleAuthorityLoot[] { + const loot: RoyaleAuthorityLoot[] = []; + for (let id = 1; id <= lootCount; id += 1) { + let position = findSpawn(seed ^ 0xa51c, id * 31); + const roll = royaleRandom(seed ^ 0xb771, id); + const kind: RoyaleLootKind = roll > 0.82 ? "weapon" : roll > 0.55 ? "ammo" : roll > 0.28 ? "armor" : "medkit"; + const weapon: RoyaleWeapon = royaleRandom(seed ^ 0x991a, id) > 0.78 ? "marksman" : "rifle"; + if (collidesWithRoyaleMap(position.x, position.z, 1.2)) position = findSpawn(seed ^ 0x4411, id * 67); + loot.push({ + id, + kind, + weapon, + amount: kind === "ammo" ? 30 : kind === "armor" ? 35 : kind === "medkit" ? 30 : 1, + x: position.x, + z: position.z, + active: true, + }); + } + return loot; +} + +function findSpawn(seed: number, index: number): { x: number; z: number; yaw: number } { + for (let attempt = 0; attempt < 24; attempt += 1) { + const radius = 120 + Math.sqrt(royaleRandom(seed, index + attempt * 3)) * 690; + const angle = royaleRandom(seed ^ 0x7f4a, index + attempt * 3 + 1) * Math.PI * 2; + const x = Math.cos(angle) * radius; + const z = Math.sin(angle) * radius; + if (!collidesWithRoyaleMap(x, z, 1.2)) return { x, z, yaw: angle + Math.PI }; + } + return { x: 0, z: 0, yaw: 0 }; +} + +function nearestTarget( + state: RoyaleAuthorityState, + source: RoyaleAuthorityPlayer, + maximumDistance: number, +): RoyaleAuthorityPlayer | null { + let nearest: RoyaleAuthorityPlayer | null = null; + let distance = maximumDistance; + for (const candidate of state.players) { + if (!candidate.alive || candidate.id === source.id || candidate.altitude > 0) continue; + const candidateDistance = Math.hypot(candidate.x - source.x, candidate.z - source.z); + if (candidateDistance < distance) { + nearest = candidate; + distance = candidateDistance; + } + } + return nearest; +} + +function beginReload(player: RoyaleAuthorityPlayer): void { + const weapon = ROYALE_WEAPONS[player.weapon]; + if (player.reloadTicks > 0 || player.magazine >= weapon.magazine || player.reserve <= 0) return; + player.reloadTicks = weapon.reloadTicks; + player.firing = false; +} + +function updateTimers(player: RoyaleAuthorityPlayer): void { + if (player.cooldownTicks > 0) player.cooldownTicks -= 1; + if (player.reloadTicks > 0) { + player.reloadTicks -= 1; + if (player.reloadTicks === 0) { + const capacity = ROYALE_WEAPONS[player.weapon].magazine; + const loaded = Math.min(capacity - player.magazine, player.reserve); + player.magazine += loaded; + player.reserve -= loaded; + } + } +} + +function resetMatch(state: RoyaleAuthorityState): void { + state.elapsedTicks = 0; + state.round += 1; + state.winnerId = null; + state.stormRadius = initialStormRadius; + state.stormX = (royaleRandom(state.secretSeed, 901 + state.round) - 0.5) * 180; + state.stormZ = (royaleRandom(state.secretSeed, 902 + state.round) - 0.5) * 180; + state.loot = createLoot(state.secretSeed ^ state.round * 0x9e37); + for (const player of state.players) { + const fresh = createPlayer(state, player.id, player.bot, player.bot ? 0 : 110); + Object.assign(player, fresh, { kills: player.kills }); + } +} + +function cloneAuthorityState(state: RoyaleAuthorityState): RoyaleAuthorityState { + return { + ...state, + players: state.players.map((player) => ({ + ...player, + botState: player.botState ? { ...player.botState } : null, + })), + loot: state.loot.map((item) => ({ ...item })), + }; +} + +function cloneClientState(state: RoyaleClientState): RoyaleClientState { + return { + ...state, + players: state.players.map((player) => ({ ...player })), + loot: state.loot.map((item) => ({ ...item })), + stream: { ...state.stream }, + events: state.events.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })), + }; +} + +function findPlayer(state: RoyaleAuthorityState, playerId: number): RoyaleAuthorityPlayer | undefined { + return state.players.find((player) => player.id === playerId); +} + +function randomSeed(): number { + return Math.floor(Math.random() * 0x1_0000_0000) >>> 0; +} + +function approach(value: number, target: number, amount: number): number { + return value < target ? Math.min(target, value + amount) : Math.max(target, value - amount); +} + +function normalizeAngle(value: number): number { + return Math.atan2(Math.sin(value), Math.cos(value)); +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/packages/shared/src/royale-map.ts b/packages/shared/src/royale-map.ts new file mode 100644 index 0000000..8181e44 --- /dev/null +++ b/packages/shared/src/royale-map.ts @@ -0,0 +1,143 @@ +export const ROYALE_MAP_SEED = 0x51_9c_ae_27; +export const ROYALE_WORLD_SIZE = 2_000; +export const ROYALE_WORLD_RADIUS = 960; +export const ROYALE_CHUNK_SIZE = 100; + +export type RoyaleBiome = "meadow" | "forest" | "highland" | "coast"; +export type RoyaleObstacleKind = "building" | "rock" | "tree"; + +export interface RoyaleObstacle { + id: string; + kind: RoyaleObstacleKind; + x: number; + z: number; + width: number; + depth: number; + height: number; +} + +export interface RoyaleMapChunk { + x: number; + z: number; + biome: RoyaleBiome; + tint: number; + elevation: number; + roadX: boolean; + roadZ: boolean; + obstacles: RoyaleObstacle[]; +} + +const chunkCache = new Map(); + +/** Public, deterministic static world generation shared by server and clients. */ +export function generateRoyaleChunk(chunkX: number, chunkZ: number): RoyaleMapChunk { + const key = `${chunkX}:${chunkZ}`; + const cached = chunkCache.get(key); + if (cached) return cached; + + const centerX = (chunkX + 0.5) * ROYALE_CHUNK_SIZE; + const centerZ = (chunkZ + 0.5) * ROYALE_CHUNK_SIZE; + const normalizedRadius = Math.hypot(centerX, centerZ) / ROYALE_WORLD_RADIUS; + const biomeNoise = random01(PROCEDURAL_SEED, chunkX, chunkZ, 1); + const biome: RoyaleBiome = + normalizedRadius > 0.84 + ? "coast" + : biomeNoise > 0.68 + ? "forest" + : biomeNoise < 0.2 + ? "highland" + : "meadow"; + const roadX = Math.abs(chunkZ) % 5 === 0; + const roadZ = Math.abs(chunkX + 2) % 6 === 0; + const obstacleCount = biome === "coast" ? 2 : 4 + Math.floor(random01(PROCEDURAL_SEED, chunkX, chunkZ, 2) * 5); + const obstacles: RoyaleObstacle[] = []; + + for (let index = 0; index < obstacleCount; index += 1) { + const localX = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 10 + index * 5) - 0.5) * 82; + const localZ = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 11 + index * 5) - 0.5) * 82; + if ((roadZ && Math.abs(localX) < 11) || (roadX && Math.abs(localZ) < 11)) continue; + const kindRoll = random01(PROCEDURAL_SEED, chunkX, chunkZ, 12 + index * 5); + const kind: RoyaleObstacleKind = + kindRoll > 0.72 ? "building" : kindRoll > 0.38 ? "tree" : "rock"; + const scale = random01(PROCEDURAL_SEED, chunkX, chunkZ, 13 + index * 5); + const width = kind === "building" ? 10 + scale * 15 : kind === "tree" ? 3.2 : 4 + scale * 7; + const depth = kind === "building" ? 9 + random01(PROCEDURAL_SEED, chunkX, chunkZ, 14 + index * 5) * 14 : width; + obstacles.push({ + id: `${chunkX}:${chunkZ}:${index}`, + kind, + x: chunkX * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localX, + z: chunkZ * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localZ, + width, + depth, + height: kind === "building" ? 7 + scale * 13 : kind === "tree" ? 8 + scale * 7 : 2.5 + scale * 5, + }); + } + + const chunk: RoyaleMapChunk = { + x: chunkX, + z: chunkZ, + biome, + tint: + biome === "coast" ? 0xb9a56e : biome === "forest" ? 0x315d3f : biome === "highland" ? 0x697064 : 0x52764b, + elevation: Math.round(random01(PROCEDURAL_SEED, chunkX, chunkZ, 3) * 3) * 0.16, + roadX, + roadZ, + obstacles, + }; + chunkCache.set(key, chunk); + return chunk; +} + +export function collidesWithRoyaleMap(x: number, z: number, radius = 0.72): boolean { + if (Math.hypot(x, z) + radius > ROYALE_WORLD_RADIUS) return true; + const chunkX = Math.floor(x / ROYALE_CHUNK_SIZE); + const chunkZ = Math.floor(z / ROYALE_CHUNK_SIZE); + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + for (let offsetZ = -1; offsetZ <= 1; offsetZ += 1) { + for (const obstacle of generateRoyaleChunk(chunkX + offsetX, chunkZ + offsetZ).obstacles) { + const nearestX = clamp(x, obstacle.x - obstacle.width / 2, obstacle.x + obstacle.width / 2); + const nearestZ = clamp(z, obstacle.z - obstacle.depth / 2, obstacle.z + obstacle.depth / 2); + const dx = x - nearestX; + const dz = z - nearestZ; + if (dx * dx + dz * dz < radius * radius) return true; + } + } + } + return false; +} + +export function hasRoyaleLineOfSight(fromX: number, fromZ: number, toX: number, toZ: number): boolean { + const distance = Math.hypot(toX - fromX, toZ - fromZ); + const steps = Math.max(2, Math.ceil(distance / 4)); + for (let step = 1; step < steps; step += 1) { + const ratio = step / steps; + if (collidesWithRoyaleMap(fromX + (toX - fromX) * ratio, fromZ + (toZ - fromZ) * ratio, 0.12)) { + return false; + } + } + return true; +} + +export function royaleChunkCoordinate(value: number): number { + return Math.floor(value / ROYALE_CHUNK_SIZE); +} + +export function royaleRandom(seed: number, index: number): number { + return random01(seed, index, index * 17 + 3, index * 97 + 11); +} + +const PROCEDURAL_SEED = ROYALE_MAP_SEED; + +function random01(seed: number, a: number, b: number, c: number): number { + let value = seed ^ Math.imul(a, 0x9e3779b1) ^ Math.imul(b, 0x85ebca77) ^ Math.imul(c, 0xc2b2ae3d); + value ^= value >>> 16; + value = Math.imul(value, 0x7feb352d); + value ^= value >>> 15; + value = Math.imul(value, 0x846ca68b); + value ^= value >>> 16; + return (value >>> 0) / 0x1_0000_0000; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/packages/shared/src/royale-types.ts b/packages/shared/src/royale-types.ts new file mode 100644 index 0000000..50d5d92 --- /dev/null +++ b/packages/shared/src/royale-types.ts @@ -0,0 +1,160 @@ +export type RoyaleWeapon = "pistol" | "rifle" | "marksman"; +export type RoyaleLootKind = "weapon" | "ammo" | "armor" | "medkit"; + +export interface RoyaleInput { + forward: number; + strafe: number; + yaw: number; + pitch: number; + fire: boolean; + sprint: boolean; + reload: boolean; +} + +export interface RoyaleBotState { + phase: number; + targetId: number | null; +} + +export interface RoyaleAuthorityPlayer { + id: number; + bot: boolean; + alive: boolean; + x: number; + z: number; + velocityX: number; + velocityZ: number; + yaw: number; + pitch: number; + altitude: number; + health: number; + armor: number; + weapon: RoyaleWeapon; + magazine: number; + reserve: number; + cooldownTicks: number; + reloadTicks: number; + kills: number; + inputForward: number; + inputStrafe: number; + firing: boolean; + sprinting: boolean; + botState: RoyaleBotState | null; +} + +export interface RoyaleAuthorityLoot { + id: number; + kind: RoyaleLootKind; + weapon: RoyaleWeapon; + amount: number; + x: number; + z: number; + active: boolean; +} + +export interface RoyaleAuthorityState { + players: RoyaleAuthorityPlayer[]; + loot: RoyaleAuthorityLoot[]; + elapsedTicks: number; + round: number; + stormX: number; + stormZ: number; + stormRadius: number; + resetTicks: number; + winnerId: number | null; + nextEventId: number; + secretSeed: number; +} + +export interface RoyalePlayerView { + id: number; + bot: boolean; + alive: boolean; + x: number; + z: number; + velocityX: number; + velocityZ: number; + yaw: number; + pitch: number; + altitude: number; + /** Exact combat state exists only on the owning player's projection. */ + health: number | null; + armor: number | null; + weapon: RoyaleWeapon; + magazine: number | null; + reserve: number | null; + cooldownTicks: number; + reloadTicks: number; + kills: number; + firing: boolean; + sprinting: boolean; + inputForward: number; + inputStrafe: number; +} + +export interface RoyaleLootView { + id: number; + kind: RoyaleLootKind; + weapon: RoyaleWeapon; + amount: number; + x: number; + z: number; +} + +export interface RoyaleStreamStats { + candidateCount: number; + droppedCount: number; + usedBytes: number; + budgetBytes: number; + chunkX: number; + chunkZ: number; +} + +export interface RoyaleClientState { + mapSeed: number; + worldSize: number; + chunkSize: number; + players: RoyalePlayerView[]; + loot: RoyaleLootView[]; + elapsedTicks: number; + round: number; + aliveCount: number; + stormX: number; + stormZ: number; + stormRadius: number; + resetTicks: number; + winnerId: number | null; + stream: RoyaleStreamStats; + events: RoyalePresentationEvent[]; +} + +export type RoyaleAuthorityEvent = + | { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number } + | { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number } + | { id: number; type: "elimination"; killerId: number; victimId: number } + | { id: number; type: "pickup"; playerId: number; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number } + | { id: number; type: "landed"; playerId: number } + | { id: number; type: "winner"; playerId: number; round: number }; + +export type RoyalePerception = + | { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number } + | { id: number; type: "gunfire"; bearing: number; intensity: number } + | { id: number; type: "damage"; amount: number; health: number } + | { id: number; type: "hit"; amount: number; eliminated: boolean } + | { id: number; type: "elimination"; killerId: number; victimId: number } + | { id: number; type: "pickup"; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number } + | { id: number; type: "landed" } + | { id: number; type: "winner"; playerId: number; round: number }; + +export interface RoyalePresentationEvent { + receivedTick: number; + event: RoyalePerception; +} + +export interface RoyaleGameContract { + authority: RoyaleAuthorityState; + client: RoyaleClientState; + input: RoyaleInput; + authorityEvent: RoyaleAuthorityEvent; + perceptionEvent: RoyalePerception; +} diff --git a/packages/shared/src/shooter-codecs.ts b/packages/shared/src/shooter-codecs.ts new file mode 100644 index 0000000..b5597fb --- /dev/null +++ b/packages/shared/src/shooter-codecs.ts @@ -0,0 +1,506 @@ +import type { BinaryCodec } from "@syncer/engine"; +import { + isWeaponId, + type DistanceBand, + type ImpactKind, + type LoadoutState, + type PickupKind, + type PlayerKind, + type PlayerState, + type PickupState, + type ScoreEntry, + type ShooterInput, + type ShooterPerception, + type ShooterWorldState, + type WeaponId, +} from "./shooter-types.js"; + +const INPUT_BYTES = 18; +const WORLD_HEADER_BYTES = 17; +const PLAYER_BYTES = 55; +const SCORE_BYTES = 9; +const PICKUP_BYTES = 15; + +export const shooterInputCodec: BinaryCodec = { + encode(input) { + const payload = new Uint8Array(INPUT_BYTES); + const view = new DataView(payload.buffer); + view.setFloat32(0, input.strafe, true); + view.setFloat32(4, input.forward, true); + view.setFloat32(8, input.yaw, true); + view.setFloat32(12, input.pitch, true); + view.setUint8( + 16, + (input.fire ? 1 : 0) | (input.sprint ? 2 : 0) | (input.reload ? 4 : 0), + ); + view.setUint8(17, input.weapon); + return payload; + }, + decode(payload) { + assertLength(payload, INPUT_BYTES, "Shooter input"); + const view = viewOf(payload); + const weapon = view.getUint8(17); + if (!isWeaponId(weapon)) { + throw new RangeError("Unknown weapon in shooter input"); + } + const flags = view.getUint8(16); + return { + strafe: view.getFloat32(0, true), + forward: view.getFloat32(4, true), + yaw: view.getFloat32(8, true), + pitch: view.getFloat32(12, true), + fire: Boolean(flags & 1), + sprint: Boolean(flags & 2), + reload: Boolean(flags & 4), + weapon, + }; + }, +}; + +export const shooterWorldCodec: BinaryCodec = { + encode(state) { + if ( + state.players.size > 65_535 || + state.scoreboard.length > 65_535 || + state.pickups.size > 65_535 + ) { + throw new RangeError("Shooter world exceeds the binary protocol limit"); + } + + const length = + WORLD_HEADER_BYTES + + state.players.size * PLAYER_BYTES + + state.scoreboard.length * SCORE_BYTES + + state.pickups.size * PICKUP_BYTES; + const payload = new Uint8Array(length); + const view = new DataView(payload.buffer); + let offset = 0; + + view.setUint8(offset, 1); + offset += 1; + view.setUint32(offset, state.match.elapsedTicks, true); + offset += 4; + view.setUint16(offset, state.match.scoreLimit, true); + offset += 2; + view.setUint32(offset, state.match.leaderId, true); + offset += 4; + view.setUint16(offset, state.players.size, true); + offset += 2; + view.setUint16(offset, state.scoreboard.length, true); + offset += 2; + view.setUint16(offset, state.pickups.size, true); + offset += 2; + + const players = [...state.players.values()].sort((a, b) => a.id - b.id); + for (const player of players) { + offset = encodePlayer(view, offset, player); + } + for (const score of [...state.scoreboard].sort((a, b) => a.id - b.id)) { + offset = encodeScore(view, offset, score); + } + for (const pickup of [...state.pickups.values()].sort((a, b) => a.id - b.id)) { + view.setUint16(offset, pickup.id, true); + view.setUint8(offset + 2, pickupKindToByte(pickup.kind)); + view.setUint8(offset + 3, pickup.weapon); + view.setFloat32(offset + 4, pickup.x, true); + view.setFloat32(offset + 8, pickup.z, true); + view.setUint8(offset + 12, pickup.active ? 1 : 0); + view.setUint16(offset + 13, clampUint16(pickup.respawnTicks), true); + offset += PICKUP_BYTES; + } + + return payload; + }, + decode(payload) { + if (payload.byteLength < WORLD_HEADER_BYTES) { + throw new RangeError("Shooter world is missing its header"); + } + const view = viewOf(payload); + let offset = 0; + const version = view.getUint8(offset); + offset += 1; + if (version !== 1) { + throw new RangeError(`Unsupported shooter world version ${version}`); + } + + const elapsedTicks = view.getUint32(offset, true); + offset += 4; + const scoreLimit = view.getUint16(offset, true); + offset += 2; + const leaderId = view.getUint32(offset, true); + offset += 4; + const playerCount = view.getUint16(offset, true); + offset += 2; + const scoreCount = view.getUint16(offset, true); + offset += 2; + const pickupCount = view.getUint16(offset, true); + offset += 2; + assertLength( + payload, + WORLD_HEADER_BYTES + + playerCount * PLAYER_BYTES + + scoreCount * SCORE_BYTES + + pickupCount * PICKUP_BYTES, + "Shooter world", + ); + + const players = new Map(); + for (let index = 0; index < playerCount; index += 1) { + const decoded = decodePlayer(view, offset); + players.set(decoded.player.id, decoded.player); + offset = decoded.offset; + } + + const scoreboard: ScoreEntry[] = []; + for (let index = 0; index < scoreCount; index += 1) { + const id = view.getUint32(offset, true); + const kind = byteToPlayerKind(view.getUint8(offset + 4)); + scoreboard.push({ + id, + kind, + kills: view.getUint16(offset + 5, true), + deaths: view.getUint16(offset + 7, true), + }); + offset += SCORE_BYTES; + } + + const pickups = new Map(); + for (let index = 0; index < pickupCount; index += 1) { + const id = view.getUint16(offset, true); + const weapon = view.getUint8(offset + 3); + if (!isWeaponId(weapon)) { + throw new RangeError("Unknown pickup weapon"); + } + pickups.set(id, { + id, + kind: byteToPickupKind(view.getUint8(offset + 2)), + weapon, + x: view.getFloat32(offset + 4, true), + z: view.getFloat32(offset + 8, true), + active: Boolean(view.getUint8(offset + 12)), + respawnTicks: view.getUint16(offset + 13, true), + }); + offset += PICKUP_BYTES; + } + + return { + players, + pickups, + scoreboard, + match: { elapsedTicks, scoreLimit, leaderId }, + events: [], + }; + }, +}; + +export const shooterEventCodec: BinaryCodec = { + encode(event) { + const length = perceptionLength(event); + const payload = new Uint8Array(length); + const view = new DataView(payload.buffer); + view.setUint32(0, event.id, true); + view.setUint8(4, perceptionTypeToByte(event.type)); + + switch (event.type) { + case "shot": + view.setUint8(5, event.weapon); + view.setUint32(6, event.sourceId, true); + view.setFloat32(10, event.originX, true); + view.setFloat32(14, event.originY, true); + view.setFloat32(18, event.originZ, true); + view.setFloat32(22, event.endX, true); + view.setFloat32(26, event.endY, true); + view.setFloat32(30, event.endZ, true); + view.setUint8(34, impactToByte(event.impact)); + break; + case "sound": + view.setUint8(5, event.sound === "gunshot" ? 0 : 1); + view.setUint8(6, event.weapon); + view.setFloat32(7, event.bearingRadians, true); + view.setFloat32(11, event.intensity, true); + view.setUint8(15, distanceBandToByte(event.distanceBand)); + break; + case "damage": + view.setUint16(5, clampUint16(event.amount), true); + view.setUint16(7, clampUint16(event.health), true); + view.setFloat32(9, event.bearingRadians, true); + view.setUint8(13, event.critical ? 1 : 0); + break; + case "hit": + view.setUint16(5, clampUint16(event.amount), true); + view.setUint8(7, event.eliminated ? 1 : 0); + view.setUint8(8, event.critical ? 1 : 0); + break; + case "elimination": + view.setUint32(5, event.killerId, true); + view.setUint32(9, event.victimId, true); + view.setUint8(13, event.weapon); + view.setUint8(14, event.critical ? 1 : 0); + break; + case "pickup": + view.setUint8(5, pickupKindToByte(event.pickup)); + view.setUint8(6, event.weapon); + view.setUint16(7, clampUint16(event.amount), true); + break; + case "respawn": + break; + } + return payload; + }, + decode(payload) { + if (payload.byteLength < 5) { + throw new RangeError("Shooter event is missing its header"); + } + const view = viewOf(payload); + const id = view.getUint32(0, true); + const type = byteToPerceptionType(view.getUint8(4)); + + switch (type) { + case "shot": { + assertLength(payload, 35, "Shot event"); + const weapon = decodeWeapon(view.getUint8(5)); + return { + id, + type, + weapon, + sourceId: view.getUint32(6, true), + originX: view.getFloat32(10, true), + originY: view.getFloat32(14, true), + originZ: view.getFloat32(18, true), + endX: view.getFloat32(22, true), + endY: view.getFloat32(26, true), + endZ: view.getFloat32(30, true), + impact: byteToImpact(view.getUint8(34)), + }; + } + case "sound": { + assertLength(payload, 16, "Sound event"); + return { + id, + type, + sound: view.getUint8(5) === 0 ? "gunshot" : "footstep", + weapon: decodeWeapon(view.getUint8(6)), + bearingRadians: view.getFloat32(7, true), + intensity: view.getFloat32(11, true), + distanceBand: byteToDistanceBand(view.getUint8(15)), + }; + } + case "damage": + assertLength(payload, 14, "Damage event"); + return { + id, + type, + amount: view.getUint16(5, true), + health: view.getUint16(7, true), + bearingRadians: view.getFloat32(9, true), + critical: Boolean(view.getUint8(13)), + }; + case "hit": + assertLength(payload, 9, "Hit event"); + return { + id, + type, + amount: view.getUint16(5, true), + eliminated: Boolean(view.getUint8(7)), + critical: Boolean(view.getUint8(8)), + }; + case "elimination": + assertLength(payload, 15, "Elimination event"); + return { + id, + type, + killerId: view.getUint32(5, true), + victimId: view.getUint32(9, true), + weapon: decodeWeapon(view.getUint8(13)), + critical: Boolean(view.getUint8(14)), + }; + case "pickup": + assertLength(payload, 9, "Pickup event"); + return { + id, + type, + pickup: byteToPickupKind(view.getUint8(5)), + weapon: decodeWeapon(view.getUint8(6)), + amount: view.getUint16(7, true), + }; + case "respawn": + assertLength(payload, 5, "Respawn event"); + return { id, type }; + } + }, +}; + +function encodePlayer(view: DataView, offset: number, player: PlayerState): number { + view.setUint32(offset, player.id, true); + view.setUint8( + offset + 4, + (player.kind === "bot" ? 1 : 0) | (player.alive ? 2 : 0), + ); + view.setFloat32(offset + 5, player.x, true); + view.setFloat32(offset + 9, player.z, true); + view.setFloat32(offset + 13, player.velocityX, true); + view.setFloat32(offset + 17, player.velocityZ, true); + view.setFloat32(offset + 21, player.yaw, true); + view.setFloat32(offset + 25, player.pitch, true); + view.setUint8(offset + 29, clampByte(player.health)); + view.setUint8(offset + 30, clampByte(player.armor)); + view.setUint8(offset + 31, player.weapon); + let ammoOffset = offset + 32; + for (const ammo of player.ammo) { + view.setUint16(ammoOffset, clampUint16(ammo.magazine), true); + view.setUint16(ammoOffset + 2, clampUint16(ammo.reserve), true); + ammoOffset += 4; + } + view.setUint16(offset + 44, clampUint16(player.cooldownTicks), true); + view.setUint16(offset + 46, clampUint16(player.reloadTicks), true); + view.setUint16(offset + 48, clampUint16(player.respawnTicks), true); + view.setUint16(offset + 50, clampUint16(player.spawnProtectionTicks), true); + view.setInt8(offset + 52, Math.round(clamp(player.inputStrafe, -1, 1) * 127)); + view.setInt8(offset + 53, Math.round(clamp(player.inputForward, -1, 1) * 127)); + view.setUint8(offset + 54, (player.firing ? 1 : 0) | (player.sprinting ? 2 : 0)); + return offset + PLAYER_BYTES; +} + +function decodePlayer(view: DataView, offset: number): { player: PlayerState; offset: number } { + const flags = view.getUint8(offset + 4); + const weapon = decodeWeapon(view.getUint8(offset + 31)); + const ammo = [] as unknown as LoadoutState; + let ammoOffset = offset + 32; + for (let index = 0; index < 3; index += 1) { + ammo[index] = { + magazine: view.getUint16(ammoOffset, true), + reserve: view.getUint16(ammoOffset + 2, true), + }; + ammoOffset += 4; + } + const inputFlags = view.getUint8(offset + 54); + return { + player: { + id: view.getUint32(offset, true), + kind: flags & 1 ? "bot" : "human", + alive: Boolean(flags & 2), + x: view.getFloat32(offset + 5, true), + z: view.getFloat32(offset + 9, true), + velocityX: view.getFloat32(offset + 13, true), + velocityZ: view.getFloat32(offset + 17, true), + yaw: view.getFloat32(offset + 21, true), + pitch: view.getFloat32(offset + 25, true), + health: view.getUint8(offset + 29), + armor: view.getUint8(offset + 30), + weapon, + ammo, + cooldownTicks: view.getUint16(offset + 44, true), + reloadTicks: view.getUint16(offset + 46, true), + respawnTicks: view.getUint16(offset + 48, true), + spawnProtectionTicks: view.getUint16(offset + 50, true), + inputStrafe: view.getInt8(offset + 52) / 127, + inputForward: view.getInt8(offset + 53) / 127, + firing: Boolean(inputFlags & 1), + sprinting: Boolean(inputFlags & 2), + }, + offset: offset + PLAYER_BYTES, + }; +} + +function encodeScore(view: DataView, offset: number, score: ScoreEntry): number { + view.setUint32(offset, score.id, true); + view.setUint8(offset + 4, score.kind === "bot" ? 1 : 0); + view.setUint16(offset + 5, clampUint16(score.kills), true); + view.setUint16(offset + 7, clampUint16(score.deaths), true); + return offset + SCORE_BYTES; +} + +function perceptionLength(event: ShooterPerception): number { + switch (event.type) { + case "shot": return 35; + case "sound": return 16; + case "damage": return 14; + case "hit": return 9; + case "elimination": return 15; + case "pickup": return 9; + case "respawn": return 5; + } +} + +function perceptionTypeToByte(type: ShooterPerception["type"]): number { + return type === "shot" ? 0 : type === "sound" ? 1 : type === "damage" ? 2 : type === "elimination" ? 3 : type === "pickup" ? 4 : type === "respawn" ? 5 : 6; +} + +function byteToPerceptionType(value: number): ShooterPerception["type"] { + if (value === 0) return "shot"; + if (value === 1) return "sound"; + if (value === 2) return "damage"; + if (value === 3) return "elimination"; + if (value === 4) return "pickup"; + if (value === 5) return "respawn"; + if (value === 6) return "hit"; + throw new RangeError("Unknown shooter perception type"); +} + +function pickupKindToByte(kind: PickupKind): number { + return kind === "health" ? 0 : kind === "ammo" ? 1 : 2; +} + +function byteToPickupKind(value: number): PickupKind { + if (value === 0) return "health"; + if (value === 1) return "ammo"; + if (value === 2) return "weapon"; + throw new RangeError("Unknown pickup kind"); +} + +function distanceBandToByte(band: DistanceBand): number { + return band === "near" ? 0 : band === "medium" ? 1 : 2; +} + +function byteToDistanceBand(value: number): DistanceBand { + if (value === 0) return "near"; + if (value === 1) return "medium"; + if (value === 2) return "far"; + throw new RangeError("Unknown distance band"); +} + +function impactToByte(impact: ImpactKind): number { + return impact === "wall" ? 0 : impact === "player" ? 1 : 2; +} + +function byteToImpact(value: number): ImpactKind { + if (value === 0) return "wall"; + if (value === 1) return "player"; + if (value === 2) return "miss"; + throw new RangeError("Unknown impact kind"); +} + +function byteToPlayerKind(value: number): PlayerKind { + if (value === 0) return "human"; + if (value === 1) return "bot"; + throw new RangeError("Unknown player kind"); +} + +function decodeWeapon(value: number): WeaponId { + if (!isWeaponId(value)) { + throw new RangeError("Unknown weapon"); + } + return value; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} + +function clampByte(value: number): number { + return Math.round(clamp(value, 0, 255)); +} + +function clampUint16(value: number): number { + return Math.round(clamp(value, 0, 65_535)); +} + +function viewOf(payload: Uint8Array): DataView { + return new DataView(payload.buffer, payload.byteOffset, payload.byteLength); +} + +function assertLength(payload: Uint8Array, expected: number, name: string): void { + if (payload.byteLength !== expected) { + throw new RangeError(`${name} must be exactly ${expected} bytes`); + } +} diff --git a/packages/shared/src/shooter-game.ts b/packages/shared/src/shooter-game.ts new file mode 100644 index 0000000..1a3014f --- /dev/null +++ b/packages/shared/src/shooter-game.ts @@ -0,0 +1,1200 @@ +import { + defineNetworkedGame, + deterministicHash, + withLagCompensation, + withReplayTransport, + withTimeTravel, +} from "@syncer/engine"; +import { + ARENA_SPAWNS, + BOT_PATROL_POINTS, + PLAYER_EYE_HEIGHT, + collidesWithArena, + hasLineOfSight, + rayBlockDistance, +} from "./arena.js"; +import { + shooterEventCodec, + shooterInputCodec, + shooterWorldCodec, +} from "./shooter-codecs.js"; +import { + WEAPONS, + Weapon, + type AuthoritativePlayer, + type AuthoritativeShooterState, + type DistanceBand, + type ImpactKind, + type LoadoutState, + type PickupKind, + type PickupState, + type PlayerKind, + type PlayerState, + type ShooterAuthorityEvent, + type ShooterInput, + type ShooterPerception, + type ShooterWorldState, + type WeaponId, +} from "./shooter-types.js"; + +export const SHOOTER_TICK_RATE = 60; +export const SHOOTER_SNAPSHOT_RATE = 20; +export const SHOOTER_SCORE_LIMIT = 20; +export const BOT_COUNT = 6; +export const BOT_ID_BASE = 10_000; + +export interface ShooterGameOptions { + /** Deterministic authority-controlled opponents created with the match. */ + botCount?: number; +} + +const deltaSeconds = 1 / SHOOTER_TICK_RATE; +const normalSpeed = 4.7; +const sprintSpeed = 6.45; +const movementAcceleration = 31; +const movementFriction = 11; +const visibilityRange = 31; +const fieldOfViewRadians = Math.PI * 0.62; +const footstepHearingRange = 14; +const gunshotHearingRange = 38; +const respawnDelayTicks = 180; +const spawnProtectionTicks = 75; +const pickupRespawnTicks = 720; +const presentationEventLifetime = 240; + +interface TraceResult { + distance: number; + target: ShooterLagPlayer | null; + critical: boolean; + impact: ImpactKind; +} + +interface ShooterLagPlayer { + id: number; + alive: boolean; + x: number; + z: number; + spawnProtectionTicks: number; +} + +interface ShooterLagState { + players: Map; +} + +type ShooterLagAction = { type: "hitscan" }; + +export function createShooterGame(options: ShooterGameOptions = {}) { +const botCount = Math.max(0, Math.floor(options.botCount ?? BOT_COUNT)); +const shooterDefinition = defineNetworkedGame< + AuthoritativeShooterState, + ShooterWorldState, + ShooterInput, + ShooterAuthorityEvent, + ShooterPerception +>({ + tickRateHz: SHOOTER_TICK_RATE, + snapshotRateHz: SHOOTER_SNAPSHOT_RATE, + + server: { + createInitialState: () => createAuthoritativeWorld(botCount), + cloneState: cloneAuthoritativeWorld, + addPlayer(state, { playerId }) { + const spawn = safestSpawn(state, playerId); + state.players.set(playerId, createPlayer(playerId, "human", spawn)); + }, + removePlayer(state, { playerId }) { + state.players.delete(playerId); + }, + applyInput(state, input, { playerId }) { + const player = state.players.get(playerId); + if (!player || player.kind !== "human" || !player.alive) return; + applyPlayerCommand(player, input, true); + }, + step(state, { tick, emit }) { + state.elapsedTicks += 1; + updatePickups(state); + + for (const player of state.players.values()) { + updatePlayerTimers(state, player, emit); + } + for (const player of state.players.values()) { + if (player.alive && player.bot) updateBot(state, player, tick); + } + for (const player of [...state.players.values()].sort((a, b) => a.id - b.id)) { + if (!player.alive) continue; + simulateMovement(player, deltaSeconds); + emitFootstep(state, player, tick, emit); + if (player.bot) tryFireWeapon(state, player, emit); + collectNearbyPickup(state, player, emit); + player.lastFirePressed = player.firing; + } + }, + validateState: validateAuthoritativeState, + }, + + client: { + createInitialState: createClientWorld, + cloneState: cloneClientWorld, + applyInput(state, input, { playerId }) { + const player = state.players.get(playerId); + if (!player || !player.alive) return; + applyPlayerCommand(player, input, false); + }, + step(state, { tick }) { + state.match.elapsedTicks += 1; + for (const player of state.players.values()) { + if (!player.alive) continue; + if (player.cooldownTicks > 0) player.cooldownTicks -= 1; + if (player.reloadTicks > 0) player.reloadTicks -= 1; + if (player.spawnProtectionTicks > 0) player.spawnProtectionTicks -= 1; + simulateMovement(player, deltaSeconds); + } + state.events = state.events.filter( + (entry) => entry.receivedTick >= tick - presentationEventLifetime, + ); + }, + mergeSnapshot(predicted, snapshot, { tick }) { + const events = predicted.events + .filter((entry) => entry.receivedTick >= tick - presentationEventLifetime) + .map((entry) => ({ + receivedTick: entry.receivedTick, + event: { ...entry.event }, + })); + const merged = cloneClientWorld(snapshot); + merged.events = events; + return merged; + }, + applyEvent(state, event, { tick }) { + if (state.events.some((entry) => entry.event.id === event.id)) return; + state.events.push({ receivedTick: tick, event: { ...event } }); + if (state.events.length > 48) state.events.shift(); + }, + validateState: validateClientWorld, + }, + + replication: { + createSnapshot(authoritative, { playerId }) { + const viewer = authoritative.players.get(playerId); + const players = new Map(); + if (viewer) { + players.set(viewer.id, clonePublicPlayer(viewer)); + for (const target of authoritative.players.values()) { + if (target.id !== viewer.id && canViewerSee(viewer, target)) { + players.set(target.id, cloneVisibleOpponent(target)); + } + } + } + + const scoreboard = [...authoritative.players.values()] + .map((player) => ({ + id: player.id, + kind: player.kind, + kills: player.kills, + deaths: player.deaths, + })) + .sort((a, b) => b.kills - a.kills || a.deaths - b.deaths || a.id - b.id); + return { + players, + pickups: clonePickups(authoritative.pickups), + scoreboard, + match: { + elapsedTicks: authoritative.elapsedTicks, + scoreLimit: SHOOTER_SCORE_LIMIT, + leaderId: scoreboard[0]?.id ?? 0, + }, + events: [], + }; + }, + validateClientState(authoritative, candidate, { playerId }) { + const expected = authoritative.players.get(playerId); + const reported = candidate.players.get(playerId); + if (!expected || !reported) return false; + if (!finitePlayer(reported)) return false; + if (expected.alive !== reported.alive) return false; + return Math.hypot(expected.x - reported.x, expected.z - reported.z) <= 1.15; + }, + perceive(authoritative, event, { playerId }) { + return perceiveEvent(authoritative, event, playerId); + }, + }, + + validateInput(input) { + return ( + Number.isFinite(input.strafe) && + Number.isFinite(input.forward) && + Number.isFinite(input.yaw) && + Number.isFinite(input.pitch) && + Math.abs(input.strafe) <= 1 && + Math.abs(input.forward) <= 1 && + input.pitch >= -1.35 && + input.pitch <= 1.35 && + (input.weapon === 0 || input.weapon === 1 || input.weapon === 2) && + typeof input.fire === "boolean" && + typeof input.sprint === "boolean" && + typeof input.reload === "boolean" + ); + }, + + codecs: { + input: shooterInputCodec, + state: shooterWorldCodec, + event: shooterEventCodec, + }, +}); + +const lagCompensatedShooterDefinition = withLagCompensation(shooterDefinition, { + historySeconds: 0.5, + captureState(state) { + return { + players: new Map( + [...state.players].map(([id, player]) => [ + id, + { + id, + alive: player.alive, + x: player.x, + z: player.z, + spawnProtectionTicks: player.spawnProtectionTicks, + }, + ]), + ), + }; + }, + cloneHistoricalState: cloneShooterLagState, + classifyAction(input): ShooterLagAction | null { + return input.fire ? { type: "hitscan" } : null; + }, + cloneAction: (action) => ({ ...action }), + actions: { + hitscan: { + mode: "rewind", + maximumRewindMs: 250, + maximumFutureMs: 25, + outOfWindow: "clamp", + validate({ currentState, playerId }) { + const player = currentState.players.get(playerId); + return Boolean(player?.alive && player.kind === "human"); + }, + resolve({ + currentState, + historicalState, + playerId, + emit, + }) { + const player = currentState.players.get(playerId); + if (player) { + tryFireWeapon(currentState, player, emit, historicalState, true); + } + }, + }, + }, +}); + +const timeTravelShooterGame = withTimeTravel(lagCompensatedShooterDefinition, { + createSeed: () => 0, + hashState: deterministicHash, + checkpointIntervalTicks: SHOOTER_TICK_RATE * 2, + verificationIntervalTicks: 1, +}); + +return withReplayTransport(timeTravelShooterGame, { + historySeconds: 30, + captureRateHz: SHOOTER_SNAPSHOT_RATE, + listPerspectives: (state) => state.players.keys(), + createTickets(event, { tick, connectedPlayerIds }) { + if ( + event.type !== "elimination" || + !connectedPlayerIds.includes(event.victimId) + ) { + return null; + } + return { + requesterId: event.victimId, + perspectiveId: event.killerId, + fromTick: Math.max(0, tick - SHOOTER_TICK_RATE * 4), + toTick: tick, + playbackRate: 0.72, + }; + }, + authorizeReplay({ requesterId, perspectiveId, authorityState }) { + return ( + authorityState.players.has(requesterId) && + authorityState.players.has(perspectiveId) + ); + }, +}); +} + +export const shooterGame = createShooterGame(); + +function createAuthoritativeWorld(botCount: number): AuthoritativeShooterState { + const state: AuthoritativeShooterState = { + players: new Map(), + pickups: createPickups(), + elapsedTicks: 0, + nextEventId: 1, + }; + for (let index = 0; index < botCount; index += 1) { + const id = BOT_ID_BASE + index; + const bot = createPlayer(id, "bot", ARENA_SPAWNS[index % ARENA_SPAWNS.length]!); + bot.bot = { + targetId: null, + patrolIndex: (index * 2) % BOT_PATROL_POINTS.length, + nextThinkTick: index, + strafeSign: index % 2 === 0 ? 1 : -1, + }; + state.players.set(id, bot); + } + return state; +} + +function createClientWorld(): ShooterWorldState { + return { + players: new Map(), + pickups: new Map(), + scoreboard: [], + match: { elapsedTicks: 0, scoreLimit: SHOOTER_SCORE_LIMIT, leaderId: 0 }, + events: [], + }; +} + +function createPlayer( + id: number, + kind: PlayerKind, + spawn: { x: number; z: number; yaw: number }, +): AuthoritativePlayer { + return { + id, + kind, + alive: true, + x: spawn.x, + z: spawn.z, + velocityX: 0, + velocityZ: 0, + yaw: spawn.yaw, + pitch: 0, + health: 100, + armor: 25, + weapon: Weapon.PulseRifle, + ammo: createLoadout(), + cooldownTicks: 0, + reloadTicks: 0, + respawnTicks: 0, + spawnProtectionTicks, + inputStrafe: 0, + inputForward: 0, + firing: false, + sprinting: false, + kills: 0, + deaths: 0, + lastFirePressed: false, + lastReloadPressed: false, + lastFootstepTick: -100, + bot: null, + }; +} + +function createLoadout(): LoadoutState { + return [ + { magazine: WEAPONS[0].magazineSize, reserve: WEAPONS[0].startingReserve }, + { magazine: WEAPONS[1].magazineSize, reserve: WEAPONS[1].startingReserve }, + { magazine: WEAPONS[2].magazineSize, reserve: WEAPONS[2].startingReserve }, + ]; +} + +function createPickups(): Map { + const definitions: Array> = [ + { id: 1, kind: "health", weapon: Weapon.PulseRifle, x: 0, z: -7.4 }, + { id: 2, kind: "health", weapon: Weapon.PulseRifle, x: 0, z: 7.4 }, + { id: 3, kind: "ammo", weapon: Weapon.PulseRifle, x: -12.8, z: 0 }, + { id: 4, kind: "ammo", weapon: Weapon.PulseRifle, x: 12.8, z: 0 }, + { id: 5, kind: "weapon", weapon: Weapon.Scattergun, x: -11.8, z: -11.8 }, + { id: 6, kind: "weapon", weapon: Weapon.RailRifle, x: 11.8, z: 11.8 }, + ]; + return new Map( + definitions.map((pickup) => [pickup.id, { ...pickup, active: true, respawnTicks: 0 }]), + ); +} + +function cloneAuthoritativeWorld(state: AuthoritativeShooterState): AuthoritativeShooterState { + return { + players: new Map( + [...state.players].map(([id, player]) => [id, { + ...player, + ammo: cloneLoadout(player.ammo), + bot: player.bot ? { ...player.bot } : null, + }]), + ), + pickups: clonePickups(state.pickups), + elapsedTicks: state.elapsedTicks, + nextEventId: state.nextEventId, + }; +} + +function cloneClientWorld(state: ShooterWorldState): ShooterWorldState { + return { + players: new Map( + [...state.players].map(([id, player]) => [id, clonePublicPlayer(player)]), + ), + pickups: clonePickups(state.pickups), + scoreboard: state.scoreboard.map((score) => ({ ...score })), + match: { ...state.match }, + events: state.events.map((entry) => ({ + receivedTick: entry.receivedTick, + event: { ...entry.event }, + })), + }; +} + +function clonePublicPlayer(player: PlayerState): PlayerState { + return { + id: player.id, + kind: player.kind, + alive: player.alive, + x: player.x, + z: player.z, + velocityX: player.velocityX, + velocityZ: player.velocityZ, + yaw: player.yaw, + pitch: player.pitch, + health: player.health, + armor: player.armor, + weapon: player.weapon, + ammo: cloneLoadout(player.ammo), + cooldownTicks: player.cooldownTicks, + reloadTicks: player.reloadTicks, + respawnTicks: player.respawnTicks, + spawnProtectionTicks: player.spawnProtectionTicks, + inputStrafe: player.inputStrafe, + inputForward: player.inputForward, + firing: player.firing, + sprinting: player.sprinting, + }; +} + +function cloneVisibleOpponent(player: PlayerState): PlayerState { + return { + ...clonePublicPlayer(player), + // Exact health is present only because this player projection already + // passed the viewer's FOV and line-of-sight gate. Armor, inventory, and + // server timers remain redacted. + armor: 0, + ammo: [ + { magazine: 0, reserve: 0 }, + { magazine: 0, reserve: 0 }, + { magazine: 0, reserve: 0 }, + ], + cooldownTicks: 0, + reloadTicks: 0, + respawnTicks: 0, + spawnProtectionTicks: 0, + }; +} + +function cloneLoadout(loadout: LoadoutState): LoadoutState { + return [ + { ...loadout[0] }, + { ...loadout[1] }, + { ...loadout[2] }, + ]; +} + +function cloneShooterLagState(state: Readonly): ShooterLagState { + return { + players: new Map( + [...state.players].map(([id, player]) => [id, { ...player }]), + ), + }; +} + +function clonePickups(pickups: ReadonlyMap): Map { + return new Map([...pickups].map(([id, pickup]) => [id, { ...pickup }])); +} + +function applyPlayerCommand( + player: PlayerState | AuthoritativePlayer, + input: ShooterInput, + authoritative: boolean, +): void { + player.inputStrafe = clamp(input.strafe, -1, 1); + player.inputForward = clamp(input.forward, -1, 1); + player.yaw = normalizeAngle(input.yaw); + player.pitch = clamp(input.pitch, -1.25, 1.25); + player.firing = input.fire; + player.sprinting = input.sprint; + + if (player.weapon !== input.weapon && player.reloadTicks === 0) { + player.weapon = input.weapon; + player.cooldownTicks = Math.max(player.cooldownTicks, 8); + } + + if (authoritative) { + const authority = player as AuthoritativePlayer; + if (input.reload && !authority.lastReloadPressed) startReload(authority); + authority.lastReloadPressed = input.reload; + } else if (input.reload && player.reloadTicks === 0) { + const ammo = player.ammo[player.weapon]; + if (ammo.magazine < WEAPONS[player.weapon].magazineSize && ammo.reserve > 0) { + player.reloadTicks = WEAPONS[player.weapon].reloadTicks; + } + } +} + +function simulateMovement(player: PlayerState, dt: number): void { + const magnitude = Math.hypot(player.inputStrafe, player.inputForward); + const inputScale = magnitude > 1 ? 1 / magnitude : 1; + const strafe = player.inputStrafe * inputScale; + const forward = player.inputForward * inputScale; + const forwardX = Math.sin(player.yaw); + const forwardZ = -Math.cos(player.yaw); + const rightX = Math.cos(player.yaw); + const rightZ = Math.sin(player.yaw); + const speed = player.sprinting ? sprintSpeed : normalSpeed; + const targetX = (rightX * strafe + forwardX * forward) * speed; + const targetZ = (rightZ * strafe + forwardZ * forward) * speed; + const acceleration = magnitude > 0.01 ? movementAcceleration : movementFriction; + player.velocityX = approach(player.velocityX, targetX, acceleration * dt); + player.velocityZ = approach(player.velocityZ, targetZ, acceleration * dt); + + const nextX = player.x + player.velocityX * dt; + if (!collidesWithArena(nextX, player.z)) { + player.x = nextX; + } else { + player.velocityX = 0; + } + const nextZ = player.z + player.velocityZ * dt; + if (!collidesWithArena(player.x, nextZ)) { + player.z = nextZ; + } else { + player.velocityZ = 0; + } +} + +function updatePlayerTimers( + state: AuthoritativeShooterState, + player: AuthoritativePlayer, + emit: (event: ShooterAuthorityEvent) => void, +): void { + if (!player.alive) { + player.respawnTicks = Math.max(0, player.respawnTicks - 1); + if (player.respawnTicks === 0) { + respawnPlayer(state, player); + emit({ id: nextEventId(state), type: "respawn", playerId: player.id }); + } + return; + } + + player.cooldownTicks = Math.max(0, player.cooldownTicks - 1); + player.spawnProtectionTicks = Math.max(0, player.spawnProtectionTicks - 1); + if (player.reloadTicks > 0) { + player.reloadTicks -= 1; + if (player.reloadTicks === 0) finishReload(player); + } +} + +function updatePickups(state: AuthoritativeShooterState): void { + for (const pickup of state.pickups.values()) { + if (pickup.active) continue; + pickup.respawnTicks = Math.max(0, pickup.respawnTicks - 1); + if (pickup.respawnTicks === 0) pickup.active = true; + } +} + +function emitFootstep( + state: AuthoritativeShooterState, + player: AuthoritativePlayer, + tick: number, + emit: (event: ShooterAuthorityEvent) => void, +): void { + const speed = Math.hypot(player.velocityX, player.velocityZ); + const interval = player.sprinting ? 15 : 21; + if (speed > 1.2 && tick - player.lastFootstepTick >= interval) { + player.lastFootstepTick = tick; + emit({ id: nextEventId(state), type: "footstep", sourceId: player.id }); + } +} + +function tryFireWeapon( + state: AuthoritativeShooterState, + player: AuthoritativePlayer, + emit: (event: ShooterAuthorityEvent) => void, + historicalState?: Readonly, + triggerAction = false, +): void { + const weapon = WEAPONS[player.weapon]; + const canRepeat = + triggerAction || weapon.automatic || player.bot !== null || !player.lastFirePressed; + if ( + !player.firing || + !canRepeat || + player.cooldownTicks > 0 || + player.reloadTicks > 0 + ) { + return; + } + + const ammo = player.ammo[player.weapon]; + if (ammo.magazine <= 0) { + startReload(player); + return; + } + + ammo.magazine -= 1; + player.cooldownTicks = weapon.cooldownTicks; + const originX = player.x; + const originY = PLAYER_EYE_HEIGHT; + const originZ = player.z; + const shotId = nextEventId(state); + const centralDirection = directionFromAngles(player.yaw, player.pitch); + const candidates = (historicalState?.players ?? state.players).values(); + const centralTrace = traceShot(candidates, player, centralDirection, weapon.range); + const endX = originX + centralDirection.x * centralTrace.distance; + const endY = originY + centralDirection.y * centralTrace.distance; + const endZ = originZ + centralDirection.z * centralTrace.distance; + + emit({ + id: shotId, + type: "shot", + sourceId: player.id, + weapon: player.weapon, + originX, + originY, + originZ, + endX, + endY, + endZ, + impact: centralTrace.impact, + }); + + const damageByPlayer = new Map(); + for (let pellet = 0; pellet < weapon.pellets; pellet += 1) { + const yawOffset = signedNoise(shotId, pellet * 2 + 1) * weapon.spread; + const pitchOffset = signedNoise(shotId, pellet * 2 + 2) * weapon.spread; + const direction = directionFromAngles(player.yaw + yawOffset, player.pitch + pitchOffset); + const trace = traceShot( + (historicalState?.players ?? state.players).values(), + player, + direction, + weapon.range, + ); + if (!trace.target) continue; + const damage = Math.round(weapon.damage * (trace.critical ? 1.55 : 1)); + const previous = damageByPlayer.get(trace.target.id); + damageByPlayer.set(trace.target.id, { + amount: (previous?.amount ?? 0) + damage, + critical: Boolean(previous?.critical || trace.critical), + }); + } + + for (const [targetId, damage] of damageByPlayer) { + const target = state.players.get(targetId); + if (target?.alive) applyDamage(state, player, target, damage.amount, damage.critical, emit); + } +} + +function traceShot( + candidates: Iterable, + shooter: AuthoritativePlayer, + direction: { x: number; y: number; z: number }, + maximumDistance: number, +): TraceResult { + const wallDistance = rayBlockDistance( + shooter.x, + PLAYER_EYE_HEIGHT, + shooter.z, + direction.x, + direction.y, + direction.z, + maximumDistance, + ); + let distance = wallDistance ?? maximumDistance; + let target: ShooterLagPlayer | null = null; + let critical = false; + + for (const candidate of candidates) { + if ( + candidate.id === shooter.id || + !candidate.alive || + candidate.spawnProtectionTicks > 0 + ) continue; + + const bodyDistance = raySphereDistance( + shooter.x, + PLAYER_EYE_HEIGHT, + shooter.z, + direction, + candidate.x, + 0.83, + candidate.z, + 0.49, + ); + const headDistance = raySphereDistance( + shooter.x, + PLAYER_EYE_HEIGHT, + shooter.z, + direction, + candidate.x, + 1.55, + candidate.z, + 0.24, + ); + const candidateDistance = minimumNullable(bodyDistance, headDistance); + if (candidateDistance !== null && candidateDistance < distance) { + distance = candidateDistance; + target = candidate; + critical = headDistance !== null && headDistance <= candidateDistance + 0.0001; + } + } + + return { + distance, + target, + critical, + impact: target ? "player" : wallDistance !== null ? "wall" : "miss", + }; +} + +function raySphereDistance( + originX: number, + originY: number, + originZ: number, + direction: { x: number; y: number; z: number }, + centerX: number, + centerY: number, + centerZ: number, + radius: number, +): number | null { + const offsetX = originX - centerX; + const offsetY = originY - centerY; + const offsetZ = originZ - centerZ; + const projection = + offsetX * direction.x + offsetY * direction.y + offsetZ * direction.z; + const constant = + offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ - radius * radius; + const discriminant = projection * projection - constant; + if (discriminant < 0) return null; + const near = -projection - Math.sqrt(discriminant); + const far = -projection + Math.sqrt(discriminant); + if (near >= 0) return near; + return far >= 0 ? far : null; +} + +function applyDamage( + state: AuthoritativeShooterState, + attacker: AuthoritativePlayer, + target: AuthoritativePlayer, + amount: number, + critical: boolean, + emit: (event: ShooterAuthorityEvent) => void, +): void { + const absorbed = Math.min(target.armor, Math.round(amount * 0.3)); + target.armor -= absorbed; + const healthDamage = Math.max(1, amount - absorbed); + target.health = Math.max(0, target.health - healthDamage); + emit({ + id: nextEventId(state), + type: "damage", + sourceId: attacker.id, + targetId: target.id, + amount, + health: target.health, + critical, + }); + + if (target.health > 0) return; + target.alive = false; + target.deaths += 1; + target.respawnTicks = respawnDelayTicks; + target.velocityX = 0; + target.velocityZ = 0; + target.inputForward = 0; + target.inputStrafe = 0; + target.firing = false; + attacker.kills += 1; + emit({ + id: nextEventId(state), + type: "elimination", + killerId: attacker.id, + victimId: target.id, + weapon: attacker.weapon, + critical, + }); +} + +function startReload(player: PlayerState): void { + const definition = WEAPONS[player.weapon]; + const ammo = player.ammo[player.weapon]; + if ( + player.reloadTicks === 0 && + ammo.magazine < definition.magazineSize && + ammo.reserve > 0 + ) { + player.reloadTicks = definition.reloadTicks; + } +} + +function finishReload(player: PlayerState): void { + const definition = WEAPONS[player.weapon]; + const ammo = player.ammo[player.weapon]; + const needed = definition.magazineSize - ammo.magazine; + const moved = Math.min(needed, ammo.reserve); + ammo.magazine += moved; + ammo.reserve -= moved; +} + +function respawnPlayer(state: AuthoritativeShooterState, player: AuthoritativePlayer): void { + const spawn = safestSpawn(state, player.id); + player.alive = true; + player.x = spawn.x; + player.z = spawn.z; + player.yaw = spawn.yaw; + player.pitch = 0; + player.health = 100; + player.armor = 25; + player.velocityX = 0; + player.velocityZ = 0; + player.inputForward = 0; + player.inputStrafe = 0; + player.firing = false; + player.sprinting = false; + player.cooldownTicks = 0; + player.reloadTicks = 0; + player.respawnTicks = 0; + player.spawnProtectionTicks = spawnProtectionTicks; + player.lastFirePressed = false; + player.lastReloadPressed = false; + for (const weapon of [Weapon.PulseRifle, Weapon.Scattergun, Weapon.RailRifle] as const) { + const ammo = player.ammo[weapon]; + ammo.magazine = WEAPONS[weapon].magazineSize; + ammo.reserve = Math.max(ammo.reserve, Math.ceil(WEAPONS[weapon].startingReserve / 2)); + } +} + +function collectNearbyPickup( + state: AuthoritativeShooterState, + player: AuthoritativePlayer, + emit: (event: ShooterAuthorityEvent) => void, +): void { + for (const pickup of state.pickups.values()) { + if (!pickup.active || Math.hypot(player.x - pickup.x, player.z - pickup.z) > 0.9) { + continue; + } + const amount = applyPickup(player, pickup); + if (amount <= 0) continue; + pickup.active = false; + pickup.respawnTicks = pickupRespawnTicks; + emit({ + id: nextEventId(state), + type: "pickup", + playerId: player.id, + pickup: pickup.kind, + weapon: pickup.weapon, + amount, + }); + } +} + +function applyPickup(player: AuthoritativePlayer, pickup: PickupState): number { + if (pickup.kind === "health") { + const gained = Math.min(45, 100 - player.health); + player.health += gained; + return gained; + } + if (pickup.kind === "ammo") { + let gained = 0; + for (const weapon of [Weapon.PulseRifle, Weapon.Scattergun, Weapon.RailRifle] as const) { + const amount = weapon === Weapon.PulseRifle ? 30 : weapon === Weapon.Scattergun ? 8 : 4; + player.ammo[weapon].reserve += amount; + gained += amount; + } + return gained; + } + const definition = WEAPONS[pickup.weapon]; + player.weapon = pickup.weapon; + player.ammo[pickup.weapon].reserve += definition.magazineSize * 2; + return definition.magazineSize * 2; +} + +function updateBot( + state: AuthoritativeShooterState, + bot: AuthoritativePlayer, + tick: number, +): void { + const mind = bot.bot!; + if (tick >= mind.nextThinkTick) { + mind.nextThinkTick = tick + 7 + (bot.id % 4); + let target: AuthoritativePlayer | null = null; + let bestDistance = Infinity; + for (const candidate of state.players.values()) { + if (candidate.id === bot.id || !candidate.alive) continue; + const distance = Math.hypot(candidate.x - bot.x, candidate.z - bot.z); + if ( + distance < bestDistance && + distance <= visibilityRange && + hasLineOfSight(bot.x, bot.z, candidate.x, candidate.z) + ) { + target = candidate; + bestDistance = distance; + } + } + mind.targetId = target?.id ?? null; + if (signedNoise(bot.id, Math.floor(tick / 90)) > 0.65) mind.strafeSign *= -1; + } + + const target = mind.targetId === null ? null : state.players.get(mind.targetId); + if (target?.alive && hasLineOfSight(bot.x, bot.z, target.x, target.z)) { + const dx = target.x - bot.x; + const dz = target.z - bot.z; + const distance = Math.hypot(dx, dz); + const aimNoise = signedNoise(bot.id, Math.floor(tick / 16)) * 0.045; + const desiredYaw = Math.atan2(dx, -dz) + aimNoise; + bot.yaw = rotateToward(bot.yaw, desiredYaw, 0.095); + bot.pitch = rotateToward(bot.pitch, -0.035 + aimNoise * 0.25, 0.035); + bot.weapon = distance < 8.5 ? Weapon.Scattergun : distance > 19 ? Weapon.RailRifle : Weapon.PulseRifle; + if (bot.ammo[bot.weapon].magazine === 0) startReload(bot); + bot.firing = Math.abs(normalizeAngle(desiredYaw - bot.yaw)) < 0.19; + bot.inputStrafe = mind.strafeSign * (distance < 15 ? 0.72 : 0.25); + bot.inputForward = distance > 11 ? 0.72 : distance < 5 ? -0.55 : 0.12; + bot.sprinting = false; + return; + } + + mind.targetId = null; + const patrol = BOT_PATROL_POINTS[mind.patrolIndex % BOT_PATROL_POINTS.length]!; + let dx = patrol.x - bot.x; + let dz = patrol.z - bot.z; + if (Math.hypot(dx, dz) < 1.15) { + mind.patrolIndex = (mind.patrolIndex + 1 + (bot.id % 3)) % BOT_PATROL_POINTS.length; + const next = BOT_PATROL_POINTS[mind.patrolIndex]!; + dx = next.x - bot.x; + dz = next.z - bot.z; + } + let desiredYaw = Math.atan2(dx, -dz); + const lookAheadX = bot.x + Math.sin(desiredYaw) * 1.1; + const lookAheadZ = bot.z - Math.cos(desiredYaw) * 1.1; + if (collidesWithArena(lookAheadX, lookAheadZ, 0.55)) { + desiredYaw += mind.strafeSign * Math.PI * 0.48; + } + bot.yaw = rotateToward(bot.yaw, desiredYaw, 0.08); + bot.pitch = rotateToward(bot.pitch, 0, 0.03); + bot.inputForward = 1; + bot.inputStrafe = 0; + bot.firing = false; + bot.sprinting = true; +} + +function perceiveEvent( + state: AuthoritativeShooterState, + event: ShooterAuthorityEvent, + viewerId: number, +): ShooterPerception | null { + const viewer = state.players.get(viewerId); + if (!viewer) return null; + + if (event.type === "elimination") { + return { ...event }; + } + if (event.type === "damage") { + if (event.sourceId === viewerId) { + return { + id: event.id, + type: "hit", + amount: event.amount, + eliminated: event.health === 0, + critical: event.critical, + }; + } + if (event.targetId !== viewerId) return null; + const source = state.players.get(event.sourceId); + const bearing = source + ? normalizeAngle(Math.atan2(source.x - viewer.x, -(source.z - viewer.z)) - viewer.yaw) + : 0; + return { + id: event.id, + type: "damage", + amount: event.amount, + health: event.health, + bearingRadians: bearing, + critical: event.critical, + }; + } + if (event.type === "pickup") { + if (event.playerId !== viewerId) return null; + return { + id: event.id, + type: "pickup", + pickup: event.pickup, + weapon: event.weapon, + amount: event.amount, + }; + } + if (event.type === "respawn") { + return event.playerId === viewerId ? { id: event.id, type: "respawn" } : null; + } + + const sourceId = event.type === "shot" ? event.sourceId : event.sourceId; + const source = state.players.get(sourceId); + if (!source) return null; + const distance = Math.hypot(source.x - viewer.x, source.z - viewer.z); + const bearing = normalizeAngle( + Math.atan2(source.x - viewer.x, -(source.z - viewer.z)) - viewer.yaw, + ); + + if (event.type === "shot") { + if (source.id === viewerId) { + return { + id: event.id, + type: "shot", + weapon: event.weapon, + sourceId: event.sourceId, + originX: event.originX, + originY: event.originY, + originZ: event.originZ, + endX: event.endX, + endY: event.endY, + endZ: event.endZ, + impact: event.impact, + }; + } + if (distance <= gunshotHearingRange) { + const occlusion = hasLineOfSight(viewer.x, viewer.z, source.x, source.z) + ? 1 + : 0.48; + return { + id: event.id, + type: "sound", + sound: "gunshot", + weapon: event.weapon, + bearingRadians: bearing, + intensity: + Math.max(0.08, 1 - distance / gunshotHearingRange) * occlusion, + distanceBand: distanceBand(distance, gunshotHearingRange), + }; + } + return null; + } + + if (distance > footstepHearingRange || source.id === viewerId) return null; + const occlusion = hasLineOfSight(viewer.x, viewer.z, source.x, source.z) + ? 1 + : 0.48; + return { + id: event.id, + type: "sound", + sound: "footstep", + weapon: Weapon.PulseRifle, + bearingRadians: bearing, + intensity: Math.max(0.05, 1 - distance / footstepHearingRange) * occlusion, + distanceBand: distanceBand(distance, footstepHearingRange), + }; +} + +function canViewerSee(viewer: AuthoritativePlayer, target: AuthoritativePlayer): boolean { + if (!target.alive) return false; + const dx = target.x - viewer.x; + const dz = target.z - viewer.z; + const distance = Math.hypot(dx, dz); + if (distance > visibilityRange) return false; + const angle = normalizeAngle(Math.atan2(dx, -dz) - viewer.yaw); + if (distance > 3.2 && Math.abs(angle) > fieldOfViewRadians) return false; + return hasLineOfSight(viewer.x, viewer.z, target.x, target.z); +} + +function safestSpawn(state: AuthoritativeShooterState, playerId: number) { + let best = ARENA_SPAWNS[playerId % ARENA_SPAWNS.length]!; + let bestScore = -Infinity; + for (let offset = 0; offset < ARENA_SPAWNS.length; offset += 1) { + const spawn = ARENA_SPAWNS[(playerId + offset) % ARENA_SPAWNS.length]!; + let closest = 100; + for (const player of state.players.values()) { + if (player.id === playerId || !player.alive) continue; + closest = Math.min(closest, Math.hypot(spawn.x - player.x, spawn.z - player.z)); + } + if (closest > bestScore) { + best = spawn; + bestScore = closest; + } + } + return best; +} + +function validateAuthoritativeState(state: AuthoritativeShooterState): boolean { + return ( + state.players.size <= 128 && + state.pickups.size <= 64 && + [...state.players.values()].every( + (player) => finitePlayer(player) && !collidesWithArena(player.x, player.z, 0.39), + ) + ); +} + +function validateClientWorld(state: ShooterWorldState): boolean { + return ( + state.players.size <= 128 && + state.pickups.size <= 64 && + state.scoreboard.length <= 128 && + [...state.players.values()].every(finitePlayer) + ); +} + +function finitePlayer(player: PlayerState): boolean { + return ( + Number.isFinite(player.x) && + Number.isFinite(player.z) && + Number.isFinite(player.velocityX) && + Number.isFinite(player.velocityZ) && + Number.isFinite(player.yaw) && + Number.isFinite(player.pitch) && + player.health >= 0 && + player.health <= 100 && + player.armor >= 0 && + player.armor <= 100 + ); +} + +function nextEventId(state: AuthoritativeShooterState): number { + const id = state.nextEventId; + state.nextEventId = (state.nextEventId + 1) >>> 0; + return id; +} + +function directionFromAngles(yaw: number, pitch: number) { + const horizontal = Math.cos(pitch); + return { + x: Math.sin(yaw) * horizontal, + y: Math.sin(pitch), + z: -Math.cos(yaw) * horizontal, + }; +} + +function signedNoise(a: number, b: number): number { + let value = (a * 374_761_393 + b * 668_265_263) >>> 0; + value = ((value ^ (value >>> 13)) * 1_274_126_177) >>> 0; + return ((value ^ (value >>> 16)) / 4_294_967_295) * 2 - 1; +} + +function distanceBand(distance: number, range: number): DistanceBand { + return distance < range / 3 ? "near" : distance < (range * 2) / 3 ? "medium" : "far"; +} + +function minimumNullable(left: number | null, right: number | null): number | null { + if (left === null) return right; + if (right === null) return left; + return Math.min(left, right); +} + +function approach(value: number, target: number, amount: number): number { + return value < target ? Math.min(target, value + amount) : Math.max(target, value - amount); +} + +function rotateToward(value: number, target: number, amount: number): number { + return normalizeAngle(value + clamp(normalizeAngle(target - value), -amount, amount)); +} + +function normalizeAngle(value: number): number { + return Math.atan2(Math.sin(value), Math.cos(value)); +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/packages/shared/src/shooter-types.ts b/packages/shared/src/shooter-types.ts new file mode 100644 index 0000000..fcca6bd --- /dev/null +++ b/packages/shared/src/shooter-types.ts @@ -0,0 +1,141 @@ +export const Weapon = { + PulseRifle: 0, + Scattergun: 1, + RailRifle: 2, +} as const; + +export type WeaponId = (typeof Weapon)[keyof typeof Weapon]; +export type PlayerKind = "human" | "bot"; +export type PickupKind = "health" | "ammo" | "weapon"; +export type DistanceBand = "near" | "medium" | "far"; + +export interface WeaponDefinition { + id: WeaponId; + name: string; + shortName: string; + damage: number; + pellets: number; + spread: number; + range: number; + cooldownTicks: number; + reloadTicks: number; + magazineSize: number; + startingReserve: number; + automatic: boolean; + accent: string; +} + +export const WEAPONS: Readonly> = { + [Weapon.PulseRifle]: { id: Weapon.PulseRifle, name: "Nova-9 Pulse Rifle", shortName: "NOVA-9", damage: 18, pellets: 1, spread: 0.012, range: 42, cooldownTicks: 6, reloadTicks: 92, magazineSize: 30, startingReserve: 120, automatic: true, accent: "#5de4c7" }, + [Weapon.Scattergun]: { id: Weapon.Scattergun, name: "Arc-12 Scattergun", shortName: "ARC-12", damage: 10, pellets: 8, spread: 0.105, range: 16, cooldownTicks: 48, reloadTicks: 112, magazineSize: 8, startingReserve: 32, automatic: false, accent: "#ffad5c" }, + [Weapon.RailRifle]: { id: Weapon.RailRifle, name: "Vantage Rail Rifle", shortName: "VANTAGE", damage: 72, pellets: 1, spread: 0.002, range: 58, cooldownTicks: 76, reloadTicks: 138, magazineSize: 4, startingReserve: 16, automatic: false, accent: "#a98cff" }, +}; + +export interface ShooterInput { + strafe: number; + forward: number; + yaw: number; + pitch: number; + fire: boolean; + sprint: boolean; + reload: boolean; + weapon: WeaponId; +} + +export interface AmmoState { magazine: number; reserve: number } +export type LoadoutState = [AmmoState, AmmoState, AmmoState]; + +export interface PlayerState { + id: number; + kind: PlayerKind; + alive: boolean; + x: number; + z: number; + velocityX: number; + velocityZ: number; + yaw: number; + pitch: number; + health: number; + armor: number; + weapon: WeaponId; + ammo: LoadoutState; + cooldownTicks: number; + reloadTicks: number; + respawnTicks: number; + spawnProtectionTicks: number; + inputStrafe: number; + inputForward: number; + firing: boolean; + sprinting: boolean; +} + +export interface ScoreEntry { id: number; kind: PlayerKind; kills: number; deaths: number } + +export interface PickupState { + id: number; + kind: PickupKind; + weapon: WeaponId; + x: number; + z: number; + active: boolean; + respawnTicks: number; +} + +export interface MatchState { elapsedTicks: number; scoreLimit: number; leaderId: number } +export type SoundKind = "gunshot" | "footstep"; +export type ImpactKind = "wall" | "player" | "miss"; + +export type ShooterPerception = + | { id: number; type: "shot"; weapon: WeaponId; sourceId: number; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind } + | { id: number; type: "sound"; sound: SoundKind; weapon: WeaponId; bearingRadians: number; intensity: number; distanceBand: DistanceBand } + | { id: number; type: "damage"; amount: number; health: number; bearingRadians: number; critical: boolean } + | { id: number; type: "hit"; amount: number; eliminated: boolean; critical: boolean } + | { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean } + | { id: number; type: "pickup"; pickup: PickupKind; weapon: WeaponId; amount: number } + | { id: number; type: "respawn" }; + +export interface TimedPerception { receivedTick: number; event: ShooterPerception } + +export interface ShooterWorldState { + players: Map; + pickups: Map; + scoreboard: ScoreEntry[]; + match: MatchState; + events: TimedPerception[]; +} + +export interface BotMind { targetId: number | null; patrolIndex: number; nextThinkTick: number; strafeSign: number } + +export interface AuthoritativePlayer extends PlayerState { + kills: number; + deaths: number; + lastFirePressed: boolean; + lastReloadPressed: boolean; + lastFootstepTick: number; + bot: BotMind | null; +} + +export interface AuthoritativeShooterState { + players: Map; + pickups: Map; + elapsedTicks: number; + nextEventId: number; +} + +export type ShooterAuthorityEvent = + | { id: number; type: "shot"; sourceId: number; weapon: WeaponId; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind } + | { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number; critical: boolean } + | { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean } + | { id: number; type: "pickup"; playerId: number; pickup: PickupKind; weapon: WeaponId; amount: number } + | { id: number; type: "respawn"; playerId: number } + | { id: number; type: "footstep"; sourceId: number }; + +export function isWeaponId(value: number): value is WeaponId { + return value === 0 || value === 1 || value === 2; +} + +export function playerDisplayName(id: number, kind: PlayerKind): string { + if (kind === "human") return `RANGER ${id}`; + const names = ["CIPHER", "ROOK", "MARA", "KESTREL", "VEX", "HALO"]; + return names[Math.abs(id) % names.length] ?? `BOT ${id}`; +} diff --git a/packages/shared/test/flux.test.mjs b/packages/shared/test/flux.test.mjs new file mode 100644 index 0000000..ccc2d07 --- /dev/null +++ b/packages/shared/test/flux.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + FLUX_BOT_IDS, + FLUX_SNAPSHOT_RATE, + FLUX_TICK_RATE, + fluxGame, +} from "../dist/index.js"; + +function createThrustPacket(sequence, targetTick, thrust = true) { + return { + sequence, + targetTick, + observedTick: Math.max(0, targetTick - 1), + input: { thrust }, + }; +} + +test("Flux Relay is a complete game defined through the public authoring API", () => { + assert.equal(fluxGame.tickRateHz, FLUX_TICK_RATE); + assert.equal(fluxGame.snapshotRateHz, FLUX_SNAPSHOT_RATE); + + const server = fluxGame.createServer(); + server.addPlayer(1); + assert.deepEqual(server.submitInput(1, createThrustPacket(1, 1)), { + accepted: true, + }); + + const events = []; + for (let tick = 0; tick < 180; tick += 1) { + events.push(...server.step().events); + } + + assert.ok(server.currentState.cyanScore >= 2); + assert.equal(server.currentState.orangeScore, 0); + assert.ok( + events.some((event) => event.type === "round-won" && event.team === "cyan"), + ); + assert.ok(server.currentState.players.some((player) => player.id === FLUX_BOT_IDS[0])); +}); + +test("Flux snapshots disclose exact energy only to its owner", () => { + const server = fluxGame.createServer(); + server.addPlayer(1); + server.addPlayer(2); + + const playerOneView = server.createSnapshot(1, 0).state; + const playerTwoView = server.createSnapshot(2, 0).state; + + assert.equal(playerOneView.players.find((player) => player.id === 1)?.energy, 100); + assert.equal(playerOneView.players.find((player) => player.id === 2)?.energy, null); + assert.equal(playerTwoView.players.find((player) => player.id === 1)?.energy, null); + assert.equal(playerTwoView.players.find((player) => player.id === 2)?.energy, 100); + assert.ok( + playerOneView.players + .filter((player) => player.bot) + .every((player) => player.energy === null), + ); + assert.equal("nextEventId" in playerOneView, false); +}); + +test("the same private Flux event becomes a self cue or anonymous field cue", () => { + const server = fluxGame.createServer(); + server.addPlayer(1); + server.addPlayer(2); + const player = server.currentState.players.find((candidate) => candidate.id === 1); + player.energy = 0.2; + + server.submitInput(1, createThrustPacket(1, 1)); + const result = server.step(); + const overheat = result.events.find((event) => event.type === "overheated"); + assert.ok(overheat); + + const ownCue = server.createPerceptions(1, [overheat])[0]; + const fieldCue = server.createPerceptions(2, [overheat])[0]; + assert.equal(ownCue.scope, "self"); + assert.equal(fieldCue.scope, "field"); + assert.equal("playerId" in ownCue, false); + assert.equal("playerId" in fieldCue, false); +}); + +test("Flux uses the generic protocol, prediction, acknowledgement, and reconciliation", () => { + const server = fluxGame.createServer(); + server.addPlayer(7); + const client = fluxGame.createClient(); + client.initialize(7, server.createSnapshot(7, 10)); + + const packet = client.createInput({ thrust: true }, 1); + const wirePacket = fluxGame.protocol.decodeClient( + fluxGame.protocol.encodeClient({ kind: "input", packet }), + ); + assert.equal(wirePacket.kind, "input"); + assert.deepEqual(wirePacket.packet, packet); + assert.equal(server.submitInput(7, packet).accepted, true); + + client.step(); + const result = server.step(); + client.acknowledge(result.acknowledgements[0].sequence); + const wireSnapshot = fluxGame.protocol.decodeServer( + fluxGame.protocol.encodeServer({ + kind: "snapshot", + snapshot: server.createSnapshot(7, 20), + }), + ); + assert.equal(wireSnapshot.kind, "snapshot"); + client.reconcile(wireSnapshot.snapshot); + + const local = client.currentState.players.find((player) => player.id === 7); + assert.equal(local.thrust, true); + assert.ok(local.energy < 100); +}); diff --git a/packages/shared/test/royale.test.mjs b/packages/shared/test/royale.test.mjs new file mode 100644 index 0000000..7bb728e --- /dev/null +++ b/packages/shared/test/royale.test.mjs @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ROYALE_BOT_COUNT, + ROYALE_BOT_ID_BASE, + ROYALE_CHUNK_SIZE, + ROYALE_MAP_SEED, + collidesWithRoyaleMap, + generateRoyaleChunk, + hasRoyaleLineOfSight, + royaleGame, +} from "../dist/index.js"; + +test("the public world seed deterministically generates a 2 km chunked island", () => { + const first = generateRoyaleChunk(-3, 4); + const second = generateRoyaleChunk(-3, 4); + const neighbor = generateRoyaleChunk(-2, 4); + assert.deepEqual(first, second); + assert.notDeepEqual(first, neighbor); + assert.equal(ROYAALE_MAP_SEED_FOR_TEST(), ROYALE_MAP_SEED); + assert.equal(ROYAALE_CHUNK_SIZE_FOR_TEST(), ROYALE_CHUNK_SIZE); + assert.ok(first.obstacles.every((obstacle) => obstacle.id.startsWith("-3:4:"))); +}); + +test("Syncer Royale simulates a complete authority battle headlessly", () => { + const server = royaleGame.createServer(); + const totals = { shots: 0, damage: 0, eliminations: 0, winners: 0 }; + for (let tick = 0; tick < 6_000; tick += 1) { + for (const event of server.step().events) { + if (event.type === "shot") totals.shots += 1; + if (event.type === "damage") totals.damage += 1; + if (event.type === "elimination") totals.eliminations += 1; + if (event.type === "winner") totals.winners += 1; + } + } + assert.equal(server.currentState.players.length, ROYALE_BOT_COUNT); + assert.ok(totals.shots > 150, `expected firefights, saw ${totals.shots}`); + assert.ok(totals.damage > 50, `expected damage, saw ${totals.damage}`); + assert.ok(totals.eliminations > 15, `expected eliminations, saw ${totals.eliminations}`); + assert.ok(server.currentState.players.every((player) => Number.isFinite(player.x + player.z))); +}); + +test("Royale projections hide authority secrets and distant combatants", () => { + const server = royaleGame.createServer(); + server.addPlayer(1); + const authority = server.currentState; + const viewer = authority.players.find((player) => player.id === 1); + const hidden = authority.players.find((player) => player.id === ROYALE_BOT_ID_BASE); + viewer.altitude = 0; + viewer.x = 0; + viewer.z = 0; + hidden.x = 430; + hidden.z = 0; + hidden.alive = true; + + const snapshot = server.createSnapshot(1, 0).state; + assert.equal(snapshot.mapSeed, ROYALE_MAP_SEED); + assert.equal("secretSeed" in snapshot, false); + assert.ok(snapshot.players.some((player) => player.id === 1)); + assert.ok(!snapshot.players.some((player) => player.id === hidden.id)); + assert.ok( + snapshot.players + .filter((player) => player.id !== 1) + .every((player) => player.health === null && player.armor === null), + ); + + const [sound] = server.createPerceptions(1, [ + { id: 99, type: "shot", sourceId: hidden.id, x: hidden.x, y: 1.55, z: hidden.z, yaw: 0, pitch: 0 }, + ]); + assert.equal(sound.type, "gunfire"); + assert.equal("sourceId" in sound, false); + assert.equal("x" in sound, false); + + const [ownShot] = server.createPerceptions(1, [ + { + id: 100, + type: "shot", + sourceId: viewer.id, + x: viewer.x, + y: 1.55, + z: viewer.z, + yaw: viewer.yaw, + pitch: viewer.pitch, + }, + ]); + assert.equal(ownShot.type, "shot"); + assert.equal(ownShot.sourceId, viewer.id); +}); + +test("Royale enforces a viewer bandwidth budget after ordinary visibility", () => { + const server = royaleGame.createServer(); + server.addPlayer(1); + const authority = server.currentState; + const viewer = authority.players.find((player) => player.id === 1); + viewer.altitude = 0; + viewer.x = 0; + viewer.z = 0; + for (const bot of authority.players.filter((player) => player.bot)) { + bot.x = 800; + bot.z = 0; + } + for (let index = 0; index < 100; index += 1) { + const item = authority.loot[index]; + item.active = true; + item.x = (index % 10) * 2 - 9; + item.z = Math.floor(index / 10) * 2 - 9; + } + + const snapshot = server.createSnapshot(1, 0).state; + assert.ok(snapshot.stream.droppedCount > 0); + assert.ok(snapshot.stream.usedBytes <= snapshot.stream.budgetBytes); + assert.ok(snapshot.loot.length < 100); + assert.equal(snapshot.players.filter((player) => player.id === 1).length, 1); +}); + +test("Royale input and projected state round-trip through the generic protocol", () => { + const server = royaleGame.createServer(); + server.addPlayer(7); + const client = royaleGame.createClient(); + const welcome = server.createSnapshot(7, 10); + client.initialize(7, welcome); + const packet = client.createInput({ + forward: 1, + strafe: 0.25, + yaw: 0.7, + pitch: -0.2, + fire: false, + sprint: true, + reload: false, + }, 1); + const decoded = royaleGame.protocol.decodeClient( + royaleGame.protocol.encodeClient({ kind: "input", packet }), + ); + assert.equal(decoded.kind, "input"); + assert.deepEqual(decoded.packet, packet); + assert.equal(server.submitInput(7, decoded.packet).accepted, true); + server.step(); + assert.equal(server.currentState.players.find((player) => player.id === 7).pitch, -0.2); + client.step(); + client.reconcile(server.createSnapshot(7, 20)); + assert.ok(client.currentState.players.some((player) => player.id === 7)); +}); + +test("Royale pitch drives the authoritative three-dimensional hit ray", () => { + const server = royaleGame.createServer(); + server.addPlayer(7); + server.addPlayer(8); + const authority = server.currentState; + for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false; + const shooter = authority.players.find((player) => player.id === 7); + const target = authority.players.find((player) => player.id === 8); + let origin = null; + for (let x = -100; x <= 100 && !origin; x += 10) { + for (let z = -100; z <= 60; z += 10) { + if ( + !collidesWithRoyaleMap(x, z) && + !collidesWithRoyaleMap(x, z + 40) && + hasRoyaleLineOfSight(x, z, x, z + 40) + ) { + origin = { x, z }; + break; + } + } + } + assert.ok(origin, "expected an unobstructed public-map firing lane"); + Object.assign(shooter, { x: origin.x, z: origin.z, altitude: 0, yaw: 0 }); + Object.assign(target, { x: origin.x, z: origin.z + 40, altitude: 20, health: 100, armor: 0 }); + const pitch = Math.atan2(20, 40); + assert.equal(server.submitInput(7, { + sequence: 1, + targetTick: 1, + observedTick: 0, + input: { + forward: 0, + strafe: 0, + yaw: 0, + pitch, + fire: true, + sprint: false, + reload: false, + }, + }).accepted, true); + const result = server.step(); + assert.ok(result.events.some((event) => event.type === "damage" && event.targetId === 8)); + assert.ok(target.health < 100); +}); + +test("Royale strafe-right matches the first-person camera's screen right", () => { + const server = royaleGame.createServer(); + server.addPlayer(7); + const authority = server.currentState; + for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false; + const player = authority.players.find((candidate) => candidate.id === 7); + Object.assign(player, { x: 0, z: 0, altitude: 20, yaw: 0 }); + assert.equal(server.submitInput(7, { + sequence: 1, + targetTick: 1, + observedTick: 0, + input: { + forward: 0, + strafe: 1, + yaw: 0, + pitch: 0, + fire: false, + sprint: false, + reload: false, + }, + }).accepted, true); + server.step(); + assert.ok(player.velocityX < 0, "screen-right is world -X while facing world +Z"); +}); + +function ROYAALE_MAP_SEED_FOR_TEST() { + return ROYALE_MAP_SEED; +} + +function ROYAALE_CHUNK_SIZE_FOR_TEST() { + return ROYALE_CHUNK_SIZE; +} diff --git a/packages/shared/test/shooter.test.mjs b/packages/shared/test/shooter.test.mjs new file mode 100644 index 0000000..d95a74d --- /dev/null +++ b/packages/shared/test/shooter.test.mjs @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + BOT_COUNT, + BOT_ID_BASE, + Weapon, + shooterGame, +} from "../dist/index.js"; + +function runHeadlessMatch(ticks) { + const server = shooterGame.createServer(); + const totals = { + shots: 0, + damageEvents: 0, + eliminations: 0, + respawns: 0, + }; + + for (let tick = 0; tick < ticks; tick += 1) { + const result = server.step(); + for (const event of result.events) { + if (event.type === "shot") totals.shots += 1; + if (event.type === "damage") totals.damageEvents += 1; + if (event.type === "elimination") totals.eliminations += 1; + if (event.type === "respawn") totals.respawns += 1; + } + } + + return { + ...totals, + tick: server.tick, + scores: [...server.currentState.players.values()] + .map((player) => ({ + id: player.id, + kills: player.kills, + deaths: player.deaths, + alive: player.alive, + x: Number(player.x.toFixed(5)), + z: Number(player.z.toFixed(5)), + })) + .sort((a, b) => a.id - b.id), + }; +} + +test("the complete shooter match simulates headlessly on the backend", () => { + const match = runHeadlessMatch(7_200); + assert.equal(match.tick, 7_200); + assert.equal(match.scores.length, BOT_COUNT); + assert.ok(match.shots > 700, `expected active combat, saw ${match.shots} shots`); + assert.ok(match.damageEvents > 50, `expected hits, saw ${match.damageEvents}`); + assert.ok(match.eliminations >= 10, `expected kills, saw ${match.eliminations}`); + assert.ok(match.respawns >= 8, `expected respawns, saw ${match.respawns}`); + assert.equal( + match.scores.reduce((sum, score) => sum + score.kills, 0), + match.eliminations, + ); + assert.ok(match.scores.every((score) => Number.isFinite(score.x + score.z))); +}); + +test("backend simulation is deterministic", () => { + assert.deepEqual(runHeadlessMatch(2_400), runHeadlessMatch(2_400)); +}); + +test("human hitscan damages the historical target without rewinding live positions", () => { + const server = shooterGame.createServer(); + server.addPlayer(1); + const shooter = server.currentState.players.get(1); + const target = server.currentState.players.get(BOT_ID_BASE); + + for (const bot of server.currentState.players.values()) { + if (bot.id === BOT_ID_BASE || bot.id === 1) continue; + bot.alive = false; + bot.respawnTicks = 10_000; + } + shooter.x = -4; + shooter.z = -15; + shooter.yaw = Math.PI / 2; + shooter.pitch = 0; + shooter.spawnProtectionTicks = 0; + target.x = 0; + target.z = -15; + target.velocityX = 0; + target.velocityZ = 0; + target.inputForward = 0; + target.inputStrafe = 0; + target.spawnProtectionTicks = 0; + target.bot = null; + + server.step(); + target.z = -12.5; + const healthBefore = target.health; + server.submitInput(1, { + sequence: 1, + targetTick: 2, + observedTick: 1, + input: { + strafe: 0, + forward: 0, + yaw: Math.PI / 2, + pitch: 0, + fire: true, + sprint: false, + reload: false, + weapon: Weapon.PulseRifle, + }, + }); + const result = server.step(); + + assert.equal(target.z, -12.5, "the live target must not move backward"); + assert.ok(target.health < healthBefore, "the rewound hitbox should take damage"); + assert.ok( + result.events.some( + (event) => event.type === "damage" && event.targetId === BOT_ID_BASE, + ), + ); + const shot = result.events.find( + (event) => event.type === "shot" && event.sourceId === 1, + ); + assert.ok(shot); + assert.ok(Math.abs(shot.endZ + 15) < 0.01); +}); + +test("shooter recordings seek through checkpoints with privacy intact", () => { + const server = shooterGame.createServer({ replaySeed: 99 }); + server.addPlayer(1); + for (let tick = 0; tick < 420; tick += 1) server.step(); + + const recording = server.exportRecording(); + assert.equal(recording.durationTicks, 420); + assert.deepEqual( + recording.checkpoints.map(({ tick }) => tick), + [0, 120, 240, 360], + ); + + const replay = shooterGame.createReplay(recording); + replay.seek(135); + const finalFrame = replay.seek(420); + assert.deepEqual(finalFrame.state, server.currentState); + + const playerView = replay.viewAs(1).state; + assert.ok(playerView.players.has(1)); + assert.equal("nextEventId" in playerView, false); + assert.ok( + [...playerView.players.values()].every((player) => !("bot" in player)), + ); +}); + +test("a human elimination creates a bounded attacker-perspective killcam", () => { + const server = shooterGame.createServer(); + server.addPlayer(1); + let ticket = null; + for (let tick = 0; tick < 600 && !ticket; tick += 1) { + server.step(); + ticket = server.drainReplayTickets(1)[0] ?? null; + } + + assert.ok(ticket, "expected the standing human to receive a bot killcam"); + assert.equal(ticket.requesterId, 1); + assert.ok(ticket.perspectiveId >= BOT_ID_BASE); + assert.equal(ticket.toTick, ticket.issuedAtTick); + assert.ok(ticket.frames.length > 60); + assert.ok(ticket.frames.length < 300); + assert.ok( + ticket.frames.every(({ state }) => + [...state.players.values()].every( + (player) => !("bot" in player) && !("kills" in player), + ), + ), + ); +}); + +test("snapshots and perception events cross the binary protocol", () => { + const server = shooterGame.createServer(); + server.addPlayer(1); + const snapshot = server.createSnapshot(1, 123.5); + const snapshotFrame = shooterGame.protocol.encodeServer({ + kind: "snapshot", + snapshot, + }); + const decodedSnapshot = shooterGame.protocol.decodeServer(snapshotFrame); + assert.equal(decodedSnapshot.kind, "snapshot"); + assert.equal(decodedSnapshot.snapshot.state.players.get(1).ammo.length, 3); + + const event = { + id: 99, + type: "shot", + weapon: Weapon.RailRifle, + sourceId: 1, + originX: 1, + originY: 1.58, + originZ: 2, + endX: 11, + endY: 1.4, + endZ: -4, + impact: "wall", + }; + const eventFrame = shooterGame.protocol.encodeServer({ + kind: "event", + tick: 50, + event, + }); + const decodedEvent = shooterGame.protocol.decodeServer(eventFrame); + assert.equal(decodedEvent.kind, "event"); + assert.equal(decodedEvent.tick, 50); + assert.deepEqual( + { + ...decodedEvent.event, + originY: Number(decodedEvent.event.originY.toFixed(2)), + endY: Number(decodedEvent.event.endY.toFixed(2)), + }, + event, + ); +}); + +test("occluded combatants stay private while their gunfire becomes anonymous audio", () => { + const server = shooterGame.createServer(); + server.addPlayer(1); + const authority = server.currentState; + const viewer = authority.players.get(1); + const hiddenBot = authority.players.get(BOT_ID_BASE); + viewer.x = 0; + viewer.z = -6; + viewer.yaw = 0; + hiddenBot.x = 0; + hiddenBot.z = 6; + + const snapshot = server.createSnapshot(1, 0); + assert.equal(snapshot.state.players.has(BOT_ID_BASE), false); + + const perceptions = server.createPerceptions(1, [ + { + id: 777, + type: "shot", + sourceId: BOT_ID_BASE, + weapon: Weapon.PulseRifle, + originX: hiddenBot.x, + originY: 1.58, + originZ: hiddenBot.z, + endX: hiddenBot.x, + endY: 1.58, + endZ: hiddenBot.z - 10, + impact: "wall", + }, + ]); + assert.equal(perceptions.length, 1); + assert.equal(perceptions[0].type, "sound"); + assert.equal("sourceId" in perceptions[0], false); + assert.equal("originX" in perceptions[0], false); +}); + +test("visible opponents expose exact health but redact armor and inventory", () => { + const server = shooterGame.createServer(); + server.addPlayer(1); + const viewer = server.currentState.players.get(1); + const opponent = server.currentState.players.get(BOT_ID_BASE); + viewer.x = -4; + viewer.z = -15; + viewer.yaw = Math.PI / 2; + opponent.x = 0; + opponent.z = -15; + opponent.health = 37; + opponent.armor = 19; + opponent.ammo[Weapon.PulseRifle].magazine = 3; + + const replicated = server.createSnapshot(1, 0).state.players.get(BOT_ID_BASE); + assert.ok(replicated, "opponent should be visible in the clear corridor"); + assert.equal(replicated.health, 37); + assert.equal(replicated.armor, 0); + assert.ok( + replicated.ammo.every( + (ammo) => ammo.magazine === 0 && ammo.reserve === 0, + ), + ); + assert.equal(replicated.x, opponent.x); + assert.equal(replicated.weapon, opponent.weapon); + + viewer.yaw = -Math.PI / 2; + assert.equal( + server.createSnapshot(1, 0).state.players.has(BOT_ID_BASE), + false, + ); +}); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..c0325cc --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"] +} + diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..fa91ab7 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + } +} +