@genex-ai/cli-demo 1.36.8 → 1.36.9

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/dist/index.js CHANGED
@@ -876,6 +876,90 @@ async function writeGameConfigFiles(meta, log, cwd = process.cwd()) {
876
876
  await writeIfAbsent(path3.join(cwd, ".env.development.local"), overrides, log);
877
877
  }
878
878
  }
879
+ var SLUG_CONFIG_FILES = ["src/genex.config.ts", "src/genex.config.js"];
880
+ var SLUG_ENV_FILES = [".env.production.local", ".env.production", ".env.local", ".env"];
881
+ var SLUG_LITERAL = /^(["'`])([a-z0-9][a-z0-9-]*)\1$/;
882
+ function topLevelFields(code, start) {
883
+ const fields = [];
884
+ let field = "";
885
+ let depth = 0;
886
+ for (let i = start; i < code.length; i++) {
887
+ const ch = code[i];
888
+ if (ch === "/" && code[i + 1] === "/") {
889
+ const end = code.indexOf("\n", i);
890
+ if (end === -1) return null;
891
+ i = end - 1;
892
+ continue;
893
+ }
894
+ if (ch === "/" && code[i + 1] === "*") {
895
+ const end = code.indexOf("*/", i + 2);
896
+ if (end === -1) return null;
897
+ i = end + 1;
898
+ continue;
899
+ }
900
+ if (ch === '"' || ch === "'" || ch === "`") {
901
+ let j = i + 1;
902
+ while (j < code.length && code[j] !== ch) j += code[j] === "\\" ? 2 : 1;
903
+ if (j >= code.length) return null;
904
+ field += code.slice(i, j + 1);
905
+ i = j;
906
+ continue;
907
+ }
908
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
909
+ else if (ch === ")" || ch === "]") depth--;
910
+ else if (ch === "}") {
911
+ if (depth === 0) {
912
+ fields.push(field);
913
+ return fields;
914
+ }
915
+ depth--;
916
+ } else if (ch === "," && depth === 0) {
917
+ fields.push(field);
918
+ field = "";
919
+ continue;
920
+ }
921
+ field += ch;
922
+ }
923
+ return null;
924
+ }
925
+ function bakedConfigSlug(content) {
926
+ const object = /export\s+const\s+GENEX(?:\s*:[^=]+)?\s*=\s*\{/.exec(content);
927
+ if (!object) return null;
928
+ const fields = topLevelFields(content, object.index + object[0].length);
929
+ const value = fields?.map((f) => /^\s*(?:slug|["']slug["'])\s*:([\s\S]*)$/.exec(f)?.[1]?.trim()).find((v) => v !== void 0);
930
+ if (!value) return null;
931
+ const fallback = value.includes("??") ? value.slice(value.lastIndexOf("??") + 2).trim() : value;
932
+ return SLUG_LITERAL.exec(fallback)?.[2] ?? null;
933
+ }
934
+ function envFileSlug(content) {
935
+ let found = null;
936
+ for (const line of content.split(/\r?\n/)) {
937
+ const match = /^[ \t]*(?:export[ \t]+)?VITE_GENEX_SLUG[ \t]*=[ \t]*(.*)$/.exec(line);
938
+ if (!match) continue;
939
+ const raw = match[1].trim();
940
+ const quote2 = raw[0] === '"' || raw[0] === "'" ? raw[0] : null;
941
+ const value = quote2 ? raw.slice(1, raw.indexOf(quote2, 1) === -1 ? void 0 : raw.indexOf(quote2, 1)) : raw.replace(/[ \t]+#.*$/, "");
942
+ if (value.trim()) found = value.trim();
943
+ }
944
+ return found;
945
+ }
946
+ async function findForeignSlug(cwd, slug, env = process.env) {
947
+ const read = (rel) => fs2.readFile(path3.join(cwd, rel), "utf8").catch(() => null);
948
+ const judge = (file, found) => found === slug ? null : { file, found };
949
+ const fromShell = env.VITE_GENEX_SLUG?.trim();
950
+ if (fromShell) return judge("VITE_GENEX_SLUG (environment)", fromShell);
951
+ for (const rel of SLUG_ENV_FILES) {
952
+ const content = await read(rel);
953
+ const found = content === null ? null : envFileSlug(content);
954
+ if (found) return judge(rel, found);
955
+ }
956
+ for (const rel of SLUG_CONFIG_FILES) {
957
+ const content = await read(rel);
958
+ const found = content === null ? null : bakedConfigSlug(content);
959
+ if (found !== null) return judge(rel, found);
960
+ }
961
+ return null;
962
+ }
879
963
 
880
964
  // src/lib/ssh.ts
881
965
  import fs3 from "fs/promises";
@@ -2630,6 +2714,26 @@ async function detectEmbedSdkVersion(cwd = process.cwd()) {
2630
2714
  return null;
2631
2715
  }
2632
2716
  }
2717
+ var MIN_MULTIPLAYER_SDK = { major: 0, minor: 17 };
2718
+ async function outdatedMultiplayerSdk(cwd = process.cwd()) {
2719
+ const readJson = async (rel) => {
2720
+ try {
2721
+ return JSON.parse(await fs13.readFile(path14.join(cwd, rel), "utf8"));
2722
+ } catch {
2723
+ return null;
2724
+ }
2725
+ };
2726
+ const pkg = await readJson("package.json");
2727
+ const range = pkg?.dependencies?.["@genex-ai/multiplayer"] ?? pkg?.devDependencies?.["@genex-ai/multiplayer"];
2728
+ if (!range) return null;
2729
+ const installed = (await readJson("node_modules/@genex-ai/multiplayer/package.json"))?.version;
2730
+ const version = typeof installed === "string" ? installed : range;
2731
+ const match = /^[\^~>=v\s]*(\d+)\.(\d+)/.exec(version);
2732
+ if (!match || /[|<]|\s-\s/.test(version)) return null;
2733
+ const [major, minor] = [Number(match[1]), Number(match[2])];
2734
+ const old = major < MIN_MULTIPLAYER_SDK.major || major === MIN_MULTIPLAYER_SDK.major && minor < MIN_MULTIPLAYER_SDK.minor;
2735
+ return old ? version : null;
2736
+ }
2633
2737
  async function detectMultiplayer(cwd = process.cwd()) {
2634
2738
  try {
2635
2739
  const raw = await fs13.readFile(path14.join(cwd, "package.json"), "utf8");
@@ -5690,6 +5794,23 @@ async function deployGame(ctx, opts, log) {
5690
5794
  return false;
5691
5795
  }
5692
5796
  }
5797
+ const expectedSlug = ctx.slug ?? meta?.slug;
5798
+ if (expectedSlug) {
5799
+ const foreign = await findForeignSlug(cwd, expectedSlug);
5800
+ if (foreign) {
5801
+ log.error(
5802
+ `${foreign.file} names the game "${foreign.found}", but this folder is the Genex game "${expectedSlug}". The build would sign players into "${foreign.found}". Set it to "${expectedSlug}" and deploy again. Nothing was deployed.`
5803
+ );
5804
+ return false;
5805
+ }
5806
+ }
5807
+ const oldSdk = await outdatedMultiplayerSdk(cwd);
5808
+ if (oldSdk) {
5809
+ log.error(
5810
+ `This game uses @genex-ai/multiplayer ${oldSdk}, which Genex's multiplayer server no longer accepts. Run \`npm i @genex-ai/multiplayer@^0.17.0\` and deploy again. Nothing was deployed.`
5811
+ );
5812
+ return false;
5813
+ }
5693
5814
  if (!opts.noBuild && await hasBuildScript(cwd)) {
5694
5815
  log.step("Building the production bundle\u2026");
5695
5816
  const built = await run("npm", ["run", "build"]);
@@ -6813,6 +6934,12 @@ async function runPreview(opts) {
6813
6934
  log.plain("");
6814
6935
  log.plain(nudge);
6815
6936
  }
6937
+ if (detections.multiplayer) {
6938
+ log.plain("");
6939
+ log.plain(
6940
+ "Multiplayer on this preview runs in rooms of its own, apart from the live game. Test it as two different players: open the preview-- address printed above in a normal and an incognito window, or send it to a friend. The game's page is yours only."
6941
+ );
6942
+ }
6816
6943
  }
6817
6944
 
6818
6945
  // src/commands/remix.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "1.36.8",
3
+ "version": "1.36.9",
4
4
  "description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -469,8 +469,9 @@ Rules:
469
469
  `[::1]` — not https, not a LAN IP), the page is not inside an iframe, and
470
470
  `@genex-ai/embed-sdk` is 0.5.0+ (older: apply the pending platform update).
471
471
  - **Multiplayer `connect()` fails in local test mode** — by design: no relay
472
- credential exists there. Validate multiplayer on the hosted draft (the
473
- owner's session) or the published game, and say plainly when it wasn't.
472
+ credential exists there. Validate multiplayer on the preview link (it plays in
473
+ rooms of its own; a normal and a private window are two players), and say
474
+ plainly when it wasn't.
474
475
  - **Multiplayer join rejected with 401** — the join ran before
475
476
  `waitForPlayer()` resolved, without `auth: () => getColyseusAuth()`, or with a
476
477
  stale cached token on reconnect (read it fresh each call).
@@ -33,23 +33,42 @@ example, the shared-object/ball code, rotation, and host usage. Read
33
33
  calling multiplayer done, run the mandatory
34
34
  [netcode feel gate](references/genex-netcode-feel-checklist.md).
35
35
 
36
+ ## Testing multiplayer: on the preview link, with two different players
37
+
38
+ **A preview build is multiplayer in rooms of its own.** `npx genex preview` (the
39
+ `preview--<slug>` address) plays in rooms the relay keeps apart from the live game: testers on the
40
+ preview link meet each other and never meet real players, and a published game's players never see
41
+ an untested build. Anyone with the preview link can join, as a guest or signed in, even before the
42
+ game is published. You do nothing for this: the game always passes its plain slug; the relay picks
43
+ the room.
44
+
45
+ - **Test it on the preview, as two DIFFERENT players:** the preview link is the `preview--<slug>`
46
+ address `npx genex preview` prints on its "Live" line (the dashboard's draft page is the owner's
47
+ only). Open it in a normal window and an incognito window (the incognito one plays as a guest),
48
+ or send it to a friend.
49
+ Two tabs on one account are ONE seat: the newer tab takes it (close code 4409).
50
+ - Until two players have played it, tell the user multiplayer is built but not yet verified, give
51
+ them the preview link and say exactly that: open it in a normal and an incognito window.
52
+ - Nobody can save or buy on a preview, the owner included. Test progression saves and purchases
53
+ after publishing.
54
+
36
55
  ## Install
37
56
 
38
57
  ```bash
39
- npm i @genex-ai/multiplayer@^0.16.0
58
+ npm i @genex-ai/multiplayer@^0.17.0
40
59
  ```
41
60
 
42
- > Pin `@^0.16.0` (not a bare `npm i`, and not an older range): automatic TURN — the thing that
61
+ > Pin `@^0.17.0` (not a bare `npm i`, and not an older range): 0.17 speaks the relay's current
62
+ > wire protocol, and the relay **refuses every older SDK** — a game built on 0.16 or earlier
63
+ > cannot join a room at all (its joins fail with `unsupported protocol`). 0.17 also added a
64
+ > relay-stamped per-game player id (`p.uid`), relay-assigned teams for the `open` preset
65
+ > (`p.team`) and the `room:open` event. Earlier floors still hold: automatic TURN — the thing that
43
66
  > lets players behind symmetric NAT or carrier CGNAT be heard at all — landed in 0.16, mesh voice
44
- > in 0.15, room text chat in 0.14, host-only `setRoomOpen()` in 0.13; cross-region invites can
45
- > select the inviter's exact relay with the optional `url` override since 0.11; unowned object
46
- > writes warn instead of failing silently, live connected-player presence, supplier-form
47
- > `connect()` auth and regional relay selection (`getColyseusUrls()` + `urls`) landed in 0.10;
48
- > confirmed object controls, snaps, host-tick teardown, and reconnect rebasing in 0.9. An older
49
- > resolve does not have those — and because this package is still `0.x`, a caret range is
50
- > minor-locked, so `^0.12.0` would resolve to 0.12.x and give you no voice at all.
51
-
52
- This skill targets `@genex-ai/multiplayer` **≥ 0.16.0** (`objects`/`host` since 0.4; verified per-player `avatarUrl` since 0.12;
67
+ > in 0.15, room text chat in 0.14, host-only `setRoomOpen()` in 0.13. Because this package is
68
+ > still `0.x`, a caret range is minor-locked, so `^0.16.0` would resolve to 0.16.x and never join.
69
+
70
+ This skill targets `@genex-ai/multiplayer` **≥ 0.17.0** (relay protocol 3, `p.uid`/`p.team` and
71
+ `room:open` since 0.17; `objects`/`host` since 0.4; verified per-player `avatarUrl` since 0.12;
53
72
  room text chat via `room.chat` since 0.14; mesh voice via `room.voice` since 0.15;
54
73
  automatic TURN (voice connects behind symmetric NAT with no configuration) since 0.16;
55
74
  `matchmake()` since 0.5; private lobbies since 0.7; auto-reconnect + `inputs`/`onHostTick`
@@ -132,6 +151,8 @@ mm.on("matched", () => syncSession());
132
151
  mm.on("matchStart", () => syncSession()); // duel/arena/teams only
133
152
  mm.on("matchEnded", (result) => showResult(result)); // duel/arena/teams only
134
153
  mm.on("error", (e) => showQueueError(e)); // after SDK retries; fix, then mm.retry()
154
+ // 4409 = opened in another tab/device, 4401 = signed out: the handle stops, never requeues.
155
+ mm.on("disconnect", () => showMenu("This game is open somewhere else.")); // mm.retry() only on a click
135
156
 
136
157
  // Every frame AND a low-rate timer: the handle swaps in a new Session after
137
158
  // seating, terminal drop/requeue, and the next match.
@@ -330,8 +351,9 @@ halves of this rule are non-negotiable:
330
351
  is never written (host bug, host left), every player is stuck on the waiting screen forever.
331
352
  A ready-check or countdown is fine as an ADDITION layered on top of `status === 'playing'`
332
353
  (host writes it to `shared` so it survives host migration), never as a replacement for it.
333
- 2. **Verify it for real before calling multiplayer done**: open the game in two browser windows
334
- (one regular + one incognito, so they're two players), join with both, and watch the waiting
354
+ 2. **Verify it for real before calling multiplayer done**: on the preview link (see "Testing
355
+ multiplayer"), open it in two browser windows (one regular + one incognito, so they're two
356
+ players), join with both, and watch the waiting
335
357
  screen disappear on BOTH the moment the count reaches `minPlayers` (with
336
358
  `minPlayers: 2, maxPlayers: 2`: the instant the second player joins). If it doesn't close on
337
359
  both, the lobby is broken — fix it; do not ship a waiting room you haven't watched close.
@@ -669,7 +691,8 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
669
691
  - `ids()` — all object ids seen.
670
692
  - `room.isHost` / `room.host` — you are (or who is) the elected authority. Use to pick the single
671
693
  writer of `shared` scores/rounds and the single simulator of host-owned objects. Settles within
672
- the first patch after connect — read in your loop / react to `on('host')`, not once.
694
+ the first patch after connect — read in your loop / react to `on('host')`, not once. `isHost` is
695
+ false while reconnecting (until the fresh snapshot lands), so gating a write on it is safe.
673
696
  `room.host === undefined` before that patch means **authority is not ready**:
674
697
  do not start host-owned simulation and do not synthesize an "acting host" from
675
698
  a locally sorted roster. Clients can briefly observe different rosters, so
@@ -930,11 +953,11 @@ function awardOnce(scorerUid: string, name: string, marker: `__${string}`, seq:
930
953
  ```
931
954
 
932
955
  **Key scores by a STABLE identity, never the session id.** A session id dies on every reload —
933
- the points orphan into a duplicate row and the player's color changes. Publish a short `uid` in
934
- each player's state (from the embed identity: `const { user } = await waitForPlayer()`,
935
- `uid = user.id` — stable for signed-in players AND guests; see `$genex-threejs-embed-auth`), and
936
- key `scores`/colors/`isMe` by that uid. When an event only carries a session id, map it via
937
- `room.players.get(sid)?.stateRaw.uid`.
956
+ the points orphan into a duplicate row and the player's color changes. The relay stamps every
957
+ player with `uid` — their stable id **in this game**, the same across reloads, never their account
958
+ id — so key `scores`/colors/`isMe` by it. When an event only carries a session id, map it via
959
+ `room.players.get(sid)?.uid`. Never publish the account id (`user.id`) in player state yourself:
960
+ every peer reads it.
938
961
 
939
962
  For host-simulated NPCs, the host claims and drives each enemy as an `object`; when the host
940
963
  leaves, its enemies are reassigned to the new host, which reads their `stateRaw` and keeps
@@ -985,10 +1008,11 @@ from any still capture whether movement feels smooth.** Don't try — it leads t
985
1008
  other (and the ball, if any). That's all a capture can prove. Local test mode (what the dev
986
1009
  server boots into — the embed-auth skill) can NOT do this: it mints no relay credential, so `connect()`
987
1010
  fails there by design and two local-test tabs never see each other — run the two-client check
988
- on the published game (or have the owner open their draft), and if multiplayer wasn't
989
- exercised, say exactly that in your handoff instead of implying it was.
1011
+ on the preview link (see "Testing multiplayer"), and if multiplayer wasn't exercised, say
1012
+ exactly that in your handoff instead of implying it was.
990
1013
  3. **Then say plainly:** *"Multiplayer smoothness depends on your network and can only be felt by a
991
- person — open it in two tabs or with a friend and tell me how it feels."* Stop there.
1014
+ person — open the preview link in a normal and a private window, or send it to a friend, and
1015
+ tell me how it feels."* Stop there.
992
1016
 
993
1017
  ### When the human reports a problem, match the symptom (don't guess)
994
1018
 
@@ -1062,7 +1086,7 @@ host-driven saving works as long as ANY account is in the room.
1062
1086
 
1063
1087
  ## Checklist
1064
1088
 
1065
- - [ ] `npm i @genex-ai/multiplayer@^0.16.0` (automatic TURN, mesh voice, room text chat, connected presence, supplier auth, confirmed controls, snap epochs, reconnect-safe host ticks, unowned-write warnings, cross-region exact-relay overrides); config wired into the build.
1089
+ - [ ] `npm i @genex-ai/multiplayer@^0.17.0` (the only range the relay accepts; per-game `p.uid`, automatic TURN, mesh voice, room text chat, connected presence, supplier auth, confirmed controls, snap epochs, reconnect-safe host ticks, unowned-write warnings, cross-region exact-relay overrides); config wired into the build.
1066
1090
  - [ ] The plan names one net model, the player-experience reason, start/quorum, late-join/backfill,
1067
1091
  and below-quorum/end behavior. The agent inferred it unless the experience was genuinely ambiguous.
1068
1092
  - [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
@@ -1081,7 +1105,7 @@ host-driven saving works as long as ANY account is in the room.
1081
1105
  dedupe keys, the victim applies its own damage, respawn publishes via `me.snap`, and
1082
1106
  projectiles are hard-capped objects removed with `removeConfirmed`.
1083
1107
  - [ ] Host score writes are exactly-once (marker in the same `shared` write) and keyed by the
1084
- stable embed `uid`, never the session id.
1108
+ relay-stamped `p.uid`, never the session id.
1085
1109
  - [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
1086
1110
  - [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
1087
1111
  hang) and passes `auth: () => getColyseusAuth()` (the relay rejects tokenless joins —
@@ -44,8 +44,9 @@ suite for the game.
44
44
 
45
45
  ## One focused proof with two distinct identities
46
46
 
47
- Use regular + incognito guest identities, two accounts/devices, or ask the user to perform the exact
48
- sequence. Two tabs sharing one identity are one enforced seat.
47
+ Run it on the preview link (`npx genex preview`): a preview plays in rooms of its own, apart from
48
+ the live game. Use regular + incognito guest identities, two accounts/devices, or ask the user to
49
+ perform the exact sequence. Two tabs sharing one identity are one enforced seat.
49
50
 
50
51
  1. Enter online using real clicks. Then press Space/Enter/the main action using real key input; confirm
51
52
  focus did not re-trigger Play/Leave and both sessions remain live.
@@ -169,7 +169,7 @@ SKILL.md is the assignment story, verbatim. Everything else is Recipe 1/2 mechan
169
169
 
170
170
  | Thing | Channel | Authority |
171
171
  | --- | --- | --- |
172
- | Each player's avatar (+ `uid`, hp, `life`) | `me.set` / `players` | each player |
172
+ | Each player's avatar (hp, `life`; the relay stamps `uid`) | `me.set` / `players` | each player |
173
173
  | The team map (`id → red/blue`) | `shared` (`"teams"`) | the **host** — the SKILL.md reconciler, every tick |
174
174
  | Team scores | `shared` (exactly-once marker — SKILL.md host section) | `room.isHost` |
175
175
  | Attacks / defeats | `send` (Recipe 2 rules verbatim) | attacker / victim |