@jgengine/convex 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/dist/createConvexGameTransport.d.ts +3 -2
- package/dist/createConvexGameTransport.js +1 -1
- package/dist/hostedServer.d.ts +96 -0
- package/dist/hostedServer.js +350 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/occ.d.ts +24 -0
- package/dist/occ.js +16 -0
- package/dist/server.d.ts +55 -9
- package/dist/server.js +141 -18
- package/llms.txt +202 -654
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,42 @@ Agents building on the published SDK can also read this programmatically:
|
|
|
11
11
|
same data as typed values, so an updater can diff its installed version against
|
|
12
12
|
the latest and surface the migration steps.
|
|
13
13
|
|
|
14
|
+
## 0.10.0
|
|
15
|
+
|
|
16
|
+
### Migrate
|
|
17
|
+
|
|
18
|
+
- **Nothing to change for existing games** — the shared mobile-composition system (viewport allocation, region collision, `--jg-*` viewport vars) is wired automatically by the shell for every game; `HudPanel`s inside a `HudCanvas` register themselves. Legacy `orientation: "landscape" | "portrait"` keeps its advisory dismissible-hint behavior unchanged.
|
|
19
|
+
- **Opt a driving/landscape game into the strict gate**: replace the advisory `orientation` with the contract form — `orientation: { mobile: "landscape-required" }` (rules: `any` · `portrait`/`landscape` advisory · `portrait-required`/`landscape-required` · `unsupported`). In portrait the engine now shows a polished `RotateDeviceScreen`, suppresses input, freezes the sim, and unmounts the HUD/controls until the device is landscape.
|
|
20
|
+
- **Declare HUD intent** where useful: `HudPanel` gains `priority` (`critical`/`secondary`/`tertiary`), `mobileBehavior` (`hidden` unmounts on phones, `transient` softens collision, …), `allowOverlapWith`, and `collisionGroup`. Optional — omitting them keeps prior behavior.
|
|
21
|
+
- **Mobile validation now also fails on overlap**: `bun run shoot <game> --device mobile|mobile-landscape|both` exits non-zero on forbidden inter-element collisions (not just viewport overflow), naming both regions.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Shared mobile layout composition — `@jgengine/core/ui/gameLayout` (pure geometry: `resolveLayoutMode`, `intersects`, `overlapArea`, `detectLayoutCollisions`, `computeGameplayRect`, `LayoutRegion`, `GameViewportLayout`) and `@jgengine/core/ui/orientation` (`resolveOrientationRequirement`, `orientationGateActive`, `MobileOrientationRule`). `@jgengine/react` adds `GameViewportProvider` (mounted by the shell), hooks (`useGameViewportLayout`, `useGameLayoutMode`, `useGameOrientation`, `useReservedControlZones`, `useLayoutCollisions`, `useRegisterLayoutRegion`), and a headless `RotateDeviceScreen`. The provider tracks `window.visualViewport`, publishes `--jg-viewport-*` / `--jg-visual-viewport-*` / `--jg-safe-*` CSS vars, resolves the explicit layout mode, and detects forbidden region overlaps (dev `data-jg-layout-collision` + console diagnostic).
|
|
26
|
+
- Mandatory orientation contract — `PlayableGame.orientation` accepts `{ mobile: <rule> }`; `landscape-required`/`portrait-required` render an engine-owned rotate gate that blocks gameplay (input suppressed, simulation frozen, HUD/controls unmounted), self-correcting when the device is turned. Reduced-motion and safe-area respected; `visualViewport`-sized for mobile Safari/PWA.
|
|
27
|
+
- Reserved touch-control zones — the touch dock registers its joystick / action-cluster / utility rectangles (runtime-measured) as `control` regions, so HUD placement and collision validation know exactly where controls sit.
|
|
28
|
+
- HUD placement metadata — `HudPanel` gains `priority`, `mobileBehavior`, `allowOverlapWith`, `collisionGroup`, and registers as a collision-tracked layout region.
|
|
29
|
+
- Mobile-landscape shoot device — `bun run shoot --device mobile-landscape` (844×390) plus collision-gate reads that fail mobile validation on forbidden overlaps.
|
|
30
|
+
- Canyon Chase migrated as the reference landscape-required game — declares `orientation: { mobile: "landscape-required" }`, composes its HUD through coordinated `HudPanel` regions (pursuit distance critical, border secondary, survey map hidden on mobile, radio transient) instead of independent fixed corners.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- First-person projectile tracers now originate from the weapon muzzle instead of the camera/eye centerline. Hit detection is unchanged (still crosshair-accurate) — only the drawn tracer's start point moved to the viewmodel muzzle, which tracks full aim yaw and pitch. Non-viewmodel games and enemy tracers are unaffected.
|
|
35
|
+
|
|
36
|
+
## 0.9.0
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- Mobile HUD fit — design-resolution UI scaling now applies to every game by default: `HudCanvas` measures the live viewport and scales the whole HUD from `PlayableGame.hudFit.designSize` (default 1600×900, clamps `minScale`/`maxScale` 0.4–1) so desktop-authored layouts shrink to fit a phone instead of overflowing it; `hudFit.mobile` overrides tune the phone fit separately. Pure math lives in `@jgengine/core/ui/hudScale` (`hudScaleForViewport`, `resolveHudFit`, `rectOverflow`, `overflowingPanels`); the shell mounts `@jgengine/react`'s `HudViewportProvider` around `GameUI` so games need no wiring.
|
|
41
|
+
- Graphics → UI scale — a player-facing `graphics.uiScale` slider (0.5–1.5, default 1) multiplies the computed HUD scale on every platform; the same resolution system drives desktop preference and mobile shrink.
|
|
42
|
+
- HUD overflow gate — `HudCanvas` measures every `HudPanel` against the viewport at runtime and reports offenders on a `data-hud-overflow` attribute (plus a console warning); `bun run shoot <game> --device mobile|both` now exits non-zero naming the panels that escape the viewport.
|
|
43
|
+
- Automatic visibility & streaming defaults — an engine-level `VisibilitySystem` (exported from `@jgengine/core`) gives every scene renderer-agnostic culling and asset-streaming policy with no per-object wiring: distance culling, preload margins, hysteresis, delayed unloading, multi-camera awareness (including cameras excluded from streaming), and per-object overrides (always-visible, never-unload, disabled culling/streaming, custom render distance and preload margin).
|
|
44
|
+
- Self-hosted asset mirror — `DEFAULT_RELEASE_BASE` now points at this repo's rolling `packs` release instead of the upstream host; a catalog-driven `mirror-assets` workflow (weekly cron + manual dispatch) keeps the mirror in sync with the asset catalog.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- Mobile HUD fit is on by default — design-resolution HUD scaling now applies to every game with no config change (previously opt-in via `platforms: ["web", "mobile"]`). A desktop-only game keeps the legacy fixed 0.85 compact zoom by declaring `platforms: ["web"]` (without `"mobile"`).
|
|
49
|
+
|
|
14
50
|
## 0.8.0
|
|
15
51
|
|
|
16
52
|
Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
|
|
@@ -14,9 +14,9 @@ export type ConvexGameApi = {
|
|
|
14
14
|
joinServer: FunctionReference<"mutation", "public", {
|
|
15
15
|
gameId: string;
|
|
16
16
|
serverId?: string;
|
|
17
|
-
slotsPerServer?: number;
|
|
18
|
-
save?: unknown;
|
|
19
17
|
mode?: string;
|
|
18
|
+
visibility?: "public" | "private";
|
|
19
|
+
joinCode?: string;
|
|
20
20
|
externalId?: string;
|
|
21
21
|
}, {
|
|
22
22
|
serverId: string;
|
|
@@ -60,6 +60,7 @@ export type ConvexGameApi = {
|
|
|
60
60
|
presence: {
|
|
61
61
|
list: FunctionReference<"query", "public", {
|
|
62
62
|
serverId: string;
|
|
63
|
+
externalId?: string;
|
|
63
64
|
}, PresencePoseRow[]>;
|
|
64
65
|
sync: FunctionReference<"mutation", "public", {
|
|
65
66
|
serverId: string;
|
|
@@ -73,7 +73,7 @@ export function createConvexPresenceSync(client, api, config, tuning) {
|
|
|
73
73
|
const gate = createPoseSyncGate(tuning ?? DEFAULT_CONVEX_POSE_TUNING);
|
|
74
74
|
return {
|
|
75
75
|
subscribe(serverId, onChange) {
|
|
76
|
-
return watchConvexQuery(client, api.presence.list, { serverId }, (rows) => rows, onChange);
|
|
76
|
+
return watchConvexQuery(client, api.presence.list, { serverId, externalId: config.userId }, (rows) => rows, onChange);
|
|
77
77
|
},
|
|
78
78
|
syncPose(serverId, pose) {
|
|
79
79
|
if (!gate.evaluate(pose, Date.now()))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { GameDefinition } from "@jgengine/core/game/defineGame";
|
|
2
|
+
import type { GameContextContent } from "@jgengine/core/runtime/gameContext";
|
|
3
|
+
import { type InputFrame } from "@jgengine/core/runtime/hostedGameRunner";
|
|
4
|
+
import { type HostedWorldSession, type HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
|
|
5
|
+
import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
|
|
6
|
+
import { type JgAuthMode } from "./server.js";
|
|
7
|
+
/** One game the hosted path can serve, bound per `gameId`: its definition plus content lookup. */
|
|
8
|
+
export interface HostedGameConfig {
|
|
9
|
+
definition: GameDefinition<ModelAssetRef, unknown>;
|
|
10
|
+
content: GameContextContent;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`,
|
|
14
|
+
* plus the member roster and held inputs the snapshot doesn't carry, and the op to apply to the fresh session.
|
|
15
|
+
*/
|
|
16
|
+
export interface HostedWorldInvocation<T> {
|
|
17
|
+
game: HostedGameConfig;
|
|
18
|
+
store: HostedWorldStore;
|
|
19
|
+
members?: readonly string[];
|
|
20
|
+
inputs?: Readonly<Record<string, InputFrame>>;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
op: (session: HostedWorldSession) => T;
|
|
23
|
+
}
|
|
24
|
+
/** 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. */
|
|
25
|
+
export interface HostedWorldOutcome<T> {
|
|
26
|
+
value: T;
|
|
27
|
+
revision: number;
|
|
28
|
+
changed: boolean;
|
|
29
|
+
members: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
|
|
33
|
+
* `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
|
|
34
|
+
* members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
35
|
+
*/
|
|
36
|
+
export declare function invokeHostedWorld<T>(invocation: HostedWorldInvocation<T>): HostedWorldOutcome<T>;
|
|
37
|
+
/** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
|
|
38
|
+
export declare const JG_HOSTED_TICK_MS = 1000;
|
|
39
|
+
/**
|
|
40
|
+
* The Convex counterpart of ws's `createWorldGameHost`: serves GameContext worlds statelessly by
|
|
41
|
+
* reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
|
|
42
|
+
* {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
|
|
43
|
+
* drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createHostedGameServerFunctions(options: {
|
|
46
|
+
games: Record<string, HostedGameConfig>;
|
|
47
|
+
auth?: JgAuthMode;
|
|
48
|
+
tickMs?: number;
|
|
49
|
+
}): {
|
|
50
|
+
getHostedServer: import("convex/server").RegisteredQuery<"public", {
|
|
51
|
+
externalId?: string | undefined;
|
|
52
|
+
gameId: string;
|
|
53
|
+
serverId?: string | undefined;
|
|
54
|
+
}, Promise<{
|
|
55
|
+
serverId: string;
|
|
56
|
+
gameId: string;
|
|
57
|
+
revision: number;
|
|
58
|
+
serverState: any;
|
|
59
|
+
memberUserIds: string[];
|
|
60
|
+
} | null>>;
|
|
61
|
+
joinHostedServer: import("convex/server").RegisteredMutation<"public", {
|
|
62
|
+
externalId?: string | undefined;
|
|
63
|
+
gameId: string;
|
|
64
|
+
serverId?: string | undefined;
|
|
65
|
+
}, Promise<{
|
|
66
|
+
serverId: string;
|
|
67
|
+
isNew: boolean;
|
|
68
|
+
}>>;
|
|
69
|
+
leaveHostedServer: import("convex/server").RegisteredMutation<"public", {
|
|
70
|
+
externalId?: string | undefined;
|
|
71
|
+
gameId: string;
|
|
72
|
+
serverId?: string | undefined;
|
|
73
|
+
}, Promise<null>>;
|
|
74
|
+
runHostedCommand: import("convex/server").RegisteredMutation<"public", {
|
|
75
|
+
command: string;
|
|
76
|
+
externalId?: string | undefined;
|
|
77
|
+
gameId: string;
|
|
78
|
+
input: any;
|
|
79
|
+
serverId?: string | undefined;
|
|
80
|
+
}, Promise<{
|
|
81
|
+
ok: false;
|
|
82
|
+
reason: string;
|
|
83
|
+
} | {
|
|
84
|
+
ok: true;
|
|
85
|
+
}>>;
|
|
86
|
+
submitHostedInput: import("convex/server").RegisteredMutation<"public", {
|
|
87
|
+
externalId?: string | undefined;
|
|
88
|
+
frame: any;
|
|
89
|
+
gameId: string;
|
|
90
|
+
serverId?: string | undefined;
|
|
91
|
+
}, Promise<null>>;
|
|
92
|
+
tickHostedWorlds: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
|
|
93
|
+
ticked: number;
|
|
94
|
+
saved: number;
|
|
95
|
+
}>>;
|
|
96
|
+
};
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { defineSchema, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
|
|
2
|
+
import { ConvexError, v } from "convex/values";
|
|
3
|
+
import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
|
|
4
|
+
import { createHostedWorldSession, } from "@jgengine/core/runtime/hostedWorldSession";
|
|
5
|
+
import { commitIfRevisionMatch } from "./occ.js";
|
|
6
|
+
import { jgengineHostedTables, resolveActor } from "./server.js";
|
|
7
|
+
/**
|
|
8
|
+
* The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
|
|
9
|
+
* `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
|
|
10
|
+
* members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
|
|
11
|
+
*/
|
|
12
|
+
export function invokeHostedWorld(invocation) {
|
|
13
|
+
const { game, store, members = [], inputs = {}, now, op } = invocation;
|
|
14
|
+
const loaded = store.load();
|
|
15
|
+
const loadedJson = loaded === null ? null : JSON.stringify(loaded.snapshot);
|
|
16
|
+
const session = createHostedWorldSession({
|
|
17
|
+
definition: game.definition,
|
|
18
|
+
content: game.content,
|
|
19
|
+
store: { load: () => loaded, save: () => { } },
|
|
20
|
+
...(now === undefined ? {} : { now }),
|
|
21
|
+
});
|
|
22
|
+
for (const userId of members)
|
|
23
|
+
session.runner().resume(userId);
|
|
24
|
+
for (const [userId, frame] of Object.entries(inputs))
|
|
25
|
+
session.input(userId, frame);
|
|
26
|
+
const value = op(session);
|
|
27
|
+
const snapshot = session.runner().snapshot();
|
|
28
|
+
const baseRevision = loaded?.revision ?? 0;
|
|
29
|
+
if (loadedJson !== null && JSON.stringify(snapshot) === loadedJson) {
|
|
30
|
+
return { value, revision: baseRevision, changed: false, members: session.members() };
|
|
31
|
+
}
|
|
32
|
+
const record = { snapshot, revision: baseRevision + 1 };
|
|
33
|
+
store.save(record);
|
|
34
|
+
return { value, revision: record.revision, changed: true, members: session.members() };
|
|
35
|
+
}
|
|
36
|
+
const hostedSchemaForTypes = defineSchema(jgengineHostedTables());
|
|
37
|
+
const query = queryGeneric;
|
|
38
|
+
const mutation = mutationGeneric;
|
|
39
|
+
const internalMutation = internalMutationGeneric;
|
|
40
|
+
/** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
|
|
41
|
+
export const JG_HOSTED_TICK_MS = 1_000;
|
|
42
|
+
async function getWorldRow(ctx, gameId, serverId) {
|
|
43
|
+
return await ctx.db
|
|
44
|
+
.query("jgHostedWorlds")
|
|
45
|
+
.withIndex("by_game_and_server", (q) => q.eq("gameId", gameId).eq("serverId", serverId))
|
|
46
|
+
.unique();
|
|
47
|
+
}
|
|
48
|
+
function heldInputsOf(row) {
|
|
49
|
+
return (row?.inputs ?? {});
|
|
50
|
+
}
|
|
51
|
+
function worldStoreForRow(row) {
|
|
52
|
+
const loaded = row === null ? null : { snapshot: row.snapshot, revision: row.revision };
|
|
53
|
+
let captured = null;
|
|
54
|
+
return {
|
|
55
|
+
store: {
|
|
56
|
+
load: () => loaded,
|
|
57
|
+
save(record) {
|
|
58
|
+
captured = record;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
captured: () => captured,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function persistWorldRow(ctx, args) {
|
|
65
|
+
const nowMs = Date.now();
|
|
66
|
+
if (args.row === null) {
|
|
67
|
+
if (args.record === null)
|
|
68
|
+
return;
|
|
69
|
+
await ctx.db.insert("jgHostedWorlds", {
|
|
70
|
+
gameId: args.gameId,
|
|
71
|
+
serverId: args.serverId,
|
|
72
|
+
snapshot: args.record.snapshot,
|
|
73
|
+
revision: args.record.revision,
|
|
74
|
+
memberUserIds: [...args.memberUserIds],
|
|
75
|
+
inputs: args.inputs,
|
|
76
|
+
tickAnchorMs: args.tickAnchorMs ?? nowMs,
|
|
77
|
+
createdAt: nowMs,
|
|
78
|
+
updatedAt: nowMs,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await ctx.db.patch(args.row._id, {
|
|
83
|
+
...(args.record === null
|
|
84
|
+
? {}
|
|
85
|
+
: { snapshot: args.record.snapshot, revision: args.record.revision }),
|
|
86
|
+
memberUserIds: [...args.memberUserIds],
|
|
87
|
+
inputs: args.inputs,
|
|
88
|
+
...(args.tickAnchorMs === undefined ? {} : { tickAnchorMs: args.tickAnchorMs }),
|
|
89
|
+
updatedAt: nowMs,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The Convex counterpart of ws's `createWorldGameHost`: serves GameContext worlds statelessly by
|
|
94
|
+
* reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
|
|
95
|
+
* {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
|
|
96
|
+
* drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
|
|
97
|
+
*/
|
|
98
|
+
export function createHostedGameServerFunctions(options) {
|
|
99
|
+
const mode = options.auth ?? "anonymous";
|
|
100
|
+
const tickMs = options.tickMs ?? JG_HOSTED_TICK_MS;
|
|
101
|
+
function resolveGame(gameId) {
|
|
102
|
+
const game = options.games[gameId];
|
|
103
|
+
if (game === undefined)
|
|
104
|
+
throw new ConvexError(`Unknown hosted game "${gameId}"`);
|
|
105
|
+
return game;
|
|
106
|
+
}
|
|
107
|
+
const getHostedServer = query({
|
|
108
|
+
args: {
|
|
109
|
+
gameId: v.string(),
|
|
110
|
+
serverId: v.optional(v.string()),
|
|
111
|
+
externalId: v.optional(v.string()),
|
|
112
|
+
},
|
|
113
|
+
returns: v.union(v.object({
|
|
114
|
+
serverId: v.string(),
|
|
115
|
+
gameId: v.string(),
|
|
116
|
+
revision: v.number(),
|
|
117
|
+
serverState: v.any(),
|
|
118
|
+
memberUserIds: v.array(v.string()),
|
|
119
|
+
}), v.null()),
|
|
120
|
+
handler: async (ctx, args) => {
|
|
121
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
122
|
+
if (!actorUserId)
|
|
123
|
+
return null;
|
|
124
|
+
const row = await getWorldRow(ctx, args.gameId, args.serverId ?? args.gameId);
|
|
125
|
+
if (!row)
|
|
126
|
+
return null;
|
|
127
|
+
if (!row.memberUserIds.includes(actorUserId))
|
|
128
|
+
return null;
|
|
129
|
+
return {
|
|
130
|
+
serverId: row.serverId,
|
|
131
|
+
gameId: row.gameId,
|
|
132
|
+
revision: row.revision,
|
|
133
|
+
serverState: row.snapshot,
|
|
134
|
+
memberUserIds: row.memberUserIds,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const joinHostedServer = mutation({
|
|
139
|
+
args: {
|
|
140
|
+
gameId: v.string(),
|
|
141
|
+
serverId: v.optional(v.string()),
|
|
142
|
+
externalId: v.optional(v.string()),
|
|
143
|
+
},
|
|
144
|
+
returns: v.object({ serverId: v.string(), isNew: v.boolean() }),
|
|
145
|
+
handler: async (ctx, args) => {
|
|
146
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
147
|
+
if (!actorUserId) {
|
|
148
|
+
throw new ConvexError("Not authenticated");
|
|
149
|
+
}
|
|
150
|
+
const game = resolveGame(args.gameId);
|
|
151
|
+
const serverId = args.serverId ?? args.gameId;
|
|
152
|
+
const row = await getWorldRow(ctx, args.gameId, serverId);
|
|
153
|
+
const isNew = !(row?.memberUserIds ?? []).includes(actorUserId);
|
|
154
|
+
const { store, captured } = worldStoreForRow(row);
|
|
155
|
+
const outcome = invokeHostedWorld({
|
|
156
|
+
game,
|
|
157
|
+
store,
|
|
158
|
+
members: row?.memberUserIds ?? [],
|
|
159
|
+
inputs: heldInputsOf(row),
|
|
160
|
+
op: (session) => session.join(actorUserId, isNew),
|
|
161
|
+
});
|
|
162
|
+
await persistWorldRow(ctx, {
|
|
163
|
+
gameId: args.gameId,
|
|
164
|
+
serverId,
|
|
165
|
+
row,
|
|
166
|
+
record: captured(),
|
|
167
|
+
memberUserIds: outcome.members,
|
|
168
|
+
inputs: heldInputsOf(row),
|
|
169
|
+
});
|
|
170
|
+
return { serverId, isNew };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const leaveHostedServer = mutation({
|
|
174
|
+
args: {
|
|
175
|
+
gameId: v.string(),
|
|
176
|
+
serverId: v.optional(v.string()),
|
|
177
|
+
externalId: v.optional(v.string()),
|
|
178
|
+
},
|
|
179
|
+
returns: v.null(),
|
|
180
|
+
handler: async (ctx, args) => {
|
|
181
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
182
|
+
if (!actorUserId) {
|
|
183
|
+
throw new ConvexError("Not authenticated");
|
|
184
|
+
}
|
|
185
|
+
const serverId = args.serverId ?? args.gameId;
|
|
186
|
+
const row = await getWorldRow(ctx, args.gameId, serverId);
|
|
187
|
+
if (!row)
|
|
188
|
+
return null;
|
|
189
|
+
if (!row.memberUserIds.includes(actorUserId))
|
|
190
|
+
return null;
|
|
191
|
+
const game = resolveGame(args.gameId);
|
|
192
|
+
const { store, captured } = worldStoreForRow(row);
|
|
193
|
+
const outcome = invokeHostedWorld({
|
|
194
|
+
game,
|
|
195
|
+
store,
|
|
196
|
+
members: row.memberUserIds,
|
|
197
|
+
inputs: heldInputsOf(row),
|
|
198
|
+
op: (session) => session.leave(actorUserId),
|
|
199
|
+
});
|
|
200
|
+
const inputs = heldInputsOf(row);
|
|
201
|
+
delete inputs[actorUserId];
|
|
202
|
+
await persistWorldRow(ctx, {
|
|
203
|
+
gameId: args.gameId,
|
|
204
|
+
serverId,
|
|
205
|
+
row,
|
|
206
|
+
record: captured(),
|
|
207
|
+
memberUserIds: outcome.members,
|
|
208
|
+
inputs,
|
|
209
|
+
});
|
|
210
|
+
return null;
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
const runHostedCommand = mutation({
|
|
214
|
+
args: {
|
|
215
|
+
gameId: v.string(),
|
|
216
|
+
serverId: v.optional(v.string()),
|
|
217
|
+
command: v.string(),
|
|
218
|
+
input: v.any(),
|
|
219
|
+
externalId: v.optional(v.string()),
|
|
220
|
+
},
|
|
221
|
+
returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
|
|
222
|
+
handler: async (ctx, args) => {
|
|
223
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
224
|
+
if (!actorUserId) {
|
|
225
|
+
throw new ConvexError("Not authenticated");
|
|
226
|
+
}
|
|
227
|
+
const serverId = args.serverId ?? args.gameId;
|
|
228
|
+
const row = await getWorldRow(ctx, args.gameId, serverId);
|
|
229
|
+
if (!row) {
|
|
230
|
+
return { ok: false, reason: "Server not found" };
|
|
231
|
+
}
|
|
232
|
+
if (!row.memberUserIds.includes(actorUserId)) {
|
|
233
|
+
return { ok: false, reason: "Not a member of this server" };
|
|
234
|
+
}
|
|
235
|
+
if (args.command === INPUT_COMMAND) {
|
|
236
|
+
await ctx.db.patch(row._id, {
|
|
237
|
+
inputs: { ...heldInputsOf(row), [actorUserId]: args.input },
|
|
238
|
+
updatedAt: Date.now(),
|
|
239
|
+
});
|
|
240
|
+
return { ok: true };
|
|
241
|
+
}
|
|
242
|
+
const game = resolveGame(args.gameId);
|
|
243
|
+
const loadedRevision = row.revision;
|
|
244
|
+
const { store, captured } = worldStoreForRow(row);
|
|
245
|
+
const outcome = invokeHostedWorld({
|
|
246
|
+
game,
|
|
247
|
+
store,
|
|
248
|
+
members: row.memberUserIds,
|
|
249
|
+
inputs: heldInputsOf(row),
|
|
250
|
+
op: (session) => session.command(actorUserId, args.command, args.input),
|
|
251
|
+
});
|
|
252
|
+
if (outcome.value.status === "rejected") {
|
|
253
|
+
return { ok: false, reason: outcome.value.reason };
|
|
254
|
+
}
|
|
255
|
+
if (outcome.value.status === "unknown-command") {
|
|
256
|
+
return { ok: false, reason: "unknown-command" };
|
|
257
|
+
}
|
|
258
|
+
const latest = await getWorldRow(ctx, args.gameId, serverId);
|
|
259
|
+
if (!latest) {
|
|
260
|
+
return { ok: false, reason: "Server not found" };
|
|
261
|
+
}
|
|
262
|
+
const commit = commitIfRevisionMatch(loadedRevision, latest.revision);
|
|
263
|
+
if (!commit.ok) {
|
|
264
|
+
return commit;
|
|
265
|
+
}
|
|
266
|
+
await persistWorldRow(ctx, {
|
|
267
|
+
gameId: args.gameId,
|
|
268
|
+
serverId,
|
|
269
|
+
row: latest,
|
|
270
|
+
record: captured(),
|
|
271
|
+
memberUserIds: outcome.members,
|
|
272
|
+
inputs: heldInputsOf(latest),
|
|
273
|
+
});
|
|
274
|
+
return { ok: true };
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
const submitHostedInput = mutation({
|
|
278
|
+
args: {
|
|
279
|
+
gameId: v.string(),
|
|
280
|
+
serverId: v.optional(v.string()),
|
|
281
|
+
frame: v.any(),
|
|
282
|
+
externalId: v.optional(v.string()),
|
|
283
|
+
},
|
|
284
|
+
returns: v.null(),
|
|
285
|
+
handler: async (ctx, args) => {
|
|
286
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
287
|
+
if (!actorUserId) {
|
|
288
|
+
throw new ConvexError("Not authenticated");
|
|
289
|
+
}
|
|
290
|
+
const row = await getWorldRow(ctx, args.gameId, args.serverId ?? args.gameId);
|
|
291
|
+
if (!row || !row.memberUserIds.includes(actorUserId)) {
|
|
292
|
+
throw new ConvexError("Not a member of this server");
|
|
293
|
+
}
|
|
294
|
+
await ctx.db.patch(row._id, {
|
|
295
|
+
inputs: { ...heldInputsOf(row), [actorUserId]: args.frame },
|
|
296
|
+
updatedAt: Date.now(),
|
|
297
|
+
});
|
|
298
|
+
return null;
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const tickHostedWorlds = internalMutation({
|
|
302
|
+
args: {},
|
|
303
|
+
handler: async (ctx) => {
|
|
304
|
+
const nowMs = Date.now();
|
|
305
|
+
const rows = await ctx.db.query("jgHostedWorlds").collect();
|
|
306
|
+
let ticked = 0;
|
|
307
|
+
let saved = 0;
|
|
308
|
+
for (const row of rows) {
|
|
309
|
+
const game = options.games[row.gameId];
|
|
310
|
+
if (game === undefined)
|
|
311
|
+
continue;
|
|
312
|
+
if (row.memberUserIds.length === 0)
|
|
313
|
+
continue;
|
|
314
|
+
const elapsedMs = nowMs - row.tickAnchorMs;
|
|
315
|
+
if (elapsedMs < tickMs)
|
|
316
|
+
continue;
|
|
317
|
+
const { store, captured } = worldStoreForRow(row);
|
|
318
|
+
const outcome = invokeHostedWorld({
|
|
319
|
+
game,
|
|
320
|
+
store,
|
|
321
|
+
members: row.memberUserIds,
|
|
322
|
+
inputs: heldInputsOf(row),
|
|
323
|
+
op: (session) => session.tick(elapsedMs / 1_000),
|
|
324
|
+
});
|
|
325
|
+
ticked += 1;
|
|
326
|
+
const record = captured();
|
|
327
|
+
if (record !== null)
|
|
328
|
+
saved += 1;
|
|
329
|
+
await persistWorldRow(ctx, {
|
|
330
|
+
gameId: row.gameId,
|
|
331
|
+
serverId: row.serverId,
|
|
332
|
+
row,
|
|
333
|
+
record,
|
|
334
|
+
memberUserIds: outcome.members,
|
|
335
|
+
inputs: heldInputsOf(row),
|
|
336
|
+
tickAnchorMs: nowMs,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return { ticked, saved };
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
return {
|
|
343
|
+
getHostedServer,
|
|
344
|
+
joinHostedServer,
|
|
345
|
+
leaveHostedServer,
|
|
346
|
+
runHostedCommand,
|
|
347
|
+
submitHostedInput,
|
|
348
|
+
tickHostedWorlds,
|
|
349
|
+
};
|
|
350
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./convexChatTransport.js";
|
|
2
|
+
export * from "./convexPresenceTransport.js";
|
|
3
|
+
export * from "./createConvexBackend.js";
|
|
4
|
+
export * from "./createConvexGameTransport.js";
|
|
5
|
+
export * from "./hostedServer.js";
|
|
6
|
+
export * from "./occ.js";
|
|
7
|
+
export * from "./resolveConvexMultiplayer.js";
|
|
8
|
+
export * from "./server.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./convexChatTransport.js";
|
|
2
|
+
export * from "./convexPresenceTransport.js";
|
|
3
|
+
export * from "./createConvexBackend.js";
|
|
4
|
+
export * from "./createConvexGameTransport.js";
|
|
5
|
+
export * from "./hostedServer.js";
|
|
6
|
+
export * from "./occ.js";
|
|
7
|
+
export * from "./resolveConvexMultiplayer.js";
|
|
8
|
+
export * from "./server.js";
|
package/dist/occ.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
2
|
+
import type { GameRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
|
|
3
|
+
export declare const REVISION_CONFLICT_REASON = "Revision conflict";
|
|
4
|
+
export declare function commitIfRevisionMatch(loadedRevision: number, currentRevision: number): {
|
|
5
|
+
ok: true;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function applyCommandWithOcc(args: {
|
|
11
|
+
loadedRevision: number;
|
|
12
|
+
currentRevision: number;
|
|
13
|
+
snapshot: GameRuntimeSnapshot;
|
|
14
|
+
runtime: GameRuntime;
|
|
15
|
+
actorUserId: string;
|
|
16
|
+
command: string;
|
|
17
|
+
input: unknown;
|
|
18
|
+
}): {
|
|
19
|
+
ok: true;
|
|
20
|
+
snapshot: GameRuntimeSnapshot;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
package/dist/occ.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const REVISION_CONFLICT_REASON = "Revision conflict";
|
|
2
|
+
export function commitIfRevisionMatch(loadedRevision, currentRevision) {
|
|
3
|
+
if (currentRevision !== loadedRevision) {
|
|
4
|
+
return { ok: false, reason: REVISION_CONFLICT_REASON };
|
|
5
|
+
}
|
|
6
|
+
return { ok: true };
|
|
7
|
+
}
|
|
8
|
+
export function applyCommandWithOcc(args) {
|
|
9
|
+
const gate = commitIfRevisionMatch(args.loadedRevision, args.currentRevision);
|
|
10
|
+
if (!gate.ok)
|
|
11
|
+
return gate;
|
|
12
|
+
if (args.snapshot.revision !== args.loadedRevision) {
|
|
13
|
+
return { ok: false, reason: REVISION_CONFLICT_REASON };
|
|
14
|
+
}
|
|
15
|
+
return args.runtime.runCommand(args.snapshot, args.actorUserId, args.command, args.input);
|
|
16
|
+
}
|