From 0daff877867e9bd8a23eda6d258b4e7a08a4af3c Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:45:45 -0400 Subject: [PATCH] Implement shared-world host primitives and prepare 0.18.1 --- .claude/skills/game-design/SKILL.md | 10 + .claude/skills/jgengine-gameplay/SKILL.md | 10 + .claude/skills/jgengine-gameplay/api.md | 25 +- .../skills/jgengine-gameplay/capabilities.md | 12 + .claude/skills/jgengine-multiplayer/SKILL.md | 25 + .claude/skills/jgengine-multiplayer/api.md | 94 ++-- .../jgengine-multiplayer/capabilities.md | 32 ++ .claude/skills/jgengine-ui/SKILL.md | 8 + .claude/skills/jgengine-ui/api.md | 30 +- .claude/skills/jgengine-ui/capabilities.md | 16 + .claude/skills/jgengine-world/SKILL.md | 6 + .claude/skills/jgengine-world/api.md | 30 +- .claude/skills/jgengine-world/capabilities.md | 28 + .claude/skills/jgengine/api.md | 39 +- .claude/skills/jgengine/capabilities.md | 8 + .claude/skills/workflow/SKILL.md | 2 +- .github/workflows/publish.yml | 4 +- CHANGELOG.md | 35 +- packages/assets/package.json | 4 +- packages/convex/package.json | 4 +- .../convex/src/convexPresenceTransport.ts | 16 +- .../convex/src/createConvexGameTransport.ts | 87 ++- packages/convex/src/hostUtilities.test.ts | 123 +++++ packages/convex/src/hostedServer.test.ts | 46 +- packages/convex/src/presence.test.ts | 2 +- packages/convex/src/server.test.ts | 23 +- packages/convex/src/server.ts | 514 ++++++++++++++---- packages/convex/src/sharedHost.test.ts | 148 +++++ packages/convex/src/territory.test.ts | 30 + packages/convex/src/territory.ts | 42 ++ packages/convex/src/testFixtures.ts | 20 +- packages/convex/src/worldPresence.test.ts | 67 +++ packages/convex/src/worldPresence.ts | 149 +++++ packages/core/package.json | 2 +- packages/core/src/economy/currency.test.ts | 31 ++ packages/core/src/economy/currency.ts | 69 ++- packages/core/src/economy/wallet.ts | 21 +- packages/core/src/game/chat.test.ts | 19 +- packages/core/src/game/chat.ts | 69 ++- packages/core/src/meta/changelog.ts | 80 ++- .../core/src/multiplayer/poseSyncGate.test.ts | 9 + packages/core/src/multiplayer/poseSyncGate.ts | 11 +- .../core/src/multiplayer/presenceContract.ts | 7 + .../src/multiplayer/presenceModel.test.ts | 13 + .../core/src/multiplayer/presenceModel.ts | 13 + packages/core/src/runtime/adapter.test.ts | 7 + packages/core/src/runtime/adapter.ts | 16 +- .../core/src/runtime/commandInput.test.ts | 11 + packages/core/src/runtime/commandInput.ts | 16 + packages/core/src/runtime/commandRunner.ts | 15 +- .../core/src/runtime/commandScope.test.ts | 8 +- packages/core/src/runtime/gameContext.test.ts | 12 + packages/core/src/runtime/gameContext.ts | 1 + packages/core/src/runtime/gameContextTypes.ts | 12 +- packages/core/src/runtime/gameRuntime.ts | 14 +- packages/core/src/runtime/hostPolicy.test.ts | 10 + packages/core/src/runtime/hostPolicy.ts | 17 +- .../core/src/runtime/hostedWorldSession.ts | 23 +- packages/core/src/runtime/hostedWorldStore.ts | 23 + .../src/runtime/initialPlayerState.test.ts | 18 + packages/core/src/runtime/snapshot.ts | 5 + packages/core/src/runtime/transport.ts | 7 +- .../core/src/runtime/worldChannel.test.ts | 3 + packages/core/src/runtime/worldChunks.test.ts | 8 + packages/core/src/runtime/worldChunks.ts | 16 + packages/core/src/time/accrueSince.test.ts | 14 + packages/core/src/time/accrueSince.ts | 16 + packages/core/src/time/rateWindow.test.ts | 15 + packages/core/src/time/rateWindow.ts | 18 + packages/core/src/time/serverTick.test.ts | 7 +- packages/core/src/time/serverTick.ts | 12 +- packages/core/src/world/placement.ts | 4 + .../src/world/placementController.test.ts | 6 + .../core/src/world/placementController.ts | 9 +- packages/core/src/world/territory.test.ts | 45 ++ packages/core/src/world/territory.ts | 173 ++++++ packages/editor/package.json | 8 +- packages/github/package.json | 2 +- packages/jgengine/package.json | 4 +- packages/jgengine/src/create.ts | 10 +- packages/jgengine/src/gameShape.ts | 1 + packages/jgengine/src/packaging.test.ts | 2 +- packages/jgengine/src/sharedBuilder.test.ts | 57 ++ packages/jgengine/src/templates.test.ts | 2 +- packages/jgengine/src/templates.ts | 4 +- packages/jgengine/src/templates/gameFiles.ts | 2 +- .../jgengine/src/templates/sharedBuilder.ts | 144 +++++ packages/jgengine/src/templates/types.ts | 1 + packages/node/package.json | 6 +- packages/node/src/worldServer.test.ts | 13 +- packages/rapier/package.json | 33 +- packages/react/package.json | 4 +- packages/react/src/chat.tsx | 61 ++- packages/react/src/chatPanel.test.ts | 19 + packages/react/src/standaloneChatPanel.tsx | 82 +++ packages/react/src/useServerSession.ts | 36 ++ packages/shell/package.json | 8 +- packages/shell/src/GamePlayerShell.tsx | 4 +- packages/shell/src/JoinGate.tsx | 26 + packages/shell/src/ShellChrome.tsx | 10 +- packages/shell/src/TerritoryOverlay.tsx | 28 + packages/shell/src/joinTerritory.test.ts | 34 ++ packages/shell/src/useShellMultiplayerSync.ts | 28 +- packages/sql/package.json | 4 +- packages/sql/src/sqlWorldStore.ts | 2 +- packages/ws/package.json | 4 +- packages/ws/src/createWsBackend.test.ts | 2 +- packages/ws/src/createWsBackend.ts | 19 +- packages/ws/src/hostRouter.test.ts | 4 +- packages/ws/src/worldHost.test.ts | 15 +- scripts/api-doc-baseline.json | 3 - scripts/export-manifest.json | 14 +- scripts/release.test.ts | 9 +- scripts/release.ts | 16 +- scripts/set-version.ts | 5 +- scripts/stateful-primitive-baseline.json | 1 - 116 files changed, 2908 insertions(+), 413 deletions(-) create mode 100644 packages/convex/src/hostUtilities.test.ts create mode 100644 packages/convex/src/sharedHost.test.ts create mode 100644 packages/convex/src/territory.test.ts create mode 100644 packages/convex/src/territory.ts create mode 100644 packages/convex/src/worldPresence.test.ts create mode 100644 packages/convex/src/worldPresence.ts create mode 100644 packages/core/src/economy/currency.test.ts create mode 100644 packages/core/src/runtime/commandInput.test.ts create mode 100644 packages/core/src/runtime/commandInput.ts create mode 100644 packages/core/src/runtime/hostedWorldStore.ts create mode 100644 packages/core/src/runtime/initialPlayerState.test.ts create mode 100644 packages/core/src/time/accrueSince.test.ts create mode 100644 packages/core/src/time/accrueSince.ts create mode 100644 packages/core/src/time/rateWindow.test.ts create mode 100644 packages/core/src/time/rateWindow.ts create mode 100644 packages/core/src/world/territory.test.ts create mode 100644 packages/core/src/world/territory.ts create mode 100644 packages/jgengine/src/sharedBuilder.test.ts create mode 100644 packages/jgengine/src/templates/sharedBuilder.ts create mode 100644 packages/react/src/chatPanel.test.ts create mode 100644 packages/react/src/standaloneChatPanel.tsx create mode 100644 packages/react/src/useServerSession.ts create mode 100644 packages/shell/src/JoinGate.tsx create mode 100644 packages/shell/src/TerritoryOverlay.tsx create mode 100644 packages/shell/src/joinTerritory.test.ts diff --git a/.claude/skills/game-design/SKILL.md b/.claude/skills/game-design/SKILL.md index b1348efc0..ee149da0c 100644 --- a/.claude/skills/game-design/SKILL.md +++ b/.claude/skills/game-design/SKILL.md @@ -28,3 +28,13 @@ Every design pass yields an observed journey, a north star (promise, pillars, an An audit is not completion for a build/improve request. Finish when the slice is playable, a fresh player can state goal/choice/consequence, failure/recovery behave as intended, and evidence supports the pillars. Use `jgengine-verify` and `workflow` to ship. **Required: player death is a designed, visible moment.** If the game can kill or down the player, death is part of the experience contract, not an invisible state reset. A lethal hit must resolve into an authored beat the player perceives and understands — a death or downed moment, the stakes it carries, and a legible path back into play (respawn, revive, restart) — never a silent teleport to spawn. Design what death means for this pitch (permadeath, checkpoint, bleed-out-and-revive, run reset) and treat "player dies with no acknowledged consequence" as a failure/recovery defect. `jgengine-ui` owns building the screen and respawn feedback. + +## Persistent-builder checks + +Before content, write a first-hour balance sheet: starting cash, time to first income, net income per hour at starting hardware and at ten times that hardware, bill cadence versus income, and the insolvency recovery path. Insolvency resets the affected player or changes their options; it never deletes the world. Express clicks and event rewards as a fraction of the designed income rate, never an unrelated flat payout. + +Declare currency `decimals` for income paid per second. Anchor time-based decay to `game.createdAt` or a persisted per-instance creation time, never a fixed wall-clock epoch. Test fractional income over real tick cadences before tuning prices. + +The first-60-seconds gate requires the first tutorial verb to be executable from spawn with starting inventory. Grant dependencies such as land, power, and slots or let players buy them in-context; the tutorial cannot lock the surface needed to complete itself. Placement previews show cost and affordability inline. + +For a persistent shared-world builder, compose neighborhood presence, declared command read scopes, membership storage, online-player batched ticks, territory, chat, and join retry UI through their engine owners. Verify capacity and first-player onboarding before scaling the content catalog. diff --git a/.claude/skills/jgengine-gameplay/SKILL.md b/.claude/skills/jgengine-gameplay/SKILL.md index d5d3fb2e7..04e3f069f 100644 --- a/.claude/skills/jgengine-gameplay/SKILL.md +++ b/.claude/skills/jgengine-gameplay/SKILL.md @@ -45,3 +45,13 @@ A character kit-bashed from primitives/`ModelPart`s (no skeleton, no clips) anim - Do not fuse save semantics with a specific cloud/backend adapter. - Targeting, damage, effects, abilities, and loot resolution route to `jgengine-combat`. - World movement/input/interaction route to `jgengine-world`; HUD rendering routes to `jgengine-ui`. + +## Persistent economies and input + +- Declare `CurrencyDefinition.decimals` for per-second income. Use the definition in wallet and `ctx.game.economy` operations; public amounts are major units, arithmetic rounds in integer minor units at each write. Accrue sub-minor income over elapsed time before writing, or choose finer precision. +- `formatCurrency(definition, value)` shares that precision with display. Currency strings keep the existing wallet behavior; pass the definition to enforce precision. +- Anchor elapsed production and decay to game creation or a persisted last-run time, never a fixed epoch. `accrueSince(anchorMs, nowMs, { capMs })` returns elapsed time and the next anchor; persist both earnings and that anchor together. +- Validate purchase counts with `readQuantity(value, { min, max })`; NaN, Infinity, fractions, strings, and out-of-range values reject without coercion. +- Reset a profile with `initialPlayerState(runtime, userId)` from the same `onNewPlayer` hook used at join; insolvency never deletes a world. +- `ctx.game.chat.send(userId, text)` targets global chat; `recent({ limit })` returns at most 100 messages. The default is 240 characters and one message per author per channel every two seconds. Rejected messages need visible UI feedback. +- Hosts reuse `validateChatMessage` and `decideRateWindow`, storing the rate window transactionally with each accepted operation. Preserve chat rate windows when saving/restoring chat state. diff --git a/.claude/skills/jgengine-gameplay/api.md b/.claude/skills/jgengine-gameplay/api.md index 234b142da..05f254516 100644 --- a/.claude/skills/jgengine-gameplay/api.md +++ b/.claude/skills/jgengine-gameplay/api.md @@ -171,6 +171,9 @@ - `CurrencyAdjustment` (type): type CurrencyAdjustment = | { success: true; newBalance: number; appliedDelta: number } | { success: false; reason: string } — ⚠ undocumented - `CurrencyDefinition` (interface): interface CurrencyDefinition — ⚠ undocumented - `CurrencyOperation` (type): type CurrencyOperation = "add" | "deduct" — ⚠ undocumented +- `formatCurrency` (function): function formatCurrency(currency: CurrencyDefinition, value: number): string — Format a major-unit value using the currency's declared precision. +- `fromMinorUnits` (function): function fromMinorUnits(currency: Pick | undefined, value: number): number — Convert stored integer minor units to major units for display or existing balance records. +- `toMinorUnits` (function): function toMinorUnits(currency: Pick | undefined, value: number): number — Convert major units to integer minor units, rounding once at the write boundary. ## @jgengine/core/economy/listingBook @@ -269,13 +272,13 @@ - `ChargeResult` (type): type ChargeResult = { status: "ok"; state: WalletState } | { status: "rejected"; reason: "insufficient-funds" } — Outcome of a {@link charge}/{@link chargeAll} attempt: `status: "ok"` carries the debited {@link WalletState}, while `status: "rejected"` leaves the wallet untouched and reports why (currently only `"insufficient-funds"`). Discriminate on `status` before reading `state`. - `Overdraft` (type): type Overdraft = boolean | { max: number } — Opt-in debt affordance for {@link charge}/{@link chargeAll}: `true` allows the balance to go arbitrarily negative, a number caps how far into the red it may go (the charge is rejected once `balance - amount` would fall below `-max`). Omitted (the default) keeps the strict no-debt rule. - `WalletState` (interface): interface WalletState — ⚠ undocumented -- `balance` (function): function balance(state: WalletState, currency: string): number — ⚠ undocumented +- `balance` (function): function balance(state: WalletState, currency: string | CurrencyDefinition): number — ⚠ undocumented - `canAfford` (function): function canAfford(state: WalletState, costs: Readonly>): boolean — True when every currency in `costs` has at least that much balance (a pure, non-mutating check). -- `charge` (function): function charge(state: WalletState, currency: string, amount: number, options?: ChargeOptions): ChargeResult — Deduct `amount`, rejecting when it would leave the balance negative unless `options.overdraft` opts into carrying debt (`true` unlimited, `{ max }` capped) — the strict same-tick affordability check stays the default with `options` omitted. +- `charge` (function): function charge(state: WalletState, currency: string | CurrencyDefinition, amount: number, options?: ChargeOptions): ChargeResult — Deduct `amount`, rejecting when it would leave the balance negative unless `options.overdraft` opts into carrying debt (`true` unlimited, `{ max }` capped) — the strict same-tick affordability check stays the default with `options` omitted. - `chargeAll` (function): function chargeAll(state: WalletState, costs: Readonly>, options?: ChargeOptions): ChargeResult — ⚠ undocumented - `createEmptyWallet` (function): function createEmptyWallet(): WalletState — Hold per-currency balances with affordability checks and charge/grant operations. -- `grant` (function): function grant(state: WalletState, currency: string, amount: number): WalletState — ⚠ undocumented -- `isOverdrawn` (function): function isOverdrawn(state: WalletState, currency: string): boolean — True once `balance(state, currency)` has gone negative under an overdraft-enabled charge. +- `grant` (function): function grant(state: WalletState, currency: string | CurrencyDefinition, amount: number): WalletState — ⚠ undocumented +- `isOverdrawn` (function): function isOverdrawn(state: WalletState, currency: string | CurrencyDefinition): boolean — True once `balance(state, currency)` has gone negative under an overdraft-enabled charge. ## @jgengine/core/game/achievements @@ -343,13 +346,15 @@ - `ChatRecipients` (type): type ChatRecipients = readonly string[] | "all" — ⚠ undocumented - `ChatSendResult` (type): type ChatSendResult = | { message: ChatMessage; recipients: ChatRecipients } | { reason: string } — ⚠ undocumented - `ChatSnapshot` (interface): interface ChatSnapshot — ⚠ undocumented -- `DEFAULT_CHAT_BODY_LENGTH` (const): const DEFAULT_CHAT_BODY_LENGTH: 500 — ⚠ undocumented +- `ChatValidation` (type): type ChatValidation = { ok: true; text: string } | { ok: false; reason: string } — Sanitized chat text or a displayable validation failure. +- `DEFAULT_CHAT_BODY_LENGTH` (const): const DEFAULT_CHAT_BODY_LENGTH: 240 — ⚠ undocumented - `DEFAULT_CHAT_HISTORY_LIMIT` (const): const DEFAULT_CHAT_HISTORY_LIMIT: 100 — ⚠ undocumented - `DEFAULT_CHAT_RATE_LIMIT` (const): const DEFAULT_CHAT_RATE_LIMIT: ChatRateLimit — ⚠ undocumented - `DEFAULT_PROXIMITY_CHAT_RADIUS` (const): const DEFAULT_PROXIMITY_CHAT_RADIUS: 20 — ⚠ undocumented - `WHISPER_CHANNEL_PREFIX` (const): const WHISPER_CHANNEL_PREFIX: "whisper:" — ⚠ undocumented - `createChat` (function): function createChat(deps: ChatDeps): Chat — ⚠ undocumented - `createChatRateLimiter` (function): function createChatRateLimiter(limit: ChatRateLimit): ChatRateLimiter — ⚠ undocumented +- `validateChatMessage` (function): function validateChatMessage(value: unknown, options: { maxLength?: number } = {}): ChatValidation — Strip control characters and enforce the shared chat message policy before storage or broadcast. - `whisperChannelId` (function): function whisperChannelId(a: string, b: string): string — ⚠ undocumented ## @jgengine/core/game/chatFilter @@ -1084,7 +1089,7 @@ - `CropTileState` (interface): interface CropTileState — ⚠ undocumented - `CrossThresholdsOptions` (interface): interface CrossThresholdsOptions — Exact-boundary and dead-band policy for {@link crossThresholds}. - `Curve` (type): type Curve = CurveDef & CurveShape — A fully specified progression curve — a {@link CurveDef} growth shape plus optional {@link CurveShape} rounding/clamp. -- `DEFAULT_CHAT_BODY_LENGTH` (const): const DEFAULT_CHAT_BODY_LENGTH: 500 — ⚠ undocumented +- `DEFAULT_CHAT_BODY_LENGTH` (const): const DEFAULT_CHAT_BODY_LENGTH: 240 — ⚠ undocumented - `DEFAULT_CHAT_HISTORY_LIMIT` (const): const DEFAULT_CHAT_HISTORY_LIMIT: 100 — ⚠ undocumented - `DEFAULT_CHAT_RATE_LIMIT` (const): const DEFAULT_CHAT_RATE_LIMIT: ChatRateLimit — ⚠ undocumented - `DEFAULT_FIXED_STAGES` (const): const DEFAULT_FIXED_STAGES: readonly ["input", "movement", "combat", "ai", "activities", "cleanup"] — Default fixed-sim stage order — systems pick a stage; most need only this. @@ -1415,7 +1420,7 @@ - `applyBindingOverrides` (function): function applyBindingOverrides(input: ActionCodesMap, overrides: BindingOverrides): ActionCodesMap — Merge player rebinds over a game's authored `input` map. Only actions the game already declares can be overridden; unknown override keys are ignored so a stale localStorage entry can't inject phantom actions. - `applySetBonuses` (function): function applySetBonuses(stats: Record, bonuses: readonly SetBonus[]): Record — Fold a set of active bonuses' additive stats onto a stat map, returning a new map (the input is not mutated). - `applyWear` (function): function applyWear(state: DurabilityState, amount: number): DurabilityState — Apply wear to an item, tracking breakage and repair eligibility. -- `balance` (function): function balance(state: WalletState, currency: string): number — ⚠ undocumented +- `balance` (function): function balance(state: WalletState, currency: string | CurrencyDefinition): number — ⚠ undocumented - `balanceOf` (function): function balanceOf(ledger: ResourceLedger, account: string, currency: string): number — Read a single balance; unknown account/currency pairs read as `0`. - `canAfford` (function): function canAfford(state: WalletState, costs: Readonly>): boolean — True when every currency in `costs` has at least that much balance (a pure, non-mutating check). - `canCraft` (function): function canCraft(state: InventoryState, layout: InventoryLayout, traits: ItemTraits, recipe: RecipeDef, context: CraftContext = {}): CraftCheck — ⚠ undocumented @@ -1424,7 +1429,7 @@ - `candidateViolatesForbid` (function): function candidateViolatesForbid(partial: ItemIdentity, candidate: CandidatePlacement, rules: readonly CompatibilityRule[]): ForbidRule | null — The generic backtracking contract for procedural generation (see #908): given a partial identity and a candidate part, return the first forbid rule the placement would violate, or null if it stays viable. Require rules are ignored here because they may still be satisfied by a later placement. - `capAmount` (function): function capAmount(max: number | ((ctx: PolicyContext) => number)): ResourcePolicy — Clamp a transaction's amount to at most `max` (a fixed number or a function of context). - `captureProvenance` (function): function captureProvenance(identity: ItemIdentity, activeBonuses: readonly SetBonus[], seed?: number): ItemProvenance — Capture the provenance of a generated item — family, tags, per-slot parts, active bonus ids, and optional seed — as a JSON-safe record. -- `charge` (function): function charge(state: WalletState, currency: string, amount: number, options?: ChargeOptions): ChargeResult — Deduct `amount`, rejecting when it would leave the balance negative unless `options.overdraft` opts into carrying debt (`true` unlimited, `{ max }` capped) — the strict same-tick affordability check stays the default with `options` omitted. +- `charge` (function): function charge(state: WalletState, currency: string | CurrencyDefinition, amount: number, options?: ChargeOptions): ChargeResult — Deduct `amount`, rejecting when it would leave the balance negative unless `options.overdraft` opts into carrying debt (`true` unlimited, `{ max }` capped) — the strict same-tick affordability check stays the default with `options` omitted. - `chargeAll` (function): function chargeAll(state: WalletState, costs: Readonly>, options?: ChargeOptions): ChargeResult — ⚠ undocumented - `clampValue` (function): function clampValue(value: number, bounds?: NumericBounds): number — Clamp a scalar to `bounds` (identity when `bounds` is omitted). Pure — touches no record. - `clearBindingOverride` (function): function clearBindingOverride(gameId: string, action: string, storage: Pick | null | undefined = defaultStorage()): BindingOverrides — ⚠ undocumented @@ -1533,7 +1538,7 @@ - `generate` (function): function generate(schema: GenSchema, rng: () => number, options: GenerateOptions = {}): GenOutcome — Run a caller-defined {@link GenSchema} against an injected `rng` into a deterministic, serializable {@link GenResult} with full provenance. Composes weighted/uniform choice, dependent choice, constraints with bounded backtracking, field transforms, and validation reroll over plain data — the generic seam procedural loot, affix, and modular-part rollers assemble on. Identical schema, seed, and pins reproduce an identical result across server/client and save/load. - `getRuleEffect` (function): function getRuleEffect(id: string): RuleEffectDefinition | undefined — Look up a declared rule effect, or `undefined` when the id was never registered — lets callers reject unknown effect references in authored content. - `getValue` (function): function getValue(record: Record, key: string, fallback = 0): number — Current value for `key`, or `fallback` (default `0`) when the record has no entry. -- `grant` (function): function grant(state: WalletState, currency: string, amount: number): WalletState — ⚠ undocumented +- `grant` (function): function grant(state: WalletState, currency: string | CurrencyDefinition, amount: number): WalletState — ⚠ undocumented - `identityOf` (function): function identityOf(family: string, tags: readonly string[], parts: readonly InstalledPart[]): ItemIdentity — Assemble an {@link ItemIdentity} from a family, tags, and installed parts. - `idleRaceSession` (function): function idleRaceSession(): RaceSessionState — The pre-race session on the grid: `idle`, both clocks at zero. Call {@link startRaceCountdown} to light the lights, or hold here until the field is ready. - `initDecayMeters` (function): function initDecayMeters(defs: readonly DecayMeterConfig[]): DecayMeterValues — Starting values for `defs` — each meter's `start ?? max`, clamped to its range. Seed a serialized state record with this instead of holding a {@link createDecayMeterSet} closure. @@ -1542,7 +1547,7 @@ - `isComplete` (function): function isComplete(def: ModularItemDef, installed: readonly InstalledPart[]): boolean — ⚠ undocumented - `isDisabled` (function): function isDisabled(spec: DurabilitySpec, state: DurabilityState): boolean — ⚠ undocumented - `isIdentityValid` (function): function isIdentityValid(identity: ItemIdentity, rules: readonly CompatibilityRule[]): boolean — Convenience predicate: true when {@link validateIdentity} finds no violations. -- `isOverdrawn` (function): function isOverdrawn(state: WalletState, currency: string): boolean — True once `balance(state, currency)` has gone negative under an overdraft-enabled charge. +- `isOverdrawn` (function): function isOverdrawn(state: WalletState, currency: string | CurrencyDefinition): boolean — True once `balance(state, currency)` has gone negative under an overdraft-enabled charge. - `jobById` (function): function jobById(state: WorkQueueState, id: JobId): Job | null — Look up a job by id, or `null` if absent/terminal. - `jobProgress` (function): function jobProgress(job: Job): number — Fractional progress of a job (0…1); a zero-duration job reads as complete. - `lapDurations` (function): function lapDurations(splits: readonly number[], gatesPerLap: number): number[] — Per-lap durations from a cumulative split book with `gatesPerLap` checkpoints per lap — each lap's time is its finish-gate split minus the previous lap's finish. Only complete laps are returned. diff --git a/.claude/skills/jgengine-gameplay/capabilities.md b/.claude/skills/jgengine-gameplay/capabilities.md index 82cbd4886..46eba0f7a 100644 --- a/.claude/skills/jgengine-gameplay/capabilities.md +++ b/.claude/skills/jgengine-gameplay/capabilities.md @@ -65,6 +65,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `breedOffspring` (function) · `import { breedOffspring } from "@jgengine/core/game/breeding"` +## currency-major-units — convert safe minor-unit integers into currency amounts + +- `fromMinorUnits` (function) · `import { fromMinorUnits } from "@jgengine/core/economy/currency"` + +## currency-minor-units — round decimal currency into safe integer minor units + +- `toMinorUnits` (function) · `import { toMinorUnits } from "@jgengine/core/economy/currency"` + +## currency-precision-format — display a currency using its declared decimal precision + +- `formatCurrency` (function) · `import { formatCurrency } from "@jgengine/core/economy/currency"` + ## decay-meter — survival meters that drain/refill over game time (hunger, water, oxygen, stamina) - `createDecayMeterSet` (function) · `import { createDecayMeterSet } from "@jgengine/core/gameplay"` diff --git a/.claude/skills/jgengine-multiplayer/SKILL.md b/.claude/skills/jgengine-multiplayer/SKILL.md index 51783ea55..58a59689c 100644 --- a/.claude/skills/jgengine-multiplayer/SKILL.md +++ b/.claude/skills/jgengine-multiplayer/SKILL.md @@ -45,3 +45,28 @@ Use [capabilities.md](capabilities.md) for intent-to-import discovery, [api.md]( - Presence/chat/voice are session channels, not game-state ownership. - Do not couple a primitive to WebSocket, Convex, Postgres, or one deployment topology. - Reward allocation, inventory mutation, and progression policy remain gameplay/combat concerns. + +## Shared-world capacity and persistence + +| Topology | Membership and session state | Command reads | Presence fan-out | +| --- | --- | --- | --- | +| Rooms | **256 members maximum** in the room document | Declared actor/player and chunk scope | Neighborhood index | +| Shared | Indexed `jgServerMembers`, separate `jgServerCapacity`, per-user profile session state; no 256-member engine cap | Declared actor/player and chunk scope; player-only writes avoid the world row | Neighborhood index, 10 Hz maximum default pose writes | + +Use `createGameServerFunctions({ topology: "shared", runtimes })` for one persistent world. Shared topology defaults to singleton matchmaking. Capacity is not a throughput guarantee; verify the command and subscription read sets under crowd load. Subscribe to `getServerCapacity` for shared membership/status without volatile world revision reads. + +Every gameplay command declares `scope(input)`: use `{ players: "actor", chunkKeys: [] }` for player-only work and `chunkKeysAround(position, 1)` for spatial work. The omitted-command default is actor-only with no chunks; an explicit `{}` requests the whole world and needs a justified bounded workload. Hosts use `helpers.runCommand` and its `beforePersist` callback to pair game writes with membership and revision checks in one transaction. Never copy that check/apply/persist loop into a game. + +Every presence query is neighborhood-scoped. Pass `PresenceSession.viewerChunkKey` through `createConvexPresenceTransport`. Hosts with custom authentication, bot ownership, or spawn policy use `createWorldPresenceStore` from `@jgengine/convex/worldPresence`: resolve trusted identities and access first, then call `ensure`, `sync`, `revoke`, `active`, `nearby`, and `reap`. Store rows in `jgPoses`, never a game-owned presence table. Optional snapshot callbacks persist game-owned last-position fields at the store's cadence. Bot identity may differ from its authorized owner; preserve that mapping explicitly. Resident rows implement `PresenceResidentRow.actorExternalId` and `ownerActorId` exactly. + +Persist only dirty profiles/chunks. Join/leave are roster writes; they do not dirty or rewrite the world. Reset a player with `helpers.resetPlayerProfile`, which runs the same initializer used by joins, and keep the shared world intact. + +## Scheduled work + +A cron never collects a whole table. Use presence-gated `forEachOnlinePlayer` with a bounded `batchSize`, an internal batch handler, and a continuation reference: each page schedules its handler and the next page as separate transactions. `OnlinePlayer.homeGameId` carries optional application identity from its pose row. Persist elapsed-time anchors with effects; use `accrueSince` to cap offline catch-up. `allServers` is only for explicitly global systems with a bounded server budget. + +Pass runtimes to `jgengineCronSpecs` so no tick cron registers when no runtime declares `onTick`; set per-spec `intervalSeconds` deliberately. Retention sweeps and one-shot backfills are bounded, report `remaining`, and stop at convergence. Delete completed migration crons instead of leaving hourly table scans installed forever. + +Join failures return typed `full`, `closed`, or `unauthorized` outcomes. Surface them through a retry gate; returning a rejection without visible UI is unfinished. Browser adapters leave sessions on `pagehide`. + +For the explicit shared-builder composition, `jgengine create "World Name" --shape shared-world-builder` generates the connected Convex shell, runtime scope declarations, claim/accrual examples, chat, indexed presence, and an online batch pipeline. The generated README owns setup; replace the demonstration economy and author buildable world content in the editor. diff --git a/.claude/skills/jgengine-multiplayer/api.md b/.claude/skills/jgengine-multiplayer/api.md index 95cbf1f58..e1532bda0 100644 --- a/.claude/skills/jgengine-multiplayer/api.md +++ b/.claude/skills/jgengine-multiplayer/api.md @@ -7,7 +7,8 @@ - `ConvexBackend` (type): type ConvexBackend = LiveGameBackend & { leaderboard: ConvexLeaderboardReads; } — ⚠ undocumented - `ConvexBackendOptions` (type): type ConvexBackendOptions = { client: ConvexReactClient; gameId: string; userId: string; api?: ConvexGameApi; poseTuning?: PoseSyncTuning; presence?: { functions: ConvexPresenceFunctions; mapRow: (row: TRawPresenceRow) => TPresenceRo… — ⚠ undocumented - `ConvexChatFunctions` (interface): interface ConvexChatFunctions — ⚠ undocumented -- `ConvexGameApi` (type): type ConvexGameApi = { runtime: { joinServer: FunctionReference< "mutation", "public", { gameId: string; serverId?: string; mode?: string; visibility?: "public" | "private"; joinCode?: string; externalId?: string; }, { serverId: string; isNew: boolean } >; leaveServer: FunctionReference<"mutation", … — ⚠ undocumented +- `ConvexGameApi` (type): type ConvexGameApi = { runtime: { joinServer: FunctionReference< "mutation", "public", { gameId: string; serverId?: string; mode?: string; visibility?: "public" | "private"; joinCode?: string; externalId?: string; }, JoinServerOutcome >; leaveServer: FunctionReference<"mutation", "public", { serverI… — ⚠ undocumented +- `ConvexGameClient` (type): type ConvexGameClient = Pick — Structural client seam avoids requiring the engine and game to share a class instance type. - `ConvexGameTransportConfig` (type): type ConvexGameTransportConfig = { gameId: string; userId?: string; } — ⚠ undocumented - `ConvexLeaderboardReads` (type): type ConvexLeaderboardReads = ReturnType — ⚠ undocumented - `ConvexPresenceFunctions` (interface): interface ConvexPresenceFunctions — The Convex functions a **game** supplies for a world it owns: presence keyed by the game's own ids, including which residents exist while offline, which is game content and has no engine analogue. Not satisfied by `createPresenceFunctions` (`@jgengine/convex/server`) — that is the pose lane of a hosted `jgGameServers` server, backing `PresenceSync` rather than `PresenceTransport`. @@ -15,21 +16,23 @@ - `ConvexSaveFunctions` (interface): interface ConvexSaveFunctions — The three Convex functions a save backend calls — a `query` that returns the stored string (or `null`) and two `mutation`s. Point them at your app's own module, or lean on the default `saves.*` convention. - `DEFAULT_CONVEX_POSE_RULES` (const): const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules — ⚠ undocumented - `DEFAULT_MAX_SERVERS_PER_TICK` (const): const DEFAULT_MAX_SERVERS_PER_TICK: 32 — How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. -- `GameServerHelpers` (type): type GameServerHelpers = { loadSnapshot: ( ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope, ) => Promise; applyCommand: (args: { gameId: string; loadedRevision: number; currentRevision: number; snapshot: GameRuntimeSnapshot; actorUserId: string; command: … — The plain-function half of {@link createGameServerFunctions}, bound to the same runtime registry and auth mode as its mutations. Reach for it when a host mutation must pair snapshot work with its own table writes in one transaction, which a pre-registered mutation cannot do. +- `GameServerHelpers` (type): type GameServerHelpers = { ensureServer: (ctx: JGMutationCtx, gameId: string) => Promise; loadSnapshot: ( ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope, ) => Promise; applyCommand: (args: { gameId: string; loadedRevision: number; currentRevis… — The plain-function half of {@link createGameServerFunctions}, bound to the same runtime registry and auth mode as its mutations. Reach for it when a host mutation must pair snapshot work with its own table writes in one transaction, which a pre-registered mutation cannot do. - `HostedGameConfig` (interface): interface HostedGameConfig — One game the hosted path can serve, bound per `gameId`: its definition plus content lookup. - `HostedWorldInvocation` (interface): interface HostedWorldInvocation — Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`, plus the member roster and held inputs the snapshot doesn't carry, and the op to apply to the fresh session. - `HostedWorldOutcome` (interface): interface HostedWorldOutcome — What one stateless invocation produced: the op's value, the resulting revision, whether the world changed (and was saved back), and the post-op roster. - `JGDataModel` (type): type JGDataModel = DataModelFromSchemaDefinition — Data model of the {@link jgengineTables} schema — the shape a host's own Convex ctx must satisfy to call the exported persistence helpers. - `JGMutationCtx` (type): type JGMutationCtx = GenericMutationCtx — Mutation ctx accepted by {@link loadServerSnapshot} / {@link persistServerSnapshot}. +- `JGQueryCtx` (type): type JGQueryCtx = GenericQueryCtx — Read-only context accepted by engine host queries. - `JG_HOSTED_TICK_MS` (const): const JG_HOSTED_TICK_MS: 1000 — Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. -- `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row. +- `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead. - `JG_RUNTIME_TICK_MS` (const): const JG_RUNTIME_TICK_MS: 1000 — ⚠ undocumented - `JgAuthMode` (type): type JgAuthMode = "anonymous" | "required" — ⚠ undocumented -- `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers" | "reapIdlePresence"; /** Which factory's exports the function lives in, i.e. which `convex/*.ts` file to reach it through. */ module: "runtime" | "presence"; } — ⚠ undocumented +- `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers" | "reapIdlePresence" | "pruneChatMessages" | "pruneClientErrors" | "pruneRateLimits"; /** Which factory's exports the function lives in, i.e. which `convex/*.ts` file to reach it through… — ⚠ undocumented - `LoadSnapshotScope` (type): type LoadSnapshotScope = CommandScope — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`). - `LoadedServerSnapshot` (type): type LoadedServerSnapshot = { server: ServerDoc; runtime: GameRuntime; snapshot: GameRuntimeSnapshot; } — A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot. - `MAX_CHUNKS_PER_QUERY` (const): const MAX_CHUNKS_PER_QUERY: 64 — How many chunk rows one `getChunks` call may return. - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied. +- `OnlinePlayer` (type): type OnlinePlayer = { serverId: string; userId: string; homeGameId?: string } — One active player delivered to an online-system batch. - `PresenceListRow` (type): type PresenceListRow = { userId: string; sessionId?: string; kind?: string; label?: string; position: { x: number; y: number; z: number }; rotationY: number; rotationPitch: number; lastSeenAt: number; } — One member's pose as `list` reports it. - `PresenceSyncResult` (type): type PresenceSyncResult = { pose: { x: number; y: number; z: number; rotationY: number; rotationPitch: number }; lastSeenAt: number; displaced: boolean; } — What `sync` hands back: the pose the server holds after clamping, and whether this session had been displaced. - `REVISION_CONFLICT_REASON` (const): const REVISION_CONFLICT_REASON: "Revision conflict" — ⚠ undocumented @@ -37,25 +40,30 @@ - `RunCommandOutcome` (type): type RunCommandOutcome = { ok: true } | { ok: false; reason: string } — Outcome of a runtime command: applied, or refused with a reason (unknown server, non-member, validation, revision conflict). - `ServerDoc` (type): type ServerDoc = DocumentByName — A `jgGameServers` row — the server handle the persistence helpers load from and write back to. - `canJoinPrivateServer` (function): function canJoinPrivateServer(args: { isMember: boolean; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Private-server join-code gate. Existing members always pass; non-members must present a matching `joinCode` (loose-normalized via {@link normalizeJoinCode}). Callers still decide whether the server is private — this only answers the code/membership half. +- `createClientErrorFunctions` (function): function createClientErrorFunctions(): { reportClientError: RegisteredMutation<"public", { kind?: string | undefined; stack?: string | undefined; componentStack?: string | undefined; url?: string | undefined; pathname?: string | undefined; userAgent?: string | undefined; agentPrompt?: string | undef… — Authenticated, bounded client diagnostics and bounded retention sweeps. - `createConvexBackend` (function): function createConvexBackend(options: ConvexBackendOptions): ConvexBackend(functions: ConvexChatFunctions, options?: { mapRow?: (row: TRawRow) => ChatMessage; extraArgs?: Record; }): ChatTransport — Wires a game's Convex chat functions into the engine's ChatTransport contract: one live query per subscribed channel (the channel's recent history, newest last) and one send mutation. mapRow converts backend rows into ChatMessage (defaults to structural passthrough); extraArgs is spread into both calls for games that scope chat by server or world. -- `createConvexFeedWrites` (function): function createConvexFeedWrites(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig): { pushFeedEntry(args: { serverId: string; action: string; entry: unknown; }): Promise; } — ⚠ undocumented -- `createConvexGameFeeds` (function): function createConvexGameFeeds(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig): GameRuntimeFeeds — ⚠ undocumented -- `createConvexGameTransport` (function): function createConvexGameTransport(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig): GameRuntimeTransport — ⚠ undocumented +- `createConvexFeedWrites` (function): function createConvexFeedWrites(client: ConvexGameClient, api: ConvexGameApi, config: ConvexGameTransportConfig): { pushFeedEntry(args: { serverId: string; action: string; entry: unknown; }): Promise; } — ⚠ undocumented +- `createConvexGameFeeds` (function): function createConvexGameFeeds(client: ConvexGameClient, api: ConvexGameApi, config: ConvexGameTransportConfig): GameRuntimeFeeds — ⚠ undocumented +- `createConvexGameTransport` (function): function createConvexGameTransport(client: ConvexGameClient, api: { runtime: Pick }, config: ConvexGameTransportConfig): GameRuntimeTransport — ⚠ undocumented - `createConvexLeaderboardReads` (function): function createConvexLeaderboardReads(api: ConvexGameApi, config: ConvexGameTransportConfig): { getTop(args: { stat: string; scope: LeaderboardScope; serverId?: string | undefined; limit?: number | undefined; }): { query: FunctionReference<"query", "public", { gameId: string; stat: string; scope: Le… — ⚠ undocumented -- `createConvexPresenceSync` (function): function createConvexPresenceSync(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig, tuning?: PoseSyncTuning): PresenceSync — ⚠ undocumented -- `createConvexPresenceTransport` (function): function createConvexPresenceTransport(functions: ConvexPresenceFunctions, mapRow: (row: TRawRow) => TRow): PresenceTransport — Wires a game's Convex presence functions into the engine's PresenceTransport contract: one snapshot subscription (my location + online players), one residents subscription (dormant candidates, decoupled from pose writes), and one tick mutation for pose upload + keep-alive. Dormant rows are derived client-side by subtracting the snapshot's online actors, so pose ticks never re-execute the residents query. mapRow converts backend rows into the game's row type (e.g. branding positions into its coordinate space). +- `createConvexPresenceSync` (function): function createConvexPresenceSync(client: ConvexGameClient, api: ConvexGameApi, config: ConvexGameTransportConfig, tuning?: PoseSyncTuning): PresenceSync — ⚠ undocumented +- `createConvexPresenceTransport` (function): function createConvexPresenceTransport(functions: ConvexPresenceFunctions, mapRow: (row: TRawRow) => TRow): PresenceTransport — Wires a game's Convex presence functions into the engine's PresenceTransport contract: one snapshot subscription (my location + online players), one residents subscription (dormant candidates, decoupled from pose writes), and one tick mutation for pose upload + keep-alive. Dormant rows are derived client-side by subtracting the snapshot's online actors, so pose ticks never re-execute the residents query. mapRow converts backend rows into the game's row type (e.g. branding positions into its coordinate space). - `createConvexSaveBackend` (function): function createConvexSaveBackend(options: ConvexSaveBackendOptions): SaveBackend — A cloud {@link SaveBackend} backed by Convex — reads through a query and writes through mutations, so a {@link createSaveStore} configured with it saves to the server instead of `localStorage`. The only change from an offline game is swapping the backend; autosave, slots, and migration behave the same. - `defaultConvexGameApi` (function): function defaultConvexGameApi(): ConvexGameApi — ⚠ undocumented - `defaultConvexSaveFunctions` (function): function defaultConvexSaveFunctions(): ConvexSaveFunctions — Default `saves.read` / `saves.write` / `saves.remove` refs for apps that follow the convention — pass your own `functions` to override. +- `forEachOnlinePlayer` (function): function forEachOnlinePlayer(ctx: JGMutationCtx, options: { batchSize?: number; handler: FunctionReference<"mutation", "internal", { players: OnlinePlayer[]; nowMs: number }>; continuation: FunctionReference<"mutation", "internal", { cursor?: string | null; nowMs?: number }>; cursor?: string | null;… — Scan one bounded presence page and schedule its handler and continuation in separate transactions. - `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results. - `loadServerSnapshot` (function): function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime, scope?: LoadSnapshotScope): Promise — Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks. Reach for it when writing your own mutation that must read runtime state before touching host tables. Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world. - `persistServerSnapshot` (function): function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise — Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard increments, all under `save`. Pair it with your own table writes to keep both in one transaction. - `randomConvexPlayerId` (function): function randomConvexPlayerId(): string — ⚠ undocumented +- `rateLimit` (function): function rateLimit(ctx: JGMutationCtx, args: { key: string; windowMs: number; max: number; nowMs?: number }): Promise<{ ok: boolean; retryAfterMs: number }> — Consume one request in a fixed rate window; concurrent callers serialize on the indexed key. +- `recentChatMessages` (function): function recentChatMessages(ctx: { db: Pick }, args: { serverId: string; channelId?: string; limit?: number }): Promise<{ _id: Id<"jgChatMessages">; _creationTime: number; authorName?: string | undefined; serverId: string; userId: string; channelId: string; body: string; a… — Bounded recent chat for a resolved server; authorization belongs to the calling query. - `resolveConvexMultiplayer` (function): function resolveConvexMultiplayer(args: { game: GameDefinition; gameId: string; url?: string; client?: ConvexReactClient; api?: ConvexGameApi; userId?: string; force?: boolean; feedActions?: string[]; poseTuning?: PoseSyncTuning; }): MultiplayerSession | null — ⚠ undocumented -- `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true; } | { ok: false; reason: string; } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. -- `watchConvexQuery` (function): function watchConvexQuery(client: ConvexReactClient, query: FunctionReference<"query", "public", TArgs, TResult>, args: TArgs, toView: (result: TResult) => TView, onChange: (view: TView) => void): () => void — ⚠ undocumented +- `sendChatMessage` (function): function sendChatMessage(ctx: JGMutationCtx, args: { serverId: string; userId: string; body: string; channelId?: string; authorName?: string; maxBodyLength?: number; minIntervalMs?: number }): Promise — Write validated chat in a host transaction after the caller resolves its actor and world. +- `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number, topology?: "rooms" | "shared"): { ok: true; } | { ok: false; reason: string; } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. +- `watchConvexQuery` (function): function watchConvexQuery(client: ConvexGameClient, query: FunctionReference<"query", "public", TArgs, TResult>, args: TArgs, toView: (result: TResult) => TView, onChange: (view: TView) => void): () => void — ⚠ undocumented ## @jgengine/convex/convexChatTransport @@ -65,7 +73,7 @@ ## @jgengine/convex/convexPresenceTransport - `ConvexPresenceFunctions` (interface): interface ConvexPresenceFunctions — The Convex functions a **game** supplies for a world it owns: presence keyed by the game's own ids, including which residents exist while offline, which is game content and has no engine analogue. Not satisfied by `createPresenceFunctions` (`@jgengine/convex/server`) — that is the pose lane of a hosted `jgGameServers` server, backing `PresenceSync` rather than `PresenceTransport`. -- `createConvexPresenceTransport` (function): function createConvexPresenceTransport(functions: ConvexPresenceFunctions, mapRow: (row: TRawRow) => TRow): PresenceTransport — Wires a game's Convex presence functions into the engine's PresenceTransport contract: one snapshot subscription (my location + online players), one residents subscription (dormant candidates, decoupled from pose writes), and one tick mutation for pose upload + keep-alive. Dormant rows are derived client-side by subtracting the snapshot's online actors, so pose ticks never re-execute the residents query. mapRow converts backend rows into the game's row type (e.g. branding positions into its coordinate space). +- `createConvexPresenceTransport` (function): function createConvexPresenceTransport(functions: ConvexPresenceFunctions, mapRow: (row: TRawRow) => TRow): PresenceTransport — Wires a game's Convex presence functions into the engine's PresenceTransport contract: one snapshot subscription (my location + online players), one residents subscription (dormant candidates, decoupled from pose writes), and one tick mutation for pose upload + keep-alive. Dormant rows are derived client-side by subtracting the snapshot's online actors, so pose ticks never re-execute the residents query. mapRow converts backend rows into the game's row type (e.g. branding positions into its coordinate space). ## @jgengine/convex/convexSaveBackend @@ -83,16 +91,17 @@ ## @jgengine/convex/createConvexGameTransport -- `ConvexGameApi` (type): type ConvexGameApi = { runtime: { joinServer: FunctionReference< "mutation", "public", { gameId: string; serverId?: string; mode?: string; visibility?: "public" | "private"; joinCode?: string; externalId?: string; }, { serverId: string; isNew: boolean } >; leaveServer: FunctionReference<"mutation", … — ⚠ undocumented +- `ConvexGameApi` (type): type ConvexGameApi = { runtime: { joinServer: FunctionReference< "mutation", "public", { gameId: string; serverId?: string; mode?: string; visibility?: "public" | "private"; joinCode?: string; externalId?: string; }, JoinServerOutcome >; leaveServer: FunctionReference<"mutation", "public", { serverI… — ⚠ undocumented +- `ConvexGameClient` (type): type ConvexGameClient = Pick — Structural client seam avoids requiring the engine and game to share a class instance type. - `ConvexGameTransportConfig` (type): type ConvexGameTransportConfig = { gameId: string; userId?: string; } — ⚠ undocumented -- `createConvexChatSync` (function): function createConvexChatSync(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig, serverId: string): ChatSync — ⚠ undocumented -- `createConvexFeedWrites` (function): function createConvexFeedWrites(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig): { pushFeedEntry(args: { serverId: string; action: string; entry: unknown; }): Promise; } — ⚠ undocumented -- `createConvexGameFeeds` (function): function createConvexGameFeeds(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig): GameRuntimeFeeds — ⚠ undocumented -- `createConvexGameTransport` (function): function createConvexGameTransport(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig): GameRuntimeTransport — ⚠ undocumented +- `createConvexChatSync` (function): function createConvexChatSync(client: ConvexGameClient, api: ConvexGameApi, config: ConvexGameTransportConfig, serverId: string): ChatSync — ⚠ undocumented +- `createConvexFeedWrites` (function): function createConvexFeedWrites(client: ConvexGameClient, api: ConvexGameApi, config: ConvexGameTransportConfig): { pushFeedEntry(args: { serverId: string; action: string; entry: unknown; }): Promise; } — ⚠ undocumented +- `createConvexGameFeeds` (function): function createConvexGameFeeds(client: ConvexGameClient, api: ConvexGameApi, config: ConvexGameTransportConfig): GameRuntimeFeeds — ⚠ undocumented +- `createConvexGameTransport` (function): function createConvexGameTransport(client: ConvexGameClient, api: { runtime: Pick }, config: ConvexGameTransportConfig): GameRuntimeTransport — ⚠ undocumented - `createConvexLeaderboardReads` (function): function createConvexLeaderboardReads(api: ConvexGameApi, config: ConvexGameTransportConfig): { getTop(args: { stat: string; scope: LeaderboardScope; serverId?: string | undefined; limit?: number | undefined; }): { query: FunctionReference<"query", "public", { gameId: string; stat: string; scope: Le… — ⚠ undocumented -- `createConvexPresenceSync` (function): function createConvexPresenceSync(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig, tuning?: PoseSyncTuning): PresenceSync — ⚠ undocumented +- `createConvexPresenceSync` (function): function createConvexPresenceSync(client: ConvexGameClient, api: ConvexGameApi, config: ConvexGameTransportConfig, tuning?: PoseSyncTuning): PresenceSync — ⚠ undocumented - `defaultConvexGameApi` (function): function defaultConvexGameApi(): ConvexGameApi — ⚠ undocumented -- `watchConvexQuery` (function): function watchConvexQuery(client: ConvexReactClient, query: FunctionReference<"query", "public", TArgs, TResult>, args: TArgs, toView: (result: TResult) => TView, onChange: (view: TView) => void): () => void — ⚠ undocumented +- `watchConvexQuery` (function): function watchConvexQuery(client: ConvexGameClient, query: FunctionReference<"query", "public", TArgs, TResult>, args: TArgs, toView: (result: TResult) => TView, onChange: (view: TView) => void): () => void — ⚠ undocumented ## @jgengine/convex/hostedServer @@ -114,27 +123,46 @@ - `DEFAULT_CONVEX_POSE_RULES` (const): const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules — ⚠ undocumented - `DEFAULT_MAX_SERVERS_PER_TICK` (const): const DEFAULT_MAX_SERVERS_PER_TICK: 32 — How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. -- `GameServerHelpers` (type): type GameServerHelpers = { loadSnapshot: ( ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope, ) => Promise; applyCommand: (args: { gameId: string; loadedRevision: number; currentRevision: number; snapshot: GameRuntimeSnapshot; actorUserId: string; command: … — The plain-function half of {@link createGameServerFunctions}, bound to the same runtime registry and auth mode as its mutations. Reach for it when a host mutation must pair snapshot work with its own table writes in one transaction, which a pre-registered mutation cannot do. +- `GameServerHelpers` (type): type GameServerHelpers = { ensureServer: (ctx: JGMutationCtx, gameId: string) => Promise; loadSnapshot: ( ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope, ) => Promise; applyCommand: (args: { gameId: string; loadedRevision: number; currentRevis… — The plain-function half of {@link createGameServerFunctions}, bound to the same runtime registry and auth mode as its mutations. Reach for it when a host mutation must pair snapshot work with its own table writes in one transaction, which a pre-registered mutation cannot do. - `JGDataModel` (type): type JGDataModel = DataModelFromSchemaDefinition — Data model of the {@link jgengineTables} schema — the shape a host's own Convex ctx must satisfy to call the exported persistence helpers. - `JGMutationCtx` (type): type JGMutationCtx = GenericMutationCtx — Mutation ctx accepted by {@link loadServerSnapshot} / {@link persistServerSnapshot}. -- `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row. +- `JGQueryCtx` (type): type JGQueryCtx = GenericQueryCtx — Read-only context accepted by engine host queries. +- `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead. - `JG_RUNTIME_TICK_MS` (const): const JG_RUNTIME_TICK_MS: 1000 — ⚠ undocumented - `JgAuthMode` (type): type JgAuthMode = "anonymous" | "required" — ⚠ undocumented -- `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers" | "reapIdlePresence"; /** Which factory's exports the function lives in, i.e. which `convex/*.ts` file to reach it through. */ module: "runtime" | "presence"; } — ⚠ undocumented +- `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers" | "reapIdlePresence" | "pruneChatMessages" | "pruneClientErrors" | "pruneRateLimits"; /** Which factory's exports the function lives in, i.e. which `convex/*.ts` file to reach it through… — ⚠ undocumented - `LoadSnapshotScope` (type): type LoadSnapshotScope = CommandScope — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`). - `LoadedServerSnapshot` (type): type LoadedServerSnapshot = { server: ServerDoc; runtime: GameRuntime; snapshot: GameRuntimeSnapshot; } — A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot. - `MAX_CHUNKS_PER_QUERY` (const): const MAX_CHUNKS_PER_QUERY: 64 — How many chunk rows one `getChunks` call may return. - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied. +- `OnlinePlayer` (type): type OnlinePlayer = { serverId: string; userId: string; homeGameId?: string } — One active player delivered to an online-system batch. - `PresenceListRow` (type): type PresenceListRow = { userId: string; sessionId?: string; kind?: string; label?: string; position: { x: number; y: number; z: number }; rotationY: number; rotationPitch: number; lastSeenAt: number; } — One member's pose as `list` reports it. - `PresenceSyncResult` (type): type PresenceSyncResult = { pose: { x: number; y: number; z: number; rotationY: number; rotationPitch: number }; lastSeenAt: number; displaced: boolean; } — What `sync` hands back: the pose the server holds after clamping, and whether this session had been displaced. - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; externalId?: string; /** * How much of the server to hydrate before applying the command. Overrides whatever the * {@link CommandDef} declares through its own `scope`; omit both to hydrate the whole world. */ scope?: LoadSnap… — Arguments of the `runCommand` mutation, and of the equivalent {@link GameServerHelpers.runCommand} helper. - `RunCommandOutcome` (type): type RunCommandOutcome = { ok: true } | { ok: false; reason: string } — Outcome of a runtime command: applied, or refused with a reason (unknown server, non-member, validation, revision conflict). - `ServerDoc` (type): type ServerDoc = DocumentByName — A `jgGameServers` row — the server handle the persistence helpers load from and write back to. - `canJoinPrivateServer` (function): function canJoinPrivateServer(args: { isMember: boolean; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Private-server join-code gate. Existing members always pass; non-members must present a matching `joinCode` (loose-normalized via {@link normalizeJoinCode}). Callers still decide whether the server is private — this only answers the code/membership half. +- `createClientErrorFunctions` (function): function createClientErrorFunctions(): { reportClientError: RegisteredMutation<"public", { kind?: string | undefined; stack?: string | undefined; componentStack?: string | undefined; url?: string | undefined; pathname?: string | undefined; userAgent?: string | undefined; agentPrompt?: string | undef… — Authenticated, bounded client diagnostics and bounded retention sweeps. +- `forEachOnlinePlayer` (function): function forEachOnlinePlayer(ctx: JGMutationCtx, options: { batchSize?: number; handler: FunctionReference<"mutation", "internal", { players: OnlinePlayer[]; nowMs: number }>; continuation: FunctionReference<"mutation", "internal", { cursor?: string | null; nowMs?: number }>; cursor?: string | null;… — Scan one bounded presence page and schedule its handler and continuation in separate transactions. - `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results. - `loadServerSnapshot` (function): function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime, scope?: LoadSnapshotScope): Promise — Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks. Reach for it when writing your own mutation that must read runtime state before touching host tables. Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world. - `persistServerSnapshot` (function): function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise — Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard increments, all under `save`. Pair it with your own table writes to keep both in one transaction. -- `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true; } | { ok: false; reason: string; } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. +- `rateLimit` (function): function rateLimit(ctx: JGMutationCtx, args: { key: string; windowMs: number; max: number; nowMs?: number }): Promise<{ ok: boolean; retryAfterMs: number }> — Consume one request in a fixed rate window; concurrent callers serialize on the indexed key. +- `recentChatMessages` (function): function recentChatMessages(ctx: { db: Pick }, args: { serverId: string; channelId?: string; limit?: number }): Promise<{ _id: Id<"jgChatMessages">; _creationTime: number; authorName?: string | undefined; serverId: string; userId: string; channelId: string; body: string; a… — Bounded recent chat for a resolved server; authorization belongs to the calling query. +- `sendChatMessage` (function): function sendChatMessage(ctx: JGMutationCtx, args: { serverId: string; userId: string; body: string; channelId?: string; authorName?: string; maxBodyLength?: number; minIntervalMs?: number }): Promise — Write validated chat in a host transaction after the caller resolves its actor and world. +- `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number, topology?: "rooms" | "shared"): { ok: true; } | { ok: false; reason: string; } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. + +## @jgengine/convex/territory + +- `loadTerritoryState` (function): function loadTerritoryState(ctx: JGQueryCtx | JGMutationCtx, args: { serverId: string; gameId: string; userId: string; chunkKeys: readonly string[] }): Promise — Trusted host hydration for claim/bootstrap work; the caller checks access before invoking it. +- `persistTerritoryState` (function): function persistTerritoryState(ctx: JGMutationCtx, snapshot: GameRuntimeSnapshot, nowMs = Date.now()): Promise — Persist dirty territory chunks and profiles in the caller's mutation, without a shared-server write. + +## @jgengine/convex/worldPresence + +- `PresenceSnapshotWriter` (type): type PresenceSnapshotWriter = (presence: WorldPresenceRecord, nowMs: number) => Promise — Persist game-owned last-position state when the host store requests a checkpoint. +- `WorldPresenceOptions` (interface): interface WorldPresenceOptions — World chunk size and host-owned pose, checkpoint and retention policies. +- `WorldPresenceRecord` (interface): interface WorldPresenceRecord extends PresenceResidentRow — Normalized actor identity and pose read from an indexed jgPoses row. +- `createWorldPresenceStore` (function): function createWorldPresenceStore(options: WorldPresenceOptions = {}): { active: (ctx: ReadContext, actorExternalId: string, serverId?: string | undefined) => Promise; ensure(ctx: JGMutationCtx, args: { serverId: string; actorExternalId: string; homeGameId: string; kind: … — Indexed presence storage for hosts that own authentication, bot ownership and spawn policy. ## @jgengine/core/multiplayer @@ -168,7 +196,7 @@ - `browseSessions` (function): function browseSessions(listings: readonly SessionListing[], filter: MatchFilter = {}, options: BrowseOptions = {}): SessionListing[] — ⚠ undocumented - `createFeedWriteGate` (function): function createFeedWriteGate(allowedActions: readonly string[] = []): FeedWriteGate — ⚠ undocumented - `createLocalVoiceTransport` (function): function createLocalVoiceTransport(options?: { userId?: string }): { transport: VoiceTransport; participants(channelId: string): readonly VoiceParticipant[]; } — ⚠ undocumented -- `createPoseSyncGate` (function): function createPoseSyncGate(tuning: PoseSyncTuning): PoseSyncGate — ⚠ undocumented +- `createPoseSyncGate` (function): function createPoseSyncGate(tuning: PoseSyncTuning = DEFAULT_POSE_SYNC_TUNING): PoseSyncGate — ⚠ undocumented - `createPushToTalk` (function): function createPushToTalk(config?: { mode?: PushToTalkMode; onChange?: (transmitting: boolean) => void; }): PushToTalk — ⚠ undocumented - `findByJoinCode` (function): function findByJoinCode(listings: readonly SessionListing[], code: string): SessionListing | null — ⚠ undocumented - `fromRuntimeObjectRow` (function): function fromRuntimeObjectRow(row: RuntimeObjectRow): SceneObject — Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted. @@ -237,10 +265,11 @@ ## @jgengine/core/multiplayer/poseSyncGate +- `DEFAULT_POSE_SYNC_TUNING` (const): const DEFAULT_POSE_SYNC_TUNING: PoseSyncTuning — Default ten-hertz client pose gate with a five-second heartbeat. - `PlayerPose` (interface): interface PlayerPose — ⚠ undocumented - `PoseSyncGate` (interface): interface PoseSyncGate — ⚠ undocumented - `PoseSyncTuning` (interface): interface PoseSyncTuning — ⚠ undocumented -- `createPoseSyncGate` (function): function createPoseSyncGate(tuning: PoseSyncTuning): PoseSyncGate — ⚠ undocumented +- `createPoseSyncGate` (function): function createPoseSyncGate(tuning: PoseSyncTuning = DEFAULT_POSE_SYNC_TUNING): PoseSyncGate — ⚠ undocumented ## @jgengine/core/multiplayer/presenceContract @@ -251,6 +280,7 @@ - `PresenceFeeds` (interface): interface PresenceFeeds — ⚠ undocumented - `PresencePose` (interface): interface PresencePose — ⚠ undocumented - `PresencePosition` (interface): interface PresencePosition — ⚠ undocumented +- `PresenceResidentRow` (interface): interface PresenceResidentRow — Resident identity used to suppress offline actors while their owner is online. - `PresenceSession` (interface): interface PresenceSession — ⚠ undocumented - `PresenceTransport` (interface): interface PresenceTransport — Backend seam for multiplayer presence. Feeds are reactive data and change identity whenever any player's pose updates; actions MUST be identity-stable for the lifetime of a mounted session so join/leave lifecycle effects can depend on them without re-running per pose tick. The use* members are called as React hooks by consumers, so a mounted transport must never change identity — remount the subtree to switch backends. - `createLocalPresenceTransport` (function): function createLocalPresenceTransport(): { transport: PresenceTransport; actions: PresenceActions; } — ⚠ undocumented @@ -490,13 +520,13 @@ - `WorldGameHost` (interface): interface WorldGameHost extends GameHost — A {@link GameHost} whose worlds run on `HostedWorldSession`s; `tick` advances them and re-broadcasts on change. - `WorldGameHostOptions` (interface): interface WorldGameHostOptions — Config for {@link createWorldGameHost}: how to resolve a hosted world's authoritative session per server. - `WsAppearance` (type): type WsAppearance = Record — Client-set cosmetic/state tags carried alongside a pose (skin, mount, emote, ...). Primitive values only. -- `WsBackend` (type): type WsBackend = GameBackend & { pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; joinByCode: (args: { gameId: string; code: string; role?: "player" | "… — ⚠ undocumented +- `WsBackend` (type): type WsBackend = GameBackend & { rtt: { sampleMs: number; smoothedMs: number }; pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; joinByCode: (args: { ga… — ⚠ undocumented - `WsBackendOptions` (type): type WsBackendOptions = { url?: string; pipe?: TransportPipeFactory; userId: string; token?: string; webSocketFactory?: (url: string) => WebSocket; reconnectDelayMs?: number; maxReconnectDelayMs?: number; rpcTimeoutMs?: number; poseTuning?: PoseSyncTuning; now?: () => number; setTimeoutFn?: typeof s… — ⚠ undocumented - `WsBrowseResult` (type): type WsBrowseResult = SessionListing[] — ⚠ undocumented - `WsChannel` (type): type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice" — ⚠ undocumented - `WsChatMessage` (type): type WsChatMessage = { id: string; channelId: string; fromUserId: string; body: string; at: number; } — ⚠ undocumented - `WsChatSync` (type): type WsChatSync = { subscribe: ( serverId: string, channelId: string, onChange: (messages: WsChatMessage[]) => void, ) => () => void; send: (serverId: string, channelId: string, body: string) => Promise; } — ⚠ undocumented -- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: "player" | "spectator"; } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: s… — ⚠ undocumented +- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "ping"; id: number; at: number } | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: "player" | "spectator"; } | { v: 1; t: "jo… — ⚠ undocumented - `WsDecodeFailure` (type): type WsDecodeFailure = { reason: string; id?: number; } — ⚠ undocumented - `WsJoinByCodeResult` (type): type WsJoinByCodeResult = JoinServerResult | null — ⚠ undocumented - `WsJoinResult` (type): type WsJoinResult = JoinServerResult — ⚠ undocumented @@ -504,7 +534,7 @@ - `WsPresenceRow` (type): type WsPresenceRow = PresencePoseRow & { appearance?: WsAppearance } — ⚠ undocumented - `WsPresenceSync` (type): type WsPresenceSync = { subscribe: (serverId: string, onChange: (rows: WsPresenceRow[]) => void) => () => void; /** `pose.appearance`, when provided, is forwarded to the host as-is and surfaces on every subscriber's presence row for that user. */ syncPose: (serverId: string, pose: WsPose) => void; } — ⚠ undocumented - `WsRunCommandResult` (type): type WsRunCommandResult = TransportRunCommandResult — ⚠ undocumented -- `WsServerMessage` (type): type WsServerMessage = | { v: 1; t: "reply"; id: number; ok: true; result?: unknown } | { v: 1; t: "reply"; id: number; ok: false; reason: string } | WsUpdateMessage — ⚠ undocumented +- `WsServerMessage` (type): type WsServerMessage = | { v: 1; t: "pong"; id: number; at: number; serverAt: number } | { v: 1; t: "reply"; id: number; ok: true; result?: unknown } | { v: 1; t: "reply"; id: number; ok: false; reason: string } | WsUpdateMessage — ⚠ undocumented - `WsUpdateMessage` (type): type WsUpdateMessage = | { v: 1; t: "update"; channel: "server"; serverId: string; data: GameRuntimeServerView | null } | { v: 1; t: "update"; channel: "player"; serverId: string; data: GameRuntimePlayerView | null } | { v: 1; t: "update"; channel: "feed"; serverId: string; action: string; data: unk… — ⚠ undocumented - `WsVoiceParticipant` (type): type WsVoiceParticipant = { userId: string; streamId?: string; } — ⚠ undocumented - `WsVoiceSync` (type): type WsVoiceSync = { subscribe: ( serverId: string, channelId: string, onChange: (participants: WsVoiceParticipant[]) => void, ) => () => void; join: (serverId: string, channelId: string, streamId?: string) => Promise; leave: (serverId: string, channelId: string) => Promise; publish: (se… — ⚠ undocumented @@ -552,7 +582,7 @@ ## @jgengine/ws/createWsBackend -- `WsBackend` (type): type WsBackend = GameBackend & { pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; joinByCode: (args: { gameId: string; code: string; role?: "player" | "… — ⚠ undocumented +- `WsBackend` (type): type WsBackend = GameBackend & { rtt: { sampleMs: number; smoothedMs: number }; pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; browse: (args: { gameId: string; filter?: MatchFilter; limit?: number }) => Promise; joinByCode: (args: { ga… — ⚠ undocumented - `WsBackendOptions` (type): type WsBackendOptions = { url?: string; pipe?: TransportPipeFactory; userId: string; token?: string; webSocketFactory?: (url: string) => WebSocket; reconnectDelayMs?: number; maxReconnectDelayMs?: number; rpcTimeoutMs?: number; poseTuning?: PoseSyncTuning; now?: () => number; setTimeoutFn?: typeof s… — ⚠ undocumented - `WsChatSync` (type): type WsChatSync = { subscribe: ( serverId: string, channelId: string, onChange: (messages: WsChatMessage[]) => void, ) => () => void; send: (serverId: string, channelId: string, body: string) => Promise; } — ⚠ undocumented - `WsPresenceSync` (type): type WsPresenceSync = { subscribe: (serverId: string, onChange: (rows: WsPresenceRow[]) => void) => () => void; /** `pose.appearance`, when provided, is forwarded to the host as-is and surfaces on every subscriber's presence row for that user. */ syncPose: (serverId: string, pose: WsPose) => void; } — ⚠ undocumented @@ -624,14 +654,14 @@ - `WsBrowseResult` (type): type WsBrowseResult = SessionListing[] — ⚠ undocumented - `WsChannel` (type): type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice" — ⚠ undocumented - `WsChatMessage` (type): type WsChatMessage = { id: string; channelId: string; fromUserId: string; body: string; at: number; } — ⚠ undocumented -- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: "player" | "spectator"; } | { v: 1; t: "joinByCode"; id: number; gameId: string; code: s… — ⚠ undocumented +- `WsClientMessage` (type): type WsClientMessage = | { v: 1; t: "ping"; id: number; at: number } | { v: 1; t: "hello"; id: number; userId: string; token?: string } | { v: 1; t: "join"; id: number; gameId: string; serverId?: string; attributes?: SessionAttributes; code?: string; role?: "player" | "spectator"; } | { v: 1; t: "jo… — ⚠ undocumented - `WsDecodeFailure` (type): type WsDecodeFailure = { reason: string; id?: number; } — ⚠ undocumented - `WsJoinByCodeResult` (type): type WsJoinByCodeResult = JoinServerResult | null — ⚠ undocumented - `WsJoinResult` (type): type WsJoinResult = JoinServerResult — ⚠ undocumented - `WsPose` (type): type WsPose = PlayerPose & { appearance?: WsAppearance } — ⚠ undocumented - `WsPresenceRow` (type): type WsPresenceRow = PresencePoseRow & { appearance?: WsAppearance } — ⚠ undocumented - `WsRunCommandResult` (type): type WsRunCommandResult = TransportRunCommandResult — ⚠ undocumented -- `WsServerMessage` (type): type WsServerMessage = | { v: 1; t: "reply"; id: number; ok: true; result?: unknown } | { v: 1; t: "reply"; id: number; ok: false; reason: string } | WsUpdateMessage — ⚠ undocumented +- `WsServerMessage` (type): type WsServerMessage = | { v: 1; t: "pong"; id: number; at: number; serverAt: number } | { v: 1; t: "reply"; id: number; ok: true; result?: unknown } | { v: 1; t: "reply"; id: number; ok: false; reason: string } | WsUpdateMessage — ⚠ undocumented - `WsUpdateMessage` (type): type WsUpdateMessage = | { v: 1; t: "update"; channel: "server"; serverId: string; data: GameRuntimeServerView | null } | { v: 1; t: "update"; channel: "player"; serverId: string; data: GameRuntimePlayerView | null } | { v: 1; t: "update"; channel: "feed"; serverId: string; action: string; data: unk… — ⚠ undocumented - `WsVoiceParticipant` (type): type WsVoiceParticipant = { userId: string; streamId?: string; } — ⚠ undocumented diff --git a/.claude/skills/jgengine-multiplayer/capabilities.md b/.claude/skills/jgengine-multiplayer/capabilities.md index 86bebae16..35557ba69 100644 --- a/.claude/skills/jgengine-multiplayer/capabilities.md +++ b/.claude/skills/jgengine-multiplayer/capabilities.md @@ -4,6 +4,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the primitive that already does it*. +## client-error-reporting — Accept authenticated bounded diagnostics with cooldown and retention sweeps. + +- `createClientErrorFunctions` (function) · `import { createClientErrorFunctions } from "@jgengine/convex"` + ## convex-load-server-snapshot — read a hosted server's runtime snapshot inside a host-written Convex mutation - `loadServerSnapshot` (function) · `import { loadServerSnapshot } from "@jgengine/convex"` @@ -16,6 +20,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `createConvexSaveBackend` (function) · `import { createConvexSaveBackend } from "@jgengine/convex"` +## host-chat-history — Read bounded channel history inside an authorized host query. + +- `recentChatMessages` (function) · `import { recentChatMessages } from "@jgengine/convex"` + +## host-chat-send — Validate, rate-limit and persist chat inside an authorized host mutation. + +- `sendChatMessage` (function) · `import { sendChatMessage } from "@jgengine/convex"` + ## host-join-code-gate — membership-or-code gate for private hosted servers - `canJoinPrivateServer` (function) · `import { canJoinPrivateServer } from "@jgengine/convex"` @@ -24,11 +36,19 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `isListablePublicly` (function) · `import { isListablePublicly } from "@jgengine/convex"` +## host-rate-limit — Enforce indexed per-key request windows inside authoritative mutations. + +- `rateLimit` (function) · `import { rateLimit } from "@jgengine/convex"` + ## hosted-world-persistence — Persist hosted authoritative world snapshots in a local file. - `fileWorldStore` (function) · `import { fileWorldStore } from "@jgengine/node"` - `sqlWorldStore` (function) · `import { sqlWorldStore } from "@jgengine/sql"` +## online-player-batches — Schedule bounded active-player or home-game work in separate transactions. + +- `forEachOnlinePlayer` (function) · `import { forEachOnlinePlayer } from "@jgengine/convex"` + ## placed-object-rehydrate — rebuild live placed objects, with their state and slot contents, from persisted snapshot rows - `fromRuntimeObjectRow` (function) · `import { fromRuntimeObjectRow } from "@jgengine/core/multiplayer"` @@ -36,3 +56,15 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p ## placed-object-rows — a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores - `toRuntimeObjectRow` (function) · `import { toRuntimeObjectRow } from "@jgengine/core/multiplayer"` + +## territory-host-hydration — Load an actor profile and bounded claim chunks for a host transaction. + +- `loadTerritoryState` (function) · `import { loadTerritoryState } from "@jgengine/convex/territory"` + +## territory-host-persistence — Persist dirty ownership chunks and profiles without rewriting the shared server. + +- `persistTerritoryState` (function) · `import { persistTerritoryState } from "@jgengine/convex/territory"` + +## world-presence-store — indexed neighborhood presence with pose throttling and physical expiry Call only after resolving the actor and their world access; browser arguments are not trusted identities. + +- `createWorldPresenceStore` (function) · `import { createWorldPresenceStore } from "@jgengine/convex/worldPresence"` diff --git a/.claude/skills/jgengine-ui/SKILL.md b/.claude/skills/jgengine-ui/SKILL.md index d9e3964bb..971d9ab30 100644 --- a/.claude/skills/jgengine-ui/SKILL.md +++ b/.claude/skills/jgengine-ui/SKILL.md @@ -39,6 +39,14 @@ Existing React games keep their entity store and use the focused - Layout and skin remain caller-controlled; shared primitives own reusable behavior, not product look. - SSR-visible output is hydration-stable; round computed SVG values at the boundary. +## Shared-world interaction rules + +- Every rejected verb displays its reason on the triggering surface. Use `useServerSession(transport, gameId)` for join lifecycle. Join failure blocks play with `JoinGate` and retry; returning `{ ok: false }` without feedback is incomplete. +- The first tutorial verb works within 60 seconds from spawn with starting inventory. Land, power, and slots are granted or purchasable in-context; tutorial locks must not hide prerequisites. +- Placement previews display claim cost and affordability inline with `TerritoryOverlay`. +- `ChatPanel` owns collapsed/expanded state, own-message styling, Enter focus and Escape blur; standalone hosts pass `messages`, `userId`, and `onSend` (typed `{ ok, reason }`), while context-backed games use channel props. Use native inputs so shell typing-target suspension applies. +- Register Tailwind `@source` for consumed package files and inspect `bun run shoot --mode ui` captures of join, chat, and placement feedback. + ## Traps - Do not put environment beautification, authored-scene rendering, or world placement here. diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index 5f06987b1..907d8973f 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -540,8 +540,8 @@ - `ChatBubble` (interface): interface ChatBubble — ⚠ undocumented - `ChatBubblesOptions` (interface): interface ChatBubblesOptions — ⚠ undocumented - `ChatInput` (function): function ChatInput({ channelId, className, inputClassName, buttonClassName, placeholder, sendLabel, onSent, onRejected, }: { channelId: string; className?: string; inputClassName?: string; buttonClassName?: string; placeholder?: string; sendLabel?: ReactNode; onSent?: (message: ChatMessage) => void;… — ⚠ undocumented -- `ChatLog` (function): function ChatLog({ channelId, limit, className, messageClassName, renderMessage, }: { channelId: string; limit?: number; className?: string; messageClassName?: string; renderMessage?: (message: ChatMessage) => ReactNode; }): React.JSX.Element — ⚠ undocumented -- `ChatPanel` (function): function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, renderTab, onRejected, }: { channels?: readonly string[]; initialCha… — ⚠ undocumented +- `ChatLog` (function): function ChatLog({ channelId, limit, className, messageClassName, ownMessageClassName, renderMessage, }: { channelId: string; limit?: number; className?: string; messageClassName?: string; ownMessageClassName?: string; renderMessage?: (message: ChatMessage) => ReactNode; }): React.JSX.Element — ⚠ undocumented +- `ChatPanel` (function): function ChatPanel(props: Parameters[0] | StandaloneChatPanelProps): React.JSX.Element — Chat behavior over a game context or externally supplied server messages. - `ClerkUserShape` (interface): interface ClerkUserShape — ⚠ undocumented - `ClerkUserState` (interface): interface ClerkUserState — ⚠ undocumented - `Clock` (function): function Clock({ format = "24h", showDay = true, controls = false, style, className, }: { format?: "24h" | "12h"; showDay?: boolean; controls?: boolean; style?: CSSProperties; className?: string; }): React.JSX.Element — A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds pause + the game's speed multipliers as clickable pills (the "fast-forward" bar), off by default so a game opts into letting the player scrub time. @@ -1069,8 +1069,8 @@ - `ChannelTabs` (function): function ChannelTabs({ channels, active, onSelect, className, tabClassName, activeTabClassName, renderTab, }: { channels?: readonly string[]; active: string; onSelect: (channelId: string) => void; className?: string; tabClassName?: string; activeTabClassName?: string; renderTab?: (channelId: string,… — ⚠ undocumented - `ChatInput` (function): function ChatInput({ channelId, className, inputClassName, buttonClassName, placeholder, sendLabel, onSent, onRejected, }: { channelId: string; className?: string; inputClassName?: string; buttonClassName?: string; placeholder?: string; sendLabel?: ReactNode; onSent?: (message: ChatMessage) => void;… — ⚠ undocumented -- `ChatLog` (function): function ChatLog({ channelId, limit, className, messageClassName, renderMessage, }: { channelId: string; limit?: number; className?: string; messageClassName?: string; renderMessage?: (message: ChatMessage) => ReactNode; }): React.JSX.Element — ⚠ undocumented -- `ChatPanel` (function): function ChatPanel({ channels, initialChannel, limit, className, tabsClassName, tabClassName, activeTabClassName, logClassName, messageClassName, inputClassName, inputFieldClassName, sendButtonClassName, placeholder, renderMessage, renderTab, onRejected, }: { channels?: readonly string[]; initialCha… — ⚠ undocumented +- `ChatLog` (function): function ChatLog({ channelId, limit, className, messageClassName, ownMessageClassName, renderMessage, }: { channelId: string; limit?: number; className?: string; messageClassName?: string; ownMessageClassName?: string; renderMessage?: (message: ChatMessage) => ReactNode; }): React.JSX.Element — ⚠ undocumented +- `ChatPanel` (function): function ChatPanel(props: Parameters[0] | StandaloneChatPanelProps): React.JSX.Element — Chat behavior over a game context or externally supplied server messages. - `chatTransportFromSync` (function): function chatTransportFromSync(sync: ChatSync): ChatTransport — Lifts a callback-style ChatSync (e.g. createWsBackend().chatSyncFor(serverId)) into the hook-shaped ChatTransport contract. Create once per sync — outside render or inside useMemo — so subscriptions survive re-renders. ## @jgengine/react/chatBubbles @@ -1646,6 +1646,11 @@ - `WorldBrowser` (function): function WorldBrowser({ listings, onJoin, className, rowClassName, joinClassName, emptyState, renderListing, }: { listings: readonly SessionListing[]; onJoin: (listing: SessionListing) => void; className?: string; rowClassName?: string; joinClassName?: string; emptyState?: ReactNode; renderListing?:… — ⚠ undocumented - `WorldInviteToast` (function): function WorldInviteToast({ className, acceptClassName, declineClassName, onAccepted, renderInvite, }: { className?: string; acceptClassName?: string; declineClassName?: string; onAccepted: (target: WorldInviteTarget) => void; renderInvite?: (invite: WorldInvite) => ReactNode; }): React.JSX.Element … — ⚠ undocumented +## @jgengine/react/standaloneChatPanel + +- `StandaloneChatPanel` (function): function StandaloneChatPanel({ messages, userId, onSend, className, logClassName, inputClassName, inputFieldClassName, messageClassName, ownMessageClassName, style, defaultExpanded = false, collapsedLimit = 3, limit = 50, maxLength = DEFAULT_CHAT_BODY_LENGTH, hotkeysEnabled = … — Headless chat interaction for external message stores. Prefer the ChatPanel entrypoint. +- `StandaloneChatPanelProps` (interface): interface StandaloneChatPanelProps — Message-store inputs and caller-owned styling for standalone chat. + ## @jgengine/react/startScreen - `ControlHint` (interface): interface ControlHint — One row of a control legend. Name the game action(s) whose bound key(s) to show (`action`) so the glyphs come straight from the keybind map — never re-typed — or give literal `keys` for controls that live outside the map (`"Mouse"`, `"LMB"`). `label` says what the control does. @@ -1695,6 +1700,10 @@ - `DebouncedCommit` (interface): interface DebouncedCommit — Live-mirrored, trailing-debounced commit binding for a single control value. - `useDebouncedCommit` (function): function useDebouncedCommit(value: T, commit: (value: T) => void, delayMs = 180): DebouncedCommit — See {@link DebouncedCommit}. `commit` and `delayMs` may change between renders (kept in refs); the binding identity stays stable except when `value` (the local mirror) changes. +## @jgengine/react/useServerSession + +- `useServerSession` (function): function useServerSession(transport: GameRuntimeTransport, gameId: string, preferredServerId?: string): { retry: () => void; serverId: string | null; status: "joining" | "joined" | "failed"; failureReason: string | null; } — Joins a host and exposes a retryable blocking state until membership is confirmed. + ## @jgengine/react/voice - `MicToggle` (function): function MicToggle({ voice, className, mutedLabel, unmutedLabel, }: { voice: VoiceState; className?: string; mutedLabel?: ReactNode; unmutedLabel?: ReactNode; }): React.JSX.Element — ⚠ undocumented @@ -1737,6 +1746,15 @@ - `UiPreviewScenario` (type): type UiPreviewScenario = (ctx: GameContext, playable: PlayableGame) => void — ⚠ undocumented - `defaultUiScenario` (const): const defaultUiScenario: UiPreviewScenario — ⚠ undocumented +## @jgengine/shell/JoinGate + +- `JoinGate` (function): function JoinGate({ status, failureReason, retry, joiningLabel = "Joining world…", failedLabel = "Unable to join", retryLabel = "Retry", className, children, renderJoining, renderFailure }: { status: "joining" | "joined" | "failed"; failureReason?: string | null; retry: () => void; joiningLabel?: Re… — Blocking join feedback; callers may supply their own copy and classes. + +## @jgengine/shell/TerritoryOverlay + +- `TerritoryOverlay` (function): function TerritoryOverlay({ cells, cost, affordable, formatCost = String, renderCell, className, style }: { cells: readonly TerritoryPreviewCell[]; cost: number; affordable: boolean; formatCost?: (cost: number) => ReactNode; renderCell?: (cell: TerritoryPreviewCell) => ReactNode; className?: string;… — Placement footprint feedback with inline claim cost and affordability. +- `TerritoryPreviewCell` (type): type TerritoryPreviewCell = { key: string; x: number; z: number; status: "owned" | "claimable" | "blocked" } — A placement footprint cell and its authoritative preview status. + ## @jgengine/shell/audio/AudioComponents - `AudioListener` (function): function AudioListener({ engine }: { engine: AudioEngine }): null — ⚠ undocumented @@ -2644,7 +2662,7 @@ ## @jgengine/shell/useShellMultiplayerSync -- `useShellMultiplayerSync` (function): function useShellMultiplayerSync(ctx: GameContext | null, multiplayer: ShellMultiplayer | null, playable: PlayableGame, serverIdRef: { current: string | null }, setRemotePlayers: Dispatch>, authoritativeFrameRef?: { current: AuthoritativeFrameHandler | null }): void — Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. +- `useShellMultiplayerSync` (function): function useShellMultiplayerSync(ctx: GameContext | null, multiplayer: ShellMultiplayer | null, playable: PlayableGame, serverIdRef: { current: string | null }, setRemotePlayers: Dispatch>, authoritativeFrameRef?: { current: AuthoritativeFrameHandler | null }): { st… — Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. ## @jgengine/shell/vfx/ParticleField @@ -2905,7 +2923,7 @@ ## @jgengine/shell/world/WorldScene -- `RemotePlayers` (function): function RemotePlayers({ rows }: { rows: PresencePoseRow[] }): React.JSX.Element — ⚠ undocumented +- `RemotePlayers` (function): function RemotePlayers({ rows, presenceInterpolationMs = 100 }: { rows: PresencePoseRow[]; presenceInterpolationMs?: number }): React.JSX.Element — ⚠ undocumented - `WorldView` (function): function WorldView({ entitySprites, entityModels, objectModels, objectStyles, environment, assets, renderEntity, renderObject, selectedIds, hideLocalActor, }: { entitySprites: Record | undefined; entityModels: Record | undefined; objectModels… — ⚠ undocumented ## @jgengine/shell/world/entityPose diff --git a/.claude/skills/jgengine-ui/capabilities.md b/.claude/skills/jgengine-ui/capabilities.md index 7ccfb87a7..4b6c67d27 100644 --- a/.claude/skills/jgengine-ui/capabilities.md +++ b/.claude/skills/jgengine-ui/capabilities.md @@ -286,6 +286,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `InteractionPrompt` (function) · `import { InteractionPrompt } from "@jgengine/react"` +## join-gate — block gameplay until joined and show failures with retry + +- `JoinGate` (function) · `import { JoinGate } from "@jgengine/shell/JoinGate"` + ## key-hint — keyboard/mouse control hint that hides itself on touch - `KeyHint` (function) · `import { KeyHint } from "@jgengine/react"` @@ -499,6 +503,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `SelectionPanel` (function) · `import { SelectionPanel } from "@jgengine/react"` +## server-session — join a multiplayer host with status, retry, and teardown + +- `useServerSession` (function) · `import { useServerSession } from "@jgengine/react/useServerSession"` + ## shop-grid-host — drop-in vendor/shop grid over a caller-owned wallet — item cards with icon/price/stock, afford-aware Buy, optional Sell, and a balance readout, token-themed - `ShopGrid` (function) · `import { ShopGrid } from "@jgengine/react"` @@ -512,6 +520,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `createSpriteClipPlayer` (function) · `import { createSpriteClipPlayer } from "@jgengine/core/render/sprite2d"` - `sortingOrder` (function) · `import { sortingOrder } from "@jgengine/core/render/sprite2d"` +## standalone-chat — render persisted chat with focus controls and visible send failures + +- `StandaloneChatPanel` (function) · `import { StandaloneChatPanel } from "@jgengine/react/standaloneChatPanel"` + ## start-screen — headless title/attract overlay the game fills and skins - `StartScreen` (function) · `import { StartScreen } from "@jgengine/react"` @@ -528,6 +540,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `TalentTree` (function) · `import { TalentTree } from "@jgengine/react"` +## territory-overlay — show footprint ownership, claim cost, and affordability + +- `TerritoryOverlay` (function) · `import { TerritoryOverlay } from "@jgengine/shell/TerritoryOverlay"` + ## timer-readout — live digital mm:ss/m:ss.d timer readout bound to a TimerSet - `TimerReadout` (function) · `import { TimerReadout } from "@jgengine/react"` diff --git a/.claude/skills/jgengine-world/SKILL.md b/.claude/skills/jgengine-world/SKILL.md index 6b9c7df8a..e0cf0a2bc 100644 --- a/.claude/skills/jgengine-world/SKILL.md +++ b/.claude/skills/jgengine-world/SKILL.md @@ -54,3 +54,9 @@ Render seams fall back to placeholders when content is unauthored: default green - Generated environments still need deterministic data assertions; authored environments need scene-document assertions. - Audio files and licensing belong to `jgengine-assets`; spatial playback policy belongs here. + +### Territory and placement + +Use `world/territory` for chunk-backed ownership: `planFootprintClaims` prices previews, `placeWithTerritory` commits claims and object placement atomically, and `createTerritory` binds host-owned snapshot storage to `ctx.game.territory`. Use `territoryChunkKeys` to load every footprint and gap-neighbor chunk before planning; never interpret an unloaded neighborhood as vacant. Claims persist in `RuntimeChunkRow.territory`; `RuntimePlayerRow.territoryOwnedCount` preserves the price curve and `ownedTerritoryChunkKeys` bounds owner queries. Convex hosts use `loadTerritoryState`/`persistTerritoryState` from `@jgengine/convex/territory` to commit dirty claim chunks/profiles without a shared-server write. Grant `starterBlock` before the first placement verb. Placement previews must show the claim cost and affordability on the placement surface. + +Use `chunkKeysAround(position, rings)` for bounded command/presence reads; declare `commands[name].scope(input)` for every hosted command. Missing scopes load only the actor and no chunks. A whole-world command must opt in explicitly with `scope: () => ({})` and justify its read budget. diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md index ab64365c8..fc753b5f8 100644 --- a/.claude/skills/jgengine-world/api.md +++ b/.claude/skills/jgengine-world/api.md @@ -1548,6 +1548,11 @@ - `VisionTarget` (interface): interface VisionTarget — ⚠ undocumented - `VisionWall` (interface): interface VisionWall — Structurally matches `world/walls` `WallSegment` — pass those straight in as occluders. +## @jgengine/core/time/accrueSince + +- `Accrual` (interface): interface Accrual — Elapsed duration and the next anchor to persist with accrued effects. +- `accrueSince` (function): function accrueSince(anchorMs: number, nowMs: number, options: { capMs?: number } = {}): Accrual — Elapsed time since a persisted anchor, capped once; persist anchorMs with the accrued result. + ## @jgengine/core/time/beatClock - `BeatAccuracyTier` (interface): interface BeatAccuracyTier — ⚠ undocumented @@ -1589,6 +1594,12 @@ - `LinearCatchUpInput` (interface): interface LinearCatchUpInput — ⚠ undocumented - `SteppedCatchUpResult` (interface): interface SteppedCatchUpResult — ⚠ undocumented +## @jgengine/core/time/rateWindow + +- `RateWindowDecision` (interface): interface RateWindowDecision — Admission result, next stored timestamps, and wait before retrying. +- `RateWindowPolicy` (interface): interface RateWindowPolicy — Maximum accepted operations in a sliding duration. +- `decideRateWindow` (function): function decideRateWindow(timestamps: readonly number[], nowMs: number, policy: RateWindowPolicy): RateWindowDecision — A sliding rate window; persist timestamps only alongside the accepted operation. + ## @jgengine/core/time/serverTick - `PlanServerTickOptions` (type): type PlanServerTickOptions = { /** Cap catch-up runs per system per heartbeat to avoid spiral-of-death. Default 3. */ maxCatchUp?: number; } — ⚠ undocumented @@ -3285,7 +3296,7 @@ - `PlacementObstacle` (interface): interface PlacementObstacle — ⚠ undocumented - `PlacementRequest` (interface): interface PlacementRequest — ⚠ undocumented -- `PlacementResult` (type): type PlacementResult = | { status: "ok"; center: Vec2; aabb: Aabb } | { status: "rejected"; reason: "out-of-bounds" } | { status: "rejected"; reason: "overlap"; obstacle: PlacementObstacle; index: number } — ⚠ undocumented +- `PlacementResult` (type): type PlacementResult = | { status: "ok"; center: Vec2; aabb: Aabb } | { status: "rejected"; reason: "out-of-bounds" } | { status: "rejected"; reason: "territory.blocked" } | { status: "rejected"; reason: "overlap"; obstacle: PlacementObstacle; index: number } — ⚠ undocumented - `PlacementRules` (interface): interface PlacementRules — ⚠ undocumented - `footprintObstacle` (function): function footprintObstacle(request: PlacementRequest, id?: string): PlacementObstacle — ⚠ undocumented - `validatePlacement` (function): function validatePlacement(request: PlacementRequest, rules: PlacementRules = {}): PlacementResult — Footprint validity: bounds + obstacle overlap after optional grid snap. @@ -3672,6 +3683,23 @@ - `surfaceRing` (function): function surfaceRing(sampleHeight: HeightSampler, center: GroundPoint, radius: number, segments = 48, options: DrapeOptions = {}): number[] — A closed circle draped on the surface — the surface-following placement guide ring under a cursor or a selected object. Returns flat `[x, y, z, ...]` world triples forming a loop (last vertex repeats the first). `segments` sets the smoothness of the ring. - `terrainContourGuides` (function): function terrainContourGuides(field: Pick, region: GuideRegion, targetLines = 12, resolution = 128): { interval: number; summary: ElevationSummary; contours: ContourLine[] } — Convenience over {@link extractContours} that auto-picks the interval from the field's own relief: summarises the region, chooses a readable interval for `targetLines` bands, and traces the contours — the one call the editor overlay makes to turn a `TerrainField` into ready-to-draw guides. Returns an empty list for flat ground. +## @jgengine/core/world/territory + +- `Territory` (type): type Territory = ReturnType — Snapshot-backed claim, release, owner and starter-grant operations. +- `TerritoryCell` (type): type TerritoryCell = { x: number; z: number } — Integer cell coordinates used for ownership on the ground plane. +- `TerritoryPlan` (type): type TerritoryPlan = { ok: true; cells: TerritoryCell[]; cost: number } | { ok: false; reason: "territory.blocked" | "territory.unaffordable" } — Read-only footprint quote or a blocked/insufficient-balance rejection. +- `TerritoryPolicy` (type): type TerritoryPolicy = { gapCells?: number; cellSize?: number; chunkSize?: number; currency?: string; price?: (ownedCount: number) => number; starterBlock?: number; nowMs?: number; } — Cell size, foreign-owner gap, growth price, currency and starter-grant policy. +- `TerritoryResult` (type): type TerritoryResult = { ok: true; snapshot: GameRuntimeSnapshot; cost: number } | { ok: false; reason: "territory.blocked" | "territory.unaffordable" } — Atomic claim outcome with the replacement snapshot and total charge. +- `TerritoryStorage` (interface): interface TerritoryStorage — Host-owned snapshot access used by the territory facade. +- `claimTerritory` (function): function claimTerritory(snapshot: GameRuntimeSnapshot, userId: string, cells: readonly TerritoryCell[], policy: TerritoryPolicy = {}): TerritoryResult — Atomically buy every unowned footprint cell, leaving the input untouched on failure. +- `createTerritory` (function): function createTerritory(storage: TerritoryStorage, policy: () => TerritoryPolicy = () => ({})): { snapshot: () => GameRuntimeSnapshot; restore: (snapshot: GameRuntimeSnapshot) => void; ownerOf: (cell: TerritoryCell) => string | null; canUse: (userId: string, cell: TerritoryCell) => boolean; claim(u… — Snapshot-backed territory facade with host-owned storage and live policy. +- `placeWithTerritory` (function): function placeWithTerritory(snapshot: GameRuntimeSnapshot, userId: string, bounds: Aabb, policy: TerritoryPolicy, place: (snapshot: GameRuntimeSnapshot) => { ok: true; snapshot: GameRuntimeSnapshot } | { ok: false; reason: string }): { ok: true; snapshot: GameRuntimeSnapshot; cost: number } | { ok: … — Placement transaction: rejected object placement also rolls back territory and its charge. +- `planFootprintClaims` (function): function planFootprintClaims(snapshot: GameRuntimeSnapshot, userId: string, footprint: readonly TerritoryCell[], policy: TerritoryPolicy = {}): TerritoryPlan — Price and validate a footprint without mutating or charging; reuse for placement previews. +- `territoryChunkKey` (function): function territoryChunkKey(cell: TerritoryCell, policy: TerritoryPolicy = {}): string — Persisted chunk key containing one territory cell. +- `territoryChunkKeys` (function): function territoryChunkKeys(cells: readonly TerritoryCell[], policy: TerritoryPolicy = {}): string[] — Exact chunks needed to validate a footprint and its foreign-owner gap. +- `territoryFootprintCells` (function): function territoryFootprintCells(bounds: Aabb, cellSize = 1): TerritoryCell[] — Cells touched by a placement, excluding cells that only touch its outer edge. +- `territoryOwnerOf` (function): function territoryOwnerOf(snapshot: GameRuntimeSnapshot, cell: TerritoryCell, policy: TerritoryPolicy = {}): string | null — Owner of a loaded cell; hosts load the target and gap neighborhood before evaluating claims. + ## @jgengine/core/world/vegetation - `VEGETATION_DEFAULTS` (const): const VEGETATION_DEFAULTS: VegetationSettings — Defaults a bare `kind: "vegetation"` volume grows with: grass at 4 blades/m². diff --git a/.claude/skills/jgengine-world/capabilities.md b/.claude/skills/jgengine-world/capabilities.md index dce9b2ae3..1755e63fb 100644 --- a/.claude/skills/jgengine-world/capabilities.md +++ b/.claude/skills/jgengine-world/capabilities.md @@ -509,6 +509,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `createRapierBackend` (function) · `import { createRapierBackend } from "@jgengine/rapier"` +## rate-window-policy — Evaluate deterministic request windows with retry timing. + +- `decideRateWindow` (function) · `import { decideRateWindow } from "@jgengine/core/time/rateWindow"` + ## reputation — faction standing that crosses named reputation tiers - `tierForStanding` (function) · `import { tierForStanding } from "@jgengine/core/world"` @@ -650,6 +654,30 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `raycastHeightField` (function) · `import { raycastHeightField } from "@jgengine/core/world/terrain"` +## territory-cell-chunk — Map ownership cells to persisted world chunks. + +- `territoryChunkKey` (function) · `import { territoryChunkKey } from "@jgengine/core/world/territory"` + +## territory-footprint — Resolve exactly the ownership cells touched by a placement footprint. + +- `territoryFootprintCells` (function) · `import { territoryFootprintCells } from "@jgengine/core/world/territory"` + +## territory-owner-query — Read a loaded land cell owner for authoritative placement checks. + +- `territoryOwnerOf` (function) · `import { territoryOwnerOf } from "@jgengine/core/world/territory"` + +## territory-ownership — Claim, release and query land through host-owned snapshot storage. + +- `createTerritory` (function) · `import { createTerritory } from "@jgengine/core/world/territory"` + +## territory-placement — Purchase footprint cells atomically with object placement. + +- `placeWithTerritory` (function) · `import { placeWithTerritory } from "@jgengine/core/world/territory"` + +## territory-read-scope — Load the footprint and foreign-owner gap without scanning the world. + +- `territoryChunkKeys` (function) · `import { territoryChunkKeys } from "@jgengine/core/world/territory"` + ## touch-controls — switch the on-screen touch control set when gameplay context changes (enter/exit vehicle, mount, build mode) - `setTouchControlsMode` (function) · `import { setTouchControlsMode } from "@jgengine/core/input/touchControlsMode"` diff --git a/.claude/skills/jgengine/api.md b/.claude/skills/jgengine/api.md index 8700e7669..98bf9525a 100644 --- a/.claude/skills/jgengine/api.md +++ b/.claude/skills/jgengine/api.md @@ -6,7 +6,7 @@ - `CHANGELOG` (const): const CHANGELOG: Record — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). -- `VERSION` (const): const VERSION: "0.18.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. +- `VERSION` (const): const VERSION: "0.18.1" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. ## @jgengine/core/authoring @@ -228,14 +228,14 @@ - `CHANGELOG` (const): const CHANGELOG: Record — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). -- `VERSION` (const): const VERSION: "0.18.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. +- `VERSION` (const): const VERSION: "0.18.1" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. ## @jgengine/core/runtime/adapter - `MultiplayerAdapterConfig` (type): type MultiplayerAdapterConfig = | { kind: "convex"; topology?: MultiplayerTopology; authority?: MultiplayerAuthority } | { kind: "ws"; topology?: MultiplayerTopology; url?: string; authority?: MultiplayerAuthority } | { kind: "socketio"; topology?: MultiplayerTopology; url?: string; authority?: Mult… — ⚠ undocumented - `MultiplayerAuthority` (type): type MultiplayerAuthority = "server" | "client" — Where the world simulation is authoritative. - `MultiplayerTopology` (type): type MultiplayerTopology = "shared" | "lobbies" | "private" — ⚠ undocumented -- `ServersPoolConfig` (type): type ServersPoolConfig = { maxServers: number; slotsPerServer: number; minPlayersToStart?: number; adapter: MultiplayerAdapterConfig; } — ⚠ undocumented +- `ServersPoolConfig` (type): type ServersPoolConfig = { minPlayersToStart?: number; adapter: MultiplayerAdapterConfig; } & ( | { topology: "shared"; maxServers?: number; slotsPerServer?: number } | { topology?: "rooms"; maxServers: number; slotsPerServer: number } ) — ⚠ undocumented - `adapterOf` (function): function adapterOf(multiplayer: unknown): MultiplayerAdapterConfig | null — ⚠ undocumented - `convex` (function): function convex(config?: { topology?: MultiplayerTopology; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — Convex transport. Omitting `authority` (or passing `"client"`) is **presence-only** — prefer `convexPresence()` to name that intent explicitly. Pass `{ authority: "server" }` for a shared, host-authoritative world — see `examples/HOSTED.md`. - `convexPresence` (function): function convexPresence(config?: { topology?: MultiplayerTopology }): MultiplayerAdapterConfig — Presence-only Convex transport — each client runs its own `onTick`; only presence/feeds/chat sync. Sugar for `convex({ ...config, authority: "client" })`. @@ -248,7 +248,7 @@ - `offline` (function): function offline(): MultiplayerAdapterConfig — Explicit single-player adapter. Solo games never need this — omitting `multiplayer` in the shell `defineGame` already defaults to offline; pass it only where an adapter value is structurally required. - `p2p` (function): function p2p(config?: { topology?: MultiplayerTopology; room?: string; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — Serverless peer-to-peer (WebRTC) session — one peer hosts, friends join by room code. - `resolveAuthority` (function): function resolveAuthority(multiplayer: unknown): MultiplayerAuthority | null — Resolved authority for a multiplayer config. - `offline` / missing adapter → `null` (single-player; not multiplayer authority). - unset or `"client"` → `"client"` (presence-only; each client ticks). - `"server"` → host-authoritative shared sim. -- `servers` (function): function servers(config: ServersPoolConfig): ServersPoolConfig — ⚠ undocumented +- `servers` (function): function servers(config: ServersPoolConfig): { topology: "shared" | "rooms"; maxServers: number; slotsPerServer: number; minPlayersToStart?: number | undefined; adapter: MultiplayerAdapterConfig; } | { topology: "shared" | "rooms"; maxServers: number; slotsPerServer: number; minPlayersToStart?: numb… — Resolve the shared-world pool to one unbounded world; room pools keep explicit capacities. - `socketIo` (function): function socketIo(config?: { topology?: MultiplayerTopology; url?: string; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — ⚠ undocumented - `ws` (function): function ws(config?: { topology?: MultiplayerTopology; url?: string; authority?: MultiplayerAuthority }): MultiplayerAdapterConfig — WebSocket transport. Omitting `authority` (or passing `"client"`) is **presence-only** — prefer `wsPresence()` to name that intent explicitly. Pass `{ authority: "server" }` for a shared, host-authoritative world — see `examples/HOSTED.md`. - `wsPresence` (function): function wsPresence(config?: { topology?: MultiplayerTopology; url?: string }): MultiplayerAdapterConfig — Presence-only WebSocket transport — each client runs its own `onTick`; only presence/feeds/chat sync. Sugar for `ws({ ...config, authority: "client" })`. @@ -258,10 +258,16 @@ - `CameraDirector` (interface): interface CameraDirector — ⚠ undocumented - `ChaseCameraTuning` (type): type ChaseCameraTuning = Partial< Pick > — Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11), or a whole driving-feel overlay (speed→FOV, lead, bank, speed shake, drift-lag) applied only while a vehicle is piloted (#1299). +## @jgengine/core/runtime/commandInput + +- `QuantityResult` (type): type QuantityResult = { ok: true; quantity: number } | { ok: false; reason: "invalid-quantity" } — Validated integer quantity or a stable input rejection. +- `readQuantity` (function): function readQuantity(value: unknown, options: { min?: number; max?: number } = {}): QuantityResult — Read an untrusted whole-item quantity without coercion, truncation, or clamping. + ## @jgengine/core/runtime/commandRunner - `CommandDef` (type): type CommandDef = { /** * What this command reads and writes, derived from its own input. A host that hydrates through a * scope loads only this slice instead of the whole world, and refuses the command if `apply` then * dirties a player or chunk the scope did not name — writing an… — ⚠ undocumented - `CommandScope` (type): type CommandScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — Which slice of a server has to be hydrated before some work runs against it. Both fields default to "everything", which costs a read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch. +- `CommandScopeDefinition` (type): type CommandScopeDefinition = Omit & { players?: readonly string[] | "actor" } — Declarative read scope; actor is resolved before host hydration. - `CommandValidationError` (type): type CommandValidationError = { reason: string } — ⚠ undocumented - `RunCommandResult` (type): type RunCommandResult = | { ok: true; snapshot: GameRuntimeSnapshot } | { ok: false; reason: string } — ⚠ undocumented @@ -335,13 +341,14 @@ ## @jgengine/core/runtime/gameRuntime -- `GameRuntime` (type): type GameRuntime = { gameId: string; save: SaveConfig; /** * Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and * persisting a server whose `tick` is a no-op by construction — the difference between a world that * costs a full read/write every second while… — ⚠ undocumented -- `GameRuntimeDefinition` (type): type GameRuntimeDefinition = { gameId: string; save: SaveConfig; commands: Record; loop?: ServerLoopHooks; } — ⚠ undocumented +- `GameRuntime` (type): type GameRuntime = { gameId: string; topology?: "shared" | "rooms"; save: SaveConfig; /** * Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and * persisting a server whose `tick` is a no-op by construction — the difference between a world that * costs a ful… — ⚠ undocumented +- `GameRuntimeDefinition` (type): type GameRuntimeDefinition = { gameId: string; topology?: "shared" | "rooms"; save: SaveConfig; commands: Record; loop?: ServerLoopHooks; } — ⚠ undocumented - `HydrateInput` (type): type HydrateInput = { gameId: string; serverId: string; serverRow: RuntimeServerRow; playersByUserId: Record; chunksByKey: Record; revision?: number; /** Host wall clock for `onInit`, in ms. Defaults to `Date.now()`. */ nowMs?: number; } — ⚠ undocumented - `RuntimeInitContext` (type): type RuntimeInitContext = { snapshot: GameRuntimeSnapshot; setSnapshot: (snapshot: GameRuntimeSnapshot) => void; /** * Host wall clock at the start of this call, in ms. The host already knows it, so anything keyed to * real time — a UTC date rollover, a `lastTickAt` anchor other code paths read, a s… — ⚠ undocumented - `RuntimeLoopContext` (type): type RuntimeLoopContext = RuntimeInitContext & { player: { userId: string; isNew: boolean; }; } — ⚠ undocumented - `RuntimeWorldContext` (type): type RuntimeWorldContext = RuntimeInitContext & { playerIds: readonly string[]; } — ⚠ undocumented - `ServerLoopHooks` (type): type ServerLoopHooks = { onInit?: (ctx: RuntimeInitContext) => void; onNewPlayer?: (ctx: RuntimeLoopContext) => void; onTick?: (ctx: RuntimeWorldContext, dtSeconds: number) => void; /** * What `onNewPlayer` touches, so a join hydrates that instead of the whole world. Only consulted * when `onNewPlay… — ⚠ undocumented +- `initialPlayerState` (function): function initialPlayerState(runtime: GameRuntime, userId: string, nowMs: number = Date.now()): RuntimePlayerRow — Seed a fresh player through the same onNewPlayer hook as joining, without hydrating or modifying a world. ## @jgengine/core/runtime/headlessRunner @@ -375,7 +382,7 @@ ## @jgengine/core/runtime/hostPolicy -- `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row. +- `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead. - `JoinCandidate` (type): type JoinCandidate = { memberUserIds: readonly string[]; slotsPerServer: number; visibility?: SessionVisibility | undefined; createdAt: number; } — A row `selectJoinTarget` can choose between — the matchmaking-relevant fields of a server record. - `JoinTarget` (type): type JoinTarget = | { kind: "join"; row: T } | { kind: "create" } | { kind: "refuse"; reason: string } — What a join should do with the candidate rows it found. - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied. @@ -383,11 +390,11 @@ - `isAutoJoinCandidate` (function): function isAutoJoinCandidate(args: { memberUserIds: readonly string[]; slotsPerServer: number; visibility: SessionVisibility | undefined; userId: string; }): boolean — Auto-match candidate when no `serverId` is supplied: already a member, or a public room with free capacity. Private rooms are never auto-picked (join-by-code / direct id only). - `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results. - `isPrivateJoinBlocked` (function): function isPrivateJoinBlocked(args: { visibility: SessionVisibility | undefined; memberUserIds: readonly string[]; userId: string; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Whether a private-visibility server blocks this join (non-member without a matching code). Public / undefined visibility never blocks. -- `isServerFull` (function): function isServerFull(memberUserIds: readonly string[], slotsPerServer: number, userId: string): boolean — True when the server has no free slots for a non-member. Existing members never count as "full" so rejoin/leave cycles keep working. +- `isServerFull` (function): function isServerFull(memberCount: number, slotsPerServer: number, isMember: boolean): boolean — True when the server has no free slots for a non-member. Existing members never count as "full" so rejoin/leave cycles keep working. - `isServerMember` (function): function isServerMember(memberUserIds: readonly string[], userId: string): boolean — Whether `userId` is already on the server's member roster. - `selectJoinTarget` (function): function selectJoinTarget(rows: readonly T[], args: { userId: string; mode?: MatchmakingMode; /** Capacity to judge rows against — the host's current option, when it supersedes the stored value. */ slotsPerServer?: number; }): JoinTarget — Resolve an auto-match join against the game's joinable rows under a {@link MatchmakingMode}. Reach for it instead of hand-rolling the "find a room with space, else make one" scan, which is where a singleton world silently shards into per-player copies. - `statusAfterLeave` (function): function statusAfterLeave(remainingMemberCount: number, currentStatus: GameServerStatus): GameServerStatus — Status after a leave: empty rooms reopen; non-empty rooms keep their current status. -- `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true } | { ok: false; reason: string } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. +- `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number, topology: "rooms" | "shared" = "rooms"): { ok: true } | { ok: false; reason: string } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. - `withJoinedMember` (function): function withJoinedMember(memberUserIds: readonly string[], userId: string): string[] — Roster after a successful join: unchanged when already a member, else appended. - `withoutMember` (function): function withoutMember(memberUserIds: readonly string[], userId: string): string[] — Roster after a leave: `userId` removed; order of remaining members preserved. @@ -409,6 +416,12 @@ - `asyncMemoryWorldStore` (function): function asyncMemoryWorldStore(seed?: HostedWorldRecord): HostedWorldStore — Async in-process store for callers exercising the production persistence contract. - `createHostedWorldSessionAsync` (function): function createHostedWorldSessionAsync(options: Omit, "store"> & { store: HostedWorldStore }): Promise — Build a hosted session from an asynchronous persistence backend. +## @jgengine/core/runtime/hostedWorldStore + +- `HostedWorldRecord` (interface): interface HostedWorldRecord — One hosted world's persisted authoritative state — the unit a {@link HostedWorldStore} loads and saves. +- `HostedWorldStore` (interface): interface HostedWorldStore — Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`. Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and saves on a cadence; a stateless host reconstructs from `load()` each invocation. +- `SyncHostedWorldStore` (interface): interface SyncHostedWorldStore — Synchronous store adapter retained for deterministic in-process tests. + ## @jgengine/core/runtime/inputRecorder - `InputRecorder` (interface): interface InputRecorder — Tick-indexed input log: record per tick, look up the frame in force at any tick. @@ -508,11 +521,11 @@ - `GameRuntimeSnapshot` (type): type GameRuntimeSnapshot = { version: number; gameId: string; serverId: string; server: RuntimeServerRow; players: Record; chunks: Record; revision: number; dirty: { server: boolean; players: string[]; chunks: string[]; }; } — ⚠ undocumented - `RUNTIME_SNAPSHOT_VERSION` (const): const RUNTIME_SNAPSHOT_VERSION: 1 — ⚠ undocumented -- `RuntimeChunkRow` (type): type RuntimeChunkRow = { chunkKey: string; objects: RuntimeObjectRow[]; entities: RuntimeEntityRow[]; flags?: Record; } — ⚠ undocumented +- `RuntimeChunkRow` (type): type RuntimeChunkRow = { /** Cell key to owner id, persisted with its spatial chunk. */ territory?: Record; territoryReceipts?: Record; chunkKey: string; objects: RuntimeObjectRow[]; entities: RuntimeEntityRow[]; flags?: Record; targetInstanceId?: string | null; userId?: string; } — ⚠ undocumented - `RuntimeInventorySlot` (type): type RuntimeInventorySlot = { item: string; count: number; slot?: number; } — ⚠ undocumented - `RuntimeObjectRow` (type): type RuntimeObjectRow = { instanceId: string; catalogId: string; position: [number, number, number]; rotationY?: number; parentSpace?: string; /** Opaque per-instance state; the persisted name for `SceneObject.state`. */ flags?: Record; /** Container contents for a catalog `slotInve… — The persisted form of a placed `SceneObject`: identity, placement, per-instance state, and slot contents. Convert with `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`runtime/objectRows`). -- `RuntimePlayerRow` (type): type RuntimePlayerRow = { userId: string; inventories: Record; economy: Record; unlocks: string[]; quests?: unknown; social?: unknown; leaderboard?: Record; session?: Record; } — ⚠ undocumented +- `RuntimePlayerRow` (type): type RuntimePlayerRow = { territoryOwnedCount?: number; ownedTerritoryChunkKeys?: string[]; userId: string; inventories: Record; economy: Record; unlocks: string[]; quests?: unknown; social?: unknown; leaderboard?: Record; session?: Rec… — ⚠ undocumented - `RuntimeProfileRow` (type): type RuntimeProfileRow = { userId: string; gameId: string; player: RuntimePlayerRow; updatedAt: number; } — ⚠ undocumented - `RuntimeServerRow` (type): type RuntimeServerRow = { entities: RuntimeEntityRow[]; objects: RuntimeObjectRow[]; session: Record; feeds?: Record; } — ⚠ undocumented @@ -531,7 +544,8 @@ - `GameRuntimeFeeds` (type): type GameRuntimeFeeds = { subscribeServer: ( serverId: string, onChange: (view: GameRuntimeServerView | null) => void, ) => FeedUnsubscribe; subscribePlayer: ( args: { serverId: string }, onChange: (view: GameRuntimePlayerView | null) => void, ) => FeedUnsubscribe; subscribeFeed: ( args: { serverId:… — ⚠ undocumented - `GameRuntimePlayerView` (type): type GameRuntimePlayerView = { userId: string; gameId: string; playerState: unknown; updatedAt: number; } — ⚠ undocumented - `GameRuntimeServerView` (type): type GameRuntimeServerView = { serverId: string; gameId: string; revision: number; memberUserIds: string[]; serverState: unknown | WorldSyncFrame; updatedAt: number; } — ⚠ undocumented -- `GameRuntimeTransport` (type): type GameRuntimeTransport = { joinServer: (args: { gameId: string; serverId?: string; role?: MultiplayerRole }) => Promise; leaveServer: (args: { serverId: string }) => Promise; runCommand: (args: RunCommandArgs) => Promise; } — ⚠ undocumented +- `GameRuntimeTransport` (type): type GameRuntimeTransport = { joinServer: (args: { gameId: string; serverId?: string; role?: MultiplayerRole }) => Promise; leaveServer: (args: { serverId: string }) => Promise; runCommand: (args: RunCommandArgs) => Promise; } — ⚠ undocumented +- `JoinServerOutcome` (type): type JoinServerOutcome = | (JoinServerResult & { ok: true }) | { ok: false; reason: "full" | "closed" | "unauthorized" } — Join failures are returned to the caller so the triggering surface can offer retry. - `JoinServerResult` (type): type JoinServerResult = { serverId: string; isNew: boolean; resumeTicket?: ResumeTicket; } — ⚠ undocumented - `LiveGameBackend` (type): type LiveGameBackend = GameBackend & { presenceSync: PresenceSync; pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise; chatSyncFor… — ⚠ undocumented - `MultiplayerRole` (type): type MultiplayerRole = "player" | "spectator" — Connection role used for authoritative world access; spectators are read-only. @@ -570,6 +584,7 @@ - `chunkCoordAt` (function): function chunkCoordAt(position: readonly [number, number, number], size: number = DEFAULT_CHUNK_SIZE): ChunkCoord — Chunk coordinates containing a world position at `size` units per cell. - `chunkKeyAt` (function): function chunkKeyAt(position: readonly [number, number, number], size: number = DEFAULT_CHUNK_SIZE): string — Chunk key containing a world position at `size` units per cell. - `chunkKeyOf` (function): function chunkKeyOf(coord: ChunkCoord): string — Chunk key for a cell — the canonical `"cx,cz"` string a `jgWorldChunks` row is stored under. Games used to invent this mapping each time, which made two systems in one game disagree about which row a position belongs to. +- `chunkKeysAround` (function): function chunkKeysAround(position: readonly [number, number, number], rings = 1, size = DEFAULT_CHUNK_SIZE): string[] — Square chunk neighborhood around a world position, including its own chunk. - `chunkKeysInRadius` (function): function chunkKeysInRadius(center: readonly [number, number, number], radius: number, size: number = DEFAULT_CHUNK_SIZE): string[] — Every chunk key whose cell intersects a radius around a world position — the key list a viewer's bounded hydration or client chunk query asks for, instead of loading a whole world. - `createEmptyChunkRow` (function): function createEmptyChunkRow(chunkKey: string): RuntimeChunkRow — An empty chunk row for `chunkKey` — the starting value for a cell nothing has been placed in yet. - `deleteChunk` (function): function deleteChunk(snapshot: GameRuntimeSnapshot, chunkKey: string): GameRuntimeSnapshot — Drop a chunk from a snapshot and mark the key dirty so the host deletes its stored row. diff --git a/.claude/skills/jgengine/capabilities.md b/.claude/skills/jgengine/capabilities.md index 881e31f20..1c1b32b24 100644 --- a/.claude/skills/jgengine/capabilities.md +++ b/.claude/skills/jgengine/capabilities.md @@ -20,6 +20,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `GameContextModels` (interface) · `import { GameContextModels } from "@jgengine/core/runtime/gameContext"` +## command-quantity — validate bounded whole-item counts at command boundaries + +- `readQuantity` (function) · `import { readQuantity } from "@jgengine/core/runtime/commandInput"` + ## default-walk-codes — stock WASD + jump key codes for the shell walk controller - `DEFAULT_WALK_CODES` (const) · `import { DEFAULT_WALK_CODES } from "@jgengine/shell/gameKit"` @@ -138,6 +142,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `resetTextureErrors` (function) · `import { resetTextureErrors } from "@jgengine/core/devtools/textureErrors"` - `textureErrorsSnapshot` (function) · `import { textureErrorsSnapshot } from "@jgengine/core/devtools/textureErrors"` +## runtime — snapshot interpolation + +- `createSnapshotBuffer` (function) · `import { createSnapshotBuffer } from "@jgengine/core/runtime/snapshotBuffer"` + ## runtime-save — save/load the whole game world through a pluggable backend, autosave or save points - `createRuntimeSave` (function) · `import { createRuntimeSave } from "@jgengine/core/runtime/runtimeSave"` diff --git a/.claude/skills/workflow/SKILL.md b/.claude/skills/workflow/SKILL.md index 881d15902..3b7a1b348 100644 --- a/.claude/skills/workflow/SKILL.md +++ b/.claude/skills/workflow/SKILL.md @@ -52,6 +52,6 @@ Report to the user in a few lines: what shipped, what is still open, the PR link Enable auto-merge only in the `Noisemaker111` repo — the user never merges by hand there. For any other owner/repo, park the PR unmerged and never enable auto-merge. Never bump a version or publish an npm release to force release unless the user explicitly asks; the user owns release and publish timing. CI failure feedback is fixed on the same branch and pushed to the same PR (auto-merge stays armed and lands the fixed run). -When the user does ask for a release, it is one command: `bun run release` (`--dry-run` to preview) bumps every package, cuts `## [Unreleased]` into the new version section with the lockstep Migrate bullet, mirrors the notes into the typed `CHANGELOG` export, and regenerates `api.md`. Do not hand-assemble those edits or narrate them — run it, skim the diff, commit as `Release `, push, open the PR. +When the user does ask for a release, it is one command: `bun run release` (`--patch` for an explicitly requested patch; `--dry-run` to preview) bumps every package, cuts `## [Unreleased]` into the new version section with the lockstep Migrate bullet, mirrors the notes into the typed `CHANGELOG` export, and regenerates `api.md`. Do not hand-assemble those edits or narrate them — run it, skim the diff, commit as `Release `, push, open the PR. Restarting a branch whose PR already squash-merged (its remote branch auto-deleted): run `git fetch --prune` before pushing again. Without it, `git push --force-with-lease` rejects with `stale info` and the branch has no remote ref to compare against — start the follow-up from a fresh branch off current `origin/main` rather than the parked one. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 05a2513ef..61cb349c1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,7 +38,7 @@ jobs: restore-keys: bun-${{ runner.os }}- - run: bun install --frozen-lockfile - name: Stage the lockstep changelog into each package - run: for p in core ws sql react convex node shell editor assets github jgengine; do cp CHANGELOG.md "packages/$p/CHANGELOG.md"; done + run: for p in core rapier ws sql react convex node shell editor assets github jgengine; do cp CHANGELOG.md "packages/$p/CHANGELOG.md"; done - name: Stage per-package skills run: bun run stage-skills - run: bun run check-artifacts @@ -49,7 +49,7 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - for p in core ws sql react convex node shell editor assets github jgengine; do + for p in core rapier ws sql react convex node shell editor assets github jgengine; do name=$(node -p "require('./packages/$p/package.json').name") version=$(node -p "require('./packages/$p/package.json').version") case "$version" in diff --git a/CHANGELOG.md b/CHANGELOG.md index 726758acc..141b71eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,32 @@ between (`--json` for structured output). ## [Unreleased] + + +## 0.18.1 + +### Migrate + +- **Bump lockstep SDK packages to `^0.18.1`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.1`; `@jgengine/github` is `0.5.1`. +- Declare `topology: "shared"` on runtime definitions. Shared Convex hosts migrate the bounded legacy roster into indexed membership and per-player session rows; use `getServerCapacity` for stable lobby subscriptions. +- Put every command's read scope on its definition. Omitted scopes now load the actor and no chunks; explicitly request whole-world reads only where needed. Trusted host mutations can compose writes through `helpers.runCommand`. +- Handle typed join outcomes (`ok` and `reason`) or use `useServerSession` and `JoinGate`. Pass runtimes to `jgengineCronSpecs`; tick registration requires an actual tick hook. +- Declare currency precision with `decimals` and remove game-side cent conversions. Public amounts stay in major units and wallet writes round through integer minor units. +- Replace custom presence, chat, territory and tick batching with `createWorldPresenceStore`, chat helpers, chunk territory, and `forEachOnlinePlayer`. Legacy claim backfills must finish before removing their old schema. + +### Added + +- Indexed shared membership and capacity rows, bounded neighborhood poses with 100 ms server/client gates, durable rate limits, authenticated diagnostics and bounded retention. +- Territory footprint planning and atomic placement claims, chat validation and headless chat/join/territory surfaces, elapsed accrual, safe quantities, and player-profile reset through the initializer. +- An explicit `--patch` release option. + ### Added - Added smooth interpolation for remote presence player poses. @@ -100,15 +126,6 @@ between (`--json` for structured output). - **`TerrainDetailConfig.sweeps`** — the detail shader's two macro colour sweeps (sun-dried patches, cooler wet pockets) take per-world RGB multipliers instead of hardcoded meadow tints. The old green-leaning "lush pocket" default painted moss onto arid/ashen biomes; defaults are unchanged, so a desert world should set warm/neutral `sweeps` explicitly. - **`@jgengine/rapier`** — a Rapier-backed `PhysicsBackend` adapter with native capsules, shapecasts, rotation, CCD, joints, and collision queries. - - ## 0.18.0 ### Migrate diff --git a/packages/assets/package.json b/packages/assets/package.json index 94b31499e..78e78df31 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/assets", - "version": "0.18.0", + "version": "0.18.1", "description": "Self-generating, license-verified index of thousands of CC0 3D models for JGengine. Sources fetch from providers' own CDNs at pull time; the npm tarball ships only the typed index and CLI (no GLB bytes).", "license": "Apache-2.0", "type": "module", @@ -35,7 +35,7 @@ "test": "bun test src" }, "dependencies": { - "@jgengine/core": "^0.18.0", + "@jgengine/core": "^0.18.1", "fflate": "^0.8.2" }, "devDependencies": { diff --git a/packages/convex/package.json b/packages/convex/package.json index 40f8d3bd1..302cfb985 100644 --- a/packages/convex/package.json +++ b/packages/convex/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/convex", - "version": "0.18.0", + "version": "0.18.1", "description": "Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.", "license": "Apache-2.0", "type": "module", @@ -30,7 +30,7 @@ "check-types": "bun ../../scripts/check-types-preflight.ts && tsgo --noEmit -p tsconfig.json" }, "dependencies": { - "@jgengine/core": "^0.18.0" + "@jgengine/core": "^0.18.1" }, "peerDependencies": { "convex": "^1.31.2", diff --git a/packages/convex/src/convexPresenceTransport.ts b/packages/convex/src/convexPresenceTransport.ts index 307310717..29238236d 100644 --- a/packages/convex/src/convexPresenceTransport.ts +++ b/packages/convex/src/convexPresenceTransport.ts @@ -7,6 +7,7 @@ import type { PresenceActions, PresenceFeeds, PresenceSession, + PresenceResidentRow, PresenceTransport, } from "@jgengine/core/multiplayer/presenceContract"; @@ -24,13 +25,6 @@ export interface ConvexPresenceFunctions { tick: FunctionReference<"mutation">; } -interface RawPresenceActor { - actorExternalId: string; -} - -interface RawResidentActor extends RawPresenceActor { - ownerActorId?: string; -} /** * Wires a game's Convex presence functions into the engine's PresenceTransport @@ -42,7 +36,7 @@ interface RawResidentActor extends RawPresenceActor { * row type (e.g. branding positions into its coordinate space). */ export function createConvexPresenceTransport< - TRawRow extends RawPresenceActor, + TRawRow extends PresenceResidentRow, TRow, TLocation, TGameId extends string = string, @@ -53,10 +47,10 @@ export function createConvexPresenceTransport< function useFeeds(session: PresenceSession | "skip"): PresenceFeeds { const snapshotRaw = useQuery( functions.snapshot, - session === "skip" ? "skip" : { homeGameId: session.homeGameId, externalId: session.externalId }, + session === "skip" ? "skip" : { ...session }, ) as { myLocation: TLocation | null; online: TRawRow[] } | undefined; - const residentsRaw = useQuery(functions.cityResidents, {}) as - | (TRawRow & RawResidentActor)[] + const residentsRaw = useQuery(functions.cityResidents, session === "skip" ? "skip" : { ...(session.viewerChunkKey === undefined ? {} : { viewerChunkKey: session.viewerChunkKey }) }) as + | TRawRow[] | undefined; const onlineRaw = snapshotRaw?.online; diff --git a/packages/convex/src/createConvexGameTransport.ts b/packages/convex/src/createConvexGameTransport.ts index a1f86ccec..a843760b9 100644 --- a/packages/convex/src/createConvexGameTransport.ts +++ b/packages/convex/src/createConvexGameTransport.ts @@ -3,6 +3,7 @@ import { anyApi } from "convex/server"; import type { DefaultFunctionArgs, FunctionReference } from "convex/server"; import type { GameRuntimeFeeds, + JoinServerOutcome, GameRuntimePlayerView, GameRuntimeServerView, GameRuntimeTransport, @@ -12,8 +13,12 @@ import type { import type { ChatMessage } from "@jgengine/core/game/chat"; import type { ChatSendOutcome, ChatSync } from "@jgengine/core/multiplayer/chatContract"; import { createPoseSyncGate, type PoseSyncTuning } from "@jgengine/core/multiplayer/poseSyncGate"; +import { chunkKeyAt } from "@jgengine/core/runtime/worldChunks"; import type { LeaderboardScope } from "@jgengine/core/game/leaderboard"; +/** Structural client seam avoids requiring the engine and game to share a class instance type. */ +export type ConvexGameClient = Pick; + export type ConvexGameTransportConfig = { gameId: string; userId?: string; @@ -32,7 +37,7 @@ export type ConvexGameApi = { joinCode?: string; externalId?: string; }, - { serverId: string; isNew: boolean } + JoinServerOutcome >; leaveServer: FunctionReference<"mutation", "public", { serverId: string; externalId?: string }, null>; runCommand: FunctionReference< @@ -60,7 +65,7 @@ export type ConvexGameApi = { list: FunctionReference< "query", "public", - { serverId: string; externalId?: string }, + { serverId: string; externalId?: string; viewerChunkKey?: string }, PresencePoseRow[] >; sync: FunctionReference< @@ -122,10 +127,18 @@ export function defaultConvexGameApi(): ConvexGameApi { } export function createConvexGameTransport( - client: ConvexReactClient, - api: ConvexGameApi, + client: ConvexGameClient, + api: { runtime: Pick }, config: ConvexGameTransportConfig, ): GameRuntimeTransport { + const joinedServers = new Set(); + const onPageHide = () => { + for (const serverId of joinedServers) { + void client.mutation(api.runtime.leaveServer, { serverId, externalId: config.userId }).catch(() => undefined); + } + joinedServers.clear(); + globalThis.removeEventListener?.("pagehide", onPageHide); + }; return { async joinServer(args) { const result = await client.mutation(api.runtime.joinServer, { @@ -133,11 +146,17 @@ export function createConvexGameTransport( serverId: args.serverId, externalId: config.userId, }); + if (result.ok) { + joinedServers.add(result.serverId); + globalThis.addEventListener?.("pagehide", onPageHide); + } return result; }, async leaveServer(args) { await client.mutation(api.runtime.leaveServer, { ...args, externalId: config.userId }); + joinedServers.delete(args.serverId); + if (joinedServers.size === 0) globalThis.removeEventListener?.("pagehide", onPageHide); }, async runCommand(args) { @@ -163,7 +182,7 @@ type PlayerProfileQueryResult = { } | null; export function watchConvexQuery( - client: ConvexReactClient, + client: ConvexGameClient, query: FunctionReference<"query", "public", TArgs, TResult>, args: TArgs, toView: (result: TResult) => TView, @@ -180,7 +199,7 @@ export function watchConvexQuery>(); + const viewerChunks = new Map(); + const refreshers = new Map void>>(); const lastSentAt = new Map(); return { subscribe(serverId, onChange) { - return watchConvexQuery( - client, - api.presence.list, - { serverId, externalId: config.userId }, - (rows) => rows, - onChange, - ); + let unsubscribe: (() => void) | undefined; + const refresh = () => { + unsubscribe?.(); + const viewerChunkKey = viewerChunks.get(serverId); + unsubscribe = watchConvexQuery( + client, api.presence.list, + { serverId, externalId: config.userId, ...(viewerChunkKey === undefined ? {} : { viewerChunkKey }) }, + (rows) => rows, onChange, + ); + }; + let listeners = refreshers.get(serverId); + if (listeners === undefined) { + listeners = new Set(); + refreshers.set(serverId, listeners); + } + listeners.add(refresh); + refresh(); + return () => { + unsubscribe?.(); + listeners.delete(refresh); + if (listeners.size === 0) { + refreshers.delete(serverId); + viewerChunks.delete(serverId); + gates.delete(serverId); + lastSentAt.delete(serverId); + } + }; }, syncPose(serverId, pose) { const now = Date.now(); + const viewerChunkKey = chunkKeyAt([pose.x, pose.y, pose.z]); + if (viewerChunks.get(serverId) !== viewerChunkKey) { + viewerChunks.set(serverId, viewerChunkKey); + for (const refresh of refreshers.get(serverId) ?? []) refresh(); + } + let gate = gates.get(serverId); + if (gate === undefined) { + gate = createPoseSyncGate(tuning ?? DEFAULT_CONVEX_POSE_TUNING); + gates.set(serverId, gate); + } const moved = gate.evaluate(pose, now); // The gate drops an unchanged pose, so a standing player would otherwise send nothing at all // and the reaper would eventually collect a live session. Keep the row alive without a write. @@ -296,7 +347,7 @@ export function createConvexPresenceSync( } export function createConvexChatSync( - client: ConvexReactClient, + client: ConvexGameClient, api: ConvexGameApi, config: ConvexGameTransportConfig, serverId: string, @@ -325,7 +376,7 @@ export function createConvexChatSync( } export function createConvexFeedWrites( - client: ConvexReactClient, + client: ConvexGameClient, api: ConvexGameApi, config: ConvexGameTransportConfig, ) { diff --git a/packages/convex/src/hostUtilities.test.ts b/packages/convex/src/hostUtilities.test.ts new file mode 100644 index 000000000..b01718238 --- /dev/null +++ b/packages/convex/src/hostUtilities.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { createChatFunctions, createClientErrorFunctions, forEachOnlinePlayer, rateLimit, type JGMutationCtx } from "./server"; +import { handlerOf, makeDb, serverDoc } from "./testFixtures"; +function context(userId: string | null = "alice") { + const fixture = makeDb(); + const scheduled: { fn: unknown; args: any }[] = []; + const ctx = { db: fixture.db, auth: { getUserIdentity: async () => userId === null ? null : { subject: userId } }, scheduler: { runAfter: async (_delay: number, fn: unknown, args: any) => { scheduled.push({ fn, args }); } } } as unknown as JGMutationCtx; + return { ...fixture, ctx, scheduled }; +} +test("rate limit isolates keys and resets exactly at expiry", async () => { + const f = context(), policy = { key: "alice", windowMs: 100, max: 2 }; + expect((await rateLimit(f.ctx, { ...policy, nowMs: 1000 })).ok).toBe(true); + expect((await rateLimit(f.ctx, { ...policy, nowMs: 1050 })).ok).toBe(true); + expect(await rateLimit(f.ctx, { ...policy, nowMs: 1099 })).toEqual({ ok: false, retryAfterMs: 1 }); + expect((await rateLimit(f.ctx, { ...policy, key: "bob", nowMs: 1099 })).ok).toBe(true); + expect((await rateLimit(f.ctx, { ...policy, nowMs: 1100 })).ok).toBe(true); + expect(f.rows("jgRateLimits").find(row => row.key === "alice")?.count).toBe(1); +}); +test("rate limits reject nonfinite arithmetic and malformed policies", async () => { + const f = context(); + for (const override of [{ nowMs: NaN }, { windowMs: Infinity }, { max: 1.5 }, { key: "" }, { nowMs: Number.MAX_VALUE, windowMs: Number.MAX_VALUE }]) { + await expect(rateLimit(f.ctx, { key: "a", windowMs: 100, max: 1, ...override })).rejects.toThrow(); + } + expect(f.rows("jgRateLimits")).toHaveLength(0); +}); +test("online batches skip stale/revoked rows and deduplicate sessions across pages", async () => { + const f = context(); + const seed = (id: string, userId: string, updatedAt: number, revokedAt?: number) => f.seed("jgPoses", { _id: id, _creationTime: updatedAt, serverId: "world", userId, updatedAt, revokedAt }); + seed("stale", "stale", 0); seed("a1", "alice", 900); seed("bob", "bob", 910); seed("a2", "alice", 920); seed("revoked", "carol", 930, 940); + const options = { batchSize: 2, nowMs: 1000, freshWindowMs: 200, handler: "handler" as never, continuation: "next" as never }; + expect(await forEachOnlinePlayer(f.ctx, options)).toEqual({ scheduled: 1, remaining: true }); + expect(f.scheduled[0]?.args.players).toEqual([{ serverId: "world", userId: "bob" }]); + expect(f.scheduled[1]?.args).toEqual({ cursor: "2", nowMs: 1000 }); + expect(await forEachOnlinePlayer(f.ctx, { ...options, cursor: "2" })).toEqual({ scheduled: 1, remaining: false }); + expect(f.scheduled[2]?.args.players).toEqual([{ serverId: "world", userId: "alice" }]); + expect(f.reads.has("stale")).toBe(false); +}); +test("empty online population schedules nothing and invalid windows fail", async () => { + const f = context(), options = { handler: "h" as never, continuation: "c" as never, nowMs: 0 }; + expect(await forEachOnlinePlayer(f.ctx, options)).toEqual({ scheduled: 0, remaining: false }); + expect(f.scheduled).toEqual([]); + await expect(forEachOnlinePlayer(f.ctx, { ...options, freshWindowMs: NaN })).rejects.toThrow(); + await expect(forEachOnlinePlayer(f.ctx, { ...options, batchSize: 0 })).rejects.toThrow(); +}); +test("diagnostics require identity, enforce UTF8 byte cap and author cooldown", async () => { + const api = createClientErrorFunctions(), submit = handlerOf(api.reportClientError), anonymous = context(null); + expect(await submit(anonymous.ctx, { message: "error" })).toEqual({ ok: false, reason: "unauthorized" }); + expect(anonymous.rows("jgClientErrors")).toEqual([]); + const f = context(); + expect(await submit(f.ctx, { message: "\u{1f600}".repeat(1025) })).toEqual({ ok: false, reason: "too_long" }); + expect(await submit(f.ctx, { message: "\u{1f600}".repeat(1020) })).toEqual({ ok: true }); + expect(await submit(f.ctx, { message: "again" })).toEqual({ ok: false, reason: "rate_limited" }); + expect(f.rows("jgClientErrors")).toHaveLength(1); +}); +test("retention sweeps are indexed and bounded", async () => { + const f = context(), api = createClientErrorFunctions(), now = Date.now(); + for (let i = 0; i < 260; i++) f.seed("jgClientErrors", { _id: `old:${i}`, _creationTime: i, createdAt: 0 }); + f.seed("jgClientErrors", { _id: "fresh", _creationTime: now, createdAt: now }); + expect(await handlerOf(api.pruneClientErrors)(f.ctx, {})).toEqual({ deleted: 256, remaining: true }); + expect(f.reads.has("fresh")).toBe(false); + expect(await handlerOf(api.pruneClientErrors)(f.ctx, {})).toEqual({ deleted: 4, remaining: false }); + f.seed("jgRateLimits", { _id: "expired", _creationTime: 0, key: "old", count: 1, startedAt: 0, expiresAt: 0 }); + f.seed("jgRateLimits", { _id: "active", _creationTime: now, key: "new", count: 1, startedAt: now, expiresAt: now + 100000 }); + expect(await handlerOf(api.pruneRateLimits)(f.ctx, {})).toEqual({ deleted: 1, remaining: false }); + expect(f.reads.has("active")).toBe(false); +}); +test("chat checks membership, sanitizes text, limits across channels and hides other channels", async () => { + const f = context(), api = createChatFunctions(), send = handlerOf(api.sendMessage); + f.seed("jgGameServers", serverDoc({ _id: "world", memberUserIds: ["alice"] })); + expect(await send(f.ctx, { serverId: "world", channelId: "all", externalId: "bob", body: "hello" })).toEqual({ ok: false, reason: "not signed in" }); + expect(await send(f.ctx, { serverId: "missing", channelId: "all", body: "hello" })).toEqual({ ok: false, reason: "not a member of this server" }); + expect(await send(f.ctx, { serverId: "world", channelId: "all", body: "hello\u0000world" })).toEqual({ ok: true }); + expect(f.rows("jgChatMessages")[0]?.body).toBe("helloworld"); + expect(await send(f.ctx, { serverId: "world", channelId: "other", body: "again" })).toEqual({ ok: false, reason: "sending too fast" }); + expect(await handlerOf(api.messages)(f.ctx, { serverId: "world", channelId: "other" })).toEqual([]); + expect((await handlerOf(api.messages)(f.ctx, { serverId: "world", channelId: "all" }) as unknown[])).toHaveLength(1); +}); +test("chat options reject nonfinite retention and timing", () => { + expect(() => createChatFunctions({ historyLimit: NaN })).toThrow(); + expect(() => createChatFunctions({ minIntervalMs: Infinity })).toThrow(); + expect(() => createChatFunctions({ maxBodyLength: 0 })).toThrow(); +}); + +test("diagnostic rejection does not consume the author's allowance and exact byte boundary is accepted", async () => { + const f = context(), submit = handlerOf(createClientErrorFunctions().reportClientError); + expect(await submit(f.ctx, { message: "x".repeat(4097) })).toEqual({ ok: false, reason: "too_long" }); + expect(f.rows("jgRateLimits")).toHaveLength(0); + expect(await submit(f.ctx, { message: "x".repeat(4096 - JSON.stringify({ message: "" }).length) })).toEqual({ ok: true }); + expect(f.rows("jgClientErrors")).toHaveLength(1); + const other = { ...f.ctx, auth: { getUserIdentity: async () => ({ subject: "bob" }) } }; + expect(await submit(other, { message: "same instant, different user" })).toEqual({ ok: true }); +}); + +test("home game batching delivers one lab even when different bot actors span pages", async () => { + const f = context(); + for (let i = 0; i < 3; i++) f.seed("jgPoses", { _id: `bot${i}`, _creationTime: i, serverId: "world", userId: `bot${i}`, homeGameId: "lab", updatedAt: 900 + i }); + const options = { batchSize: 1, nowMs: 1000, handler: "h" as never, continuation: "c" as never, groupBy: "homeGameId" as const }; + await forEachOnlinePlayer(f.ctx, options); + await forEachOnlinePlayer(f.ctx, { ...options, cursor: "1" }); + await forEachOnlinePlayer(f.ctx, { ...options, cursor: "2" }); + expect(f.scheduled.filter(entry => entry.fn === "h")).toHaveLength(1); +}); +test("diagnostics cap serialized bytes including JSON escaping", async () => { + const f = context(); + expect(await handlerOf(createClientErrorFunctions().reportClientError)(f.ctx, { message: "\u0000".repeat(1000) })).toEqual({ ok: false, reason: "too_long" }); + expect(f.rows("jgClientErrors")).toHaveLength(0); +}); + +test("chat rejects unbounded channel metadata without using the rate window", async () => { + const f = context(), send = handlerOf(createChatFunctions().sendMessage); + f.seed("jgGameServers", serverDoc({ _id: "world", memberUserIds: ["alice"] })); + expect(await send(f.ctx, { serverId: "world", channelId: "x".repeat(257), body: "hello" })).toEqual({ ok: false, reason: "invalid_channel_or_author" }); + expect(f.rows("jgRateLimits")).toHaveLength(0); +}); + +test("chat history orders migrated messages by event time rather than insertion time", async () => { + const f = context(); + f.seed("jgGameServers", serverDoc({ _id: "world", memberUserIds: ["alice"] })); + f.seed("jgChatMessages", { _id: "new", _creationTime: 1, serverId: "world", channelId: "all", userId: "alice", body: "new", at: 200 }); + f.seed("jgChatMessages", { _id: "imported-old", _creationTime: 2, serverId: "world", channelId: "all", userId: "alice", body: "old", at: 100 }); + const rows = await handlerOf(createChatFunctions().messages)(f.ctx, { serverId: "world", channelId: "all" }) as { body: string }[]; + expect(rows.map(row => row.body)).toEqual(["old", "new"]); +}); diff --git a/packages/convex/src/hostedServer.test.ts b/packages/convex/src/hostedServer.test.ts index 1bba5a520..af287c58b 100644 --- a/packages/convex/src/hostedServer.test.ts +++ b/packages/convex/src/hostedServer.test.ts @@ -61,17 +61,17 @@ function heroX(store: HostedWorldStore, userId: string): number | undefined { } describe("invokeHostedWorld", () => { - test("state accumulates across fresh reconstructions sharing one store", () => { + test("state accumulates across fresh reconstructions sharing one store", async () => { const g = game(); const store = memoryWorldStore(); - const join = invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); + const join = await invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); expect(join.changed).toBe(true); expect(join.revision).toBe(1); expect(join.members).toEqual(["alice"]); expect(heroX(store, "alice")).toBeCloseTo(0); - const tick1 = invokeHostedWorld({ + const tick1 = await invokeHostedWorld({ game: g, store, members: join.members, @@ -81,7 +81,7 @@ describe("invokeHostedWorld", () => { expect(tick1.revision).toBe(2); expect(heroX(store, "alice")).toBeCloseTo(1); - const command = invokeHostedWorld({ + const command = await invokeHostedWorld({ game: g, store, members: join.members, @@ -92,7 +92,7 @@ describe("invokeHostedWorld", () => { expect(store.load()?.snapshot["store"]).toContainEqual(["bumped", 3]); const before = store.load(); - const tick2 = invokeHostedWorld({ + const tick2 = await invokeHostedWorld({ game: g, store, members: join.members, @@ -108,12 +108,12 @@ describe("invokeHostedWorld", () => { expect(diff.entities.some((e) => e.id === "alice")).toBe(true); }); - test("held inputs replay onto a reconstructed session before the op", () => { + test("held inputs replay onto a reconstructed session before the op", async () => { const g = game(); const store = memoryWorldStore(); - const join = invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); + const join = await invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); - const tick = invokeHostedWorld({ + const tick = await invokeHostedWorld({ game: g, store, members: join.members, @@ -124,13 +124,13 @@ describe("invokeHostedWorld", () => { expect(heroX(store, "alice")).toBeCloseTo(10); }); - test("an unchanged invocation neither bumps the revision nor saves", () => { + test("an unchanged invocation neither bumps the revision nor saves", async () => { const g = game(); const store = memoryWorldStore(); - const join = invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); + const join = await invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); const savedBefore = store.load(); - const tick = invokeHostedWorld({ + const tick = await invokeHostedWorld({ game: g, store, members: join.members, @@ -141,13 +141,13 @@ describe("invokeHostedWorld", () => { expect(store.load()).toBe(savedBefore); }); - test("a reconstructed leave fires onPlayerLeave and despawns the member", () => { + test("a reconstructed leave fires onPlayerLeave and despawns the member", async () => { const g = game(); const store = memoryWorldStore(); - const join = invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); - invokeHostedWorld({ game: g, store, members: join.members, op: (s) => s.tick(1) }); + const join = await invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); + await invokeHostedWorld({ game: g, store, members: join.members, op: (s) => s.tick(1) }); - const leave = invokeHostedWorld({ + const leave = await invokeHostedWorld({ game: g, store, members: join.members, @@ -158,13 +158,13 @@ describe("invokeHostedWorld", () => { expect(heroX(store, "alice")).toBeUndefined(); }); - test("rejected commands leave the store untouched", () => { + test("rejected commands leave the store untouched", async () => { const g = game(); const store = memoryWorldStore(); - const join = invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); + const join = await invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); const savedBefore = store.load(); - const unknown = invokeHostedWorld({ + const unknown = await invokeHostedWorld({ game: g, store, members: join.members, @@ -175,13 +175,13 @@ describe("invokeHostedWorld", () => { expect(store.load()).toBe(savedBefore); }); - test("a second player joins an already-reconstructed world without disturbing the first", () => { + test("a second player joins an already-reconstructed world without disturbing the first", async () => { const g = game(); const store = memoryWorldStore(); - const first = invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); - invokeHostedWorld({ game: g, store, members: first.members, op: (s) => s.tick(2) }); + const first = await invokeHostedWorld({ game: g, store, op: (s) => s.join("alice", true) }); + await invokeHostedWorld({ game: g, store, members: first.members, op: (s) => s.tick(2) }); - const second = invokeHostedWorld({ + const second = await invokeHostedWorld({ game: g, store, members: first.members, @@ -194,7 +194,7 @@ describe("invokeHostedWorld", () => { }); describe("createHostedGameServerFunctions", () => { - test("returns the full hosted function surface", () => { + test("returns the full hosted function surface", async () => { const functions = createHostedGameServerFunctions({ games: { demo: game() }, auth: "anonymous", diff --git a/packages/convex/src/presence.test.ts b/packages/convex/src/presence.test.ts index 66ae606a8..f808ffd2c 100644 --- a/packages/convex/src/presence.test.ts +++ b/packages/convex/src/presence.test.ts @@ -121,7 +121,7 @@ test("a second session revokes the first, which learns it was displaced", async displaced: boolean; }; expect(back.displaced).toBe(true); - expect(poseRows(rows).find((row) => row.sessionId === "tab-2")?.revokedAt).toBeNumber(); + expect(poseRows(rows).find((row) => row.sessionId === "tab-2")?.revokedAt).toBeUndefined(); }); test("leave revokes rather than deletes, so the row stays for the reaper", async () => { diff --git a/packages/convex/src/server.test.ts b/packages/convex/src/server.test.ts index dda748ad7..2cd627b35 100644 --- a/packages/convex/src/server.test.ts +++ b/packages/convex/src/server.test.ts @@ -454,7 +454,7 @@ test("singleton matchmaking refuses a join past capacity instead of sharding the await expect( handlerOf(fns.joinServer)(anonCtx(db), { gameId: "grant-demo", externalId: "bob" }), - ).rejects.toThrow("Server is full"); + ).resolves.toEqual({ ok: false, reason: "full" }); expect(rows("jgGameServers")).toHaveLength(1); }); @@ -804,9 +804,9 @@ test("runCommand refuses a write outside the scope it hydrated under", async () ); }); -test("a command declaring no scope still hydrates the whole world", async () => { +test("a command declaring no scope hydrates only the actor and no chunks", async () => { const { db, seed, reads } = makeDb(); - const { bob } = seedPlaceServer(seed); + const { bob, far } = seedPlaceServer(seed); seed( "jgPlayerProfiles", profileDoc({ userId: "alice", gameId: "place-demo", playerState: player("alice", 1) }), @@ -826,7 +826,8 @@ test("a command declaring no scope still hydrates the whole world", async () => externalId: "alice", }), ).toEqual({ ok: true }); - expect(reads.has(bob._id)).toBe(true); + expect(reads.has(bob._id)).toBe(false); + expect(reads.has(far._id)).toBe(false); }); test("joinServer without onNewPlayer hydrates only the joining member", async () => { @@ -878,3 +879,17 @@ test("leaveServer hydrates only the leaving member", async () => { expect(reads.has(bob._id)).toBe(false); expect(reads.has(far._id)).toBe(false); }); + +test("beforePersist cannot bypass hydrated command scope", async () => { + const { db, seed, rows } = makeDb(); + seedPlaceServer(seed); + const fns = createGameServerFunctions({ runtimes: [placeRuntime()], auth: "anonymous" }); + await expect(fns.helpers.runCommand(anonCtx(db) as JGMutationCtx, { + serverId: "srv:place", externalId: "alice", command: "world.place", input: { chunkKey: "0,0" }, + beforePersist: async (_ctx, loaded) => { + loaded.snapshot.chunks["9,9"] = chunkRow("9,9", "overwritten"); + loaded.snapshot.dirty.chunks.push("9,9"); + }, + })).rejects.toThrow("outside its declared scope"); + expect(rows("jgWorldChunks").find(row => row.chunkKey === "9,9")?.snapshot).toEqual(chunkRow("9,9", "kept")); +}); diff --git a/packages/convex/src/server.ts b/packages/convex/src/server.ts index 7e483f15a..ac8b9af06 100644 --- a/packages/convex/src/server.ts +++ b/packages/convex/src/server.ts @@ -7,6 +7,7 @@ import { } from "convex/server"; import type { Auth, + FunctionReference, DataModelFromSchemaDefinition, DocumentByName, GenericMutationCtx, @@ -16,7 +17,7 @@ import type { } from "convex/server"; import type { GenericId } from "convex/values"; import { ConvexError, v } from "convex/values"; -import type { ChatMessage } from "@jgengine/core/game/chat"; +import { validateChatMessage, type ChatMessage } from "@jgengine/core/game/chat"; import type { LeaderboardScope } from "@jgengine/core/game/leaderboard"; import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract"; import { @@ -37,7 +38,7 @@ import { import type { CommandDef, CommandScope } from "@jgengine/core/runtime/commandRunner"; import { commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner"; import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime"; -import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime"; +import { createGameRuntime, initialPlayerState } from "@jgengine/core/runtime/gameRuntime"; import type { GameServerRecord, LeaderboardIncrement, PlayerProfileRecord } from "@jgengine/core/runtime/hostPersistence"; import { buildHydratePlayers, @@ -60,7 +61,8 @@ import { } from "@jgengine/core/runtime/hostPolicy"; import type { SaveConfig } from "@jgengine/core/runtime/save"; import type { GameRuntimeSnapshot, RuntimeChunkRow, RuntimePlayerRow, RuntimeServerRow } from "@jgengine/core/runtime/snapshot"; -import { createEmptyServerRow, markAllPlayersDirty } from "@jgengine/core/runtime/snapshot"; +import { createEmptyServerRow } from "@jgengine/core/runtime/snapshot"; +import { chunkKeyAt, parseChunkKey, chunkKeysAround, DEFAULT_CHUNK_SIZE } from "@jgengine/core/runtime/worldChunks"; import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ"; /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */ @@ -90,6 +92,8 @@ export function jgengineTables() { modeConfig: v.optional(v.any()), visibility: v.optional(v.union(v.literal("public"), v.literal("private"))), joinCode: v.optional(v.string()), + topology: v.optional(v.union(v.literal("shared"), v.literal("rooms"))), + memberCount: v.optional(v.number()), memberUserIds: v.array(v.string()), slotsPerServer: v.number(), save: saveConfigValidator, @@ -106,10 +110,31 @@ export function jgengineTables() { .index("by_status", ["status"]) .index("by_game_status_tick", ["gameId", "status", "tickAnchorMs"]) .index("by_dirty", ["dirtyAt"]), + jgServerCapacity: defineTable({ + serverId: v.id("jgGameServers"), + gameId: v.string(), + status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")), + mode: v.optional(v.string()), + memberCount: v.number(), + slotsPerServer: v.number(), + }).index("by_server", ["serverId"]), + jgServerMembers: defineTable({ + serverId: v.id("jgGameServers"), + userId: v.string(), + joinedAt: v.number(), + }).index("by_server", ["serverId"]).index("by_server_and_user", ["serverId", "userId"]), + jgRateLimits: defineTable({ + key: v.string(), startedAt: v.number(), count: v.number(), expiresAt: v.number(), + }).index("by_key", ["key"]).index("by_expires", ["expiresAt"]), + jgClientErrors: defineTable({ + userId: v.string(), message: v.string(), details: v.optional(v.any()), createdAt: v.number(), + }).index("by_created", ["createdAt"]), jgPlayerProfiles: defineTable({ userId: v.string(), gameId: v.string(), playerState: v.any(), + sessionState: v.optional(v.any()), + sessionServerId: v.optional(v.id("jgGameServers")), revision: v.number(), dirtyAt: v.optional(v.number()), createdAt: v.number(), @@ -143,6 +168,10 @@ export function jgengineTables() { }).index("by_server_and_action", ["serverId", "action"]), jgPoses: defineTable({ serverId: v.string(), + homeGameId: v.optional(v.string()), + ownerActorId: v.optional(v.string()), + lastWorldSnapshotAt: v.optional(v.number()), + chunkKey: v.optional(v.string()), userId: v.string(), /** One row per live session, so a second tab is a second row rather than a fight over one. */ sessionId: v.optional(v.string()), @@ -161,14 +190,22 @@ export function jgengineTables() { }) .index("by_server", ["serverId"]) .index("by_server_and_user", ["serverId", "userId"]) - .index("by_updated", ["updatedAt"]), + .index("by_server_and_chunk", ["serverId", "chunkKey"]) + .index("by_server_user_revoked_updated", ["serverId", "userId", "revokedAt", "updatedAt"]) + .index("by_updated", ["updatedAt"]) + .index("by_user", ["userId"]) + .index("by_home_game", ["homeGameId"]) + .index("by_home_game_revoked_updated", ["homeGameId", "revokedAt", "updatedAt"]) + .index("by_revoked", ["revokedAt"]) + .index("by_revoked_updated", ["revokedAt", "updatedAt"]), jgChatMessages: defineTable({ serverId: v.string(), channelId: v.string(), userId: v.string(), body: v.string(), + authorName: v.optional(v.string()), at: v.number(), - }).index("by_server_channel", ["serverId", "channelId"]), + }).index("by_server_channel", ["serverId", "channelId", "at"]).index("by_at", ["at"]), }; } @@ -200,7 +237,8 @@ const schemaForTypes = defineSchema(jgengineTables()); /** Data model of the {@link jgengineTables} schema — the shape a host's own Convex ctx must satisfy to * call the exported persistence helpers. */ export type JGDataModel = DataModelFromSchemaDefinition; -type JGQueryCtx = GenericQueryCtx; +/** Read-only context accepted by engine host queries. */ +export type JGQueryCtx = GenericQueryCtx; /** Mutation ctx accepted by {@link loadServerSnapshot} / {@link persistServerSnapshot}. */ export type JGMutationCtx = GenericMutationCtx; const query = queryGeneric as QueryBuilder; @@ -239,6 +277,13 @@ export async function resolveActor( return null; } +async function hasMember(ctx: JGQueryCtx | JGMutationCtx, server: ServerDoc, userId: string): Promise { + if (server.memberUserIds.includes(userId)) return true; + if (server.topology !== "shared") return false; + return (await ctx.db.query("jgServerMembers") + .withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique()) !== null; +} + async function requireServerMember( ctx: JGQueryCtx | JGMutationCtx, serverId: string, @@ -246,7 +291,7 @@ async function requireServerMember( ): Promise { const server = await ctx.db.get("jgGameServers", serverId as GenericId<"jgGameServers">); if (!server) return null; - if (!isServerMember(server.memberUserIds, actorUserId)) return null; + if (!(await hasMember(ctx, server, actorUserId))) return null; return server; } @@ -332,6 +377,22 @@ function serverDocToRecord(server: ServerDoc): GameServerRecord { }; } +async function serverCapacity(ctx: JGQueryCtx | JGMutationCtx, server: ServerDoc) { + return server.topology === "shared" + ? ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", server._id)).unique() + : null; +} + +async function ensureServerCapacity(ctx: JGMutationCtx, server: ServerDoc) { + const existing = await serverCapacity(ctx, server); + if (existing) return existing; + const id = await ctx.db.insert("jgServerCapacity", { + serverId: server._id, gameId: server.gameId, status: server.status, mode: server.mode, + memberCount: server.memberCount ?? server.memberUserIds.length, slotsPerServer: server.slotsPerServer, + }); + return (await ctx.db.get("jgServerCapacity", id))!; +} + /** * How much of a server to hydrate. Both fields default to "everything", which is a document read per * member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. @@ -361,10 +422,16 @@ export async function loadServerSnapshot( ): Promise { const profiles: Record = {}; const memberIds = new Set(server.memberUserIds); - const hydrateUserIds = - scope?.players === undefined - ? server.memberUserIds - : scope.players.filter((userId) => memberIds.has(userId)); + let hydrateUserIds: string[]; + if (server.topology === "shared") { + const requested = scope?.players ?? (await ctx.db.query("jgServerMembers") + .withIndex("by_server", q => q.eq("serverId", server._id)).collect()).map(row => row.userId); + hydrateUserIds = []; + for (const userId of new Set(requested)) if (await hasMember(ctx, server, userId)) hydrateUserIds.push(userId); + } else { + hydrateUserIds = scope?.players === undefined ? server.memberUserIds : scope.players.filter(userId => memberIds.has(userId)); + } + const sessionPlayers: Record = {}; for (const userId of hydrateUserIds) { const profile = await ctx.db @@ -372,6 +439,9 @@ export async function loadServerSnapshot( .withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId)) .unique(); + if (server.topology === "shared" && profile) { + sessionPlayers[userId] = { ...profile.playerState, ...(profile.sessionServerId === server._id ? { session: profile.sessionState ?? {} } : { session: {} }) }; + } profiles[userId] = profile ? { userId, @@ -383,7 +453,7 @@ export async function loadServerSnapshot( : null; } - const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles, hydrateUserIds); + const playersByUserId = buildHydratePlayers(server.topology === "shared" ? { ...serverDocToRecord(server), memberUserIds: hydrateUserIds, sessionPlayers } : serverDocToRecord(server), profiles, hydrateUserIds); const chunksByKey: Record = {}; if (scope?.chunkKeys === undefined) { @@ -462,7 +532,9 @@ export async function persistServerSnapshot( save: SaveConfig, ): Promise { const now = Date.now(); - const plan = planServerPersist(serverDocToRecord(server), snapshot, save, now); + const shared = server.topology === "shared"; + const record = serverDocToRecord(server); + const plan = planServerPersist(shared ? { ...record, memberUserIds: Object.keys(snapshot.players), sessionPlayers: {} } : record, snapshot, save, now); if (!plan.changed.any) return false; if (plan.leaderboard.length > 0) { @@ -477,6 +549,7 @@ export async function persistServerSnapshot( const profilePatch = { playerState: profile.playerState, + ...(shared ? { sessionState: snapshot.players[profile.userId]?.session ?? {}, sessionServerId: server._id } : {}), revision: profile.revision, updatedAt: profile.updatedAt, }; @@ -493,6 +566,19 @@ export async function persistServerSnapshot( } } + if (shared) { + const persisted = new Set(plan.profiles.map(profile => profile.userId)); + for (const userId of snapshot.dirty.players) { + if (persisted.has(userId)) continue; + const player = snapshot.players[userId]; + if (!player) continue; + const existing = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server!.gameId)).unique(); + const patch = { playerState: player, sessionState: player.session, sessionServerId: server._id, revision: snapshot.revision, updatedAt: now }; + if (existing) await ctx.db.patch(existing._id, patch); + else await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch }); + } + } + for (const chunk of plan.chunks) { const existing = await ctx.db .query("jgWorldChunks") @@ -524,9 +610,9 @@ export async function persistServerSnapshot( if (existing) await ctx.db.delete(existing._id); } - await ctx.db.patch(server._id, { + if (!shared || plan.changed.serverState) await ctx.db.patch(server._id, { ...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}), - ...(plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}), + ...(!shared && plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}), revision: plan.server.revision, dirtyAt: plan.server.dirtyAt, updatedAt: plan.server.updatedAt, @@ -540,16 +626,15 @@ async function flushServerIfDue( ctx: JGMutationCtx, server: ServerDoc, now: number, - runtime: GameRuntime, + _runtime: GameRuntime, loaded?: GameRuntimeSnapshot, ): Promise { if (!shouldAutoSave(server.save as SaveConfig, server.dirtyAt, server.lastSavedAt, now)) { return false; } - // Unscoped on purpose: markAllPlayersDirty writes every member, so every member has to be hydrated. - const snapshot = loaded ?? (await loadServerSnapshot(ctx, server, runtime)); - await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save as SaveConfig); + if (loaded) await persistServerSnapshot(ctx, server, loaded, server.save as SaveConfig); + await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: now }); return true; } @@ -569,6 +654,9 @@ export type RunCommandArgs = { * {@link CommandDef} declares through its own `scope`; omit both to hydrate the whole world. */ scope?: LoadSnapshotScope; + /** Trusted host override after game-specific authorization; never forward a client-supplied actor. */ + actorUserId?: string; + beforePersist?: (ctx: JGMutationCtx, loaded: LoadedServerSnapshot) => Promise; }; /** A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot. */ @@ -584,6 +672,7 @@ export type LoadedServerSnapshot = { * table writes in one transaction, which a pre-registered mutation cannot do. */ export type GameServerHelpers = { + ensureServer: (ctx: JGMutationCtx, gameId: string) => Promise; loadSnapshot: ( ctx: JGMutationCtx, serverId: string, @@ -605,6 +694,7 @@ export type GameServerHelpers = { save?: SaveConfig, ) => Promise; runCommand: (ctx: JGMutationCtx, args: RunCommandArgs) => Promise; + resetPlayerProfile: (ctx: JGMutationCtx, serverId: string, userId: string) => Promise; }; /** How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. */ @@ -616,10 +706,9 @@ export const MAX_CHUNKS_PER_QUERY = 64; /** * Build the hosted-server Convex functions for a set of game runtimes. * - * **Supported world size.** One server is one Convex document holding the world row, the member - * roster, and every member's session state, and the host rewrites that document whenever the world - * changes. `slotsPerServer` is therefore capped at {@link JG_MAX_MEMBERS_PER_SERVER} (256) and a - * larger value throws here, at wiring time, rather than at the write that overflows the document. + * Room topology keeps its roster and session map in the server document and caps capacity at + * {@link JG_MAX_MEMBERS_PER_SERVER}. Shared topology stores membership, capacity, and per-player + * session state separately; player-only commands and roster changes do not rewrite the world row. * World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key * ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`. * @@ -631,15 +720,17 @@ export function createGameServerFunctions(options?: { runtimes?: GameRuntime[]; auth?: JgAuthMode; slotsPerServer?: number; + topology?: "shared" | "rooms"; matchmaking?: MatchmakingMode; maxServersPerTick?: number; allowedFeedActions?: readonly string[]; }) { const mode: JgAuthMode = options?.auth ?? "required"; - const hostSlotsPerServer = options?.slotsPerServer ?? 16; - const slotsCheck = validateSlotsPerServer(hostSlotsPerServer); + const shared = options?.topology === "shared" || (options?.topology === undefined && (options?.runtimes?.some(runtime => runtime.topology === "shared") ?? false)); + const hostSlotsPerServer = shared ? Number.MAX_SAFE_INTEGER : options?.slotsPerServer ?? 16; + const slotsCheck = validateSlotsPerServer(hostSlotsPerServer, shared ? "shared" : "rooms"); if (!slotsCheck.ok) throw new Error(slotsCheck.reason); - const matchmaking: MatchmakingMode = options?.matchmaking ?? "auto"; + const matchmaking: MatchmakingMode = options?.matchmaking ?? (shared ? "singleton" : "auto"); const maxServersPerTick = Math.max(1, options?.maxServersPerTick ?? DEFAULT_MAX_SERVERS_PER_TICK); const feedWriteGate: FeedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []); const registry = buildRuntimeRegistry(options?.runtimes); @@ -657,12 +748,10 @@ export function createGameServerFunctions(options?: { joinCode: v.optional(v.string()), externalId: v.optional(v.string()), }, - returns: v.object({ - serverId: v.id("jgGameServers"), - isNew: v.boolean(), - }), + returns: v.union(v.object({ ok: v.literal(true), serverId: v.id("jgGameServers"), isNew: v.boolean() }), v.object({ ok: v.literal(false), reason: v.union(v.literal("full"), v.literal("closed"), v.literal("unauthorized")) })), handler: async (ctx, args) => { - const actorUserId = await requireActor(ctx, args.externalId, mode); + const actorUserId = await resolveActor(ctx, args.externalId, mode); + if (!actorUserId) return { ok: false as const, reason: "unauthorized" as const }; const now = Date.now(); const runtime = resolveRuntime(registry, args.gameId); @@ -673,13 +762,14 @@ export function createGameServerFunctions(options?: { args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null; if (args.serverId !== undefined && !server) { - throw new ConvexError("Server not found"); + return { ok: false as const, reason: "closed" as const }; } if (server && server.gameId !== args.gameId) { - throw new ConvexError("Server belongs to a different game"); + return { ok: false as const, reason: "unauthorized" as const }; } + if (server?.status === "closed") return { ok: false as const, reason: "closed" as const }; let mayCreate = true; if (!server) { const joinable = []; @@ -687,7 +777,7 @@ export function createGameServerFunctions(options?: { const rows = await ctx.db .query("jgGameServers") .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status)) - .collect(); + .take(100); joinable.push(...rows); } // Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC @@ -699,7 +789,7 @@ export function createGameServerFunctions(options?: { mode: matchmaking, slotsPerServer, }); - if (target.kind === "refuse") throw new ConvexError(target.reason); + if (target.kind === "refuse") return { ok: false as const, reason: "full" as const }; server = target.kind === "join" ? target.row : null; mayCreate = target.kind === "create"; } @@ -712,6 +802,8 @@ export function createGameServerFunctions(options?: { modeConfig: args.modeConfig, visibility: args.visibility ?? "public", joinCode: args.joinCode, + topology: shared ? "shared" : "rooms", + memberCount: 0, memberUserIds: [], slotsPerServer, save, @@ -737,36 +829,56 @@ export function createGameServerFunctions(options?: { server = reconciled; } - if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) { - throw new ConvexError("Server is full"); + if (!shared && isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) { + return { ok: false as const, reason: "full" as const }; } if ( isPrivateJoinBlocked({ visibility: server.visibility, - memberUserIds: server.memberUserIds, + memberUserIds: await hasMember(ctx, server, actorUserId) ? [actorUserId] : [], userId: actorUserId, joinCode: server.joinCode, suppliedCode: args.joinCode, }) ) { - throw new ConvexError("Server is private"); + return { ok: false as const, reason: "unauthorized" as const }; } - const profile = await ctx.db + let profile = await ctx.db .query("jgPlayerProfiles") .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId)) .unique(); + if (shared && server.topology !== "shared") { + for (const userId of server.memberUserIds) { + const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server!._id).eq("userId", userId)).unique(); + if (!member) await ctx.db.insert("jgServerMembers", { serverId: server._id, userId, joinedAt: now }); + const state = (server.sessionPlayers as Record)[userId]; + if (!state) continue; + const stored = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server!.gameId)).unique(); + const patch = { sessionState: state.session ?? {}, sessionServerId: server._id }; + if (stored) await ctx.db.patch(stored._id, patch); + else await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, playerState: state, revision: server.revision, createdAt: now, updatedAt: now, ...patch }); + } + await ctx.db.patch(server._id, { topology: "shared", memberCount: server.memberUserIds.length, memberUserIds: [], sessionPlayers: {} }); + server = (await ctx.db.get("jgGameServers", server._id))!; + profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", args.gameId)).unique(); + } const isNew = profile === null; const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId); - await ctx.db.patch(server._id, { - memberUserIds, - status: "running", - updatedAt: now, - dirtyAt: now, - }); + if (shared) { + const capacity = await ensureServerCapacity(ctx, server); + const isMember = await hasMember(ctx, server, actorUserId); + await ctx.db.patch(capacity._id, { + memberCount: capacity.memberCount + (isMember ? 0 : 1), status: "running", slotsPerServer, + }); + if (!isMember) await ctx.db.insert("jgServerMembers", { serverId: server._id, userId: actorUserId, joinedAt: now }); + if (server.status !== "running") await ctx.db.patch(server._id, { status: "running" }); + } else { + await ctx.db.patch(server._id, { memberUserIds, status: "running", updatedAt: now }); + } const refreshed = await ctx.db.get("jgGameServers", server._id); if (!refreshed) throw new ConvexError("Server missing after join"); @@ -780,7 +892,7 @@ export function createGameServerFunctions(options?: { snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew, now); await persistServerSnapshot(ctx, refreshed, snapshot, save); - return { serverId: refreshed._id, isNew }; + return { ok: true as const, serverId: refreshed._id, isNew }; }, }); @@ -809,27 +921,61 @@ export function createGameServerFunctions(options?: { const sessionPlayers = { ...(server.sessionPlayers as Record) }; delete sessionPlayers[actorUserId]; - await ctx.db.patch(server._id, { - memberUserIds, - sessionPlayers, - updatedAt: now, - dirtyAt: now, - status: statusAfterLeave(memberUserIds.length, server.status), - }); + if (server.topology === "shared") { + const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", actorUserId)).unique(); + if (member) { + const capacity = await ensureServerCapacity(ctx, server); + await ctx.db.delete(member._id); + await ctx.db.patch(capacity._id, { memberCount: Math.max(0, capacity.memberCount - 1) }); + const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", server.gameId)).unique(); + if (profile?.sessionServerId === server._id) await ctx.db.patch(profile._id, { sessionState: {}, sessionServerId: undefined }); + } + } else { + await ctx.db.patch(server._id, { memberUserIds, sessionPlayers, updatedAt: now, status: statusAfterLeave(memberUserIds.length, server.status) }); + } - const pose = await ctx.db + const poses = await ctx.db .query("jgPoses") .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId), ) - .unique(); - if (pose) await ctx.db.delete(pose._id); + .collect(); + for (const pose of poses) await ctx.db.delete(pose._id); return null; }, }); const helpers: GameServerHelpers = { + async ensureServer(ctx, gameId) { + if (!shared || matchmaking !== "singleton") throw new Error("ensureServer requires a shared singleton host"); + for (const status of ["running", "open"] as const) { + const existing = await ctx.db.query("jgGameServers").withIndex("by_game_and_status", q => q.eq("gameId", gameId).eq("status", status)).first(); + if (existing) return existing; + } + const runtime = resolveRuntime(registry, gameId); + const now = Date.now(); + const id = await ctx.db.insert("jgGameServers", { + gameId, topology: "shared", status: "running", memberUserIds: [], slotsPerServer: hostSlotsPerServer, + save: runtime.save, serverState: defaultServerStateForGame(gameId), sessionPlayers: {}, revision: 0, + tickAnchorMs: now, createdAt: now, updatedAt: now, + }); + const server = (await ctx.db.get("jgGameServers", id))!; + await ensureServerCapacity(ctx, server); + return server; + }, + async resetPlayerProfile(ctx, serverId, userId) { + const server = await requireServerMember(ctx, serverId, userId); + if (!server) throw new ConvexError("Not a member of this server"); + const state = initialPlayerState(resolveRuntime(registry, server.gameId), userId); + const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server!.gameId)).unique(); + const now = Date.now(); + const patch = { playerState: state, sessionState: state.session, sessionServerId: server._id, revision: (profile?.revision ?? 0) + 1, updatedAt: now }; + if (profile) await ctx.db.patch(profile._id, patch); + else await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch }); + if (server.topology !== "shared") await ctx.db.patch(server._id, { sessionPlayers: { ...server.sessionPlayers, [userId]: state } }); + return state; + }, async loadSnapshot(ctx, serverId, scope) { const server = await ctx.db.get("jgGameServers", serverId as GenericId<"jgGameServers">); if (!server) return null; @@ -843,14 +989,14 @@ export function createGameServerFunctions(options?: { return persistServerSnapshot(ctx, server, snapshot, save ?? (server.save as SaveConfig)); }, async runCommand(ctx, args): Promise { - const actorUserId = await requireActor(ctx, args.externalId, mode); + const actorUserId = args.actorUserId ?? await requireActor(ctx, args.externalId, mode); const serverId = args.serverId as GenericId<"jgGameServers">; const server = await ctx.db.get("jgGameServers", serverId); if (!server) { return { ok: false as const, reason: "Server not found" }; } - if (!isServerMember(server.memberUserIds, actorUserId)) { + if (!(await hasMember(ctx, server, actorUserId))) { return { ok: false as const, reason: "Not a member of this server" }; } @@ -888,6 +1034,9 @@ export function createGameServerFunctions(options?: { return commit; } + await args.beforePersist?.(ctx, { server: latest, runtime, snapshot: result.snapshot }); + const callbackEscape = commandScopeEscape(scope, result.snapshot.dirty, args.command); + if (callbackEscape !== null) throw new ConvexError(callbackEscape); await persistServerSnapshot(ctx, latest, result.snapshot, latest.save as SaveConfig); return { ok: true as const }; }, @@ -910,7 +1059,7 @@ export function createGameServerFunctions(options?: { v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() }), ), - handler: (ctx, args) => helpers.runCommand(ctx, args), + handler: (ctx, args) => helpers.runCommand(ctx, { serverId: args.serverId, command: args.command, input: args.input, externalId: args.externalId }), }); const flushSave = mutation({ @@ -925,10 +1074,7 @@ export function createGameServerFunctions(options?: { const server = await requireServerMember(ctx, args.serverId, actorUserId); if (server === null) return false; - const runtime = resolveRuntime(registry, server.gameId); - // Unscoped on purpose: markAllPlayersDirty writes every member. - const snapshot = await loadServerSnapshot(ctx, server, runtime); - await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save as SaveConfig); + await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() }); return true; }, }); @@ -992,24 +1138,41 @@ export function createGameServerFunctions(options?: { mode: v.optional(v.string()), memberCount: v.number(), slotsPerServer: v.number(), - revision: v.number(), - updatedAt: v.number(), }), v.null(), ), handler: (ctx, args) => - withMember(ctx, mode, args, null, (server) => ({ + withMember(ctx, mode, args, null, async (server) => ({ _id: server._id, gameId: server.gameId, status: server.status, mode: server.mode, - memberCount: server.memberUserIds.length, + memberCount: (await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length, slotsPerServer: server.slotsPerServer, - revision: server.revision, - updatedAt: server.updatedAt, })), }); + const getServerCapacity = query({ + args: { serverId: v.id("jgGameServers"), externalId: v.optional(v.string()) }, + returns: v.union(v.object({ + _id: v.id("jgGameServers"), gameId: v.string(), + status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")), + mode: v.optional(v.string()), memberCount: v.number(), slotsPerServer: v.number(), + }), v.null()), + handler: async (ctx, args) => { + const actor = await resolveActor(ctx, args.externalId, mode); + if (!actor) return null; + const capacity = await ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", args.serverId)).unique(); + if (capacity) { + const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actor)).unique(); + if (!member) return null; + return { _id: capacity.serverId, gameId: capacity.gameId, status: capacity.status, mode: capacity.mode, memberCount: capacity.memberCount, slotsPerServer: capacity.slotsPerServer }; + } + const server = await requireServerMember(ctx, args.serverId, actor); + return server ? { _id: server._id, gameId: server.gameId, status: server.status, mode: server.mode, memberCount: server.memberUserIds.length, slotsPerServer: server.slotsPerServer } : null; + }, + }); + /** * Read world chunks by key — the client half of the chunk escape hatch. Without it a game that moved * geometry out of `serverState` had no way to show it, because `getServer` returns only the row. @@ -1167,17 +1330,17 @@ export function createGameServerFunctions(options?: { .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running")) .take(Math.min(limit * 4, 200)); - return rows + return Promise.all(rows .filter((row) => isListablePublicly(row.visibility)) .slice(0, limit) - .map((row) => ({ + .map(async (row) => ({ _id: row._id, status: row.status, - memberCount: row.memberUserIds.length, + memberCount: (await serverCapacity(ctx, row))?.memberCount ?? row.memberCount ?? row.memberUserIds.length, slotsPerServer: row.slotsPerServer, mode: row.mode, updatedAt: row.updatedAt, - })); + }))); }, }); @@ -1201,7 +1364,7 @@ export function createGameServerFunctions(options?: { .take(budget); for (const server of servers) { - if (server.memberUserIds.length === 0) continue; + if (((await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length) === 0) continue; const elapsedMs = now - server.tickAnchorMs; if (elapsedMs < JG_RUNTIME_TICK_MS) continue; @@ -1236,10 +1399,7 @@ export function createGameServerFunctions(options?: { let saved = 0; for (const server of servers) { - const runtime = resolveRuntime(registry, server.gameId); - // Unscoped on purpose: markAllPlayersDirty writes every member. - const snapshot = await loadServerSnapshot(ctx, server, runtime); - await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save as SaveConfig); + await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() }); saved += 1; } @@ -1254,6 +1414,7 @@ export function createGameServerFunctions(options?: { flushSave, getServer, getServerMeta, + getServerCapacity, getChunks, getPlayerProfile, getFeed, @@ -1406,6 +1567,7 @@ export function createPresenceFunctions(options?: { const list = query({ args: { serverId: v.string(), + viewerChunkKey: v.optional(v.string()), externalId: v.optional(v.string()), }, returns: v.array( @@ -1421,12 +1583,16 @@ export function createPresenceFunctions(options?: { }), ), handler: async (ctx, args) => { - return withMember(ctx, mode, args, [] as PresenceListRow[], async () => { + return withMember(ctx, mode, args, [] as PresenceListRow[], async (_server, actorUserId) => { const threshold = Date.now() - freshWindowMs; - const rows = await ctx.db - .query("jgPoses") - .withIndex("by_server", (q) => q.eq("serverId", args.serverId)) - .collect(); + const own = args.viewerChunkKey === undefined ? await ctx.db.query("jgPoses") + .withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actorUserId)).first() : null; + const center = parseChunkKey(args.viewerChunkKey ?? (own ? chunkKeyAt([own.x, own.y, own.z]) : "0,0")); + if (!center) return []; + const rows = []; + for (const chunkKey of chunkKeysAround([center.cx * DEFAULT_CHUNK_SIZE, 0, center.cz * DEFAULT_CHUNK_SIZE], 1)) { + rows.push(...await ctx.db.query("jgPoses").withIndex("by_server_and_chunk", q => q.eq("serverId", args.serverId).eq("chunkKey", chunkKey)).take(256)); + } return rows .filter((row) => row.revokedAt === undefined && row.updatedAt >= threshold) @@ -1485,6 +1651,9 @@ export function createPresenceFunctions(options?: { : rows.find((row) => row.sessionId === args.sessionId); const rules = rulesFor(args.kind ?? existing?.kind); + if (existing && now - existing.updatedAt < (rules.minIntervalMs ?? 100)) { + return { pose: { x: existing.x, y: existing.y, z: existing.z, rotationY: existing.rotationY, rotationPitch: existing.rotationPitch }, lastSeenAt: existing.updatedAt, displaced: existing.revokedAt !== undefined }; + } const current: PresencePoseState = existing === undefined ? spawnPresenceState( @@ -1533,13 +1702,14 @@ export function createPresenceFunctions(options?: { const displaced = existing?.revokedAt !== undefined; if (existing) { if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) { - await ctx.db.patch(existing._id, { ...pose, ...identity, updatedAt: now, revokedAt: undefined }); + await ctx.db.patch(existing._id, { ...pose, ...identity, chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]), updatedAt: now, revokedAt: undefined }); } } else { await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...pose, + chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]), ...identity, updatedAt: now, }); @@ -1609,6 +1779,117 @@ export function createPresenceFunctions(options?: { return { list, sync, leave, reapIdlePresence }; } +/** One active player delivered to an online-system batch. */ +export type OnlinePlayer = { serverId: string; userId: string; homeGameId?: string }; + +/** + * Scan one bounded presence page and schedule its handler and continuation in separate transactions. + * @capability online-player-batches Schedule bounded active-player or home-game work in separate transactions. + */ +export async function forEachOnlinePlayer(ctx: JGMutationCtx, options: { + batchSize?: number; + handler: FunctionReference<"mutation", "internal", { players: OnlinePlayer[]; nowMs: number }>; + continuation: FunctionReference<"mutation", "internal", { cursor?: string | null; nowMs?: number }>; + cursor?: string | null; + nowMs?: number; + freshWindowMs?: number; + /** Deliver one representative per home game when several agents share a simulation. */ + groupBy?: "userId" | "homeGameId"; +}): Promise<{ scheduled: number; remaining: boolean }> { + const batchSize = options.batchSize ?? 25; + if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 256) throw new Error("batchSize must be between 1 and 256"); + const nowMs = options.nowMs ?? Date.now(); + const freshWindowMs = options.freshWindowMs ?? 60_000; + if (!Number.isFinite(nowMs) || !Number.isFinite(freshWindowMs) || freshWindowMs <= 0 || !Number.isFinite(nowMs - freshWindowMs)) throw new Error("Invalid online-player time window"); + const page = await ctx.db.query("jgPoses").withIndex("by_updated", q => q.gte("updatedAt", nowMs - freshWindowMs)) + .paginate({ numItems: batchSize, cursor: options.cursor ?? null }); + const players: OnlinePlayer[] = []; + for (const row of page.page) { + if (row.revokedAt !== undefined) continue; + const canonical = options.groupBy === "homeGameId" && row.homeGameId + ? await ctx.db.query("jgPoses").withIndex("by_home_game_revoked_updated", q => q.eq("homeGameId", row.homeGameId).eq("revokedAt", undefined)).order("desc").first() + : await ctx.db.query("jgPoses").withIndex("by_server_user_revoked_updated", q => q.eq("serverId", row.serverId).eq("userId", row.userId).eq("revokedAt", undefined)).order("desc").first(); + if (canonical?._id === row._id) players.push({ serverId: row.serverId, userId: row.userId, ...(row.homeGameId ? { homeGameId: row.homeGameId } : {}) }); + } + if (players.length) await ctx.scheduler.runAfter(0, options.handler, { players, nowMs }); + if (!page.isDone) await ctx.scheduler.runAfter(0, options.continuation, { cursor: page.continueCursor, nowMs }); + return { scheduled: players.length, remaining: !page.isDone }; +} + +/** + * Consume one request in a fixed rate window; concurrent callers serialize on the indexed key. + * @capability host-rate-limit Enforce indexed per-key request windows inside authoritative mutations. + */ +export async function rateLimit(ctx: JGMutationCtx, args: { key: string; windowMs: number; max: number; nowMs?: number }): Promise<{ ok: boolean; retryAfterMs: number }> { + const now = args.nowMs ?? Date.now(); + if (!args.key || args.key.length > 512 || !Number.isFinite(now) || !Number.isFinite(args.windowMs) || args.windowMs <= 0 || !Number.isFinite(now + args.windowMs) || !Number.isSafeInteger(args.max) || args.max < 1) throw new Error("Invalid rate limit policy"); + const row = await ctx.db.query("jgRateLimits").withIndex("by_key", q => q.eq("key", args.key)).unique(); + if (row && now < row.expiresAt && row.count >= args.max) return { ok: false, retryAfterMs: row.expiresAt - now }; + const patch = !row || now >= row.expiresAt ? { startedAt: now, expiresAt: now + args.windowMs, count: 1 } : { startedAt: row.startedAt, expiresAt: row.expiresAt, count: row.count + 1 }; + if (row) await ctx.db.patch(row._id, patch); + else await ctx.db.insert("jgRateLimits", { key: args.key, ...patch }); + return { ok: true, retryAfterMs: 0 }; +} + +/** + * Write validated chat in a host transaction after the caller resolves its actor and world. + * @capability host-chat-send Validate, rate-limit and persist chat inside an authorized host mutation. + */ +export async function sendChatMessage(ctx: JGMutationCtx, args: { serverId: string; userId: string; body: string; channelId?: string; authorName?: string; maxBodyLength?: number; minIntervalMs?: number }): Promise { + const maxBodyLength = args.maxBodyLength ?? 240; + if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096) throw new Error("Invalid chat body limit"); + if (!(args.channelId ?? "global").trim() || (args.channelId?.length ?? 0) > 256 || (args.authorName?.length ?? 0) > 160) return { ok: false, reason: "invalid_channel_or_author" }; + const validation = validateChatMessage(args.body, { maxLength: args.maxBodyLength ?? 240 }); + if (!validation.ok) return validation; + const gate = await rateLimit(ctx, { key: JSON.stringify(["chat", args.serverId, args.userId]), windowMs: args.minIntervalMs ?? 2000, max: 1 }); + if (!gate.ok) return { ok: false, reason: "rate_limited" }; + await ctx.db.insert("jgChatMessages", { serverId: args.serverId, channelId: args.channelId ?? "global", userId: args.userId, body: validation.text, authorName: args.authorName, at: Date.now() }); + return { ok: true }; +} + +/** + * Bounded recent chat for a resolved server; authorization belongs to the calling query. + * @capability host-chat-history Read bounded channel history inside an authorized host query. + */ +export async function recentChatMessages(ctx: { db: Pick }, args: { serverId: string; channelId?: string; limit?: number }) { + const limit = args.limit ?? 50; + if (!Number.isFinite(limit)) throw new Error("Invalid chat limit"); + return ctx.db.query("jgChatMessages").withIndex("by_server_channel", q => q.eq("serverId", args.serverId).eq("channelId", args.channelId ?? "global")) + .order("desc").take(Math.min(100, Math.max(1, Math.floor(limit)))); +} + +/** + * Authenticated, bounded client diagnostics and bounded retention sweeps. + * @capability client-error-reporting Accept authenticated bounded diagnostics with cooldown and retention sweeps. + */ +export function createClientErrorFunctions() { + const reportClientError = mutation({ + args: { message: v.string(), kind: v.optional(v.string()), stack: v.optional(v.string()), componentStack: v.optional(v.string()), url: v.optional(v.string()), pathname: v.optional(v.string()), userAgent: v.optional(v.string()), agentPrompt: v.optional(v.string()) }, + returns: v.object({ ok: v.boolean(), reason: v.optional(v.string()) }), + handler: async (ctx, args) => { + const actor = await resolveActor(ctx, undefined, "required"); + if (!actor) return { ok: false, reason: "unauthorized" }; + const message = JSON.stringify(args); + if (new TextEncoder().encode(message).length > 4096) return { ok: false, reason: "too_long" }; + const gate = await rateLimit(ctx, { key: `client-error:${actor}`, windowMs: 10_000, max: 1 }); + if (!gate.ok) return { ok: false, reason: "rate_limited" }; + await ctx.db.insert("jgClientErrors", { userId: actor, message, createdAt: Date.now() }); + return { ok: true }; + }, + }); + const pruneClientErrors = internalMutation({ args: {}, handler: async ctx => { + const rows = await ctx.db.query("jgClientErrors").withIndex("by_created", q => q.lt("createdAt", Date.now() - 7 * 86_400_000)).take(256); + for (const row of rows) await ctx.db.delete(row._id); + return { deleted: rows.length, remaining: rows.length === 256 }; + } }); + const pruneRateLimits = internalMutation({ args: {}, handler: async ctx => { + const rows = await ctx.db.query("jgRateLimits").withIndex("by_expires", q => q.lte("expiresAt", Date.now())).take(256); + for (const row of rows) await ctx.db.delete(row._id); + return { deleted: rows.length, remaining: rows.length === 256 }; + } }); + return { reportClientError, pruneClientErrors, pruneRateLimits }; +} + /** @internal */ export function createChatFunctions(options?: { auth?: JgAuthMode; @@ -1617,9 +1898,11 @@ export function createChatFunctions(options?: { minIntervalMs?: number; }) { const mode: JgAuthMode = options?.auth ?? "required"; - const historyLimit = options?.historyLimit ?? 100; - const maxBodyLength = options?.maxBodyLength ?? 500; - const minIntervalMs = options?.minIntervalMs ?? 300; + if (options?.historyLimit !== undefined && (!Number.isSafeInteger(options.historyLimit) || options.historyLimit < 1)) throw new Error("Invalid chat history limit"); + const historyLimit = Math.min(100, options?.historyLimit ?? 50); + const maxBodyLength = options?.maxBodyLength ?? 240; + const minIntervalMs = options?.minIntervalMs ?? 2_000; + if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096 || !Number.isFinite(minIntervalMs) || minIntervalMs <= 0) throw new Error("Invalid chat policy"); const messages = query({ args: { @@ -1672,29 +1955,8 @@ export function createChatFunctions(options?: { return { ok: false, reason: "not a member of this server" }; } - const body = args.body.trim(); - if (body.length === 0) return { ok: false, reason: "empty message" }; - if (body.length > maxBodyLength) return { ok: false, reason: "message too long" }; - - const now = Date.now(); - const newestByActor = await ctx.db - .query("jgChatMessages") - .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId)) - .order("desc") - .filter((q) => q.eq(q.field("userId"), actorUserId)) - .first(); - - if (newestByActor && now - newestByActor.at < minIntervalMs) { - return { ok: false, reason: "sending too fast" }; - } - - await ctx.db.insert("jgChatMessages", { - serverId: args.serverId, - channelId: args.channelId, - userId: actorUserId, - body, - at: now, - }); + const outcome = await sendChatMessage(ctx, { serverId: args.serverId, userId: actorUserId, channelId: args.channelId, body: args.body, maxBodyLength, minIntervalMs }); + if (!outcome.ok) return outcome.reason === "rate_limited" ? { ok: false, reason: "sending too fast" } : outcome; const recent = await ctx.db .query("jgChatMessages") @@ -1710,15 +1972,20 @@ export function createChatFunctions(options?: { }, }); - return { messages, sendMessage }; + const pruneChatMessages = internalMutation({ args: {}, handler: async ctx => { + const rows = await ctx.db.query("jgChatMessages").withIndex("by_at", q => q.lt("at", Date.now() - 86_400_000)).take(256); + for (const row of rows) await ctx.db.delete(row._id); + return { deleted: rows.length, remaining: rows.length === 256 }; + } }); + return { messages, recent: messages, sendMessage, pruneChatMessages }; } export type JgCronSpec = { name: string; intervalSeconds: number; - functionKey: "tickActiveServers" | "flushDirtyServers" | "reapIdlePresence"; + functionKey: "tickActiveServers" | "flushDirtyServers" | "reapIdlePresence" | "pruneChatMessages" | "pruneClientErrors" | "pruneRateLimits"; /** Which factory's exports the function lives in, i.e. which `convex/*.ts` file to reach it through. */ - module: "runtime" | "presence"; + module: "runtime" | "presence" | "chat" | "clientErrors"; }; const JG_CRON_SPECS: readonly JgCronSpec[] = [ @@ -1728,6 +1995,21 @@ const JG_CRON_SPECS: readonly JgCronSpec[] = [ ]; /** @internal */ -export function jgengineCronSpecs(): readonly JgCronSpec[] { - return JG_CRON_SPECS; +export function jgengineCronSpecs(options: { + runtimes?: readonly Pick[]; + intervalSeconds?: Partial>; + chat?: boolean; + clientErrors?: boolean; +} = {}): readonly JgCronSpec[] { + const specs = JG_CRON_SPECS.filter(spec => spec.functionKey !== "tickActiveServers" || options.runtimes?.some(runtime => runtime.hasTick)); + if (options.chat) specs.push({ name: "jg chat prune", intervalSeconds: 3600, functionKey: "pruneChatMessages", module: "chat" }); + if (options.clientErrors) { + specs.push({ name: "jg error prune", intervalSeconds: 3600, functionKey: "pruneClientErrors", module: "clientErrors" }); + specs.push({ name: "jg rate limit prune", intervalSeconds: 3600, functionKey: "pruneRateLimits", module: "clientErrors" }); + } + return specs.map(spec => { + const intervalSeconds = options.intervalSeconds?.[spec.functionKey] ?? spec.intervalSeconds; + if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) throw new Error("Cron intervalSeconds must be positive"); + return { ...spec, intervalSeconds }; + }); } diff --git a/packages/convex/src/sharedHost.test.ts b/packages/convex/src/sharedHost.test.ts new file mode 100644 index 000000000..52ac68e25 --- /dev/null +++ b/packages/convex/src/sharedHost.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime"; +import { createEmptyPlayerRow } from "@jgengine/core/runtime/snapshot"; +import { createGameServerFunctions, type JGMutationCtx } from "./server"; +import { handlerOf, makeDb, profileDoc, serverDoc } from "./testFixtures"; + +function setup(save: "none" | { auto: string; scope: "player+chunks" } = { auto: "60s", scope: "player+chunks" }) { + const fixture = makeDb(); + const writes: string[] = []; + const db = { ...fixture.db, patch: async (id: string, patch: Record) => { + writes.push(id); + await fixture.db.patch(id, patch); + } }; + const ctx = { db, auth: { getUserIdentity: async () => null } } as unknown as JGMutationCtx; + const runtime = createGameRuntime({ gameId: "demo", save, commands: { + earn: { validate: () => null, apply(snapshot, _input, actor) { + const player = snapshot.players[actor]!; + return { ...snapshot, players: { ...snapshot.players, [actor]: { ...player, economy: { cash: (player.economy.cash ?? 0) + 1 }, session: { visits: 7 } } } }; + } }, + }, loop: { + joinScope: userId => ({ players: [userId], chunkKeys: [] }), + onNewPlayer(ctx) { + if (ctx.player.isNew) ctx.snapshot.players[ctx.player.userId]!.economy.cash = 50; + }, + } }); + const fns = createGameServerFunctions({ topology: "shared", runtimes: [runtime], auth: "anonymous" }); + const join = async (externalId: string, serverId?: string) => { + const result = await handlerOf(fns.joinServer)(ctx, { gameId: "demo", externalId, ...(serverId ? { serverId } : {}) }) as { ok: boolean; serverId: string; isNew: boolean }; + expect(result.ok).toBe(true); + return result; + }; + return { ...fixture, writes, ctx, fns, join }; +} + +test("shared singleton accepts over 256 members without rewriting the world row on joins or leaves", async () => { + const { rows, writes, ctx, fns, join } = setup(); + const first = await join("p0"); + writes.length = 0; + for (let i = 1; i < 270; i++) expect((await join(`p${i}`)).serverId).toBe(first.serverId); + expect(rows("jgGameServers")).toHaveLength(1); + expect(rows("jgServerMembers")).toHaveLength(270); + expect(rows("jgServerCapacity")[0]!.memberCount).toBe(270); + expect(rows("jgGameServers")[0]!.memberUserIds).toEqual([]); + expect(rows("jgGameServers")[0]!.sessionPlayers).toEqual({}); + expect(writes.includes(first.serverId)).toBe(false); + await handlerOf(fns.leaveServer)(ctx, { serverId: first.serverId, externalId: "p1" }); + expect(rows("jgServerCapacity")[0]!.memberCount).toBe(269); + expect(writes.includes(first.serverId)).toBe(false); + await join("p0"); + expect(rows("jgServerCapacity")[0]!.memberCount).toBe(269); +}); + +test("shared actor commands read only that profile, preserve sessions, and never write the singleton", async () => { + const { rows, reads, seed, writes, ctx, fns, join } = setup(); + const { serverId } = await join("alice"); + await join("bob"); + const alice = rows("jgPlayerProfiles").find(row => row.userId === "alice")!; + const bob = rows("jgPlayerProfiles").find(row => row.userId === "bob")!; + const chunk = seed("jgWorldChunks", { _id: "chunk:far", _creationTime: 0, serverId, chunkKey: "999,999", snapshot: { chunkKey: "999,999", objects: [], entities: [] }, updatedAt: 0 }); + reads.clear(); writes.length = 0; + expect(await fns.helpers.runCommand(ctx, { serverId, command: "earn", input: {}, externalId: "alice" })).toEqual({ ok: true }); + expect(reads.has(alice._id)).toBe(true); + expect(reads.has(bob._id)).toBe(false); + expect(reads.has(chunk._id)).toBe(false); + expect(writes).toEqual([alice._id]); + const loaded = await fns.helpers.loadSnapshot(ctx, serverId, { players: ["alice"], chunkKeys: [] }); + expect(loaded!.snapshot.players.alice!.economy.cash).toBe(51); + expect(loaded!.snapshot.players.alice!.session).toEqual({ visits: 7 }); + await handlerOf(fns.leaveServer)(ctx, { serverId, externalId: "alice" }); + expect((await join("alice", serverId)).isNew).toBe(false); + const rejoined = await fns.helpers.loadSnapshot(ctx, serverId, { players: ["alice"], chunkKeys: [] }); + expect(rejoined!.snapshot.players.alice!.economy.cash).toBe(51); + expect(rejoined!.snapshot.players.alice!.session).toEqual({}); +}); + +test("legacy session-only members migrate without being reseeded or losing state", async () => { + const { seed, rows, ctx, fns, join } = setup("none"); + const legacy = { ...createEmptyPlayerRow("alice"), economy: { cash: 777 }, session: { tutorial: 4 } }; + seed("jgGameServers", serverDoc({ _id: "srv:legacy", memberUserIds: ["alice"], sessionPlayers: { alice: legacy }, save: "none" })); + const result = await join("alice", "srv:legacy"); + expect(result.isNew).toBe(false); + const loaded = await fns.helpers.loadSnapshot(ctx, result.serverId, { players: ["alice"], chunkKeys: [] }); + expect(loaded!.snapshot.players.alice).toEqual(legacy); + expect(rows("jgServerMembers")).toHaveLength(1); + expect(rows("jgServerCapacity")[0]!.memberCount).toBe(1); +}); + +test("flush only clears its dirty marker without hydrating or rewriting profiles and chunks", async () => { + const { seed, reads, writes, ctx, fns } = setup(); + seed("jgGameServers", serverDoc({ _id: "srv:dirty", topology: "shared", dirtyAt: 1, save: { auto: "60s", scope: "player+chunks" } })); + const profile = seed("jgPlayerProfiles", profileDoc({ userId: "alice", gameId: "demo", playerState: createEmptyPlayerRow("alice") })); + const chunk = seed("jgWorldChunks", { _id: "chunk:1", _creationTime: 0, serverId: "srv:dirty", chunkKey: "0,0", snapshot: {}, updatedAt: 0 }); + expect(await handlerOf(fns.flushDirtyServers)(ctx, {})).toEqual({ saved: 1 }); + expect(reads.has(profile._id)).toBe(false); + expect(reads.has(chunk._id)).toBe(false); + expect(writes).toEqual(["srv:dirty"]); +}); + +test("profile reset runs the initializer and leaves the shared world and neighbors untouched", async () => { + const { rows, writes, ctx, fns, join } = setup(); + const { serverId } = await join("alice"); + await join("bob"); + await fns.helpers.runCommand(ctx, { serverId, command: "earn", input: {}, externalId: "alice" }); + const alice = rows("jgPlayerProfiles").find(row => row.userId === "alice")!; + writes.length = 0; + const reset = await fns.helpers.resetPlayerProfile(ctx, serverId, "alice"); + expect(reset.economy.cash).toBe(50); + expect(reset.session).toEqual({}); + expect(writes).toEqual([alice._id]); + expect(rows("jgGameServers")).toHaveLength(1); + const loaded = await fns.helpers.loadSnapshot(ctx, serverId, { players: ["alice"], chunkKeys: [] }); + expect(loaded!.snapshot.players.alice!.economy.cash).toBe(50); + await expect(fns.helpers.resetPlayerProfile(ctx, serverId, "outsider")).rejects.toThrow("Not a member"); +}); + +test("shared capacity subscribes to membership and capacity, without reading the world document", async () => { + const { reads, ctx, fns, join } = setup(); + const { serverId } = await join("alice"); + reads.clear(); + expect(await handlerOf(fns.getServerCapacity)(ctx, { serverId, externalId: "alice" })).toMatchObject({ memberCount: 1, status: "running" }); + expect(reads.has(serverId)).toBe(false); + expect(await handlerOf(fns.getServerCapacity)(ctx, { serverId, externalId: "outsider" })).toBeNull(); +}); + +test("trusted server ensure is idempotent and the first browser join uses that world", async () => { + const { ctx, rows, fns, join } = setup(); + const first = await fns.helpers.ensureServer(ctx, "demo"); + const again = await fns.helpers.ensureServer(ctx, "demo"); + expect(again._id).toBe(first._id); + expect(rows("jgGameServers")).toHaveLength(1); + expect(rows("jgServerCapacity")).toHaveLength(1); + expect(rows("jgServerMembers")).toHaveLength(0); + expect((await join("alice")).serverId).toBe(first._id); + expect(rows("jgGameServers")).toHaveLength(1); + const rooms = createGameServerFunctions(); + await expect(rooms.helpers.ensureServer(ctx, "demo")).rejects.toThrow("shared singleton"); +}); + +test("runtime topology selects a shared singleton without repeating host configuration", async () => { + const { ctx, rows } = setup(); + const runtime = createGameRuntime({ gameId: "derived", topology: "shared", save: "none", commands: {} }); + const fns = createGameServerFunctions({ runtimes: [runtime] }); + expect(runtime.topology).toBe("shared"); + const server = await fns.helpers.ensureServer(ctx, "derived"); + expect(server.topology).toBe("shared"); + expect(server.slotsPerServer).toBe(Number.MAX_SAFE_INTEGER); + expect(rows("jgGameServers")).toHaveLength(1); +}); diff --git a/packages/convex/src/territory.test.ts b/packages/convex/src/territory.test.ts new file mode 100644 index 000000000..81a085a0a --- /dev/null +++ b/packages/convex/src/territory.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { claimTerritory } from "@jgengine/core/world/territory"; +import { createEmptyPlayerRow } from "@jgengine/core/runtime/snapshot"; +import { loadTerritoryState, persistTerritoryState } from "./territory"; +import type { JGMutationCtx } from "./server"; +import { makeDb, profileDoc, serverDoc } from "./testFixtures"; + +test("territory persistence reads only requested chunks and writes no shared-server row", async () => { + const f = makeDb(), ctx = { db: f.db } as unknown as JGMutationCtx; + f.seed("jgGameServers", serverDoc({ _id: "world" })); + f.seed("jgPlayerProfiles", profileDoc({ userId: "alice", gameId: "demo", playerState: { ...createEmptyPlayerRow("alice"), economy: { cash: 100 } } })); + f.seed("jgWorldChunks", { _id: "far", _creationTime: 0, serverId: "world", chunkKey: "90,90", snapshot: { chunkKey: "90,90", objects: [], entities: [] }, updatedAt: 0 }); + const snapshot = await loadTerritoryState(ctx, { serverId: "world", gameId: "demo", userId: "alice", chunkKeys: ["0,0"] }); + const result = claimTerritory(snapshot, "alice", [{ x: 1, z: 2 }], { currency: "cash", price: () => 25, nowMs: 100 }); + if (!result.ok) throw new Error(result.reason); + await persistTerritoryState(ctx, result.snapshot, 100); + expect(f.reads.has("far")).toBe(false); + expect(f.reads.has("world")).toBe(false); + const restored = await loadTerritoryState(ctx, { serverId: "world", gameId: "demo", userId: "alice", chunkKeys: ["0,0"] }); + expect(restored.players.alice!.economy.cash).toBe(75); + expect(restored.players.alice!.territoryOwnedCount).toBe(1); + expect(restored.players.alice!.ownedTerritoryChunkKeys).toEqual(["0,0"]); + expect(restored.chunks["0,0"]!.territoryReceipts).toEqual({ "1,2": { claimedAt: 100, costPaid: 25 } }); + expect(f.rows("jgGameServers")[0]!.revision).toBe(0); +}); +test("territory hydration rejects excessive chunk reads before touching storage", async () => { + const f = makeDb(), ctx = { db: f.db } as unknown as JGMutationCtx; + await expect(loadTerritoryState(ctx, { serverId: "world", gameId: "demo", userId: "alice", chunkKeys: Array.from({ length: 65 }, (_, i) => `${i},0`) })).rejects.toThrow(); + expect(f.reads.size).toBe(0); +}); diff --git a/packages/convex/src/territory.ts b/packages/convex/src/territory.ts new file mode 100644 index 000000000..86b7a5e17 --- /dev/null +++ b/packages/convex/src/territory.ts @@ -0,0 +1,42 @@ +import { createEmptyPlayerRow, createRuntimeSnapshot, type GameRuntimeSnapshot, type RuntimeChunkRow, type RuntimePlayerRow } from "@jgengine/core/runtime/snapshot"; +import type { JGMutationCtx, JGDataModel } from "./server"; +import type { GenericQueryCtx } from "convex/server"; +type JGQueryCtx = GenericQueryCtx; +import type { GenericId } from "convex/values"; + +/** + * Trusted host hydration for claim/bootstrap work; the caller checks access before invoking it. + * @capability territory-host-hydration Load an actor profile and bounded claim chunks for a host transaction. + */ +export async function loadTerritoryState(ctx: JGQueryCtx | JGMutationCtx, args: { serverId: string; gameId: string; userId: string; chunkKeys: readonly string[] }): Promise { + const keys = [...new Set(args.chunkKeys)]; + if (keys.length > 64) throw new RangeError("Territory read exceeds 64 chunks"); + const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", args.userId).eq("gameId", args.gameId)).unique(); + const chunks: Record = {}; + for (const chunkKey of keys) { + const row = await ctx.db.query("jgWorldChunks").withIndex("by_server_and_chunk", q => q.eq("serverId", args.serverId as GenericId<"jgGameServers">).eq("chunkKey", chunkKey)).unique(); + if (row) chunks[chunkKey] = row.snapshot as RuntimeChunkRow; + } + return createRuntimeSnapshot({ serverId: args.serverId, gameId: args.gameId, players: { [args.userId]: (profile?.playerState as RuntimePlayerRow | undefined) ?? createEmptyPlayerRow(args.userId) }, chunks, revision: profile?.revision ?? 0 }); +} + +/** + * Persist dirty territory chunks and profiles in the caller's mutation, without a shared-server write. + * @capability territory-host-persistence Persist dirty ownership chunks and profiles without rewriting the shared server. + */ +export async function persistTerritoryState(ctx: JGMutationCtx, snapshot: GameRuntimeSnapshot, nowMs = Date.now()): Promise { + for (const userId of snapshot.dirty.players) { + const playerState = snapshot.players[userId]; + if (!playerState) throw new Error("Dirty territory player was not hydrated"); + const row = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", snapshot.gameId)).unique(); + if (row) await ctx.db.patch(row._id, { playerState, revision: row.revision + 1, updatedAt: nowMs }); + else await ctx.db.insert("jgPlayerProfiles", { userId, gameId: snapshot.gameId, playerState, revision: 1, createdAt: nowMs, updatedAt: nowMs }); + } + for (const chunkKey of snapshot.dirty.chunks) { + const chunk = snapshot.chunks[chunkKey]; + const row = await ctx.db.query("jgWorldChunks").withIndex("by_server_and_chunk", q => q.eq("serverId", snapshot.serverId as GenericId<"jgGameServers">).eq("chunkKey", chunkKey)).unique(); + if (!chunk) { if (row) await ctx.db.delete(row._id); continue; } + if (row) await ctx.db.patch(row._id, { snapshot: chunk, updatedAt: nowMs }); + else await ctx.db.insert("jgWorldChunks", { serverId: snapshot.serverId as GenericId<"jgGameServers">, chunkKey, snapshot: chunk, updatedAt: nowMs }); + } +} diff --git a/packages/convex/src/testFixtures.ts b/packages/convex/src/testFixtures.ts index e9e24cffc..e9cf9ba91 100644 --- a/packages/convex/src/testFixtures.ts +++ b/packages/convex/src/testFixtures.ts @@ -57,9 +57,13 @@ export function makeDb() { const rowsFor = (table: string): Doc[] => tables.get(table) ?? []; - function builder(table: string, constraints: Constraint[], desc: boolean) { + function builder(table: string, constraints: Constraint[], desc: boolean, indexFields: string[] = []) { const resolve = (): Doc[] => { const out = rowsFor(table).filter((doc) => matches(doc, constraints)); + out.sort((a, b) => { + for (const field of indexFields) { const order = cmp(a[field], b[field]); if (order) return order; } + return cmp(a._creationTime, b._creationTime); + }); return desc ? out.slice().reverse() : out; }; const record = (docs: Doc[]) => { @@ -67,13 +71,19 @@ export function makeDb() { return docs; }; return { - withIndex: (_name: string, fn: (q: ReturnType) => unknown) => { + withIndex: (name: string, fn: (q: ReturnType) => unknown) => { const next: Constraint[] = []; fn(rangeBuilder(next)); - return builder(table, [...constraints, ...next], desc); + const fields: Record = { by_home_game_revoked_updated: ["homeGameId", "revokedAt", "updatedAt"], by_updated: ["updatedAt"], by_created: ["createdAt"], by_at: ["at"], by_expires: ["expiresAt"], by_server_channel: ["serverId", "channelId", "at"], by_server_user_revoked_updated: ["serverId", "userId", "revokedAt", "updatedAt"] }; + return builder(table, [...constraints, ...next], desc, fields[name] ?? []); + }, + order: (dir: "asc" | "desc") => builder(table, constraints, dir === "desc", indexFields), + filter: () => builder(table, constraints, desc, indexFields), + paginate: async ({ numItems, cursor }: { numItems: number; cursor: string | null }) => { + const offset = cursor === null ? 0 : Number(cursor), docs = resolve(); + const page = record(docs.slice(offset, offset + numItems)); + return { page, isDone: offset + page.length >= docs.length, continueCursor: String(offset + page.length) }; }, - order: (dir: "asc" | "desc") => builder(table, constraints, dir === "desc"), - filter: () => builder(table, constraints, desc), collect: async () => record(resolve()), take: async (n: number) => record(resolve().slice(0, n)), first: async () => record(resolve().slice(0, 1))[0] ?? null, diff --git a/packages/convex/src/worldPresence.test.ts b/packages/convex/src/worldPresence.test.ts new file mode 100644 index 000000000..ba186220a --- /dev/null +++ b/packages/convex/src/worldPresence.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; +import { createWorldPresenceStore } from "./worldPresence"; +import type { JGMutationCtx } from "./server"; +import { makeDb } from "./testFixtures"; + +function fixture() { + const db = makeDb(); + const ctx = { db: db.db } as unknown as JGMutationCtx; + const store = createWorldPresenceStore({ snapshotIntervalMs: 1000, idleTimeoutMs: 5000, revokedTtlMs: 10000 }); + return { ...db, ctx, store }; +} + +test("world presence uses chunk indexes and preserves actor, home and owner identity", async () => { + const { ctx, store, reads } = fixture(); + const near = await store.ensure(ctx, { serverId: "world", actorExternalId: "a", homeGameId: "home-a", kind: "human", presenceId: "human:a", position: { x: 0, y: 0, z: 0 } }, 1000); + const far = await store.ensure(ctx, { serverId: "world", actorExternalId: "bot:b", ownerActorId: "b", homeGameId: "home-b", kind: "bot", position: { x: 6400, y: 0, z: 0 } }, 1000); + reads.clear(); + expect((await store.nearby(ctx, "world", "0,0")).map(row => row.actorExternalId)).toEqual(["a"]); + expect(reads.has(far._id)).toBe(false); + expect((await store.active(ctx, "bot:b"))?.ownerActorId).toBe("b"); + expect((await store.active(ctx, "a"))?.homeGameId).toBe("home-a"); + expect(near.presenceId).toBe("human:a"); + expect(await store.nearby(ctx, "world", "invalid")).toEqual([]); +}); + +test("pose sync clamps speed and rejects burst writes while preserving checkpoint cadence", async () => { + const { ctx, store, rows } = fixture(); + const row = await store.ensure(ctx, { serverId: "world", actorExternalId: "a", homeGameId: "home-a", kind: "human", position: { x: 0, y: 0, z: 0 } }, 1000); + let snapshots = 0; + const onSnapshot = async () => { snapshots++; }; + const moved = await store.sync(ctx, row, { position: { x: 100, z: 0, y: 100 } }, { nowMs: 1500, onSnapshot }); + expect(moved.position).toEqual({ x: 6, y: 3, z: 0 }); + const burst = await store.sync(ctx, moved, { position: { x: 12, z: 0 } }, { nowMs: 1550, onSnapshot }); + expect(burst.position).toEqual(moved.position); + expect(rows("jgPoses")[0]!.updatedAt).toBe(1500); + expect(snapshots).toBe(1); + await store.sync(ctx, burst, undefined, { nowMs: 2600, onSnapshot }); + expect(snapshots).toBe(2); + await expect(store.sync(ctx, row, { position: { x: Infinity, z: 0 } })).rejects.toThrow("finite"); +}); + +test("ensuring reuses the actor row and revocation checkpoints before bounded physical cleanup", async () => { + const { ctx, store, rows } = fixture(); + const args = { serverId: "world", actorExternalId: "a", homeGameId: "home", kind: "human", position: { x: 1, y: 0, z: 1 } }; + const first = await store.ensure(ctx, args, 1000); + const again = await store.ensure(ctx, args, 2000); + expect(again._id).toBe(first._id); + expect(rows("jgPoses")).toHaveLength(1); + let saved = ""; + await store.revoke(ctx, again, async row => { saved = row.homeGameId; }, 3000); + expect(saved).toBe("home"); + expect(await store.active(ctx, "a")).toBeNull(); + expect(await store.nearby(ctx, "world", "0,0")).toEqual([]); + expect((await store.reap(ctx, { nowMs: 10000 })).deleted).toBe(0); + expect((await store.reap(ctx, { nowMs: 13000 })).deleted).toBe(1); + expect(rows("jgPoses")).toHaveLength(0); +}); + +test("idle reaping is bounded and is not starved by recently revoked rows", async () => { + const { ctx, store, rows } = fixture(); + for (let i = 0; i < 3; i++) await store.ensure(ctx, { serverId: "world", actorExternalId: `${i}`, homeGameId: "home", kind: "human", position: { x: 0, y: 0, z: 0 } }, 1000); + const revoked = await store.active(ctx, "0"); + await store.revoke(ctx, revoked!, undefined, 2000); + const result = await store.reap(ctx, { nowMs: 10000, batchSize: 1 }); + expect(result).toEqual({ reaped: 1, deleted: 1 }); + expect(rows("jgPoses")).toHaveLength(2); +}); diff --git a/packages/convex/src/worldPresence.ts b/packages/convex/src/worldPresence.ts new file mode 100644 index 000000000..b02d14598 --- /dev/null +++ b/packages/convex/src/worldPresence.ts @@ -0,0 +1,149 @@ +import type { DocumentByName, GenericQueryCtx } from "convex/server"; +import type { JGDataModel, JGMutationCtx } from "./server"; +import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, shouldPersistWorldSnapshot, type IncomingPose, type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel"; +import type { PresencePosition, PresenceResidentRow } from "@jgengine/core/multiplayer/presenceContract"; +import { chunkKeyAt, chunkKeysAround, parseChunkKey, DEFAULT_CHUNK_SIZE } from "@jgengine/core/runtime/worldChunks"; + +type ReadContext = { db: Pick["db"], "get" | "query"> }; +type PoseRow = DocumentByName; + +/** Normalized actor identity and pose read from an indexed jgPoses row. */ +export interface WorldPresenceRecord extends PresenceResidentRow { + _id: PoseRow["_id"]; + serverId: string; + presenceId: string; + homeGameId: string; + kind: string; + label: string | null; + position: PresencePosition; + rotationY: number; + rotationPitch: number; + lastSeenAt: number; + lastWorldSnapshotAt?: number; +} + +/** World chunk size and host-owned pose, checkpoint and retention policies. */ +export interface WorldPresenceOptions { + chunkSize?: number; + rules?: PoseSyncRules; + idleTimeoutMs?: number; + revokedTtlMs?: number; + snapshotIntervalMs?: number; +} + +/** Persist game-owned last-position state when the host store requests a checkpoint. */ +export type PresenceSnapshotWriter = (presence: WorldPresenceRecord, nowMs: number) => Promise; + +function record(row: PoseRow): WorldPresenceRecord { + return { _id: row._id, serverId: row.serverId, presenceId: row.sessionId ?? row.userId, + actorExternalId: row.userId, ownerActorId: row.ownerActorId, + homeGameId: row.homeGameId ?? "", kind: row.kind ?? "player", label: row.label ?? null, + position: { x: row.x, y: row.y, z: row.z }, rotationY: row.rotationY, + rotationPitch: row.rotationPitch, lastSeenAt: row.updatedAt, lastWorldSnapshotAt: row.lastWorldSnapshotAt }; +} + +function finitePose(position: IncomingPose["position"], rotationY?: number, rotationPitch?: number): void { + for (const value of [position.x, position.z, position.y ?? 0, rotationY ?? 0, rotationPitch ?? 0]) { + if (!Number.isFinite(value)) throw new RangeError("Presence pose must be finite"); + } +} + +/** Indexed presence storage for hosts that own authentication, bot ownership and spawn policy. + * @capability world-presence-store indexed neighborhood presence with pose throttling and physical expiry + * Call only after resolving the actor and their world access; browser arguments are not trusted identities. + */ +export function createWorldPresenceStore(options: WorldPresenceOptions = {}) { + const size = options.chunkSize ?? DEFAULT_CHUNK_SIZE; + const rules = options.rules ?? DEFAULT_POSE_SYNC_RULES; + const idle = options.idleTimeoutMs ?? 240_000; + const revokedTtl = options.revokedTtlMs ?? 600_000; + const snapshotInterval = options.snapshotIntervalMs ?? 30_000; + if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(idle) || idle < 1 || + !Number.isFinite(revokedTtl) || revokedTtl < 0 || !Number.isFinite(snapshotInterval) || snapshotInterval < 0) throw new RangeError("Invalid world presence policy"); + + async function active(ctx: ReadContext, actorExternalId: string, serverId?: string): Promise { + const rows = serverId === undefined + ? await ctx.db.query("jgPoses").withIndex("by_user", q => q.eq("userId", actorExternalId)).take(32) + : await ctx.db.query("jgPoses").withIndex("by_server_and_user", q => q.eq("serverId", serverId).eq("userId", actorExternalId)).take(32); + const live = rows.filter(row => row.revokedAt === undefined).sort((a, b) => b.updatedAt - a.updatedAt)[0]; + return live ? record(live) : null; + } + + return { + active, + async ensure(ctx: JGMutationCtx, args: { + serverId: string; actorExternalId: string; homeGameId: string; kind: string; + position: PresencePosition; presenceId?: string; label?: string; ownerActorId?: string; + }, nowMs = Date.now()): Promise { + finitePose(args.position); + const rows = await ctx.db.query("jgPoses").withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", args.actorExternalId)).take(32); + const existing = rows.filter(row => row.revokedAt === undefined).sort((a, b) => b.updatedAt - a.updatedAt)[0]; + for (const row of rows) if (row._id !== existing?._id) await ctx.db.delete(row._id); + const patch = { serverId: args.serverId, userId: args.actorExternalId, sessionId: args.presenceId ?? args.actorExternalId, + homeGameId: args.homeGameId, kind: args.kind, ownerActorId: args.ownerActorId, + label: args.label ?? existing?.label, x: args.position.x, y: args.position.y, z: args.position.z, + rotationY: 0, rotationPitch: 0, chunkKey: chunkKeyAt([args.position.x, args.position.y, args.position.z], size), + updatedAt: nowMs, revokedAt: undefined }; + const id = existing ? existing._id : await ctx.db.insert("jgPoses", patch); + if (existing) await ctx.db.patch(id, patch); + return record((await ctx.db.get("jgPoses", id))!); + }, + async sync(ctx: JGMutationCtx, presence: WorldPresenceRecord, incoming?: IncomingPose, args: { + nowMs?: number; rules?: PoseSyncRules; onSnapshot?: PresenceSnapshotWriter; + } = {}): Promise { + const nowMs = args.nowMs ?? Date.now(); + if (incoming) finitePose(incoming.position, incoming.rotationY, incoming.rotationPitch); + const current = await ctx.db.get("jgPoses", presence._id); + if (!current || current.revokedAt !== undefined) return presence; + const currentRecord = record(current); + const decision = decidePoseSync({ position: currentRecord.position, rotationY: current.rotationY, + rotationPitch: current.rotationPitch, lastSeenAtMs: current.updatedAt }, incoming ?? { position: currentRecord.position }, args.rules ?? rules, nowMs); + const next = { ...currentRecord, position: decision.position, rotationY: decision.rotationY, rotationPitch: decision.rotationPitch }; + const patch: Partial = {}; + if (decision.changed) Object.assign(patch, { x: next.position.x, y: next.position.y, z: next.position.z, + chunkKey: chunkKeyAt([next.position.x, next.position.y, next.position.z], size), rotationY: next.rotationY, rotationPitch: next.rotationPitch }); + if (decision.changed || decision.refreshKeepAlive) { patch.updatedAt = nowMs; next.lastSeenAt = nowMs; } + if (args.onSnapshot && shouldPersistWorldSnapshot(current.lastWorldSnapshotAt, nowMs, snapshotInterval)) { + await args.onSnapshot(next, nowMs); + patch.lastWorldSnapshotAt = nowMs; next.lastWorldSnapshotAt = nowMs; + } + if (Object.keys(patch).length) await ctx.db.patch(current._id, patch); + return next; + }, + async revoke(ctx: JGMutationCtx, presence: WorldPresenceRecord, onSnapshot?: PresenceSnapshotWriter, nowMs = Date.now()): Promise { + const row = await ctx.db.get("jgPoses", presence._id); + if (!row || row.revokedAt !== undefined) return; + if (onSnapshot) await onSnapshot(record(row), nowMs); + await ctx.db.patch(row._id, { revokedAt: nowMs, updatedAt: nowMs }); + }, + async nearby(ctx: ReadContext, serverId: string, viewerChunkKey = "0,0", args: { rings?: number; limit?: number } = {}): Promise { + const coord = parseChunkKey(viewerChunkKey); + if (!coord) return []; + const requestedLimit = args.limit ?? 500; + if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) throw new RangeError("Invalid presence limit"); + const limit = Math.min(1000, requestedLimit); + const rows: WorldPresenceRecord[] = []; + for (const chunkKey of chunkKeysAround([coord.cx * size, 0, coord.cz * size], args.rings ?? 1, size)) { + const chunk = await ctx.db.query("jgPoses").withIndex("by_server_and_chunk", q => q.eq("serverId", serverId).eq("chunkKey", chunkKey)).take(limit - rows.length); + rows.push(...chunk.filter(row => row.revokedAt === undefined).map(record)); + if (rows.length >= limit) break; + } + return rows; + }, + async reap(ctx: JGMutationCtx, args: { nowMs?: number; batchSize?: number; onSnapshot?: PresenceSnapshotWriter } = {}): Promise<{ reaped: number; deleted: number }> { + const nowMs = args.nowMs ?? Date.now(); + const requestedBatch = args.batchSize ?? 200; + if (!Number.isFinite(nowMs) || !Number.isSafeInteger(requestedBatch) || requestedBatch < 1) throw new RangeError("Invalid presence reaper batch"); + const batchSize = Math.min(500, requestedBatch); + const rows = await ctx.db.query("jgPoses").withIndex("by_revoked_updated", q => q.eq("revokedAt", undefined).lt("updatedAt", nowMs - idle)).take(batchSize); + let reaped = 0; + for (const row of rows) { + if (args.onSnapshot) await args.onSnapshot(record(row), nowMs); + await ctx.db.delete(row._id); reaped++; + } + const revoked = await ctx.db.query("jgPoses").withIndex("by_revoked", q => q.gte("revokedAt", 0).lte("revokedAt", nowMs - revokedTtl)).take(batchSize - reaped); + for (const row of revoked) await ctx.db.delete(row._id); + return { reaped, deleted: reaped + revoked.length }; + }, + }; +} diff --git a/packages/core/package.json b/packages/core/package.json index 582c3dfcc..b6de40a7b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/core", - "version": "0.18.0", + "version": "0.18.1", "description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com", "keywords": [ "jgengine", diff --git a/packages/core/src/economy/currency.test.ts b/packages/core/src/economy/currency.test.ts new file mode 100644 index 000000000..cc5ff2750 --- /dev/null +++ b/packages/core/src/economy/currency.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { applyCurrencyOperation, formatCurrency, toMinorUnits } from "./currency"; +import { balance, charge, createEmptyWallet, grant } from "./wallet"; + +const dollars = { id: "cash", name: "Cash", symbol: "$", decimals: 2 }; + +test("currency decimal operations do integer arithmetic and round at writes", () => { + expect(applyCurrencyOperation(dollars, 0.1, "add", 0.2)).toEqual({ success: true, newBalance: 0.3, appliedDelta: 0.2 }); + expect(toMinorUnits(dollars, 1.005)).toBe(101); + expect(applyCurrencyOperation(dollars, 1, "deduct", 1.01).success).toBe(false); + expect(applyCurrencyOperation(dollars, 1, "add", NaN).success).toBe(false); + expect(applyCurrencyOperation(dollars, 1, "deduct", -1).success).toBe(false); + expect(formatCurrency(dollars, 12.1)).toBe("$12.10"); + expect(formatCurrency({ id: "gold", name: "Gold" }, 12.6)).toBe("13"); +}); + +test("sub-dollar income accrues and wallets accept the currency precision policy", () => { + let state = createEmptyWallet(); + for (let minute = 0; minute < 60; minute++) state = grant(state, dollars, 0.005 * 60); + state = JSON.parse(JSON.stringify(state)); + expect(balance(state, dollars)).toBe(18); + const charged = charge(state, dollars, 17.99); + expect(charged.status).toBe("ok"); + if (charged.status === "ok") expect(balance(charged.state, dollars)).toBe(0.01); + expect(charge(state, dollars, 18.01).status).toBe("rejected"); +}); + +test("currency rejects unsafe magnitudes and invalid precision", () => { + for (const value of [Infinity, NaN, Number.MAX_SAFE_INTEGER]) expect(() => toMinorUnits(dollars, value)).toThrow(); + for (const decimals of [-1, 1.5, 10]) expect(() => toMinorUnits({ decimals }, 1)).toThrow(); +}); diff --git a/packages/core/src/economy/currency.ts b/packages/core/src/economy/currency.ts index c242bae83..c0834b3f8 100644 --- a/packages/core/src/economy/currency.ts +++ b/packages/core/src/economy/currency.ts @@ -1,6 +1,8 @@ export interface CurrencyDefinition { id: TCurrencyId; name: string; + /** Fractional digits in major-unit amounts; defaults to 0. */ + decimals?: number; /** Prepended when formatting amounts (e.g. "$"). */ symbol?: string; /** Appended when formatting amounts (e.g. "tokens"). */ @@ -14,8 +16,45 @@ export type CurrencyAdjustment = | { success: false; reason: string }; /** @internal */ -export function sanitizeCurrencyAmount(amount: number): number { - return Math.max(0, Math.floor(amount)); +export function sanitizeCurrencyAmount(amount: number, currency?: CurrencyDefinition): number { + if (!Number.isFinite(amount)) throw new RangeError("Currency amount must be finite"); + return fromMinorUnits(currency, toMinorUnits(currency, Math.max(0, amount))); +} + +function currencyScale(currency?: Pick): number { + const decimals = currency?.decimals ?? 0; + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 9) { + throw new RangeError("Currency decimals must be an integer between 0 and 9"); + } + return 10 ** decimals; +} + +/** Convert major units to integer minor units, rounding once at the write boundary. + * @capability currency-minor-units round decimal currency into safe integer minor units + */ +export function toMinorUnits(currency: Pick | undefined, value: number): number { + if (!Number.isFinite(value)) throw new RangeError("Currency amount must be finite"); + const scaled = value * currencyScale(currency); + const result = Math.sign(scaled) * Math.round(Math.abs(scaled) + Math.min(1e-7, Number.EPSILON * Math.abs(scaled))); + if (!Number.isSafeInteger(result)) throw new RangeError("Currency amount exceeds safe integer precision"); + return result; +} + +/** Convert stored integer minor units to major units for display or existing balance records. + * @capability currency-major-units convert safe minor-unit integers into currency amounts + */ +export function fromMinorUnits(currency: Pick | undefined, value: number): number { + if (!Number.isSafeInteger(value)) throw new RangeError("Minor units must be a safe integer"); + return value / currencyScale(currency); +} + +/** Format a major-unit value using the currency's declared precision. + * @capability currency-precision-format display a currency using its declared decimal precision + */ +export function formatCurrency(currency: CurrencyDefinition, value: number): string { + const decimals = currency.decimals ?? 0; + const rounded = fromMinorUnits(currency, toMinorUnits(currency, value)); + return `${currency.symbol ?? ""}${rounded.toFixed(decimals)}${currency.unit ? ` ${currency.unit}` : ""}`; } /** @@ -25,9 +64,7 @@ export function sanitizeCurrencyAmount(amount: number): number { * @internal */ export function formatCurrencyAmount(currency: CurrencyDefinition, amount: number): string { - const prefix = currency.symbol ?? ""; - const suffix = currency.unit ? ` ${currency.unit}` : ""; - return `${prefix}${amount}${suffix}`; + return formatCurrency(currency, amount); } /** @internal */ @@ -46,13 +83,20 @@ export function applyCurrencyOperation( operation: CurrencyOperation, amount: number, ): CurrencyAdjustment { - const safeAmount = sanitizeCurrencyAmount(amount); - if (safeAmount === 0) return { success: true, newBalance: current, appliedDelta: 0 }; - if (operation === "deduct" && current < safeAmount) { - return { success: false, reason: insufficientCurrencyReason(currency, safeAmount, current) }; + if (!Number.isFinite(amount) || amount < 0 || !Number.isFinite(current)) { + return { success: false, reason: "Invalid currency amount" }; + } + const currentMinor = toMinorUnits(currency, current); + const amountMinor = toMinorUnits(currency, amount); + if (operation === "deduct" && currentMinor < amountMinor) { + return { success: false, reason: insufficientCurrencyReason(currency, fromMinorUnits(currency, amountMinor), current) }; } - const appliedDelta = operation === "add" ? safeAmount : -safeAmount; - return { success: true, newBalance: current + appliedDelta, appliedDelta }; + const deltaMinor = operation === "add" ? amountMinor : -amountMinor; + return { + success: true, + newBalance: fromMinorUnits(currency, currentMinor + deltaMinor), + appliedDelta: fromMinorUnits(currency, deltaMinor), + }; } /** Deducts clamp to the available balance (a delta can never overdraw); adds apply in full. @@ -61,8 +105,9 @@ export function applyCurrencyOperation( export function resolveCurrencyDelta( current: number, delta: number, + currency?: CurrencyDefinition, ): { operation: CurrencyOperation; amount: number } { - const safeDelta = Math.floor(delta); + const safeDelta = fromMinorUnits(currency, toMinorUnits(currency, delta)); if (safeDelta > 0) return { operation: "add", amount: safeDelta }; return { operation: "deduct", amount: Math.min(Math.abs(safeDelta), current) }; } diff --git a/packages/core/src/economy/wallet.ts b/packages/core/src/economy/wallet.ts index 34828993e..61ebe4c79 100644 --- a/packages/core/src/economy/wallet.ts +++ b/packages/core/src/economy/wallet.ts @@ -1,3 +1,5 @@ +import { fromMinorUnits, toMinorUnits, type CurrencyDefinition } from "./currency"; + export interface WalletState { balances: Readonly>; } @@ -36,16 +38,18 @@ function assertValidAmount(amount: number): void { } } -export function balance(state: WalletState, currency: string): number { - return state.balances[currency] ?? 0; +export function balance(state: WalletState, currency: string | CurrencyDefinition): number { + return state.balances[typeof currency === "string" ? currency : currency.id] ?? 0; } -export function grant(state: WalletState, currency: string, amount: number): WalletState { +export function grant(state: WalletState, currency: string | CurrencyDefinition, amount: number): WalletState { assertValidAmount(amount); return { balances: { ...state.balances, - [currency]: balance(state, currency) + amount, + [typeof currency === "string" ? currency : currency.id]: typeof currency === "string" + ? balance(state, currency) + amount + : fromMinorUnits(currency, toMinorUnits(currency, balance(state, currency)) + toMinorUnits(currency, amount)), }, }; } @@ -64,13 +68,14 @@ function withinOverdraft(nextBalance: number, overdraft: Overdraft | undefined): */ export function charge( state: WalletState, - currency: string, + currency: string | CurrencyDefinition, amount: number, options?: ChargeOptions, ): ChargeResult { assertValidAmount(amount); const current = balance(state, currency); - const next = current - amount; + const next = typeof currency === "string" ? current - amount + : fromMinorUnits(currency, toMinorUnits(currency, current) - toMinorUnits(currency, amount)); if (!withinOverdraft(next, options?.overdraft)) { return { status: "rejected", reason: "insufficient-funds" }; } @@ -79,14 +84,14 @@ export function charge( state: { balances: { ...state.balances, - [currency]: next, + [typeof currency === "string" ? currency : currency.id]: next, }, }, }; } /** True once `balance(state, currency)` has gone negative under an overdraft-enabled charge. */ -export function isOverdrawn(state: WalletState, currency: string): boolean { +export function isOverdrawn(state: WalletState, currency: string | CurrencyDefinition): boolean { return balance(state, currency) < 0; } diff --git a/packages/core/src/game/chat.test.ts b/packages/core/src/game/chat.test.ts index bd1255022..7c6b4fcae 100644 --- a/packages/core/src/game/chat.test.ts +++ b/packages/core/src/game/chat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { createChat, createChatRateLimiter, whisperChannelId, type ChatDeps } from "./chat"; +import { validateChatMessage, createChat, createChatRateLimiter, whisperChannelId, type ChatDeps } from "./chat"; import { createGameEvents } from "./events"; function sentMessage(result: ReturnType["send"]>) { @@ -78,7 +78,7 @@ describe("chat channels", () => { test("register adds custom channels with their own limits", () => { const { chat } = createTestChat(); - chat.register({ id: "trade", kind: "global", historyLimit: 2 }); + chat.register({ id: "trade", kind: "global", historyLimit: 2, rateLimit: { count: 3, perMs: 2000 } }); chat.send("alice", "trade", "one"); chat.send("alice", "trade", "two"); chat.send("alice", "trade", "three"); @@ -158,3 +158,18 @@ describe("createChatRateLimiter", () => { expect(limiter.allow("a", 111)).toBe(true); }); }); + +test("shared chat strips controls, rejects untrusted bodies and enforces the default window after restore", () => { + expect(validateChatMessage(" hi\u0000 there\n ")).toEqual({ ok: true, text: "hi there" }); + for (const value of [null, 12, "\u0000", "x".repeat(241)]) expect(validateChatMessage(value).ok).toBe(false); + const { chat, tick } = createTestChat(); + expect("message" in chat.send("alice", "hello")).toBe(true); + const saved = JSON.parse(JSON.stringify(chat.snapshot())); + chat.hydrate(saved); + expect(chat.send("alice", "again")).toEqual({ reason: "rate limited" }); + expect("message" in chat.send("bob", "hi")).toBe(true); + tick(2000); + expect("message" in chat.send("alice", "again")).toBe(true); + expect(chat.recent({ limit: 1 }).map(m => m.body)).toEqual(["again"]); + expect(chat.recent({ limit: 0 })).toEqual([]); +}); diff --git a/packages/core/src/game/chat.ts b/packages/core/src/game/chat.ts index 910667fb4..6d163faec 100644 --- a/packages/core/src/game/chat.ts +++ b/packages/core/src/game/chat.ts @@ -1,3 +1,4 @@ +import { decideRateWindow } from "../time/rateWindow"; import type { GameEventMap, GameEvents } from "./events"; import type { ChatFilter } from "./chatFilter"; import type { EmotesDeps } from "./social"; @@ -34,12 +35,15 @@ export type ChatSendResult = export interface ChatSnapshot { messages: Record; counter: number; + rateWindows?: Record>; } export interface Chat { register(def: ChatChannelDef): void; channels(): ChatChannelDef[]; + send(fromUserId: string, body: string): ChatSendResult; send(fromUserId: string, channelId: string, body: string): ChatSendResult; + recent(options?: { limit?: number; channelId?: string; viewerUserId?: string }): ChatMessage[]; whisper(fromUserId: string, toUserId: string, body: string): ChatSendResult; history(channelId: string, options?: { limit?: number; viewerUserId?: string }): ChatMessage[]; snapshot(): ChatSnapshot; @@ -57,9 +61,9 @@ export interface ChatDeps { filter?: ChatFilter; } -export const DEFAULT_CHAT_RATE_LIMIT: ChatRateLimit = { count: 10, perMs: 10_000 }; +export const DEFAULT_CHAT_RATE_LIMIT: ChatRateLimit = { count: 1, perMs: 2_000 }; export const DEFAULT_CHAT_HISTORY_LIMIT = 100; -export const DEFAULT_CHAT_BODY_LENGTH = 500; +export const DEFAULT_CHAT_BODY_LENGTH = 240; export const DEFAULT_PROXIMITY_CHAT_RADIUS = 20; export const WHISPER_CHANNEL_PREFIX = "whisper:"; @@ -70,21 +74,24 @@ export function whisperChannelId(a: string, b: string): string { export interface ChatRateLimiter { allow(key: string, atMs: number): boolean; + snapshot(): Record; + restore(state: Record): void; } export function createChatRateLimiter(limit: ChatRateLimit): ChatRateLimiter { const windows = new Map(); return { allow(key, atMs) { - const cutoff = atMs - limit.perMs; - const stamps = (windows.get(key) ?? []).filter((stamp) => stamp > cutoff); - if (stamps.length >= limit.count) { - windows.set(key, stamps); - return false; - } - stamps.push(atMs); - windows.set(key, stamps); - return true; + const result = decideRateWindow(windows.get(key) ?? [], atMs, { windowMs: limit.perMs, max: limit.count }); + windows.set(key, result.timestamps); + return result.allowed; + }, + snapshot() { + return Object.fromEntries(Array.from(windows, ([key, stamps]) => [key, [...stamps]])); + }, + restore(state) { + windows.clear(); + for (const [key, stamps] of Object.entries(state)) windows.set(key, [...stamps]); }, }; } @@ -175,10 +182,9 @@ export function createChat(deps: ChatDeps): Chat { body: string, def: ChatChannelDef | null, ): ChatSendResult { - const trimmed = body.trim(); - if (trimmed.length === 0) return { reason: "empty message" }; - if (trimmed.length > maxBodyLength) return { reason: "message too long" }; - + const validation = validateChatMessage(body, { maxLength: maxBodyLength }); + if (!validation.ok) return { reason: validation.reason }; + const trimmed = validation.text; let filteredBody = trimmed; if (filter !== undefined) { const filtered = filter.apply(trimmed); @@ -219,10 +225,18 @@ export function createChat(deps: ChatDeps): Chat { channels() { return Array.from(channelDefs.values(), (def) => ({ ...def })); }, - send(fromUserId, channelId, body) { + send(fromUserId: string, channelOrBody: string, suppliedBody?: string) { + const channelId = suppliedBody === undefined ? "global" : channelOrBody; + const body = suppliedBody ?? channelOrBody; const def = channelDefs.get(channelId) ?? null; return sendResolved(fromUserId, channelId, body, def); }, + recent(options) { + return this.history(options?.channelId ?? "global", { + limit: Math.max(0, Math.min(100, Math.floor(options?.limit ?? 50) || 0)), + viewerUserId: options?.viewerUserId, + }); + }, whisper(fromUserId, toUserId, body) { if (fromUserId === toUserId) return { reason: "cannot whisper yourself" }; const channelId = whisperChannelId(fromUserId, toUserId); @@ -236,14 +250,15 @@ export function createChat(deps: ChatDeps): Chat { ? ring : ring.filter((message) => !hasBlocked(viewerUserId, message.fromUserId)); const limit = options?.limit; - return limit === undefined ? visible.slice() : visible.slice(-limit); + return limit === undefined ? visible.slice() : limit <= 0 ? [] : visible.slice(-limit); }, snapshot() { const messages: Record = {}; for (const [channelId, ring] of messagesByChannel) { messages[channelId] = ring.map((message) => ({ ...message })); } - return { messages, counter }; + const rateWindows = Object.fromEntries(Array.from(limiters, ([id, limiter]) => [id, limiter.snapshot()])); + return { messages, counter, rateWindows }; }, hydrate(data) { messagesByChannel.clear(); @@ -251,6 +266,24 @@ export function createChat(deps: ChatDeps): Chat { messagesByChannel.set(channelId, ring.map((message) => ({ ...message }))); } counter = data.counter; + limiters.clear(); + for (const [channelId, state] of Object.entries(data.rateWindows ?? {})) { + limiterFor(channelId, channelDefs.get(channelId)?.rateLimit ?? defaultRateLimit).restore(state); + } }, }; } + +/** Sanitized chat text or a displayable validation failure. */ +export type ChatValidation = { ok: true; text: string } | { ok: false; reason: string }; + +/** Strip control characters and enforce the shared chat message policy before storage or broadcast. */ +export function validateChatMessage(value: unknown, options: { maxLength?: number } = {}): ChatValidation { + const maxLength = options.maxLength ?? DEFAULT_CHAT_BODY_LENGTH; + if (!Number.isSafeInteger(maxLength) || maxLength < 1) throw new RangeError("Invalid chat length limit"); + if (typeof value !== "string") return { ok: false, reason: "invalid message" }; + const text = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim(); + if (!text.length) return { ok: false, reason: "empty message" }; + if (text.length > maxLength) return { ok: false, reason: "message too long" }; + return { ok: true, text }; +} diff --git a/packages/core/src/meta/changelog.ts b/packages/core/src/meta/changelog.ts index 1abe43b80..a57453769 100644 --- a/packages/core/src/meta/changelog.ts +++ b/packages/core/src/meta/changelog.ts @@ -1,5 +1,5 @@ /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */ -export const VERSION = "0.18.0"; +export const VERSION = "0.18.1"; /** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */ export interface ChangelogEntry { @@ -11,6 +11,84 @@ export interface ChangelogEntry { /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */ export const CHANGELOG: Record = { + "0.18.1": { + migrate: [ + "Bump lockstep SDK packages to `^0.18.1`: `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.1`; `@jgengine/github` is `0.5.1`.", + "Declare `topology: \"shared\"` on runtime definitions. Shared Convex hosts migrate the bounded legacy roster into indexed membership and per-player session rows; use `getServerCapacity` for stable lobby subscriptions.", + "Put every command's read scope on its definition. Omitted scopes now load the actor and no chunks; explicitly request whole-world reads only where needed. Trusted host mutations can compose writes through `helpers.runCommand`.", + "Handle typed join outcomes (`ok` and `reason`) or use `useServerSession` and `JoinGate`. Pass runtimes to `jgengineCronSpecs`; tick registration requires an actual tick hook.", + "Declare currency precision with `decimals` and remove game-side cent conversions. Public amounts stay in major units and wallet writes round through integer minor units.", + "Replace custom presence, chat, territory and tick batching with `createWorldPresenceStore`, chat helpers, chunk territory, and `forEachOnlinePlayer`. Legacy claim backfills must finish before removing their old schema.", + "`AudioEngine.setListenerPose` now accepts `{ position, forward, up }`. Bare `Vec3` positions remain compatible for one release and use forward `[0, 0, -1]` with up `[0, 1, 0]`.", + "Hosted world stores are asynchronous. Implement `HostedWorldStore.load()` and `save()` as `Promise`-returning methods and construct persisted sessions with `createHostedWorldSessionAsync`; synchronous tests can keep using `memoryWorldStore`.", + "Look presets now default to `neutral`. Games that relied on the previous implicit cinematic sky/post stack should set `look: \"photoreal\"` (or keep `look: \"cinematic\"`) explicitly.", + "`ServerTickPlan.due` is `{ id, runs }[]` instead of a repeated id list. `planServerTick` repeated a system id once per missed interval, and the obvious way to consume that — `due.includes(id)` or a `Set` — ran the system once while the returned anchor had already advanced by every repeat, silently dropping the catch-up work. A host now reads `runs` (or `tickRunCount(plan, id)`) and loops that many times.", + "`CommandDef.validate` / `CommandDef.apply` take a fourth argument, `nowMs`. Existing implementations that ignore it are unaffected. Commands that took a `now` in their `input` should read this instead — the client supplied the old one.", + ], + added: [ + "Indexed shared membership and capacity rows, bounded neighborhood poses with 100 ms server/client gates, durable rate limits, authenticated diagnostics and bounded retention.", + "Territory footprint planning and atomic placement claims, chat validation and headless chat/join/territory surfaces, elapsed accrual, safe quantities, and player-profile reset through the initializer.", + "An explicit `--patch` release option.", + "Added smooth interpolation for remote presence player poses.", + "WS ping/pong messages and client RTT sampling on the backend object.", + "Added skin-backed HUD chrome variables, font loading, and the procedural `HudSkinPreview` fixture.", + "Core gamepad snapshot model, deadzone/curve resolver, and controller glyph labels.", + "Shell gamepad source polls connected pads, merges analog actions, supports synthetic `?gamepad=1` input, and exposes `ctx.input.rumble`.", + "Core action context stacks layer gameplay, menus, and passthrough overlays with snapshot/restore; shell action tracking now respects the active context.", + "Shell audio now supports listener orientation and per-sound Web Audio panners. `setListenerPose` accepts `{ position, forward, up }`; bare positions remain supported for one release.", + "Core perception tracks deterministic sight, hearing, occlusion, and bounded observation memory for AI.", + "`syncWorldColliders` now keeps terrain and static object colliders synchronized with a physics backend.", + "Core decision graphs provide deterministic selector, sequence, condition, action, and utility runtimes with snapshot/restore.", + "Core behavior descriptors can run registered decision graphs at the interest-scheduler cadence.", + "Core navigation meshes provide deterministic polygon routing, off-mesh links, closest-point queries, and nav raycasts.", + "WS sessions now issue resume tickets and retain disconnected memberships for a 15-second grace window, allowing reconnects to rejoin without replacing player state.", + "Shell entity sprites can play atlas-backed sprite clips while preserving raw texture sprites.", + "Tilemap layers can render atlas-backed textured tiles as one instanced quad mesh with parallax.", + "Core pixel-perfect orthographic frustum and pixel-grid camera snapping are available to shell cameras.", + "Core sprite atlas adapters and deterministic 2D sprite clip playback primitives.", + "`src/art-direction.md` scaffold and `check-art-direction` gate for created games.", + "Host-authoritative shell sessions now expose each accepted world frame to the local prediction buffer, reconciling and snapping the possessed pose only when drift exceeds its threshold.", + "Shared GLTF loader configuration for Draco and KTX2-compressed assets, with CDN defaults and renderer capability detection.", + "Material map overrides now support metalness, emissive, and height maps on standard and physical materials.", + "Presentation and editor documents now accept gradient, HDRI/EXR, and cube-map environment sources.", + "Authored `light` markers now feed bounded point/spot punctual lights with an eight-light default budget and editor warning.", + "Graphics profiles now resolve per-tier render scale, shadow budgets, culling distance, particles, cascades, and post stages; games can override them with `defineGame({ graphics })`.", + "`defineGame({ physics: { backend } })` now adopts a capsule character controller for shell/headless movement and registers the backend simulation after movement, including floor collision and step-up behavior.", + "Added the `physics-probe` dev scene for Rapier crate, ragdoll, and vehicle motion.", + "Assets now expose import-spec validation and magic-byte classification for supported source files.", + "Hosted WS sessions now support spectator joins, viewer roles, throttled low-priority snapshot modules, and default command rate limits.", + "`@jgengine/navbake` bakes deterministic navigation meshes and editor documents can store named bake data.", + "Core tactical queries derive cover points and score bounded candidate positions.", + "Added createSnapshotBuffer for delayed snapshot interpolation.", + "Host-authoritative world replication over WS. Hosted sessions expose revision-cursor pulls, so server subscribers receive compact world diffs and automatically recover with a fresh baseline after missed revisions; viewer-specific projections still receive per-viewer baselines.", + "Animation graph — `ModelAnimationConfig.graph` and `@jgengine/core/anim/animGraph`. A serializable, layered animation state machine: `clip`, `blend1D`, and `blend2D` states, transitions as data (parameter comparisons, consumed triggers, exit times, crossfade durations), masked and additive layers, and clip events. `createAnimGraphRuntime` is headless and owns every clip's playback time and weight, so the shell's mixer only seeks and weights actions (`useModelAnimation` takes the graph path when `graph` is set) and a headless host or test advances the same graph without three.js. The shell feeds the entity's smoothed ground speed as `speed`, merges parameters from the entity blackboard key `ANIM_PARAMS_KEY`, arms triggers from `entity.animation` / `combat.hitReaction` / `entity.died`, and emits the new `animation.event` for clip events (foot plants, hit frames). `locomotionGraph()` builds the previous idle/walk/run plus one-shot behavior as one authored graph.", + "`PhysicsBackend` — one interface over interchangeable rigid-body solvers. `@jgengine/core/physics/physicsBackend` defines bodies (`BodyDesc` with a box / sphere / capsule / convex / trimesh / heightfield shape union, quaternion rotation, angular velocity, layers and mask, CCD flag), joints, `raycast`, `shapecast`, `overlap`, contact events, and `snapshot`/`restore` with stable handles; `capabilities` says what a backend really does. `createPhysicsWorldBackend` puts the zero-dependency `PhysicsWorld` behind it (translation-only, non-box shapes collide as their bounding box, warned once), and `runPhysicsBackendConformance` is the behavioral contract any adapter runs. `createCharacterController` (`@jgengine/core/movement/characterController`) is the first consumer: a capsule collide-and-slide controller with step-up, slope limit, ceiling test, crouch with headroom check, ground snapping, and moving-platform carry, pure over any backend and retunable during play.", + "Fixed-step simulation with render interpolation — `defineGame({ simulation: { hz } })` and `ctx.sim`. Every driver (shell frame, HUD-only, headless runner, authoritative host) now steps through one `SimLoop` (`@jgengine/core/runtime/simLoop`): a fixed `hz` accumulates real time and runs equal-dt steps behind a catch-up cap (`maxCatchUpSteps`, default 5) instead of spiralling; the default stays `\"variable\"` (one step per frame) so existing games are unchanged until they opt in. Under a fixed rate the shell renders entities at `ctx.sim.renderPose(id)` — the pose interpolated between the previous and current step (`@jgengine/core/runtime/poseInterpolation`), with teleports past `snapDistance` snapping — so a 20 Hz sim no longer stutters. `ctx.sim.tick()` counts steps and `InputFrame.tick` carries it over the wire; `ctx.sim.addStage({ phase, run })` inserts per-step work before movement, after movement, or after `onTick` (the hook a physics backend or netcode buffer registers through). System fixed-rate groups gain the same substep cap. For replay and rollback: `seededRng` streams expose `state()`/`restore()` (`rngStateOf`/`restoreRng` for any `ctx.rng`), `createSimSnapshotRegistry`/`createContextSimSnapshot` capture world, clock, loop, and rng cursor together, `createInputRecorder` logs frames by tick, and `HeadlessRunner` gains `snapshot()`/`restore()`/`tick()` — a test proves that replaying recorded input from a snapshot reproduces every entity pose.", + "City and street races share one generator. `extractCircuitRoute` lifts a sealed-off race lap from a city street network; city rules, editor baking, playground modes, and the shell renderer carry the same street dials through the resulting race surface and junction dressing.", + "`ctx.particles` — the game-reachable particle seam. Game logic queues one-shot bursts (`ctx.particles.burst(config, count)`) and keyed standing emitters (`attach`/`retune`/`detach`, with `follow` to track a scene entity) as plain serializable data (`@jgengine/core/vfx/particleDirector`); the shell auto-mounts a renderer that turns them into pooled GPU point clouds via the existing `createParticleSystem`/`ParticleField`, caps pool sizes by graphics quality (low 128 / medium 256 / high 512 per effect, degrading density instead of dropping effects), and reaps finished bursts. Dust, sparks, exhaust, and debris no longer require forking the shell; headless tests assert the requested effects straight off the director.", + "`resolveSourceWalkerStep(source, cache, position, stepX, stepZ, options)` (`@jgengine/core/movement/solidObstacles`) — `resolveWalkerStep` with no `GameContext`, for movement rules that live in a pure package with no React or three.js. `SolidObstacleSource`, `ObstacleReachCache`, `createObstacleReachCache`, `sourceObstacleReach`, `sourceObstaclesNear`, and `slideStep` are public alongside it instead of `@internal`, so a game no longer has to push the resolver up into its app layer and inject it back down. `resolveWalkerStep`'s doc now states that it already resolves X and Z separately, so a caller doing its own axis-split must hand it one axis per call.", + "`tickRunCount(plan, id)` (`@jgengine/core/time/serverTick`) — how many runs a plan owes a system, or 0 when it is not due.", + "`grass()` exposes the clump/lighting knobs its shader already had. `GrassEnvironmentConfig` gains optional `bladeBend`, `tuftRadius`, `colorVariation`, and `normalLift`, forwarded by the shell's environment renderer — a world descriptor can now author bushy wind-combed scrub instead of the fixed lawn-blade look; defaults unchanged.", + "`TerrainDetailConfig.sweeps` — the detail shader's two macro colour sweeps (sun-dried patches, cooler wet pockets) take per-world RGB multipliers instead of hardcoded meadow tints. The old green-leaning \"lush pocket\" default painted moss onto arid/ashen biomes; defaults are unchanged, so a desert world should set warm/neutral `sweeps` explicitly.", + "`@jgengine/rapier` — a Rapier-backed `PhysicsBackend` adapter with native capsules, shapecasts, rotation, CCD, joints, and collision queries.", + ], + changed: [ + "Hosted game runner queues authoritative inputs by simulation tick and applies the in-force frame each fixed step.", + "Create scaffolds now use a 60 Hz simulation with interpolation enabled by default.", + "Rigged `ModelConfig` entries can opt into shell foot IK with named thigh/shin/foot bones; the driver raycasts the shared scene terrain, solves each chain, and fades corrections while airborne.", + "Animation graph clips may carry root-bone position tracks; states with `rootMotion: true` return the sampled displacement and the shell applies it to the entity while restoring the root bone's bind translation.", + "Added renderer-free two-bone, FABRIK, and look-at inverse-kinematics solvers to `@jgengine/core`.", + "Shell model animation now routes states and one-shots through the shared animation graph runtime.", + "CI now clones the external games repository with `GAMES_CLONE_TOKEN` and runs the game shape, content, front-end, art-direction, and feel gates against it; those gates fail loudly in CI when the checkout is missing while remaining no-ops locally.", + "`jgengine create` now accepts `--player`, `--ground`, and `--scene` so generated games can choose their model, terrain ground, and empty or starter scene.", + "Directional shadow frustum config actually reaches the GPU. `shadowCameraSize` on a single (non-cascaded) shadow light was silently inert: the shell wrote the bounds through R3F pierced props and nothing refreshed the shadow camera's projection matrix, so three rendered depth with its default ~10-unit box at the world origin — the reason no open-world game had cast shadows away from spawn. The shell now syncs the projection (and reallocates the shadow map when `shadowMapSize` changes). `bloom.threshold`'s doc also now states it measures raw HDR before the exposure stage.", + "CSM (`cascades > 1`) no longer over-lights streamed meshes or clobbers material shaders. Cascade scoping is injected per material, but the scan ran every 30 frames, so a model that streamed in rendered lit by every cascade light at once (a cascades× sun) until the next scan — a sub-second flash on desktop, the whole shot on capture rigs. The scan now runs every frame behind a WeakSet, and `setupMaterial` chains with a material's own `onBeforeCompile` (rim light, detail maps) instead of replacing it.", + "The host's wall clock reaches the runtime hooks. `RuntimeInitContext` (so `onInit`, `onNewPlayer`, and `onTick` alike) carries `nowMs`, the host timestamp for that call. Anything keyed to real time — a UTC date rollover, a `lastTickAt` anchor read by non-tick code, an RNG seed or `createdAt` stamp — reads it rather than accumulating `dtSeconds` against an epoch persisted in the snapshot, which drifts by whatever the tick loses to stalls and `maxCatchUp` clamping. `GameRuntime.hydrate` takes an optional `nowMs`; `tick`, `joinPlayer`, and `runCommand` take an optional trailing `nowMs`. All default to `Date.now()`, and the shipped WS and Convex hosts pass the timestamp they already computed to plan the tick.", + "Imported models cast and receive shadows. `cloneModelScene` never set `castShadow`/`receiveShadow` on a GLB's meshes (only the magenta fallback box had them), so every catalog model in every game rendered shadowless however the lights were configured. Cloned models now default to `ModelConfig.shadows: \"both\"`; set `\"cast\"`, `\"receive\"`, or `\"none\"` per model (foliage cards, decals, viewmodels).", + "`jgengine create` installs `game-design` and `jgengine-ui` by default. The minimal skill set was intake, editor, and verify, so a real project never saw the greenfield design contract or the UI art-direction template unless someone ran `jgengine skills --all`; `MINIMAL_GAME_SKILLS` now carries both.", + ], + removed: [], + }, "0.18.0": { migrate: [ "Bump lockstep SDK packages to `^0.18.0`: `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.0`; `@jgengine/github` is `0.5.0`.", diff --git a/packages/core/src/multiplayer/poseSyncGate.test.ts b/packages/core/src/multiplayer/poseSyncGate.test.ts index ea793c239..f4f916732 100644 --- a/packages/core/src/multiplayer/poseSyncGate.test.ts +++ b/packages/core/src/multiplayer/poseSyncGate.test.ts @@ -73,3 +73,12 @@ describe("createPoseSyncGate", () => { expect(gate.evaluate({ ...withAppearance }, TUNING.minIntervalMs)).toBe(false); }); }); + + +test("default pose gate caps moving clients at ten writes per second", () => { + const gate = createPoseSyncGate(); + const pose = { x: 0, y: 0, z: 0, rotationY: 0, rotationPitch: 0 }; + expect(gate.evaluate(pose, 0)).toBe(true); + expect(gate.evaluate({ ...pose, x: 1 }, 99)).toBe(false); + expect(gate.evaluate({ ...pose, x: 1 }, 100)).toBe(true); +}); diff --git a/packages/core/src/multiplayer/poseSyncGate.ts b/packages/core/src/multiplayer/poseSyncGate.ts index 07b110232..e8968902b 100644 --- a/packages/core/src/multiplayer/poseSyncGate.ts +++ b/packages/core/src/multiplayer/poseSyncGate.ts @@ -42,7 +42,16 @@ function poseChanged(a: PlayerPose, b: PlayerPose, tuning: PoseSyncTuning): bool ); } -export function createPoseSyncGate(tuning: PoseSyncTuning): PoseSyncGate { +/** Default ten-hertz client pose gate with a five-second heartbeat. */ +export const DEFAULT_POSE_SYNC_TUNING: PoseSyncTuning = { + minIntervalMs: 100, + heartbeatMs: 5_000, + positionEpsilon: 0.01, + verticalEpsilon: 0.01, + rotationEpsilon: 0.01, +}; + +export function createPoseSyncGate(tuning: PoseSyncTuning = DEFAULT_POSE_SYNC_TUNING): PoseSyncGate { let lastSentPose: PlayerPose | null = null; let lastSentAt = 0; diff --git a/packages/core/src/multiplayer/presenceContract.ts b/packages/core/src/multiplayer/presenceContract.ts index 9d6ef983a..d6be8e721 100644 --- a/packages/core/src/multiplayer/presenceContract.ts +++ b/packages/core/src/multiplayer/presenceContract.ts @@ -14,6 +14,13 @@ export interface PresencePose { export interface PresenceSession { homeGameId: TGameId; externalId: string; + viewerChunkKey?: string; +} + +/** Resident identity used to suppress offline actors while their owner is online. */ +export interface PresenceResidentRow { + actorExternalId: string; + ownerActorId?: string; } export interface EnsurePresenceArgs { diff --git a/packages/core/src/multiplayer/presenceModel.test.ts b/packages/core/src/multiplayer/presenceModel.test.ts index 21f2f2602..130eb0c9b 100644 --- a/packages/core/src/multiplayer/presenceModel.test.ts +++ b/packages/core/src/multiplayer/presenceModel.test.ts @@ -150,6 +150,7 @@ describe("decidePoseSync", () => { for (let i = 0; i < 10; i += 1) { now += 10; const d = decidePoseSync(state, { position: { x: 100, z: 0 } }, RULES, now); + if (!d.changed && !d.refreshKeepAlive) continue; state = { position: d.position, rotationY: d.rotationY, @@ -217,3 +218,15 @@ describe("pickReusablePresence", () => { expect(pickReusablePresence([])).toBeUndefined(); }); }); + + +test("pose write interval rejects movement, appearance and rotation until the boundary", () => { + const incoming = { position: { x: 1, y: 1, z: 0 }, rotationY: 1, appearance: { skin: "new" } }; + const rejected = decidePoseSync(CURRENT, incoming, { ...RULES, minIntervalMs: 100 }, 1099); + expect(rejected.changed).toBe(false); + expect(rejected.refreshKeepAlive).toBe(false); + expect(rejected.position).toEqual(CURRENT.position); + expect(rejected.rotationY).toBe(CURRENT.rotationY); + expect(rejected.appearance).toBeUndefined(); + expect(decidePoseSync(CURRENT, incoming, { ...RULES, minIntervalMs: 100 }, 1100).changed).toBe(true); +}); diff --git a/packages/core/src/multiplayer/presenceModel.ts b/packages/core/src/multiplayer/presenceModel.ts index c1c774f08..12218838c 100644 --- a/packages/core/src/multiplayer/presenceModel.ts +++ b/packages/core/src/multiplayer/presenceModel.ts @@ -16,6 +16,8 @@ export interface IncomingPose { export interface PoseSyncRules { /** Speed cap (units/sec) for client-authoritative movement. */ maxSpeed: number; + /** Minimum time between accepted pose writes. Defaults to 100 ms. */ + minIntervalMs?: number; /** Vertical offset clamp above floorY (e.g. peak jump height). */ maxVerticalOffset: number; /** World-floor Y used as the base of the jump band. Defaults to 0. */ @@ -30,6 +32,7 @@ export interface PoseSyncRules { /** Canonical client-authoritative pose-sync tuning shared by every host transport (WS, Convex). */ export const DEFAULT_POSE_SYNC_RULES: PoseSyncRules = { maxSpeed: 12, + minIntervalMs: 100, maxVerticalOffset: 3, minElapsedSec: 0.05, maxElapsedSec: 0.5, @@ -83,6 +86,16 @@ export function decidePoseSync( nowMs: number, floorY?: number, ): PoseSyncDecision { + if (current.lastSeenAtMs !== undefined && nowMs - current.lastSeenAtMs < (rules.minIntervalMs ?? 100)) { + return { + position: { ...current.position }, + rotationY: current.rotationY, + rotationPitch: current.rotationPitch ?? 0, + appearance: current.appearance, + changed: false, + refreshKeepAlive: false, + }; + } const rawElapsedSec = Math.max(0, (nowMs - (current.lastSeenAtMs ?? nowMs)) / 1000); const elapsedSec = Math.min(rules.maxElapsedSec, rawElapsedSec); const maxDist = rules.maxSpeed * elapsedSec; diff --git a/packages/core/src/runtime/adapter.test.ts b/packages/core/src/runtime/adapter.test.ts index d79082471..4f51fc1fe 100644 --- a/packages/core/src/runtime/adapter.test.ts +++ b/packages/core/src/runtime/adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { convex, + servers, convexPresence, isPresenceOnly, isServerAuthoritative, @@ -66,3 +67,9 @@ describe("wsPresence / convexPresence", () => { }); }); }); + +test("shared server pools default to one world without a room member cap", () => { + const pool = servers({ topology: "shared", adapter: { kind: "offline" } }); + expect(pool.maxServers).toBe(1); + expect(pool.slotsPerServer).toBe(Number.MAX_SAFE_INTEGER); +}); diff --git a/packages/core/src/runtime/adapter.ts b/packages/core/src/runtime/adapter.ts index f83ab9cef..5a7211c6e 100644 --- a/packages/core/src/runtime/adapter.ts +++ b/packages/core/src/runtime/adapter.ts @@ -146,12 +146,20 @@ export function multiplayerAdapterKind(multiplayer: unknown): string | null { } export type ServersPoolConfig = { - maxServers: number; - slotsPerServer: number; minPlayersToStart?: number; adapter: MultiplayerAdapterConfig; -}; +} & ( + | { topology: "shared"; maxServers?: number; slotsPerServer?: number } + | { topology?: "rooms"; maxServers: number; slotsPerServer: number } +); +/** Resolve the shared-world pool to one unbounded world; room pools keep explicit capacities. */ export function servers(config: ServersPoolConfig) { - return config; + const shared = config.topology === "shared"; + const maxServers = shared ? 1 : config.maxServers; + const slotsPerServer = shared ? Number.MAX_SAFE_INTEGER : config.slotsPerServer; + if (!Number.isSafeInteger(maxServers) || maxServers < 1 || !Number.isSafeInteger(slotsPerServer) || slotsPerServer < 1) { + throw new RangeError("Server pool capacities must be positive safe integers"); + } + return { ...config, topology: config.topology ?? "rooms", maxServers, slotsPerServer }; } diff --git a/packages/core/src/runtime/commandInput.test.ts b/packages/core/src/runtime/commandInput.test.ts new file mode 100644 index 000000000..412f013f8 --- /dev/null +++ b/packages/core/src/runtime/commandInput.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test"; +import { readQuantity } from "./commandInput"; + +test("purchase quantities reject malformed and out of bounds input without coercion", () => { + for (const value of [NaN, Infinity, -Infinity, -1, 0, 1.5, 101, "2", null, {}, undefined]) { + expect(readQuantity(value, { min: 1, max: 100 })).toEqual({ ok: false, reason: "invalid-quantity" }); + } + expect(readQuantity(100, { min: 1, max: 100 })).toEqual({ ok: true, quantity: 100 }); + expect(readQuantity(0, { min: 0 })).toEqual({ ok: true, quantity: 0 }); + expect(() => readQuantity(1, { min: 2, max: 1 })).toThrow(); +}); diff --git a/packages/core/src/runtime/commandInput.ts b/packages/core/src/runtime/commandInput.ts new file mode 100644 index 000000000..03c261a2a --- /dev/null +++ b/packages/core/src/runtime/commandInput.ts @@ -0,0 +1,16 @@ +/** Validated integer quantity or a stable input rejection. */ +export type QuantityResult = { ok: true; quantity: number } | { ok: false; reason: "invalid-quantity" }; + +/** Read an untrusted whole-item quantity without coercion, truncation, or clamping. + * @capability command-quantity validate bounded whole-item counts at command boundaries + */ +export function readQuantity(value: unknown, options: { min?: number; max?: number } = {}): QuantityResult { + const min = options.min ?? 1; + const max = options.max ?? Number.MAX_SAFE_INTEGER; + if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min < 0 || max < min) { + throw new RangeError("Quantity bounds must be nonnegative safe integers in order"); + } + return typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max + ? { ok: true, quantity: value } + : { ok: false, reason: "invalid-quantity" }; +} diff --git a/packages/core/src/runtime/commandRunner.ts b/packages/core/src/runtime/commandRunner.ts index 5ef029150..d29c73f2a 100644 --- a/packages/core/src/runtime/commandRunner.ts +++ b/packages/core/src/runtime/commandRunner.ts @@ -12,14 +12,17 @@ export type CommandScope = { chunkKeys?: readonly string[]; }; +/** Declarative read scope; actor is resolved before host hydration. */ +export type CommandScopeDefinition = Omit & { players?: readonly string[] | "actor" }; + export type CommandDef = { /** * What this command reads and writes, derived from its own input. A host that hydrates through a * scope loads only this slice instead of the whole world, and refuses the command if `apply` then * dirties a player or chunk the scope did not name — writing an unhydrated chunk would overwrite - * the stored one with a blank. Omit it to keep the whole-world default. + * the stored one with a blank. Omit it to load only the actor and no chunks; return `{}` to request the whole world. */ - scope?: (input: TInput, actorUserId: string) => CommandScope; + scope?: (input: TInput, actorUserId: string) => CommandScopeDefinition; /** `nowMs` is the host wall clock, in ms — read it rather than a `now` the client put in `input`. */ validate: ( snapshot: import("./snapshot").GameRuntimeSnapshot, @@ -36,7 +39,7 @@ export type CommandDef = { }; /** - * The scope a command declares for this input, or `undefined` when it declares none (hydrate everything). + * The resolved scope, defaulting to the actor and no chunks. * Evaluate it before hydration: the actor and the command's own input are both known by then. * @internal */ @@ -45,8 +48,10 @@ export function resolveCommandScope( commandName: string, input: TInput, actorUserId: string, -): CommandScope | undefined { - return commands[commandName]?.scope?.(input, actorUserId); +): CommandScope { + const scope = commands[commandName]?.scope?.(input, actorUserId) ?? { players: "actor", chunkKeys: [] }; + const { players, ...rest } = scope; + return players === undefined ? rest : { ...rest, players: players === "actor" ? [actorUserId] : players }; } /** diff --git a/packages/core/src/runtime/commandScope.test.ts b/packages/core/src/runtime/commandScope.test.ts index dc3e77171..3ab340cce 100644 --- a/packages/core/src/runtime/commandScope.test.ts +++ b/packages/core/src/runtime/commandScope.test.ts @@ -24,8 +24,8 @@ test("resolveCommandScope reads the scope a command derives from its own input", players: ["alice"], chunkKeys: ["0,0"], }); - expect(resolveCommandScope(commands, "world.anything", { chunkKey: "0,0" }, "alice")).toBeUndefined(); - expect(resolveCommandScope(commands, "missing", { chunkKey: "0,0" }, "alice")).toBeUndefined(); + expect(resolveCommandScope(commands, "world.anything", { chunkKey: "0,0" }, "alice")).toEqual({ players: ["alice"], chunkKeys: [] }); + expect(resolveCommandScope(commands, "missing", { chunkKey: "0,0" }, "alice")).toEqual({ players: ["alice"], chunkKeys: [] }); }); test("scopeWithActor keeps the actor hydrated without widening an unscoped roster", () => { @@ -68,3 +68,7 @@ test("joinScope defaults to the joining member alone when a runtime declares no }); expect(scoped.joinScope("alice", true)).toEqual({ players: ["alice"] }); }); + + test("actor shorthand resolves before hydration", () => { + expect(resolveCommandScope({ place: { scope: () => ({ players: "actor", chunkKeys: ["0,0"] }), validate: () => null, apply: s => s } }, "place", {}, "alice")).toEqual({ players: ["alice"], chunkKeys: ["0,0"] }); + }); diff --git a/packages/core/src/runtime/gameContext.test.ts b/packages/core/src/runtime/gameContext.test.ts index e82a98227..ed83ae9c7 100644 --- a/packages/core/src/runtime/gameContext.test.ts +++ b/packages/core/src/runtime/gameContext.test.ts @@ -1575,3 +1575,15 @@ describe("runtime-reported collision meshes", () => { expect(shape.offset![1]).toBeCloseTo(0.8, 3); }); }); + +test("context economy uses a currency definition consistently across grant, balance, charge and debt", () => { + const ctx = makeContext(); + const cash = { id: "cash", name: "Cash", decimals: 3 }; + for (let tick = 0; tick < 1000; tick++) ctx.game.economy.grant("user_a", cash, 0.005); + expect(ctx.game.economy.balance("user_a", cash)).toBe(5); + expect(ctx.game.economy.charge("user_a", cash, 4.999)).toBeNull(); + expect(ctx.game.economy.balance("user_a", cash)).toBe(0.001); + expect(ctx.game.economy.charge("user_a", cash, 0.002)).toEqual({ reason: "insufficient-funds" }); + expect(ctx.game.economy.charge("user_a", cash, 0.002, { overdraft: true })).toBeNull(); + expect(ctx.game.economy.balance("user_a", cash)).toBe(-0.001); +}); diff --git a/packages/core/src/runtime/gameContext.ts b/packages/core/src/runtime/gameContext.ts index 480c013b8..d9d9c198c 100644 --- a/packages/core/src/runtime/gameContext.ts +++ b/packages/core/src/runtime/gameContext.ts @@ -530,6 +530,7 @@ export function createGameContext groundHeightAt: ground.sampleHeight, }, game: { + territory: options.territory, commands: { define: commandRegistry.define, has: commandRegistry.has, diff --git a/packages/core/src/runtime/gameContextTypes.ts b/packages/core/src/runtime/gameContextTypes.ts index 56666f514..f2378fcba 100644 --- a/packages/core/src/runtime/gameContextTypes.ts +++ b/packages/core/src/runtime/gameContextTypes.ts @@ -1,3 +1,5 @@ +import type { Territory } from "../world/territory"; +import type { CurrencyDefinition } from "../economy/currency"; import type { CardPile, CardPileConfig } from "../cards/cardPile"; import type { EffectInput, @@ -129,6 +131,7 @@ export interface GameContextOptions< definition: GameDefinition; content: GameContextContent; player: { userId: string; isNew: boolean }; + territory?: Territory; now?: () => number; occluder?: (from: EntityPosition, to: EntityPosition) => boolean; /** @@ -403,12 +406,12 @@ export interface GameContextLoot { } export interface GameContextEconomy { - balance(userId: string, currencyId: string): number; - grant(userId: string, currencyId: string, amount: number): void; + balance(userId: string, currencyId: string | CurrencyDefinition): number; + grant(userId: string, currencyId: string | CurrencyDefinition, amount: number): void; /** `options.overdraft` opts this charge into carrying a negative balance (`true` unlimited, `{ max }` capped) — omitted keeps the strict no-debt default. */ - charge(userId: string, currencyId: string, amount: number, options?: WalletChargeOptions): { reason: string } | null; + charge(userId: string, currencyId: string | CurrencyDefinition, amount: number, options?: WalletChargeOptions): { reason: string } | null; /** True once `balance(userId, currencyId)` has gone negative under an overdraft-enabled charge. */ - isOverdrawn(userId: string, currencyId: string): boolean; + isOverdrawn(userId: string, currencyId: string | CurrencyDefinition): boolean; } export interface GameContextItemUse { @@ -481,6 +484,7 @@ export interface GameContext { */ rng: () => number; game: { + territory?: Territory; commands: GameContextCommands; events: GameEvents; audio: GameAudio; diff --git a/packages/core/src/runtime/gameRuntime.ts b/packages/core/src/runtime/gameRuntime.ts index 9d31e504f..fee0a3676 100644 --- a/packages/core/src/runtime/gameRuntime.ts +++ b/packages/core/src/runtime/gameRuntime.ts @@ -48,6 +48,7 @@ export type RuntimeWorldContext = RuntimeInitContext & { export type GameRuntimeDefinition = { gameId: string; + topology?: "shared" | "rooms"; save: SaveConfig; commands: Record; loop?: ServerLoopHooks; @@ -66,6 +67,7 @@ export type HydrateInput = { export type GameRuntime = { gameId: string; + topology?: "shared" | "rooms"; save: SaveConfig; /** * Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and @@ -84,7 +86,7 @@ export type GameRuntime = { ) => ReturnType; /** * What a command declares it will touch, evaluated before hydration so a host can load that slice - * and nothing else. `undefined` means the command declared no scope — hydrate everything. + * and nothing else. Missing scopes load only the actor and no chunks; `{}` explicitly requests all state. */ commandScope: (commandName: string, input: unknown, actorUserId: string) => CommandScope | undefined; /** What a join has to hydrate. `undefined` means the whole world, as when `onNewPlayer` is unscoped. */ @@ -107,6 +109,7 @@ export function createGameRuntime(definition: GameRuntimeDefinition): GameRuntim return { gameId: definition.gameId, + topology: definition.topology, save: definition.save, hasTick: loop?.onTick !== undefined, @@ -222,3 +225,12 @@ export function createGameRuntime(definition: GameRuntimeDefinition): GameRuntim }, }; } + +/** Seed a fresh player through the same onNewPlayer hook as joining, without hydrating or modifying a world. */ +export function initialPlayerState(runtime: GameRuntime, userId: string, nowMs: number = Date.now()): RuntimePlayerRow { + const snapshot = createRuntimeSnapshot({ gameId: runtime.gameId, serverId: "" }); + const initialized = runtime.joinPlayer(snapshot, userId, true, nowMs); + const player = initialized.players[userId]; + if (!player) throw new Error(`onNewPlayer removed player "${userId}"`); + return structuredClone(player); +} diff --git a/packages/core/src/runtime/hostPolicy.test.ts b/packages/core/src/runtime/hostPolicy.test.ts index cae6d5f2c..88175839a 100644 --- a/packages/core/src/runtime/hostPolicy.test.ts +++ b/packages/core/src/runtime/hostPolicy.test.ts @@ -236,3 +236,13 @@ describe("selectJoinTarget", () => { expect(selectJoinTarget([], { userId: "alice", mode: "singleton" })).toEqual({ kind: "create" }); }); }); + +test("shared membership validates larger finite capacities and checks counts without a roster", () => { + expect(validateSlotsPerServer(1000, "shared")).toEqual({ ok: true }); + expect(validateSlotsPerServer(Number.MAX_SAFE_INTEGER, "shared")).toEqual({ ok: true }); + expect(validateSlotsPerServer(1000).ok).toBe(false); + for (const capacity of [NaN, Infinity, 0, 1.5, Number.MAX_SAFE_INTEGER + 1]) expect(validateSlotsPerServer(capacity, "shared").ok).toBe(false); + expect(isServerFull(1000, 1000, false)).toBe(true); + expect(isServerFull(1000, 1000, true)).toBe(false); + expect(isServerFull(999, 1000, false)).toBe(false); +}); diff --git a/packages/core/src/runtime/hostPolicy.ts b/packages/core/src/runtime/hostPolicy.ts index c7dbace72..d573398a4 100644 --- a/packages/core/src/runtime/hostPolicy.ts +++ b/packages/core/src/runtime/hostPolicy.ts @@ -2,7 +2,7 @@ import { normalizeJoinCode } from "../multiplayer/matchmaking"; import type { GameServerStatus, SessionVisibility } from "./hostPersistence"; /** - * Hard ceiling on `slotsPerServer` for a single hosted server row. + * Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead. * * A hosted server keeps its whole roster, every member's session state, and the world row in one * backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and @@ -15,11 +15,12 @@ export const JG_MAX_MEMBERS_PER_SERVER = 256; /** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */ export function validateSlotsPerServer( slotsPerServer: number, + topology: "rooms" | "shared" = "rooms", ): { ok: true } | { ok: false; reason: string } { - if (!Number.isInteger(slotsPerServer) || slotsPerServer < 1) { + if (!Number.isSafeInteger(slotsPerServer) || slotsPerServer < 1) { return { ok: false, reason: `slotsPerServer must be a positive integer, got ${slotsPerServer}` }; } - if (slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) { + if (topology !== "shared" && slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) { return { ok: false, reason: @@ -129,12 +130,16 @@ export function isServerMember(memberUserIds: readonly string[], userId: string) * True when the server has no free slots for a non-member. Existing members never count as * "full" so rejoin/leave cycles keep working. */ +export function isServerFull(memberCount: number, slotsPerServer: number, isMember: boolean): boolean; +export function isServerFull(memberUserIds: readonly string[], slotsPerServer: number, userId: string): boolean; export function isServerFull( - memberUserIds: readonly string[], + members: number | readonly string[], slotsPerServer: number, - userId: string, + member: boolean | string, ): boolean { - return memberUserIds.length >= slotsPerServer && !memberUserIds.includes(userId); + const count = typeof members === "number" ? members : members.length; + const isMember = typeof member === "boolean" ? member : typeof members !== "number" && members.includes(member); + return count >= slotsPerServer && !isMember; } /** diff --git a/packages/core/src/runtime/hostedWorldSession.ts b/packages/core/src/runtime/hostedWorldSession.ts index fe2a072b8..67b064871 100644 --- a/packages/core/src/runtime/hostedWorldSession.ts +++ b/packages/core/src/runtime/hostedWorldSession.ts @@ -6,27 +6,8 @@ import { createHostedGameRunner, type HostedGameRunner, type InputFrame } from " import type { WorldDiff } from "./worldReplication"; import type { SnapshotViewer, WorldSnapshot } from "./worldSnapshot"; -/** One hosted world's persisted authoritative state — the unit a {@link HostedWorldStore} loads and saves. */ -export interface HostedWorldRecord { - snapshot: WorldSnapshot; - revision: number; -} - -/** - * Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`. - * Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and - * saves on a cadence; a stateless host reconstructs from `load()` each invocation. - */ -export interface HostedWorldStore { - load(): Promise; - save(record: HostedWorldRecord): Promise; -} - -/** Synchronous store adapter retained for deterministic in-process tests. */ -export interface SyncHostedWorldStore { - load(): HostedWorldRecord | null; - save(record: HostedWorldRecord): void; -} +import type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore"; +export type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore"; /** In-process {@link HostedWorldStore} for tests, local play, and the browser-tab P2P host. * @internal diff --git a/packages/core/src/runtime/hostedWorldStore.ts b/packages/core/src/runtime/hostedWorldStore.ts new file mode 100644 index 000000000..ae892f71c --- /dev/null +++ b/packages/core/src/runtime/hostedWorldStore.ts @@ -0,0 +1,23 @@ +import type { WorldSnapshot } from "./worldSnapshot"; + +/** One hosted world's persisted authoritative state — the unit a {@link HostedWorldStore} loads and saves. */ +export interface HostedWorldRecord { + snapshot: WorldSnapshot; + revision: number; +} + +/** + * Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`. + * Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and + * saves on a cadence; a stateless host reconstructs from `load()` each invocation. + */ +export interface HostedWorldStore { + load(): Promise; + save(record: HostedWorldRecord): Promise; +} + +/** Synchronous store adapter retained for deterministic in-process tests. */ +export interface SyncHostedWorldStore { + load(): HostedWorldRecord | null; + save(record: HostedWorldRecord): void; +} diff --git a/packages/core/src/runtime/initialPlayerState.test.ts b/packages/core/src/runtime/initialPlayerState.test.ts new file mode 100644 index 000000000..1f9b3d716 --- /dev/null +++ b/packages/core/src/runtime/initialPlayerState.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { createGameRuntime, initialPlayerState } from "./gameRuntime"; + +test("profile resets rerun the join initializer on a fresh player with the host clock", () => { + const runtime = createGameRuntime({ gameId: "test", save: "none", commands: {}, loop: { + onNewPlayer(ctx) { + const id = ctx.player.userId; + ctx.snapshot.players[id]!.economy.cash = 50; + ctx.snapshot.players[id]!.session = { createdAt: ctx.nowMs, isNew: ctx.player.isNew }; + }, + } }); + const first = initialPlayerState(runtime, "alice", 1000); + first.economy.cash = -100; + expect(initialPlayerState(runtime, "alice", 2000)).toMatchObject({ + userId: "alice", economy: { cash: 50 }, session: { createdAt: 2000, isNew: true }, + }); + expect(initialPlayerState(createGameRuntime({ gameId: "test", save: "none", commands: {} }), "bob").economy).toEqual({}); +}); diff --git a/packages/core/src/runtime/snapshot.ts b/packages/core/src/runtime/snapshot.ts index c7184ad47..c84d12416 100644 --- a/packages/core/src/runtime/snapshot.ts +++ b/packages/core/src/runtime/snapshot.ts @@ -37,6 +37,8 @@ export type RuntimeInventorySlot = { }; export type RuntimePlayerRow = { + territoryOwnedCount?: number; + ownedTerritoryChunkKeys?: string[]; userId: string; inventories: Record; economy: Record; @@ -48,6 +50,9 @@ export type RuntimePlayerRow = { }; export type RuntimeChunkRow = { + /** Cell key to owner id, persisted with its spatial chunk. */ + territory?: Record; + territoryReceipts?: Record; chunkKey: string; objects: RuntimeObjectRow[]; entities: RuntimeEntityRow[]; diff --git a/packages/core/src/runtime/transport.ts b/packages/core/src/runtime/transport.ts index 9b1d88039..48305c14a 100644 --- a/packages/core/src/runtime/transport.ts +++ b/packages/core/src/runtime/transport.ts @@ -22,6 +22,11 @@ export type JoinServerResult = { resumeTicket?: ResumeTicket; }; +/** Join failures are returned to the caller so the triggering surface can offer retry. */ +export type JoinServerOutcome = + | (JoinServerResult & { ok: true }) + | { ok: false; reason: "full" | "closed" | "unauthorized" }; + /** Connection role used for authoritative world access; spectators are read-only. */ export type MultiplayerRole = "player" | "spectator"; @@ -57,7 +62,7 @@ export type GameRuntimeFeedView = { }; export type GameRuntimeTransport = { - joinServer: (args: { gameId: string; serverId?: string; role?: MultiplayerRole }) => Promise; + joinServer: (args: { gameId: string; serverId?: string; role?: MultiplayerRole }) => Promise; leaveServer: (args: { serverId: string }) => Promise; runCommand: (args: RunCommandArgs) => Promise; }; diff --git a/packages/core/src/runtime/worldChannel.test.ts b/packages/core/src/runtime/worldChannel.test.ts index 30a58749c..b3b8b0e49 100644 --- a/packages/core/src/runtime/worldChannel.test.ts +++ b/packages/core/src/runtime/worldChannel.test.ts @@ -108,6 +108,9 @@ describe("world channel (multi-client host↔client over an in-process loopback) linkA.join(true); const frame = { held: ["moveForward", "sprint"], pointer: { x: 0.5, y: -0.25, active: true } }; linkA.input(frame); + expect(session.runner().heldInput("alice")).toEqual(frame); + expect(session.runner().context().game.players?.input("alice")).toBeNull(); + session.tick(1 / 60); expect(session.runner().context().game.players?.input("alice")).toEqual(frame); expect(session.runner().heldInput("alice")).toEqual(frame); diff --git a/packages/core/src/runtime/worldChunks.test.ts b/packages/core/src/runtime/worldChunks.test.ts index 091ea6302..5bd8b745f 100644 --- a/packages/core/src/runtime/worldChunks.test.ts +++ b/packages/core/src/runtime/worldChunks.test.ts @@ -6,6 +6,7 @@ import { chunkKeyAt, chunkKeyOf, chunkKeysInRadius, + chunkKeysAround, createEmptyChunkRow, deleteChunk, parseChunkKey, @@ -51,3 +52,10 @@ test("updateChunk faults in an empty chunk and deleteChunk marks the key for rem expect(removed.chunks["2,-1"]).toBeUndefined(); expect(removed.dirty.chunks).toEqual(["2,-1"]); }); + +test("chunk neighborhoods cross negative coordinates and reject unbounded inputs", () => { + expect(chunkKeysAround([-1, 0, -1], 0)).toEqual(["-1,-1"]); + expect(chunkKeysAround([63, 0, 63])).toEqual(["-1,-1", "-1,0", "-1,1", "0,-1", "0,0", "0,1", "1,-1", "1,0", "1,1"]); + expect(() => chunkKeysAround([0, 0, 0], Infinity)).toThrow(); + expect(() => chunkKeysAround([NaN, 0, 0])).toThrow(); +}); diff --git a/packages/core/src/runtime/worldChunks.ts b/packages/core/src/runtime/worldChunks.ts index b4fb0c129..c1d9a6419 100644 --- a/packages/core/src/runtime/worldChunks.ts +++ b/packages/core/src/runtime/worldChunks.ts @@ -111,3 +111,19 @@ export function deleteChunk(snapshot: GameRuntimeSnapshot, chunkKey: string): Ga dirty: { ...snapshot.dirty, server: true, chunks: withChunkDirty(snapshot, chunkKey) }, }; } + +/** Square chunk neighborhood around a world position, including its own chunk. */ +export function chunkKeysAround( + position: readonly [number, number, number], + rings = 1, + size = DEFAULT_CHUNK_SIZE, +): string[] { + if (!Number.isSafeInteger(rings) || rings < 0 || rings > 128) throw new RangeError("rings must be an integer from 0 to 128"); + if (!Number.isFinite(size) || size <= 0 || !position.every(Number.isFinite)) throw new RangeError("Invalid chunk position or size"); + const center = chunkCoordAt(position, size); + const keys: string[] = []; + for (let cx = center.cx - rings; cx <= center.cx + rings; cx++) { + for (let cz = center.cz - rings; cz <= center.cz + rings; cz++) keys.push(chunkKeyOf({ cx, cz })); + } + return keys; +} diff --git a/packages/core/src/time/accrueSince.test.ts b/packages/core/src/time/accrueSince.test.ts new file mode 100644 index 000000000..e3ba6e685 --- /dev/null +++ b/packages/core/src/time/accrueSince.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { accrueSince } from "./accrueSince"; + +test("accrual settles elapsed time and caps a dormant return without replaying it", () => { + expect(accrueSince(1000, 2500)).toEqual({ elapsedMs: 1500, elapsedSeconds: 1.5, anchorMs: 2500 }); + const capped = accrueSince(1000, 101000, { capMs: 30000 }); + expect(capped.elapsedSeconds).toBe(30); + expect(accrueSince(capped.anchorMs, 101000).elapsedMs).toBe(0); + expect(accrueSince(1000, 500)).toEqual({ elapsedMs: 0, elapsedSeconds: 0, anchorMs: 1000 }); + expect(accrueSince(1000, 2000, { capMs: 0 }).elapsedMs).toBe(0); + expect(() => accrueSince(NaN, 2000)).toThrow(); + expect(() => accrueSince(1000, Infinity)).toThrow(); + expect(() => accrueSince(1000, 2000, { capMs: -1 })).toThrow(); +}); diff --git a/packages/core/src/time/accrueSince.ts b/packages/core/src/time/accrueSince.ts new file mode 100644 index 000000000..6357ed06c --- /dev/null +++ b/packages/core/src/time/accrueSince.ts @@ -0,0 +1,16 @@ +/** Elapsed duration and the next anchor to persist with accrued effects. */ +export interface Accrual { + elapsedMs: number; + elapsedSeconds: number; + anchorMs: number; +} + +/** Elapsed time since a persisted anchor, capped once; persist anchorMs with the accrued result. */ +export function accrueSince(anchorMs: number, nowMs: number, options: { capMs?: number } = {}): Accrual { + const cap = options.capMs ?? Infinity; + if (!Number.isFinite(anchorMs) || !Number.isFinite(nowMs) || Number.isNaN(cap) || cap < 0) { + throw new RangeError("Accrual requires finite anchors and a nonnegative cap"); + } + const elapsedMs = Math.min(cap, Math.max(0, nowMs - anchorMs)); + return { elapsedMs, elapsedSeconds: elapsedMs / 1000, anchorMs: Math.max(anchorMs, nowMs) }; +} diff --git a/packages/core/src/time/rateWindow.test.ts b/packages/core/src/time/rateWindow.test.ts new file mode 100644 index 000000000..f236233de --- /dev/null +++ b/packages/core/src/time/rateWindow.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test"; +import { decideRateWindow } from "./rateWindow"; + +test("sliding rate windows reject bursts and reopen exactly at expiry", () => { + const policy = { windowMs: 2000, max: 2 }; + const first = decideRateWindow([], 1000, policy); + const second = decideRateWindow(first.timestamps, 1500, policy); + expect(second.allowed).toBe(true); + expect(decideRateWindow(second.timestamps, 2000, policy)).toEqual({ allowed: false, timestamps: [1000, 1500], retryAfterMs: 1000 }); + expect(decideRateWindow(second.timestamps, 3000, policy)).toEqual({ allowed: true, timestamps: [1500, 3000], retryAfterMs: 0 }); + expect(second.timestamps).toEqual([1000, 1500]); + expect(decideRateWindow([3000], 1000, { windowMs: 2000, max: 1 }).allowed).toBe(false); + expect(() => decideRateWindow([], 0, { windowMs: 0, max: 1 })).toThrow(); + expect(() => decideRateWindow([], 0, { windowMs: 1000, max: 1.5 })).toThrow(); +}); diff --git a/packages/core/src/time/rateWindow.ts b/packages/core/src/time/rateWindow.ts new file mode 100644 index 000000000..ce943b743 --- /dev/null +++ b/packages/core/src/time/rateWindow.ts @@ -0,0 +1,18 @@ +/** Maximum accepted operations in a sliding duration. */ +export interface RateWindowPolicy { windowMs: number; max: number } +/** Admission result, next stored timestamps, and wait before retrying. */ +export interface RateWindowDecision { allowed: boolean; timestamps: number[]; retryAfterMs: number } + +/** + * A sliding rate window; persist timestamps only alongside the accepted operation. + * @capability rate-window-policy Evaluate deterministic request windows with retry timing. + */ +export function decideRateWindow(timestamps: readonly number[], nowMs: number, policy: RateWindowPolicy): RateWindowDecision { + if (!Number.isFinite(nowMs) || !Number.isFinite(policy.windowMs) || policy.windowMs <= 0 || + !Number.isSafeInteger(policy.max) || policy.max < 1) throw new RangeError("Invalid rate window policy"); + const active = timestamps.filter((at) => Number.isFinite(at) && at > nowMs - policy.windowMs).sort((a, b) => a - b); + if (active.length >= policy.max) { + return { allowed: false, timestamps: active, retryAfterMs: Math.max(0, active[active.length - policy.max]! + policy.windowMs - nowMs) }; + } + return { allowed: true, timestamps: [...active, nowMs], retryAfterMs: 0 }; +} diff --git a/packages/core/src/time/serverTick.test.ts b/packages/core/src/time/serverTick.test.ts index 4e58764f4..a3ba7e2c8 100644 --- a/packages/core/src/time/serverTick.test.ts +++ b/packages/core/src/time/serverTick.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { planServerTick, tickRunCount } from "@jgengine/core/time/serverTick"; +import { planServerTick, tickRunCount } from "./serverTick"; const SYSTEMS = [ { id: "fast", intervalMs: 5_000 }, @@ -58,3 +58,8 @@ describe("planServerTick", () => { expect(tickRunCount(plan, "slow")).toBe(0); }); }); + +test("tick plans preserve online player batching and reject unbounded batch sizes", () => { + expect(planServerTick([{ id: "income", intervalMs: 1000, scope: "onlinePlayers", batchSize: 25 }], {}, 0).due).toEqual([{ id: "income", runs: 1, scope: "onlinePlayers", batchSize: 25 }]); + expect(() => planServerTick([{ id: "income", intervalMs: 1000, batchSize: NaN }], {}, 0)).toThrow(); +}); diff --git a/packages/core/src/time/serverTick.ts b/packages/core/src/time/serverTick.ts index a32fa080f..8319bf83b 100644 --- a/packages/core/src/time/serverTick.ts +++ b/packages/core/src/time/serverTick.ts @@ -2,6 +2,8 @@ export interface TickSystemDefinition { id: TSystemId; /** Minimum real time between runs; the heartbeat fires the system on the first tick at or past it. */ intervalMs: number; + scope?: "onlinePlayers" | "allServers"; + batchSize?: number; } /** Last-run timestamp per system id. */ @@ -12,6 +14,8 @@ export interface TickSystemRun { id: TSystemId; /** Times to run the system now; above 1 when wall time stalled past several intervals. */ runs: number; + scope?: "onlinePlayers" | "allServers"; + batchSize?: number; } export interface ServerTickPlan { @@ -48,9 +52,11 @@ export function planServerTick( const due: TickSystemRun[] = []; const nextAnchors: TickAnchors = {}; for (const system of systems) { + if (system.batchSize !== undefined && (!Number.isSafeInteger(system.batchSize) || system.batchSize < 1 || system.batchSize > 1000)) throw new RangeError("batchSize must be an integer from 1 to 1000"); + const dispatch = { ...(system.scope === undefined ? {} : { scope: system.scope }), ...(system.batchSize === undefined ? {} : { batchSize: system.batchSize }) }; const lastRunAt = anchors[system.id]; if (lastRunAt === undefined || system.intervalMs <= 0) { - due.push({ id: system.id, runs: 1 }); + due.push({ id: system.id, runs: 1, ...dispatch }); nextAnchors[system.id] = now; continue; } @@ -61,11 +67,11 @@ export function planServerTick( } const missed = Math.floor(elapsed / system.intervalMs); if (missed > maxCatchUp) { - due.push({ id: system.id, runs: maxCatchUp }); + due.push({ id: system.id, runs: maxCatchUp, ...dispatch }); nextAnchors[system.id] = now; continue; } - due.push({ id: system.id, runs: missed }); + due.push({ id: system.id, runs: missed, ...dispatch }); nextAnchors[system.id] = lastRunAt + missed * system.intervalMs; } return { due, anchors: nextAnchors }; diff --git a/packages/core/src/world/placement.ts b/packages/core/src/world/placement.ts index 72b991195..88401ff7e 100644 --- a/packages/core/src/world/placement.ts +++ b/packages/core/src/world/placement.ts @@ -23,11 +23,14 @@ export interface PlacementRules { bounds?: Aabb; obstacles?: readonly PlacementObstacle[]; snap?: number; + /** Authoritative ownership/claim validation for this footprint. */ + territory?: (aabb: Aabb) => { ok: boolean }; } export type PlacementResult = | { status: "ok"; center: Vec2; aabb: Aabb } | { status: "rejected"; reason: "out-of-bounds" } + | { status: "rejected"; reason: "territory.blocked" } | { status: "rejected"; reason: "overlap"; obstacle: PlacementObstacle; index: number }; /** @@ -50,6 +53,7 @@ export function validatePlacement(request: PlacementRequest, rules: PlacementRul } } + if (rules.territory && !rules.territory(aabb).ok) return { status: "rejected", reason: "territory.blocked" }; return { status: "ok", center, aabb }; } diff --git a/packages/core/src/world/placementController.test.ts b/packages/core/src/world/placementController.test.ts index 834b4e4a8..6308824a9 100644 --- a/packages/core/src/world/placementController.test.ts +++ b/packages/core/src/world/placementController.test.ts @@ -145,3 +145,9 @@ describe("placementController slot mode", () => { expect(controller.hoveredSlotId()).toBe("c"); }); }); + +test("slot placement enforces territory rules", () => { + const controller = createPlacementController({ footprint: { w: 1, d: 1 }, slots: [{ id: "land", center: [0, 0, 0] }], rules: { territory: () => ({ ok: false }) } }); + expect(controller.hover({ point: [0, 0, 0], normal: [0, 1, 0] }).reason).toBe("territory.blocked"); + expect(controller.commit()).toBeNull(); +}); diff --git a/packages/core/src/world/placementController.ts b/packages/core/src/world/placementController.ts index a00c1bf34..b64635389 100644 --- a/packages/core/src/world/placementController.ts +++ b/packages/core/src/world/placementController.ts @@ -38,7 +38,7 @@ export interface PlacementPreview { footprint: Footprint; aabb: Aabb; valid: boolean; - reason?: "out-of-bounds" | "overlap" | "no-slot"; + reason?: "out-of-bounds" | "overlap" | "no-slot" | "territory.blocked"; snapMode: SnapMode; normal: PlacementVec3; /** Id of the resolved slot, when the controller is in slot mode and a slot matched. */ @@ -137,6 +137,13 @@ export function createPlacementController(config: PlacementControllerConfig): Pl normal: hit.normal, ...(slot === null ? { reason: "no-slot" as const } : { slotId: slot.id }), }; + if (slot !== null) { + const result = validatePlacement({ center, footprint, quarterTurns }, { ...rules, snap: undefined }); + if (result.status === "rejected") { + next.valid = false; + next.reason = result.reason; + } + } preview = next; return next; } diff --git a/packages/core/src/world/territory.test.ts b/packages/core/src/world/territory.test.ts new file mode 100644 index 000000000..29263f64e --- /dev/null +++ b/packages/core/src/world/territory.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test"; +import { createEmptyPlayerRow, createRuntimeSnapshot } from "../runtime/snapshot"; +import { claimTerritory, createTerritory, placeWithTerritory, planFootprintClaims, territoryFootprintCells, territoryOwnerOf, territoryChunkKeys } from "./territory"; +import { validatePlacement } from "./placement"; +function initial() { return createRuntimeSnapshot({ gameId: "test", serverId: "world", players: { a: { ...createEmptyPlayerRow("a"), economy: { cash: 100 } }, b: createEmptyPlayerRow("b") } }); } +test("claim costs grow once per unique cell and persist across chunks", () => { + const before = initial(); + const claimed = claimTerritory(before, "a", [{ x: -1, z: 0 }, { x: 64, z: 0 }, { x: -1, z: 0 }], { currency: "cash", price: n => (n + 1) * 5 }); + expect(claimed.ok).toBe(true); if (!claimed.ok) return; + expect(claimed.cost).toBe(15); expect(claimed.snapshot.players.a!.economy.cash).toBe(85); + expect(claimed.snapshot.dirty.chunks).toEqual(["-1,0", "1,0"]); + expect(before.chunks).toEqual({}); + expect(territoryOwnerOf(claimed.snapshot, { x: 64, z: 0 })).toBe("a"); + expect(planFootprintClaims(claimed.snapshot, "a", [{ x: 64, z: 0 }]).ok).toBe(true); +}); +test("gaps reject foreign neighbors across chunk boundaries", () => { + const result = claimTerritory(initial(), "b", [{ x: 63, z: 0 }]); if (!result.ok) throw new Error(); + expect(planFootprintClaims(result.snapshot, "a", [{ x: 64, z: 0 }], { gapCells: 1 })).toEqual({ ok: false, reason: "territory.blocked" }); + expect(planFootprintClaims(result.snapshot, "a", [{ x: 65, z: 0 }], { gapCells: 1 }).ok).toBe(true); +}); +test("affordability and placement failure do not mutate balances or claims", () => { + const snapshot = initial(); + expect(claimTerritory(snapshot, "a", [{ x: 0, z: 0 }], { currency: "cash", price: () => 101 })).toEqual({ ok: false, reason: "territory.unaffordable" }); + expect(placeWithTerritory(snapshot, "a", { minX: 0, minZ: 0, maxX: 1, maxZ: 1 }, { currency: "cash", price: () => 50 }, () => ({ ok: false, reason: "occupied" }))).toEqual({ ok: false, reason: "occupied" }); + expect(snapshot.players.a!.economy.cash).toBe(100); expect(snapshot.chunks).toEqual({}); +}); +test("starter is idempotent and release decrements persistent owned count", () => { + let snapshot = initial(); const territory = createTerritory({ get: () => snapshot, set: next => { snapshot = next; } }, () => ({ starterBlock: 3 })); + expect(territory.grantStarter("a", { x: 0, z: 0 }).ok).toBe(true); + expect(snapshot.players.a!.territoryOwnedCount).toBe(9); + territory.grantStarter("a", { x: 100, z: 100 }); + expect(snapshot.players.a!.territoryOwnedCount).toBe(9); + expect(territory.release("b", { x: 0, z: 0 })).toBe(false); + expect(territory.release("a", { x: 0, z: 0 })).toBe(true); + expect(snapshot.players.a!.territoryOwnedCount).toBe(8); +}); +test("footprints exclude touching edges and placement reports blocked territory", () => { + expect(territoryFootprintCells({ minX: -1, minZ: 0, maxX: 1, maxZ: 1 })).toEqual([{ x: -1, z: 0 }, { x: 0, z: 0 }]); + expect(validatePlacement({ center: [0, 0], footprint: { w: 1, d: 1 } }, { territory: () => ({ ok: false }) })).toEqual({ status: "rejected", reason: "territory.blocked" }); + expect(() => planFootprintClaims(initial(), "a", [{ x: 0, z: 0 }], { price: () => NaN })).toThrow(); +}); + +test("claim read scope includes cross-boundary gap chunks", () => { + expect(new Set(territoryChunkKeys([{ x: 63, z: 0 }], { gapCells: 1 }))).toEqual(new Set(["0,-1", "0,0", "1,-1", "1,0"])); +}); diff --git a/packages/core/src/world/territory.ts b/packages/core/src/world/territory.ts new file mode 100644 index 000000000..809d927c8 --- /dev/null +++ b/packages/core/src/world/territory.ts @@ -0,0 +1,173 @@ +import type { GameRuntimeSnapshot } from "../runtime/snapshot"; +import { chunkKeyAt, updateChunk } from "../runtime/worldChunks"; +import type { Aabb } from "./geometry"; + +/** Integer cell coordinates used for ownership on the ground plane. */ +export type TerritoryCell = { x: number; z: number }; +/** Cell size, foreign-owner gap, growth price, currency and starter-grant policy. */ +export type TerritoryPolicy = { + gapCells?: number; + cellSize?: number; + chunkSize?: number; + currency?: string; + price?: (ownedCount: number) => number; + starterBlock?: number; + nowMs?: number; +}; +/** Atomic claim outcome with the replacement snapshot and total charge. */ +export type TerritoryResult = { ok: true; snapshot: GameRuntimeSnapshot; cost: number } | { ok: false; reason: "territory.blocked" | "territory.unaffordable" }; +/** Read-only footprint quote or a blocked/insufficient-balance rejection. */ +export type TerritoryPlan = { ok: true; cells: TerritoryCell[]; cost: number } | { ok: false; reason: "territory.blocked" | "territory.unaffordable" }; +/** Host-owned snapshot access used by the territory facade. */ +export interface TerritoryStorage { get(): GameRuntimeSnapshot; set(snapshot: GameRuntimeSnapshot): void } + +function checked(policy: TerritoryPolicy): Required> { + if (policy.nowMs !== undefined && !Number.isFinite(policy.nowMs)) throw new RangeError("Invalid territory timestamp"); + const gapCells = policy.gapCells ?? 0, cellSize = policy.cellSize ?? 1, chunkSize = policy.chunkSize ?? 64; + if (!Number.isSafeInteger(gapCells) || gapCells < 0 || gapCells > 128 || !Number.isFinite(cellSize) || cellSize <= 0 || !Number.isFinite(chunkSize) || chunkSize <= 0) throw new RangeError("Invalid territory policy"); + return { gapCells, cellSize, chunkSize }; +} +function key(cell: TerritoryCell): string { + if (!Number.isSafeInteger(cell.x) || !Number.isSafeInteger(cell.z)) throw new RangeError("Invalid territory cell"); + return `${cell.x},${cell.z}`; +} +/** + * Persisted chunk key containing one territory cell. + * @capability territory-cell-chunk Map ownership cells to persisted world chunks. + */ +export function territoryChunkKey(cell: TerritoryCell, policy: TerritoryPolicy = {}): string { + key(cell); + const { cellSize, chunkSize } = checked(policy); + return chunkKeyAt([cell.x * cellSize, 0, cell.z * cellSize], chunkSize); +} +/** + * Exact chunks needed to validate a footprint and its foreign-owner gap. + * @capability territory-read-scope Load the footprint and foreign-owner gap without scanning the world. + */ +export function territoryChunkKeys(cells: readonly TerritoryCell[], policy: TerritoryPolicy = {}): string[] { + const { gapCells } = checked(policy); + const chunks = new Set(); + if (cells.length * (2 * gapCells + 1) ** 2 > 1_000_000) throw new RangeError("Territory neighborhood is too large"); + for (const cell of cells) { + key(cell); + for (let x = cell.x - gapCells; x <= cell.x + gapCells; x++) { + for (let z = cell.z - gapCells; z <= cell.z + gapCells; z++) chunks.add(territoryChunkKey({ x, z }, policy)); + } + } + return [...chunks]; +} +/** + * Owner of a loaded cell; hosts load the target and gap neighborhood before evaluating claims. + * @capability territory-owner-query Read a loaded land cell owner for authoritative placement checks. + */ +export function territoryOwnerOf(snapshot: GameRuntimeSnapshot, cell: TerritoryCell, policy: TerritoryPolicy = {}): string | null { + return snapshot.chunks[territoryChunkKey(cell, policy)]?.territory?.[key(cell)] ?? null; +} +/** + * Cells touched by a placement, excluding cells that only touch its outer edge. + * @capability territory-footprint Resolve exactly the ownership cells touched by a placement footprint. + */ +export function territoryFootprintCells(bounds: Aabb, cellSize = 1): TerritoryCell[] { + if (!Number.isFinite(cellSize) || cellSize <= 0 || !Object.values(bounds).every(Number.isFinite) || bounds.maxX <= bounds.minX || bounds.maxZ <= bounds.minZ) throw new RangeError("Invalid territory footprint"); + const cells: TerritoryCell[] = []; + const minX = Math.floor(bounds.minX / cellSize), maxX = Math.ceil(bounds.maxX / cellSize) - 1; + const minZ = Math.floor(bounds.minZ / cellSize), maxZ = Math.ceil(bounds.maxZ / cellSize) - 1; + if ((maxX - minX + 1) * (maxZ - minZ + 1) > 65536) throw new RangeError("Territory footprint is too large"); + for (let x = minX; x <= maxX; x++) for (let z = minZ; z <= maxZ; z++) cells.push({ x, z }); + return cells; +} +/** Price and validate a footprint without mutating or charging; reuse for placement previews. */ +export function planFootprintClaims(snapshot: GameRuntimeSnapshot, userId: string, footprint: readonly TerritoryCell[], policy: TerritoryPolicy = {}): TerritoryPlan { + const { gapCells } = checked(policy); + if (footprint.length * (2 * gapCells + 1) ** 2 > 1_000_000) throw new RangeError("Territory neighborhood is too large"); + const player = snapshot.players[userId]; + if (!player) return { ok: false, reason: "territory.blocked" }; + const cells: TerritoryCell[] = [], seen = new Set(); + let cost = 0; + const owned = Number(player.territoryOwnedCount ?? 0); + if (!Number.isSafeInteger(owned) || owned < 0) throw new RangeError("Invalid territory owned count"); + for (const cell of footprint) { + const cellKey = key(cell); + if (seen.has(cellKey)) continue; + seen.add(cellKey); + const owner = territoryOwnerOf(snapshot, cell, policy); + if (owner === userId) continue; + if (owner !== null) return { ok: false, reason: "territory.blocked" }; + for (let x = cell.x - gapCells; x <= cell.x + gapCells; x++) for (let z = cell.z - gapCells; z <= cell.z + gapCells; z++) { + const neighbor = territoryOwnerOf(snapshot, { x, z }, policy); + if (neighbor !== null && neighbor !== userId) return { ok: false, reason: "territory.blocked" }; + } + const price = policy.price?.(owned + cells.length) ?? 0; + if (!Number.isFinite(price) || price < 0) throw new RangeError("Invalid territory price"); + cost += price; + if (!Number.isFinite(cost)) throw new RangeError("Invalid territory cost"); + cells.push(cell); + } + if (cost > 0 && (!policy.currency || (player.economy[policy.currency] ?? 0) < cost)) return { ok: false, reason: "territory.unaffordable" }; + return { ok: true, cells, cost }; +} +/** Atomically buy every unowned footprint cell, leaving the input untouched on failure. */ +export function claimTerritory(snapshot: GameRuntimeSnapshot, userId: string, cells: readonly TerritoryCell[], policy: TerritoryPolicy = {}): TerritoryResult { + const plan = planFootprintClaims(snapshot, userId, cells, policy); + if (!plan.ok) return plan; + let next = snapshot; + for (const [index, cell] of plan.cells.entries()) next = updateChunk(next, territoryChunkKey(cell, policy), chunk => ({ + ...chunk, + territory: { ...chunk.territory, [key(cell)]: userId }, + territoryReceipts: { ...chunk.territoryReceipts, [key(cell)]: { claimedAt: policy.nowMs ?? 0, costPaid: policy.price?.(Number(snapshot.players[userId]?.territoryOwnedCount ?? 0) + index) ?? 0 } }, + })); + if (plan.cells.length === 0) return { ok: true, snapshot, cost: 0 }; + const player = snapshot.players[userId]!; + next = { ...next, players: { ...next.players, [userId]: { ...player, territoryOwnedCount: Number(player.territoryOwnedCount ?? 0) + plan.cells.length, ownedTerritoryChunkKeys: [...new Set([...(player.ownedTerritoryChunkKeys ?? []), ...plan.cells.map(cell => territoryChunkKey(cell, policy))])], economy: policy.currency ? { ...player.economy, [policy.currency]: (player.economy[policy.currency] ?? 0) - plan.cost } : player.economy } }, dirty: { ...next.dirty, players: [...new Set([...next.dirty.players, userId])] } }; + return { ok: true, snapshot: next, cost: plan.cost }; +} +/** + * Placement transaction: rejected object placement also rolls back territory and its charge. + * @capability territory-placement Purchase footprint cells atomically with object placement. + */ +export function placeWithTerritory(snapshot: GameRuntimeSnapshot, userId: string, bounds: Aabb, policy: TerritoryPolicy, place: (snapshot: GameRuntimeSnapshot) => { ok: true; snapshot: GameRuntimeSnapshot } | { ok: false; reason: string }): { ok: true; snapshot: GameRuntimeSnapshot; cost: number } | { ok: false; reason: string } { + const claimed = claimTerritory(snapshot, userId, territoryFootprintCells(bounds, policy.cellSize), policy); + if (!claimed.ok) return claimed; + const placed = place(claimed.snapshot); + return placed.ok ? { ...placed, cost: claimed.cost } : placed; +} +/** + * Snapshot-backed territory facade with host-owned storage and live policy. + * @capability territory-ownership Claim, release and query land through host-owned snapshot storage. + */ +export function createTerritory(storage: TerritoryStorage, policy: () => TerritoryPolicy = () => ({})) { + return { + snapshot: () => storage.get(), + restore: (snapshot: GameRuntimeSnapshot) => storage.set(snapshot), + ownerOf: (cell: TerritoryCell) => territoryOwnerOf(storage.get(), cell, policy()), + canUse: (userId: string, cell: TerritoryCell) => territoryOwnerOf(storage.get(), cell, policy()) === userId, + claim(userId: string, cells: readonly TerritoryCell[]): TerritoryResult { + const result = claimTerritory(storage.get(), userId, cells, policy()); + if (result.ok) storage.set(result.snapshot); + return result; + }, + release(userId: string, cell: TerritoryCell): boolean { + const snapshot = storage.get(), config = policy(); + if (territoryOwnerOf(snapshot, cell, config) !== userId || !snapshot.players[userId]) return false; + let next = updateChunk(snapshot, territoryChunkKey(cell, config), chunk => { + const territory = { ...chunk.territory }, territoryReceipts = { ...chunk.territoryReceipts }; delete territory[key(cell)]; delete territoryReceipts[key(cell)]; return { ...chunk, territory, territoryReceipts }; + }); + const player = next.players[userId]!; + next = { ...next, players: { ...next.players, [userId]: { ...player, territoryOwnedCount: Math.max(0, Number(player.territoryOwnedCount ?? 0) - 1), ownedTerritoryChunkKeys: (player.ownedTerritoryChunkKeys ?? []).filter(chunkKey => chunkKey !== territoryChunkKey(cell, config) || Object.values(next.chunks[chunkKey]?.territory ?? {}).includes(userId)) } }, dirty: { ...next.dirty, players: [...new Set([...next.dirty.players, userId])] } }; + storage.set(next); return true; + }, + grantStarter(userId: string, center: TerritoryCell): TerritoryResult { + const config = policy(), width = config.starterBlock ?? 3; + if (!Number.isSafeInteger(width) || width < 1 || width > 128) throw new RangeError("Invalid starter block"); + const snapshot = storage.get(); + if (Number(snapshot.players[userId]?.territoryOwnedCount ?? 0) > 0) return { ok: true, snapshot, cost: 0 }; + const startX = center.x - Math.floor(width / 2), startZ = center.z - Math.floor(width / 2); + const cells = territoryFootprintCells({ minX: startX, minZ: startZ, maxX: startX + width, maxZ: startZ + width }); + const result = claimTerritory(snapshot, userId, cells, { ...config, price: () => 0 }); + if (result.ok) storage.set(result.snapshot); + return result; + }, + }; +} +/** Snapshot-backed claim, release, owner and starter-grant operations. */ +export type Territory = ReturnType; diff --git a/packages/editor/package.json b/packages/editor/package.json index 1762a7541..438c295d9 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/editor", - "version": "0.18.0", + "version": "0.18.1", "description": "Scene/world/asset editor for JGengine — loaded lazily by the runner; never import from game entrypoints.", "license": "Apache-2.0", "type": "module", @@ -31,10 +31,10 @@ "test": "bun test src" }, "dependencies": { - "@jgengine/core": "^0.18.0", + "@jgengine/core": "^0.18.1", "@jgengine/navbake": "^0.18.0", - "@jgengine/react": "^0.18.0", - "@jgengine/shell": "^0.18.0" + "@jgengine/react": "^0.18.1", + "@jgengine/shell": "^0.18.1" }, "peerDependencies": { "@react-three/drei": "^10.0.0", diff --git a/packages/github/package.json b/packages/github/package.json index 7d33491a4..f60700fd7 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/github", - "version": "0.5.0", + "version": "0.5.1", "description": "GitHub data source for JGengine: contribution calendar fetch (GraphQL + HTML-scrape) with a server proxy handler, plus a browser client and contribution analytics.", "license": "Apache-2.0", "type": "module", diff --git a/packages/jgengine/package.json b/packages/jgengine/package.json index f4cec0113..4ea857bd1 100644 --- a/packages/jgengine/package.json +++ b/packages/jgengine/package.json @@ -1,6 +1,6 @@ { "name": "jgengine", - "version": "0.15.0", + "version": "0.15.1", "description": "jgengine — game framework SDK CLI (npm package jgengine / @jgengine/*). Not automotive. Humans: tell a coding agent Make a game that ... with jgengine. Agents: create, skills, doctor, desktop. Skills ship in the package under skills/. https://jgengine.com", "keywords": [ "jgengine", @@ -49,7 +49,7 @@ "test": "bun test src" }, "dependencies": { - "@jgengine/assets": "^0.18.0" + "@jgengine/assets": "^0.18.1" }, "devDependencies": { "@types/node": "^24" diff --git a/packages/jgengine/src/create.ts b/packages/jgengine/src/create.ts index 8852ab0aa..abe4b8593 100644 --- a/packages/jgengine/src/create.ts +++ b/packages/jgengine/src/create.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve, sep } from "node:path"; @@ -13,7 +13,7 @@ export function writeGame( name: string, variant: TemplateVariant, scene?: EditorSceneDoc, - options?: { world?: boolean; editor?: boolean; player?: string; ground?: "flat" | "terrain"; sceneMode?: "empty" | "starter" }, + options?: { shape?: "shared-world-builder"; world?: boolean; editor?: boolean; player?: string; ground?: "flat" | "terrain"; sceneMode?: "empty" | "starter" }, ): void { for (const file of gameTemplate({ id, name, variant, engineVersion: sdkVersion(), scene, ...options })) { const dest = join(targetDir, file.path); @@ -82,7 +82,7 @@ export function registerRootGameScript(rootDir: string, id: string, folderName: return true; } -const VALUE_FLAGS = new Set(["--pm", "--from-scene", "--player", "--ground", "--scene"]); +const VALUE_FLAGS = new Set(["--pm", "--shape", "--from-scene", "--player", "--ground", "--scene"]); function positionalArg(argv: string[]): string | undefined { for (let index = 0; index < argv.length; index += 1) { @@ -158,6 +158,8 @@ export function runCreate(argv: string[]): number { let folderName: string; let id: string; try { + const shape = flag(argv, "shape"); + if (shape !== undefined && shape !== "shared-world-builder") throw new Error(`Unknown --shape: ${shape}`); const sceneArg = flag(argv, "from-scene"); const player = flag(argv, "player"); const ground = flag(argv, "ground") ?? "terrain"; @@ -215,7 +217,7 @@ export function runCreate(argv: string[]): number { } const engineVersion = sdkVersion(); - writeGame(targetDir, id, displayName, variant, scene, { world, editor, player, ground, sceneMode }); + writeGame(targetDir, id, displayName, variant, scene, { world, editor, player, ground, sceneMode, shape }); console.log(`created ${displayName} (${variant}) → ${targetDir}`); console.log(` folder ${folderName} package ${id} name "${displayName}"`); if (variant === "standalone") { diff --git a/packages/jgengine/src/gameShape.ts b/packages/jgengine/src/gameShape.ts index 9d3284d91..f5f99deb1 100644 --- a/packages/jgengine/src/gameShape.ts +++ b/packages/jgengine/src/gameShape.ts @@ -23,6 +23,7 @@ export const GAME_SKELETON_OPTIONAL_FILES = [ "world.ts", "preview.tsx", "scene-ownership.json", + "art-direction.md", "editorLayers.ts", "editorLayers.test.ts", "editorCatalogs.ts", diff --git a/packages/jgengine/src/packaging.test.ts b/packages/jgengine/src/packaging.test.ts index 92f7a6a54..640018fa1 100644 --- a/packages/jgengine/src/packaging.test.ts +++ b/packages/jgengine/src/packaging.test.ts @@ -39,7 +39,7 @@ describe("jgengine CLI packaging", () => { test("publish workflow includes jgengine in the publish order", () => { const workflow = readFileSync(join(repoRoot, ".github", "workflows", "publish.yml"), "utf8"); - expect(workflow).toMatch(/for p in core ws sql react convex node shell editor assets github jgengine/); + expect(workflow).toMatch(/for p in core rapier ws sql react convex node shell editor assets github jgengine/); expect(workflow).toContain("packages/*/package.json"); }); diff --git a/packages/jgengine/src/sharedBuilder.test.ts b/packages/jgengine/src/sharedBuilder.test.ts new file mode 100644 index 000000000..efb0f6e22 --- /dev/null +++ b/packages/jgengine/src/sharedBuilder.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { gameTemplate } from "./templates"; +import { runCreate } from "./create"; +import type { GameRuntime } from "../../core/src/runtime/gameRuntime"; + +const options = { id: "shared-probe", name: "Shared Probe", variant: "standalone" as const, engineVersion: "0.18.1", shape: "shared-world-builder" as const }; + +test("shared builder emits unique connected host files, bounded ticks and component sources", () => { + const files = gameTemplate(options); + const contents = (path: string) => files.find(file => file.path === path)!.contents; + expect(new Set(files.map(file => file.path)).size).toBe(files.length); + expect(JSON.parse(contents("package.json")).dependencies["@jgengine/convex"]).toBe("^0.18.1"); + expect(contents("src/main.tsx")).toContain("resolveConvexMultiplayer"); + expect(contents("src/main.tsx")).toContain("ConvexProvider"); + expect(contents("src/game.config.ts")).toContain('servers({ topology: "shared"'); + expect(contents("src/game/ui/GameUI.tsx")).toContain("ChatPanel"); + expect(contents("src/index.css")).toContain('@source "../node_modules/@jgengine/react/dist"'); + expect(contents("src/index.css")).toContain('@source "../node_modules/@jgengine/shell/dist"'); + expect(contents("convex/online.ts")).toContain("batchSize: 25"); + expect(contents("convex/online.ts")).toContain("continuation: scanRef"); + expect(contents("convex/online.ts")).not.toContain(".collect()"); + const transpiler = new Bun.Transpiler({ loader: "tsx", target: "bun" }); + for (const file of files.filter(file => /\.tsx?$/.test(file.path))) expect(() => transpiler.transformSync(file.contents)).not.toThrow(); +}); + +test("generated runtime scopes claims and accrues fractional income without a loop tick", async () => { + const code = gameTemplate(options).find(file => file.path === "convex/gameRuntime.ts")!.contents; + const runnable = code.replace(/@jgengine\/core\/([^"\n]+)/g, (_match, path: string) => pathToFileURL(resolve(import.meta.dir, "../../core/src", path + ".ts")).href); + const path = join(mkdtempSync(join(tmpdir(), "jg-shared-runtime-")), "runtime.ts"); + writeFileSync(path, runnable); + const { runtime } = await import(pathToFileURL(path).href) as { runtime: GameRuntime }; + expect(runtime.topology).toBe("shared"); + expect(runtime.hasTick).toBe(false); + expect(runtime.commandScope("claim", { x: 0, z: 0 }, "alice")?.chunkKeys).toHaveLength(9); + let snapshot = runtime.hydrate({ gameId: "shared-probe", serverId: "world", serverRow: { objects: [], entities: [], session: {} }, playersByUserId: {}, chunksByKey: {}, nowMs: 0 }); + snapshot = runtime.joinPlayer(snapshot, "alice", true, 0); + const income = runtime.runCommand(snapshot, "alice", "accrue", {}, 1000); + expect(income.ok).toBe(true); + if (!income.ok) throw new Error(income.reason); + expect(income.snapshot.players.alice!.economy.cash).toBe(100.005); + const claim = runtime.runCommand(income.snapshot, "alice", "claim", { x: 0, z: 0 }, 1000); + expect(claim.ok).toBe(true); + if (!claim.ok) throw new Error(claim.reason); + expect(claim.snapshot.chunks["0,0"]!.territory?.["0,0"]).toBe("alice"); + expect(claim.snapshot.players.alice!.economy.cash).toBe(99.005); +}); + +test("create parses --shape before the name and rejects unknown shapes", () => { + const root = mkdtempSync(join(tmpdir(), "jg-shared-create-")); + const flags = ["--no-install", "--no-skills", "--no-assets", "--standalone"]; + expect(runCreate(["--shape", "shared-world-builder", join(root, "Shared World"), ...flags])).toBe(0); + expect(runCreate([join(root, "Other"), "--shape", "unknown", ...flags])).toBe(1); +}); diff --git a/packages/jgengine/src/templates.test.ts b/packages/jgengine/src/templates.test.ts index 88e5de819..afcdc3f55 100644 --- a/packages/jgengine/src/templates.test.ts +++ b/packages/jgengine/src/templates.test.ts @@ -239,7 +239,7 @@ describe("gameTemplate canonical shape (mirrors check-game-shape)", () => { } const worldFile = fileOf(files, "src/world.ts"); expect(worldFile).toContain("place("); - expect(worldFile).toContain('mode: "terrain"'); + expect(worldFile).toContain('mode: "flat"'); expect(worldFile).toContain("x: Infinity"); expect(worldFile).not.toContain("environment("); expect(worldFile).not.toContain("sky("); diff --git a/packages/jgengine/src/templates.ts b/packages/jgengine/src/templates.ts index 947a92074..734d5bfc0 100644 --- a/packages/jgengine/src/templates.ts +++ b/packages/jgengine/src/templates.ts @@ -1,3 +1,4 @@ +import { sharedBuilderFiles } from "./templates/sharedBuilder"; import { agentsMd, artDirectionMd, @@ -61,7 +62,7 @@ export function gameTemplate(options: TemplateOptions): TemplateFile[] { const sceneDoc = scene ?? (options.sceneMode === "starter" ? undefined : emptyScene); const sceneContents = sceneDoc ? `${JSON.stringify(sceneDoc, null, 2)}\n` : editorSceneJson; const sceneTest = sceneDoc ? (scene ? editorLayersTestFor(scene) : editorLayersTestFor(emptyScene)) : editorLayersTest; - return [ + const files: TemplateFile[] = [ { path: "index.html", contents: indexHtml(name) }, { path: "vite.config.ts", contents: viteConfig(variant) }, { @@ -97,4 +98,5 @@ export function gameTemplate(options: TemplateOptions): TemplateFile[] { : []), { path: "src/game/ui/GameUI.tsx", contents: gameUiTsx(id, name, editor) }, ]; + return options.shape === "shared-world-builder" ? sharedBuilderFiles(files, options) : files; } diff --git a/packages/jgengine/src/templates/gameFiles.ts b/packages/jgengine/src/templates/gameFiles.ts index 64ae7b5fa..2d6b83ca5 100644 --- a/packages/jgengine/src/templates/gameFiles.ts +++ b/packages/jgengine/src/templates/gameFiles.ts @@ -1271,7 +1271,7 @@ const worldTs = (id: string, ground: "flat" | "terrain" = "terrain") => `import // authored sky the engine renders its default sky. export const world = place({ id: "${id}", - ground: { mode: "${ground}", size: { x: Infinity, z: Infinity } }, + ground: { mode: "${ground === "terrain" ? "flat" : ground}", size: { x: Infinity, z: Infinity } }, physics: { gravity: -24 }, }); `; diff --git a/packages/jgengine/src/templates/sharedBuilder.ts b/packages/jgengine/src/templates/sharedBuilder.ts new file mode 100644 index 000000000..a52369659 --- /dev/null +++ b/packages/jgengine/src/templates/sharedBuilder.ts @@ -0,0 +1,144 @@ +import type { TemplateFile, TemplateOptions } from "./types"; + +/** Add a connected shared-world composition to the standard editor-backed game scaffold. */ +export function sharedBuilderFiles(files: TemplateFile[], options: TemplateOptions): TemplateFile[] { + const packageFile = files.find(file => file.path === "package.json")!; + const pkg = JSON.parse(packageFile.contents); + pkg.dependencies["@jgengine/convex"] = options.variant === "in-repo" ? "workspace:*" : `^${options.engineVersion}`; + pkg.dependencies.convex = "^1.42.2"; + pkg.scripts["dev:backend"] = "convex dev"; + const replaced = files.map(file => { + if (file.path === "package.json") return { ...file, contents: JSON.stringify(pkg, null, 2) + "\n" }; + if (file.path === "src/game.config.ts") return { ...file, contents: + 'import { convex, servers } from "@jgengine/core/runtime/adapter";\n' + file.contents.replace(" simulation:", ' multiplayer: servers({ topology: "shared", adapter: convex() }),\n features: { chat: true },\n simulation:') }; + return file; + }); + const additions: TemplateFile[] = [ + { path: "convex/schema.ts", contents: `import { defineSchema } from "convex/server"; +import { jgengineTables } from "@jgengine/convex/server"; +export default defineSchema(jgengineTables()); +` }, + { path: "convex/gameRuntime.ts", contents: `import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime"; +import { chunkKeysAround } from "@jgengine/core/runtime/worldChunks"; +import { markPlayerDirty } from "@jgengine/core/runtime/snapshot"; +import { claimTerritory, planFootprintClaims } from "@jgengine/core/world/territory"; +import { accrueSince } from "@jgengine/core/time/accrueSince"; +import { applyCurrencyOperation } from "@jgengine/core/economy/currency"; + +export const GAME_ID = ${JSON.stringify(options.id)}; +export const cash = { id: "cash", name: "Cash", symbol: "$", decimals: 3 }; +const territory = { gapCells: 1, currency: cash.id, price: (owned: number) => 1 + owned }; +function cell(input: unknown) { + const value = input as { x?: unknown; z?: unknown } | null; + if (!value || typeof value.x !== "number" || typeof value.z !== "number" || !Number.isSafeInteger(value.x) || !Number.isSafeInteger(value.z)) throw new Error("Choose a valid cell"); + return { x: value.x, z: value.z }; +} +export const runtime = createGameRuntime({ + gameId: GAME_ID, topology: "shared", save: { auto: "60s", scope: "player+chunks" }, + loop: { + joinScope: userId => ({ players: [userId], chunkKeys: [] }), + onNewPlayer(ctx) { + const player = ctx.snapshot.players[ctx.player.userId]!; + if (ctx.player.isNew) player.economy.cash = 100; + player.session = { ...player.session, incomeAt: ctx.nowMs }; + }, + }, + commands: { + claim: { + scope(input) { const target = cell(input); return { players: "actor", chunkKeys: chunkKeysAround([target.x, 0, target.z], 1) }; }, + validate(snapshot, input, actor) { const plan = planFootprintClaims(snapshot, actor, [cell(input)], territory); return plan.ok ? null : { reason: plan.reason }; }, + apply(snapshot, input, actor, nowMs) { const result = claimTerritory(snapshot, actor, [cell(input)], { ...territory, nowMs }); if (!result.ok) throw new Error(result.reason); return result.snapshot; }, + }, + accrue: { + scope: () => ({ players: "actor", chunkKeys: [] }), + validate: () => null, + apply(snapshot, _input, actor, nowMs) { + const player = snapshot.players[actor]!; + const elapsed = accrueSince(Number(player.session?.incomeAt ?? nowMs), nowMs, { capMs: 60000 }); + const adjustment = applyCurrencyOperation(cash, player.economy.cash ?? 0, "add", elapsed.elapsedSeconds * 0.005); + if (!adjustment.success) throw new Error(adjustment.reason); + return markPlayerDirty({ ...snapshot, players: { ...snapshot.players, [actor]: { + ...player, economy: { ...player.economy, cash: adjustment.newBalance }, session: { ...player.session, incomeAt: elapsed.anchorMs }, + } } }, actor); + }, + }, + }, +}); +` }, + { path: "convex/runtime.ts", contents: `import { createGameServerFunctions } from "@jgengine/convex/server"; +import { runtime } from "./gameRuntime"; +export const { joinServer, leaveServer, runCommand, getServer, getServerMeta, getServerCapacity, getPlayerProfile, getChunks, getFeed, pushFeedEntry, flushSave, flushDirtyServers, tickActiveServers, helpers } = createGameServerFunctions({ runtimes: [runtime], auth: "anonymous" }); +` }, + { path: "convex/presence.ts", contents: `import { createPresenceFunctions } from "@jgengine/convex/server"; +export const { list, sync, leave, reapIdlePresence } = createPresenceFunctions({ auth: "anonymous" }); +` }, + { path: "convex/chat.ts", contents: `import { createChatFunctions } from "@jgengine/convex/server"; +export const { sendMessage, messages, pruneChatMessages } = createChatFunctions({ auth: "anonymous" }); +` }, + { path: "convex/leaderboard.ts", contents: `import { createLeaderboardFunctions } from "@jgengine/convex/server"; +export const { getTop, getProfile, incrementMany } = createLeaderboardFunctions({ auth: "anonymous" }); +` }, + { path: "convex/online.ts", contents: `import { internalMutationGeneric, makeFunctionReference, type FunctionReference, type MutationBuilder } from "convex/server"; +import { v } from "convex/values"; +import { forEachOnlinePlayer, type JGDataModel, type OnlinePlayer } from "@jgengine/convex/server"; +import { helpers } from "./runtime"; +const mutation = internalMutationGeneric as MutationBuilder; +const batchRef = makeFunctionReference("online:batch") as unknown as FunctionReference<"mutation", "internal", { players: OnlinePlayer[]; nowMs: number }>; +const scanRef = makeFunctionReference("online:scan") as unknown as FunctionReference<"mutation", "internal", { cursor: string | null; nowMs: number }>; +export const scan = mutation({ + args: { cursor: v.union(v.string(), v.null()), nowMs: v.number() }, + handler: (ctx, args) => forEachOnlinePlayer(ctx, { ...args, batchSize: 25, handler: batchRef, continuation: scanRef }), +}); +export const start = mutation({ args: {}, handler: ctx => forEachOnlinePlayer(ctx, { batchSize: 25, handler: batchRef, continuation: scanRef }) }); +export const batch = mutation({ + args: { players: v.array(v.object({ serverId: v.string(), userId: v.string(), homeGameId: v.optional(v.string()) })), nowMs: v.number() }, + handler: async (ctx, args) => { + for (const player of args.players) await helpers.runCommand(ctx, { serverId: player.serverId, actorUserId: player.userId, command: "accrue", input: {} }); + }, +}); +` }, + { path: "convex/crons.ts", contents: `import { cronJobs, makeFunctionReference } from "convex/server"; +import { jgengineCronSpecs } from "@jgengine/convex/server"; +import { runtime } from "./gameRuntime"; +const crons = cronJobs(); +for (const spec of jgengineCronSpecs({ runtimes: [runtime], chat: true })) crons.interval(spec.name, { seconds: spec.intervalSeconds }, makeFunctionReference<"mutation">(spec.module + ":" + spec.functionKey), {}); +crons.interval("online income", { seconds: 10 }, makeFunctionReference<"mutation">("online:start"), {}); +export default crons; +` }, + { path: "src/main.tsx", contents: `import { createRoot } from "react-dom/client"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { resolveConvexMultiplayer } from "@jgengine/convex/resolveConvexMultiplayer"; +import { GameHost } from "@jgengine/shell/GameHost"; +import { game } from "./game.config"; +import "./index.css"; +const root = createRoot(document.getElementById("root")!); +const url = import.meta.env.VITE_CONVEX_URL; +if (!url) root.render(
Connect this world to continue. See README.md for setup.
); +else { + const client = new ConvexReactClient(url); + const userId = localStorage.getItem("jg-user") ?? crypto.randomUUID(); + localStorage.setItem("jg-user", userId); + const multiplayer = resolveConvexMultiplayer({ game: game.game, gameId: ${JSON.stringify(options.id)}, client, userId }); + root.render( import("@jgengine/editor")}'} />); +} +` }, + { path: "src/game/ui/GameUI.tsx", contents: `import { ChatPanel } from "@jgengine/react/chat"; +export function GameUI() { + return
; +} +` }, + { path: "README.md", contents: `# Shared world setup + +Run bun run dev:backend, select a Convex project, and copy its URL to VITE_CONVEX_URL in .env.local. Then run bun dev. +The scaffold uses explicit anonymous development identities; wire production authentication before opening the world to public users. + +The runtime declares shared topology, scoped claim/accrue commands, fractional cash precision, and actor-only joins. Presence queries use the viewer chunk, chat is rate-limited, and the online pipeline schedules batches of 25. Claiming a cell validates and pays through the territory primitive. Author buildable objects in the editor, then use placeWithTerritory for placement. + +Before content, write the first-hour balance sheet and prove the first tutorial verb works from spawn. The demo starts with 100 cash and income of 0.005 per second; replace those game-owned values deliberately. Keep costs visible at the placement cursor and display rejected commands. + +The standard CSS scans engine React and shell packages, including ChatPanel and JoinGate. Verify their rendered styling after changing Tailwind sources. Production budgets need a crowd test of command and presence read sets. +` }, + ]; + const paths = new Set(additions.map(file => file.path)); + return [...replaced.filter(file => !paths.has(file.path)), ...additions]; +} diff --git a/packages/jgengine/src/templates/types.ts b/packages/jgengine/src/templates/types.ts index bf21e5649..157253ab7 100644 --- a/packages/jgengine/src/templates/types.ts +++ b/packages/jgengine/src/templates/types.ts @@ -18,6 +18,7 @@ export interface EditorSceneDoc { export interface TemplateOptions { id: string; + shape?: "shared-world-builder"; name: string; variant: TemplateVariant; engineVersion: string; diff --git a/packages/node/package.json b/packages/node/package.json index 43379a9c3..da94c3f3c 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/node", - "version": "0.18.0", + "version": "0.18.1", "description": "Standalone authoritative game host for JGengine: in-memory server snapshots, tick loop, save-cadence flush, WebSocket server, memory/file persistence.", "license": "Apache-2.0", "type": "module", @@ -31,8 +31,8 @@ "test": "bun test src" }, "dependencies": { - "@jgengine/core": "^0.18.0", - "@jgengine/ws": "^0.18.0", + "@jgengine/core": "^0.18.1", + "@jgengine/ws": "^0.18.1", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/node/src/worldServer.test.ts b/packages/node/src/worldServer.test.ts index 7fac09b2d..a4a878c66 100644 --- a/packages/node/src/worldServer.test.ts +++ b/packages/node/src/worldServer.test.ts @@ -4,6 +4,8 @@ import { defineGameDefinition } from "@jgengine/core/game/defineGame"; import type { GameContext, GameContextContent } from "@jgengine/core/runtime/gameContext"; import type { HostedWorldRecord, HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession"; import type { GameRuntimeServerView } from "@jgengine/core/runtime/transport"; +import { createWorldMirror } from "@jgengine/core/runtime/worldMirror"; +import type { WorldSyncFrame } from "@jgengine/core/runtime/transport"; import type { WorldSnapshot } from "@jgengine/core/runtime/worldSnapshot"; import { createAssetCatalog } from "@jgengine/core/scene/assetCatalog"; import { createWsBackend } from "@jgengine/ws/createWsBackend"; @@ -119,7 +121,16 @@ describe("createWorldGameServer", () => { try { const { serverId } = await alice.transport.joinServer({ gameId: "shared" }); const views = channel(); - alice.feeds?.subscribeServer(serverId, (view) => views.push(view)); + let latestView: GameRuntimeServerView | null = null; + const mirror = createWorldMirror({ hydrate(snapshot) { + if (latestView !== null) views.push({ ...latestView, serverState: snapshot }); + } }); + alice.feeds?.subscribeServer(serverId, (view) => { + latestView = view; + const frame = view?.serverState as WorldSyncFrame | undefined; + if (frame?.kind === "baseline") mirror.applyBaseline(frame.revision, frame.snapshot); + else if (frame?.kind === "diff") mirror.applyDiff(frame.diff); + }); await views.next(); s.tick(1); expect(heroX(await views.next())).toBeCloseTo(1); diff --git a/packages/rapier/package.json b/packages/rapier/package.json index 4f4230528..974d9f044 100644 --- a/packages/rapier/package.json +++ b/packages/rapier/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/rapier", - "version": "0.18.0", + "version": "0.18.1", "description": "Rapier physics backend for JGengine", "license": "Apache-2.0", "type": "module", @@ -10,9 +10,30 @@ "url": "git+https://github.com/Noisemaker111/jgengine.git", "directory": "packages/rapier" }, - "files": ["dist", "CHANGELOG.md"], - "exports": {".": {"types": "./dist/index.d.ts", "default": "./dist/index.js"}, "./*": {"types": "./dist/*.d.ts", "default": "./dist/*.js"}}, - "scripts": {"build": "tsgo -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist", "check-types": "bun ../../scripts/check-types-preflight.ts && tsgo --noEmit -p tsconfig.json", "test": "bun test src"}, - "dependencies": {"@jgengine/core": "^0.18.0", "@dimforge/rapier3d-compat": "0.19.3"}, - "publishConfig": {"access": "public"} + "files": [ + "dist", + "CHANGELOG.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + }, + "scripts": { + "build": "tsgo -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist", + "check-types": "bun ../../scripts/check-types-preflight.ts && tsgo --noEmit -p tsconfig.json", + "test": "bun test src" + }, + "dependencies": { + "@jgengine/core": "^0.18.1", + "@dimforge/rapier3d-compat": "0.19.3" + }, + "publishConfig": { + "access": "public" + } } diff --git a/packages/react/package.json b/packages/react/package.json index efa17ec39..95887be32 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/react", - "version": "0.18.0", + "version": "0.18.1", "description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.", "license": "Apache-2.0", "type": "module", @@ -31,7 +31,7 @@ "test": "bun test src" }, "dependencies": { - "@jgengine/core": "^0.18.0" + "@jgengine/core": "^0.18.1" }, "peerDependencies": { "react": "^19.0.0", diff --git a/packages/react/src/chat.tsx b/packages/react/src/chat.tsx index cd92f9cc6..c8114a885 100644 --- a/packages/react/src/chat.tsx +++ b/packages/react/src/chat.tsx @@ -1,3 +1,4 @@ +import { StandaloneChatPanel, type StandaloneChatPanelProps } from "./standaloneChatPanel"; import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react"; import type { ChatMessage, ChatSendResult } from "@jgengine/core/game/chat"; import type { ChatSync, ChatTransport } from "@jgengine/core/multiplayer/chatContract"; @@ -34,14 +35,17 @@ export function ChatLog({ limit, className, messageClassName, + ownMessageClassName, renderMessage, }: { channelId: string; limit?: number; className?: string; messageClassName?: string; + ownMessageClassName?: string; renderMessage?: (message: ChatMessage) => ReactNode; }) { + const ctx = useGameContext(); const messages = useChat(channelId, limit === undefined ? undefined : { limit }); const scrollRef = useRef(null); useEffect(() => { @@ -53,7 +57,8 @@ export function ChatLog({ {messages.map((message) => (
@@ -92,15 +97,18 @@ export function ChatInput({ }) { const ctx = useGameContext(); const [value, setValue] = useState(""); + const [failure, setFailure] = useState(null); const chat = ctx.game.chat; if (chat === undefined) return null; const submit = (event: FormEvent) => { event.preventDefault(); const result: ChatSendResult = chat.send(ctx.player.userId, channelId, value); if ("reason" in result) { + setFailure(result.reason); onRejected?.(result.reason); return; } + setFailure(null); setValue(""); onSent?.(result.message); } @@ -109,10 +117,13 @@ export function ChatInput({ setValue(event.target.value)} /> + {failure !== null &&

{failure}

} @@ -166,7 +177,7 @@ export function ChannelTabs({ ); } -export function ChatPanel({ +function ContextChatPanel({ channels, initialChannel, limit, @@ -176,6 +187,9 @@ export function ChatPanel({ activeTabClassName, logClassName, messageClassName, + ownMessageClassName, + defaultExpanded = true, + toggleLabel = "Chat", inputClassName, inputFieldClassName, sendButtonClassName, @@ -188,11 +202,14 @@ export function ChatPanel({ initialChannel?: string; limit?: number; className?: string; + defaultExpanded?: boolean; + toggleLabel?: ReactNode; tabsClassName?: string; tabClassName?: string; activeTabClassName?: string; logClassName?: string; messageClassName?: string; + ownMessageClassName?: string; inputClassName?: string; inputFieldClassName?: string; sendButtonClassName?: string; @@ -206,9 +223,39 @@ export function ChatPanel({ const chat = ctx.game.chat; const ids = channels ?? chat?.channels().map((def) => def.id) ?? []; const [active, setActive] = useState(initialChannel ?? ids[0] ?? "global"); + const [expanded, setExpanded] = useState(defaultExpanded); + const panelRef = useRef(null); + const focusPending = useRef(false); + useEffect(() => { + if (chat === undefined) return; + const onKey = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing || event.key !== "Enter") return; + const target = event.target as HTMLElement | null; + if (target?.closest("input, textarea, select, button, [contenteditable]:not([contenteditable=false])")) return; + event.preventDefault(); + focusPending.current = true; + setExpanded(true); + panelRef.current?.querySelector("input")?.focus(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [chat]); + useEffect(() => { + if (expanded && focusPending.current) { + panelRef.current?.querySelector("input")?.focus(); + focusPending.current = false; + } + }, [expanded]); if (chat === undefined) return null; return ( -
+
{ + if (event.key === "Escape") { + (event.target as HTMLElement).blur(); + event.stopPropagation(); + } + }}> + + {expanded && <> + }
); } + + +/** Chat behavior over a game context or externally supplied server messages. */ +export function ChatPanel(props: Parameters[0] | StandaloneChatPanelProps) { + return "messages" in props ? : ; +} diff --git a/packages/react/src/chatPanel.test.ts b/packages/react/src/chatPanel.test.ts new file mode 100644 index 000000000..8af1ee104 --- /dev/null +++ b/packages/react/src/chatPanel.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ChatPanel } from "./chat"; + +const messages = Array.from({ length: 5 }, (_, index) => ({ id: String(index), channelId: "world", fromUserId: index === 4 ? "me" : "neighbor", body: `message-${index}`, at: index })); +test("standalone chat preserves collapsed history, own-message variant and editable input", () => { + const html = renderToStaticMarkup(createElement(ChatPanel, { messages, userId: "me", onSend: () => ({ ok: true }), collapsedLimit: 3, ownMessageClassName: "own" })); + expect(html).not.toContain("message-1"); + expect(html).toContain("message-2"); + expect(html).toContain('data-own-message="true"'); + expect(html).toContain('class="own"'); + expect(html).toContain('aria-label="Chat message"'); +}); +test("expanded chat renders the configured history without a GameProvider", () => { + const html = renderToStaticMarkup(createElement(ChatPanel, { messages, userId: "me", onSend: () => ({ ok: true }), defaultExpanded: true, limit: 5 })); + expect(html).toContain("message-0"); + expect(html).toContain('data-expanded="true"'); +}); diff --git a/packages/react/src/standaloneChatPanel.tsx b/packages/react/src/standaloneChatPanel.tsx new file mode 100644 index 000000000..02c2ba5fd --- /dev/null +++ b/packages/react/src/standaloneChatPanel.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { DEFAULT_CHAT_BODY_LENGTH, type ChatMessage } from "@jgengine/core/game/chat"; +import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract"; + +type StatefulClass = string | ((expanded: boolean) => string); +const classFor = (value: StatefulClass | undefined, expanded: boolean) => typeof value === "function" ? value(expanded) : value; + +/** Message-store inputs and caller-owned styling for standalone chat. */ +export interface StandaloneChatPanelProps { + messages: readonly T[]; + userId: string; + onSend: (body: string) => Promise | ChatSendOutcome | void; + className?: StatefulClass; + logClassName?: StatefulClass; + inputClassName?: StatefulClass; + inputFieldClassName?: StatefulClass; + messageClassName?: string; + ownMessageClassName?: string; + style?: CSSProperties; + defaultExpanded?: boolean; + collapsedLimit?: number; + limit?: number; + maxLength?: number; + hotkeysEnabled?: boolean; + onFocus?: () => void; + placeholder?: string; + collapsedPlaceholder?: string; + emptyLabel?: ReactNode; + renderMessage?: (message: T, state: { own: boolean; expanded: boolean; index: number; count: number }) => ReactNode; +} + +/** + * Headless chat interaction for external message stores. Prefer the ChatPanel entrypoint. + * @capability standalone-chat render persisted chat with focus controls and visible send failures + */ +export function StandaloneChatPanel({ messages, userId, onSend, className, logClassName, inputClassName, inputFieldClassName, messageClassName, ownMessageClassName, style, defaultExpanded = false, collapsedLimit = 3, limit = 50, maxLength = DEFAULT_CHAT_BODY_LENGTH, hotkeysEnabled = true, onFocus, placeholder = "Message the world...", collapsedPlaceholder = "Enter to chat", emptyLabel = "Press Enter to chat", renderMessage }: StandaloneChatPanelProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const [draft, setDraft] = useState(""); + const [failure, setFailure] = useState(null); + const [sending, setSending] = useState(false); + const inputRef = useRef(null); + const logRef = useRef(null); + useEffect(() => { + if (!hotkeysEnabled) return; + const handleKey = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing || event.key !== "Enter") return; + if ((event.target as HTMLElement | null)?.closest?.("input, textarea, select, button, [contenteditable]:not([contenteditable=false])")) return; + event.preventDefault(); + inputRef.current?.focus({ preventScroll: true }); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [hotkeysEnabled]); + useEffect(() => { + if (expanded && logRef.current !== null) logRef.current.scrollTop = logRef.current.scrollHeight; + }, [expanded, messages.length]); + const visible = messages.slice(-Math.max(1, expanded ? limit : collapsedLimit)); + return
+
+ {visible.length === 0 ? emptyLabel : visible.map((message, index) => { + const own = message.fromUserId === userId; + return
+ {renderMessage?.(message, { own, expanded, index, count: visible.length }) ?? <>{message.fromUserId}: {message.body}} +
; + })} +
+
{ + event.preventDefault(); + if (sending || draft.trim().length === 0) return; + const body = draft; + setSending(true); + setFailure(null); + void Promise.resolve().then(() => onSend(body)).then((outcome) => { + if (outcome && !outcome.ok) setFailure(outcome.reason ?? "Could not send message"); + else setDraft((current) => current === body ? "" : current); + }).catch((error: unknown) => setFailure(error instanceof Error ? error.message : "Could not send message")).finally(() => setSending(false)); + }}> + setDraft(event.target.value)} onFocus={() => { onFocus?.(); setExpanded(true); }} onBlur={() => setExpanded(false)} onKeyDown={(event) => { if (event.key === "Escape") { event.currentTarget.blur(); event.stopPropagation(); } }} /> +
+ {failure !== null &&

{failure}

} +
; +} diff --git a/packages/react/src/useServerSession.ts b/packages/react/src/useServerSession.ts new file mode 100644 index 000000000..f42af2707 --- /dev/null +++ b/packages/react/src/useServerSession.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from "react"; +import type { GameRuntimeTransport } from "@jgengine/core/runtime/transport"; + +/** + * Joins a host and exposes a retryable blocking state until membership is confirmed. + * @capability server-session join a multiplayer host with status, retry, and teardown + */ +export function useServerSession(transport: GameRuntimeTransport, gameId: string, preferredServerId?: string) { + const [attempt, setAttempt] = useState(0); + const [session, setSession] = useState<{ serverId: string | null; status: "joining" | "joined" | "failed"; failureReason: string | null }>({ serverId: null, status: "joining", failureReason: null }); + const retry = useCallback(() => setAttempt((value) => value + 1), []); + useEffect(() => { + let disposed = false; + let serverId: string | null = null; + setSession({ serverId: null, status: "joining", failureReason: null }); + void transport.joinServer({ gameId, serverId: preferredServerId }).then((result) => { + if (!result.ok) { + if (!disposed) setSession({ serverId: null, status: "failed", failureReason: result.reason }); + return; + } + if (disposed) { + void transport.leaveServer({ serverId: result.serverId }).catch(() => undefined); + return; + } + serverId = result.serverId; + setSession({ serverId, status: "joined", failureReason: null }); + }).catch((error: unknown) => { + if (!disposed) setSession({ serverId: null, status: "failed", failureReason: error instanceof Error ? error.message : "Connection failed" }); + }); + return () => { + disposed = true; + if (serverId !== null) void transport.leaveServer({ serverId }).catch(() => undefined); + }; + }, [transport, gameId, preferredServerId, attempt]); + return { ...session, retry }; +} diff --git a/packages/shell/package.json b/packages/shell/package.json index 82c3b9ee1..daf80eb59 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/shell", - "version": "0.18.0", + "version": "0.18.1", "description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.", "license": "Apache-2.0", "type": "module", @@ -35,9 +35,9 @@ "test": "bun test src" }, "dependencies": { - "@jgengine/core": "^0.18.0", - "@jgengine/react": "^0.18.0", - "@jgengine/ws": "^0.18.0" + "@jgengine/core": "^0.18.1", + "@jgengine/react": "^0.18.1", + "@jgengine/ws": "^0.18.1" }, "peerDependencies": { "@react-three/drei": "^10.0.0", diff --git a/packages/shell/src/GamePlayerShell.tsx b/packages/shell/src/GamePlayerShell.tsx index ac124947f..b8619d9af 100644 --- a/packages/shell/src/GamePlayerShell.tsx +++ b/packages/shell/src/GamePlayerShell.tsx @@ -53,6 +53,7 @@ import { type RuntimeDiagnostic, } from "./diagnostics/RuntimeDiagnostics"; import { EMPTY_RESERVED } from "./shellConstants"; +import { JoinGate } from "./JoinGate"; import { useShellMultiplayerSync } from "./useShellMultiplayerSync"; import { ShellHudPresentation } from "./ShellHudPresentation"; import { Shell3dPresentation } from "./Shell3dPresentation"; @@ -277,7 +278,7 @@ export function GamePlayerShell({ }, [playable, userId]); const authoritativeFrameRef = useRef(null); - useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef, setRemotePlayers, authoritativeFrameRef); + const join = useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef, setRemotePlayers, authoritativeFrameRef); useEffect(() => { wrapperRef.current?.focus(); @@ -290,6 +291,7 @@ export function GamePlayerShell({ ); if (ctx === null) return
; + if (join.status !== "joined") return ; const cameraConfig = playable.camera?.followEntityId !== undefined diff --git a/packages/shell/src/JoinGate.tsx b/packages/shell/src/JoinGate.tsx new file mode 100644 index 000000000..cbcbf3b63 --- /dev/null +++ b/packages/shell/src/JoinGate.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +/** + * Blocking join feedback; callers may supply their own copy and classes. + * @capability join-gate block gameplay until joined and show failures with retry + */ +export function JoinGate({ status, failureReason, retry, joiningLabel = "Joining world…", failedLabel = "Unable to join", retryLabel = "Retry", className, children, renderJoining, renderFailure }: { + status: "joining" | "joined" | "failed"; + failureReason?: string | null; + retry: () => void; + joiningLabel?: ReactNode; + failedLabel?: ReactNode; + retryLabel?: ReactNode; + className?: string; + children?: ReactNode; + renderJoining?: () => ReactNode; + renderFailure?: (reason: string | null, retry: () => void) => ReactNode; +}) { + if (status === "joined") return children; + if (status === "joining" && renderJoining) return renderJoining(); + if (status === "failed" && renderFailure) return renderFailure(failureReason ?? null, retry); + return
+

{status === "failed" ? failedLabel : joiningLabel}

+ {status === "failed" && <>

{failureReason}

} +
; +} diff --git a/packages/shell/src/ShellChrome.tsx b/packages/shell/src/ShellChrome.tsx index 371dc2a3d..f83827453 100644 --- a/packages/shell/src/ShellChrome.tsx +++ b/packages/shell/src/ShellChrome.tsx @@ -78,8 +78,8 @@ export function ShellDebugOverlays({ /** Key handler bundle shared by HUD and 3D presentation paths. @internal */ export type ShellKeyHandlers = { - onKeyDown: (event: { code: string; preventDefault: () => void }) => void; - onKeyUp: (event: { code: string }) => void; + onKeyDown: (event: { code: string; target?: EventTarget | null; preventDefault: () => void }) => void; + onKeyUp: (event: { code: string; target?: EventTarget | null }) => void; onBlur: () => void; }; @@ -102,6 +102,12 @@ export function createShellKeyHandlers({ }): ShellKeyHandlers { return { onKeyDown: (event) => { + const target = event.target as HTMLElement | null | undefined; + if (target?.closest?.("input, textarea, select, [contenteditable]:not([contenteditable=false])")) { + f2HeldRef.current = false; + tracker.reset(); + return; + } if (event.code === "F2") { event.preventDefault(); f2HeldRef.current = true; diff --git a/packages/shell/src/TerritoryOverlay.tsx b/packages/shell/src/TerritoryOverlay.tsx new file mode 100644 index 000000000..530b1de7d --- /dev/null +++ b/packages/shell/src/TerritoryOverlay.tsx @@ -0,0 +1,28 @@ +import type { CSSProperties, ReactNode } from "react"; + +/** A placement footprint cell and its authoritative preview status. */ +export type TerritoryPreviewCell = { key: string; x: number; z: number; status: "owned" | "claimable" | "blocked" }; + +/** + * Placement footprint feedback with inline claim cost and affordability. + * @capability territory-overlay show footprint ownership, claim cost, and affordability + */ +export function TerritoryOverlay({ cells, cost, affordable, formatCost = String, renderCell, className, style }: { + cells: readonly TerritoryPreviewCell[]; + cost: number; + affordable: boolean; + formatCost?: (cost: number) => ReactNode; + renderCell?: (cell: TerritoryPreviewCell) => ReactNode; + className?: string; + style?: CSSProperties; +}) { + const blocked = cells.some((cell) => cell.status === "blocked"); + return
+
{cells.map((cell) => + {renderCell ? renderCell(cell) : } + )}
+ + {blocked ? "Land unavailable" : <>Land: {formatCost(cost)}{affordable ? "" : " — Insufficient funds"}} + +
; +} diff --git a/packages/shell/src/joinTerritory.test.ts b/packages/shell/src/joinTerritory.test.ts new file mode 100644 index 000000000..0292c1854 --- /dev/null +++ b/packages/shell/src/joinTerritory.test.ts @@ -0,0 +1,34 @@ +import { createShellKeyHandlers } from "./ShellChrome"; +import { expect, test } from "bun:test"; + +import { JoinGate } from "./JoinGate"; +import { TerritoryOverlay } from "./TerritoryOverlay"; + +test("failed join blocks children and renders a retry action", () => { + const html = JSON.stringify(JoinGate({ status: "failed", failureReason: "full", retry: () => {}, children: "gameplay" })); + expect(html).not.toContain("gameplay"); + expect(html).toContain('"role":"alert"'); + expect(html).toContain("Retry"); + expect(html).toContain("full"); +}); +test("territory feedback distinguishes blocked land from unaffordable claims", () => { + const props = { cells: [{ key: "0,0", x: 0, z: 0, status: "claimable" as const }], cost: 50, affordable: false }; + expect(JSON.stringify(TerritoryOverlay(props))).toContain("Insufficient funds"); + expect(JSON.stringify(TerritoryOverlay({ ...props, cells: [{ ...props.cells[0]!, status: "blocked" }] }))).toContain("Land unavailable"); +}); + + +test("typing resets held gameplay actions without consuming spaces or devtools hotkeys", () => { + const actions: string[] = []; + let resets = 0; + let prevented = 0; + const f2HeldRef = { current: true }; + const keys = createShellKeyHandlers({ f2HeldRef, tracker: { handleDown: (code) => actions.push(code), handleUp: () => {}, reset: () => { resets += 1; } }, devtoolsEnabled: true, setDevtoolsOpen: () => { throw new Error("Typing opened devtools"); }, controlsActive: () => true }); + const target = { closest: () => ({}) } as unknown as EventTarget; + keys.onKeyDown({ code: "Space", target, preventDefault: () => { prevented += 1; } }); + keys.onKeyDown({ code: "KeyD", target, preventDefault: () => { prevented += 1; } }); + expect(actions).toEqual([]); + expect(resets).toBe(2); + expect(prevented).toBe(0); + expect(f2HeldRef.current).toBe(false); +}); diff --git a/packages/shell/src/useShellMultiplayerSync.ts b/packages/shell/src/useShellMultiplayerSync.ts index 5f0510093..c2b0fb8a6 100644 --- a/packages/shell/src/useShellMultiplayerSync.ts +++ b/packages/shell/src/useShellMultiplayerSync.ts @@ -1,4 +1,4 @@ -import { useEffect, type Dispatch, type SetStateAction } from "react"; +import { useEffect, useState, useCallback, type Dispatch, type SetStateAction } from "react"; import type { GameContext } from "@jgengine/core/runtime/gameContext"; import { isServerAuthoritative } from "@jgengine/core/runtime/adapter"; @@ -16,20 +16,34 @@ export function useShellMultiplayerSync( serverIdRef: { current: string | null }, setRemotePlayers: Dispatch>, authoritativeFrameRef?: { current: AuthoritativeFrameHandler | null }, -): void { +): { status: "joining" | "joined" | "failed"; failureReason: string | null; retry: () => void } { + const [status, setStatus] = useState<"joining" | "joined" | "failed">("joining"); + const [failureReason, setFailureReason] = useState(null); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => setAttempt((value) => value + 1), []); useEffect(() => { if (ctx === null || multiplayer === null) return; + setStatus("joining"); + setFailureReason(null); let disposed = false; const cleanups: (() => void)[] = []; void multiplayer.backend.transport .joinServer({ gameId: multiplayer.gameId }) .then((joined) => { + if (!joined.ok) { + if (!disposed) { + setFailureReason(joined.reason); + setStatus("failed"); + } + return; + } if (disposed) { void multiplayer.backend.transport.leaveServer({ serverId: joined.serverId }); return; } serverIdRef.current = joined.serverId; + setStatus("joined"); if (isServerAuthoritative(playable.game.multiplayer) && multiplayer.backend.feeds !== undefined) { cleanups.push( @@ -112,7 +126,12 @@ export function useShellMultiplayerSync( } } }) - .catch(() => undefined); + .catch((error: unknown) => { + if (!disposed) { + setFailureReason(error instanceof Error ? error.message : "Connection failed"); + setStatus("failed"); + } + }); return () => { disposed = true; @@ -124,5 +143,6 @@ export function useShellMultiplayerSync( void multiplayer.backend.transport.leaveServer({ serverId }).catch(() => undefined); } }; - }, [ctx, multiplayer, playable]); + }, [ctx, multiplayer, playable, attempt]); + return { status: multiplayer === null ? "joined" : status, failureReason, retry }; } diff --git a/packages/sql/package.json b/packages/sql/package.json index 50686757c..f699af290 100644 --- a/packages/sql/package.json +++ b/packages/sql/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/sql", - "version": "0.18.0", + "version": "0.18.1", "description": "Postgres persistence for the JGengine host: implements HostPersistence over a structural pool interface (no hard pg dependency).", "license": "Apache-2.0", "type": "module", @@ -31,7 +31,7 @@ "test": "bun test src" }, "dependencies": { - "@jgengine/core": "^0.18.0" + "@jgengine/core": "^0.18.1" }, "devDependencies": { "pg-mem": "^3.0.5" diff --git a/packages/sql/src/sqlWorldStore.ts b/packages/sql/src/sqlWorldStore.ts index 0512332f3..28aea1edb 100644 --- a/packages/sql/src/sqlWorldStore.ts +++ b/packages/sql/src/sqlWorldStore.ts @@ -1,4 +1,4 @@ -import type { HostedWorldRecord, HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession"; +import type { HostedWorldRecord, HostedWorldStore } from "@jgengine/core/runtime/hostedWorldStore"; import type { SqlQueryable } from "./sqlPersistence"; /** Create an asynchronous hosted-world store backed by one JSONB row. diff --git a/packages/ws/package.json b/packages/ws/package.json index 1cec497bb..9bff53b53 100644 --- a/packages/ws/package.json +++ b/packages/ws/package.json @@ -1,6 +1,6 @@ { "name": "@jgengine/ws", - "version": "0.18.0", + "version": "0.18.1", "description": "Browser-safe WebSocket client backend for JGengine: protocol codec, createWsBackend, and HTTP read helpers.", "license": "Apache-2.0", "type": "module", @@ -31,7 +31,7 @@ "test": "bun test src" }, "dependencies": { - "@jgengine/core": "^0.18.0" + "@jgengine/core": "^0.18.1" }, "publishConfig": { "access": "public" diff --git a/packages/ws/src/createWsBackend.test.ts b/packages/ws/src/createWsBackend.test.ts index 7a8318439..d6870a04e 100644 --- a/packages/ws/src/createWsBackend.test.ts +++ b/packages/ws/src/createWsBackend.test.ts @@ -156,7 +156,7 @@ test("reconnect uses exponential backoff and is not subscription-gated", async ( result: { serverId: "srv-1", isNew: true }, }); } - await expect(joinPromise).resolves.toEqual({ serverId: "srv-1", isNew: true }); + await expect(joinPromise).resolves.toEqual({ ok: true, serverId: "srv-1", isNew: true }); pipe.closeLatest(); expect(delays.at(-1)).toBe(10); diff --git a/packages/ws/src/createWsBackend.ts b/packages/ws/src/createWsBackend.ts index d4ee6a261..1855141f5 100644 --- a/packages/ws/src/createWsBackend.ts +++ b/packages/ws/src/createWsBackend.ts @@ -350,6 +350,10 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { }, onMessage: handleMessage, onClose: () => { + if (pingTimer !== null) { + cancel(pingTimer); + pingTimer = null; + } open = false; pipe = null; ready = null; @@ -403,8 +407,13 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { }); }; + const onPageHide = () => { + for (const serverId of joinedGames.keys()) void transport.leaveServer({ serverId }).catch(() => undefined); + }; + globalThis.addEventListener?.("pagehide", onPageHide); const transport: GameRuntimeTransport = { async joinServer(args) { + try { const result = await request((id) => ({ v: 1, t: "join", @@ -417,7 +426,14 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { joinedGames.set(joined.serverId, args.gameId); joinedRoles.set(joined.serverId, args.role ?? "player"); if (joined.resumeTicket !== undefined) resumeTickets.set(joined.serverId, joined.resumeTicket); - return joined; + return { ...joined, ok: true as const }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/full/i.test(message)) return { ok: false, reason: "full" }; + if (/closed|not found|does not exist/i.test(message)) return { ok: false, reason: "closed" }; + if (/unauthorized|authenticat|forbidden/i.test(message)) return { ok: false, reason: "unauthorized" }; + throw error; + } }, async leaveServer(args) { poseGates.delete(args.serverId); @@ -630,6 +646,7 @@ export function createWsBackend(options: WsBackendOptions): WsBackend { return joined; }, close: () => { + globalThis.removeEventListener?.("pagehide", onPageHide); closed = true; wantConnection = false; if (pingTimer !== null) { diff --git a/packages/ws/src/hostRouter.test.ts b/packages/ws/src/hostRouter.test.ts index 4d5afe929..4dd91c214 100644 --- a/packages/ws/src/hostRouter.test.ts +++ b/packages/ws/src/hostRouter.test.ts @@ -395,7 +395,7 @@ test("loopback: unknown serverId on join fails closed", async () => { const alice = stack.connect("alice"); await expect( alice.transport.joinServer({ gameId: "test-game", serverId: "srv-does-not-exist" }), - ).rejects.toThrow("Server not found"); + ).resolves.toEqual({ ok: false, reason: "closed" }); } finally { await stack.shutdown(); } @@ -548,7 +548,7 @@ test("security: anonymous hello rejected without allowAnonymous or authenticate" const router = createHostRouter({ host }); const backend = createWsBackend({ userId: "mallory", pipe: loopbackPipe(router) }); try { - await expect(backend.transport.joinServer({ gameId: "test-game" })).rejects.toThrow(); + await expect(backend.transport.joinServer({ gameId: "test-game" })).resolves.toEqual({ ok: false, reason: "closed" }); } finally { backend.close(); router.close(); diff --git a/packages/ws/src/worldHost.test.ts b/packages/ws/src/worldHost.test.ts index 218825988..c08aeef72 100644 --- a/packages/ws/src/worldHost.test.ts +++ b/packages/ws/src/worldHost.test.ts @@ -8,6 +8,8 @@ import { } from "@jgengine/core/runtime/hostedWorldSession"; import type { GameContext, GameContextContent } from "@jgengine/core/runtime/gameContext"; import type { GameRuntimeServerView } from "@jgengine/core/runtime/transport"; +import { createWorldMirror } from "@jgengine/core/runtime/worldMirror"; +import type { WorldSyncFrame } from "@jgengine/core/runtime/transport"; import type { WorldSnapshot } from "@jgengine/core/runtime/worldSnapshot"; import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner"; import { createHostRouter, loopbackPipe } from "./hostRouter"; @@ -102,7 +104,16 @@ describe("createWorldGameHost", () => { try { const { serverId } = await alice.transport.joinServer({ gameId: "shared" }); const views = channel(); - alice.feeds?.subscribeServer(serverId, (view) => views.push(view)); + let latestView: GameRuntimeServerView | null = null; + const mirror = createWorldMirror({ hydrate(snapshot) { + if (latestView !== null) views.push({ ...latestView, serverState: snapshot }); + } }); + alice.feeds?.subscribeServer(serverId, (view) => { + latestView = view; + const frame = view?.serverState as WorldSyncFrame | undefined; + if (frame?.kind === "baseline") mirror.applyBaseline(frame.revision, frame.snapshot); + else if (frame?.kind === "diff") mirror.applyDiff(frame.diff); + }); const initial = await views.next(); expect(entityIds(initial)).toContain("alice"); @@ -126,6 +137,8 @@ describe("createWorldGameHost", () => { const frame = { held: ["moveForward"], pointer: { x: 0, y: 1, active: true } }; const result = await alice.transport.runCommand({ serverId, command: INPUT_COMMAND, input: frame }); expect(result.ok).toBe(true); + expect(session.runner().context().game.players?.input("alice")).toBeNull(); + host.tick(1 / 60); expect(session.runner().context().game.players?.input("alice")).toEqual(frame); } finally { alice.close(); diff --git a/scripts/api-doc-baseline.json b/scripts/api-doc-baseline.json index f9e389c57..020755d4e 100644 --- a/scripts/api-doc-baseline.json +++ b/scripts/api-doc-baseline.json @@ -1280,7 +1280,6 @@ "@jgengine/core/runtime/adapter#fly", "@jgengine/core/runtime/adapter#lan", "@jgengine/core/runtime/adapter#multiplayerAdapterKind", - "@jgengine/core/runtime/adapter#servers", "@jgengine/core/runtime/adapter#socketIo", "@jgengine/core/runtime/cameraDirector#CameraDirector", "@jgengine/core/runtime/commandRunner#CommandDef", @@ -2320,7 +2319,6 @@ "@jgengine/react#ChatBubblesOptions", "@jgengine/react#ChatInput", "@jgengine/react#ChatLog", - "@jgengine/react#ChatPanel", "@jgengine/react#ClerkUserShape", "@jgengine/react#ClerkUserState", "@jgengine/react#CompassProps", @@ -2431,7 +2429,6 @@ "@jgengine/react/chat#ChannelTabs", "@jgengine/react/chat#ChatInput", "@jgengine/react/chat#ChatLog", - "@jgengine/react/chat#ChatPanel", "@jgengine/react/chatBubbles#ChatBubble", "@jgengine/react/chatBubbles#ChatBubblesOptions", "@jgengine/react/chatBubbles#latestChatBubbles", diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 9c948ea72..49eb41fec 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -306,6 +306,7 @@ "./rules/triggeredRules", "./runtime/adapter", "./runtime/cameraDirector", + "./runtime/commandInput", "./runtime/commandRunner", "./runtime/context/combat", "./runtime/context/combatFx", @@ -326,6 +327,7 @@ "./runtime/hostPolicy", "./runtime/hostedGameRunner", "./runtime/hostedWorldSession", + "./runtime/hostedWorldStore", "./runtime/inputRecorder", "./runtime/inputSnapshot", "./runtime/motionIntents", @@ -420,11 +422,13 @@ "./tactics/snapshot", "./tactics/surface", "./tactics/tacticalGrid", + "./time/accrueSince", "./time/beatClock", "./time/calendarClock", "./time/dayNightCycle", "./time/gameClock", "./time/idleProgress", + "./time/rateWindow", "./time/serverTick", "./time/simClock", "./time/stateSchedule", @@ -539,6 +543,7 @@ "./world/terraform", "./world/terrain", "./world/terrainGuides", + "./world/territory", "./world/vec2", "./world/vec3", "./world/vegetation", @@ -655,6 +660,7 @@ "./slots", "./social", "./spriteClipPreview", + "./standaloneChatPanel", "./startScreen", "./statusEffectBar", "./store", @@ -663,6 +669,7 @@ "./tileLayerPreview", "./timerReadout", "./useDebouncedCommit", + "./useServerSession", "./voice", "./waveHud", "./waypointMarkers" @@ -678,7 +685,9 @@ "./index", "./occ", "./resolveConvexMultiplayer", - "./server" + "./server", + "./territory", + "./worldPresence" ], "@jgengine/node": [ ".", @@ -703,9 +712,11 @@ "./GamePlayer", "./GamePlayerShell", "./GameUiPreview", + "./JoinGate", "./Shell3dPresentation", "./ShellChrome", "./ShellHudPresentation", + "./TerritoryOverlay", "./audio/AudioComponents", "./audio/audioEngine", "./audio/audioWire", @@ -1085,6 +1096,7 @@ "./templates/editorWorkspace", "./templates/gameFiles", "./templates/names", + "./templates/sharedBuilder", "./templates/types", "./upgrade" ] diff --git a/scripts/release.test.ts b/scripts/release.test.ts index 8f18d99de..8bac1c627 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { cutChangelog, lockstepBullet, mirrorEntry, parseSections, sectionLines, splitComment } from "./release"; +import { nextReleaseVersion, cutChangelog, lockstepBullet, mirrorEntry, parseSections, sectionLines, splitComment } from "./release"; const MARKDOWN = `# Changelog @@ -59,3 +59,10 @@ describe("release cut", () => { expect(() => mirrorEntry(next, "0.18.0", { migrate: [], added: [], changed: [], removed: [] })).toThrow(); }); }); + +test("patch release preserves the minor line and finalizes prereleases", () => { + expect(nextReleaseVersion("0.18.0", true)).toBe("0.18.1"); + expect(nextReleaseVersion("0.18.9", true)).toBe("0.18.10"); + expect(nextReleaseVersion("0.18.1-rc.2", true)).toBe("0.18.1"); + expect(nextReleaseVersion("0.18.0")).toBe("0.19.0"); +}); diff --git a/scripts/release.ts b/scripts/release.ts index 2886da047..c0247d45a 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -93,7 +93,7 @@ export function plainText(text: string): string { export function lockstepBullet(sdk: string, cli: string, github: string): string { return ( `**Bump lockstep SDK packages to \`^${sdk}\`:** ` + - `\`@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}\`. ` + + `\`@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets}\`. ` + `CLI \`jgengine\` is \`${cli}\`; \`@jgengine/github\` is \`${github}\`.` ); } @@ -168,6 +168,7 @@ function main(): void { const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); const skipGen = args.includes("--no-gen"); + const patchRelease = args.includes("--patch"); const read = (rel: string) => readFileSync(`${root}${rel}`, "utf8"); const version = (pkg: string) => JSON.parse(read(`packages/${pkg}/package.json`)).version as string; @@ -183,13 +184,13 @@ function main(): void { } if (!dryRun) { - const bump = spawnSync("bun", ["scripts/set-version.ts", "--release"], { cwd: root, stdio: "inherit" }); + const bump = spawnSync("bun", ["scripts/set-version.ts", "--release", ...(patchRelease ? ["--patch"] : [])], { cwd: root, stdio: "inherit" }); if (bump.status !== 0) process.exit(bump.status ?? 1); } - const sdk = dryRun ? nextMinor(version("core")) : version("core"); - const cli = dryRun ? nextMinor(version("jgengine")) : version("jgengine"); - const github = dryRun ? nextMinor(version("github")) : version("github"); + const sdk = dryRun ? nextReleaseVersion(version("core"), patchRelease) : version("core"); + const cli = dryRun ? nextReleaseVersion(version("jgengine"), patchRelease) : version("jgengine"); + const github = dryRun ? nextReleaseVersion(version("github"), patchRelease) : version("github"); const lockstep = lockstepBullet(sdk, cli, github); sections.migrate.unshift(plainText(lockstep)); @@ -215,11 +216,12 @@ function main(): void { console.log("release: review the diff, then commit and open the release PR."); } -function nextMinor(version: string): string { +/** Compute the version a normal or patch release will publish. */ +export function nextReleaseVersion(version: string, patchRelease = false): string { const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version); if (!match) throw new Error(`unparseable version: ${version}`); const [, major, minor, patch] = match; - return version.includes("-") ? `${major}.${minor}.${patch}` : `${major}.${Number(minor) + 1}.0`; + return version.includes("-") ? `${major}.${minor}.${patch}` : patchRelease ? `${major}.${minor}.${Number(patch) + 1}` : `${major}.${Number(minor) + 1}.0`; } if (import.meta.main) main(); diff --git a/scripts/set-version.ts b/scripts/set-version.ts index 9c99e6983..2cc84f2e5 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -9,11 +9,12 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -const PACKAGES = ["core", "ws", "sql", "react", "convex", "node", "shell", "editor", "assets", "github", "jgengine"]; +const PACKAGES = ["core", "rapier", "ws", "sql", "react", "convex", "node", "shell", "editor", "assets", "github", "jgengine"]; const root = fileURLToPath(new URL("..", import.meta.url)); const args = process.argv.slice(2); const check = args.includes("--check"); const release = args.includes("--release"); +const patchRelease = args.includes("--patch"); const idIndex = args.indexOf("--id"); const preId = idIndex >= 0 ? args[idIndex + 1] : "next"; @@ -26,7 +27,7 @@ function nextRelease(version: string): string { if (!match) throw new Error(`unparseable version: ${version}`); const [, major, minor, patch] = match; if (version.includes("-")) return `${major}.${minor}.${patch}`; - return `${major}.${Number(minor) + 1}.0`; + return patchRelease ? `${major}.${minor}.${Number(patch) + 1}` : `${major}.${Number(minor) + 1}.0`; } function nextPrerelease(version: string): string { diff --git a/scripts/stateful-primitive-baseline.json b/scripts/stateful-primitive-baseline.json index 32e425931..ad44d0378 100644 --- a/scripts/stateful-primitive-baseline.json +++ b/scripts/stateful-primitive-baseline.json @@ -26,7 +26,6 @@ "packages/core/src/editor/streamer.ts#createWorldStreamer", "packages/core/src/faction/factions.ts#createFactionGraph", "packages/core/src/faction/factions.ts#createFactionRoster", - "packages/core/src/game/chat.ts#createChatRateLimiter", "packages/core/src/game/chatFilter.ts#createChatFilter", "packages/core/src/game/connectedPlayers.ts#createConnectedPlayers", "packages/core/src/game/events.ts#createGameEvents",