@genex-ai/cli-demo 0.26.0 → 0.27.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/dist/index.js
CHANGED
|
@@ -598,7 +598,7 @@ async function createDraftProject(opts) {
|
|
|
598
598
|
"Content-Type": "application/json",
|
|
599
599
|
Authorization: `Bearer ${token}`
|
|
600
600
|
},
|
|
601
|
-
body: JSON.stringify({ name })
|
|
601
|
+
body: JSON.stringify(opts.repoUrl ? { name, repoUrl: opts.repoUrl } : { name })
|
|
602
602
|
});
|
|
603
603
|
} catch (err) {
|
|
604
604
|
log.warn(`Couldn't reach the API at ${apiUrl} to create the project.`);
|
|
@@ -924,15 +924,20 @@ async function runInit(opts) {
|
|
|
924
924
|
apiUrl,
|
|
925
925
|
token,
|
|
926
926
|
name: projectName,
|
|
927
|
+
repoUrl: opts.repo?.trim() || void 0,
|
|
927
928
|
colyseusUrl,
|
|
928
929
|
dashboardUrl: authBaseUrl,
|
|
929
930
|
log
|
|
930
931
|
});
|
|
931
|
-
if (meta) {
|
|
932
|
-
|
|
933
|
-
log.
|
|
934
|
-
|
|
932
|
+
if (!meta) {
|
|
933
|
+
log.plain("");
|
|
934
|
+
log.warn("Setup finished, but creating your game failed \u2014 fix the error above and re-run `genex init`.");
|
|
935
|
+
process.exitCode = 1;
|
|
936
|
+
return;
|
|
935
937
|
}
|
|
938
|
+
const { path: metaPath } = await writeProject(meta);
|
|
939
|
+
log.dim(` saved ${c.cyan(metaPath)}`);
|
|
940
|
+
await writeGameConfigFiles(meta, log);
|
|
936
941
|
log.plain("");
|
|
937
942
|
log.success("All set. \u{1F680}");
|
|
938
943
|
}
|
|
@@ -1259,12 +1264,13 @@ async function callPublish(ctx, commit, opts, log) {
|
|
|
1259
1264
|
}
|
|
1260
1265
|
async function pushSource(cwd, ctx, log) {
|
|
1261
1266
|
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1267
|
+
const target = await fetchPushUrl(ctx, log);
|
|
1268
|
+
if (!target) return false;
|
|
1269
|
+
const { pushUrl, managed } = target;
|
|
1262
1270
|
const failed = () => {
|
|
1263
1271
|
log.error("Couldn't save your game's source \u2014 please try again.");
|
|
1264
1272
|
return false;
|
|
1265
1273
|
};
|
|
1266
|
-
const pushUrl = await fetchPushUrl(ctx, log);
|
|
1267
|
-
if (!pushUrl) return false;
|
|
1268
1274
|
const gitDir = await fs9.mkdtemp(path10.join(os2.tmpdir(), "genex-source-"));
|
|
1269
1275
|
const base = { GIT_DIR: gitDir };
|
|
1270
1276
|
const ident = {
|
|
@@ -1277,7 +1283,7 @@ async function pushSource(cwd, ctx, log) {
|
|
|
1277
1283
|
if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
|
|
1278
1284
|
await fs9.writeFile(
|
|
1279
1285
|
path10.join(gitDir, "info", "exclude"),
|
|
1280
|
-
// .env* are secrets —
|
|
1286
|
+
// .env* are secrets — never publish them; `!` keeps the non-secret template.
|
|
1281
1287
|
["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
|
|
1282
1288
|
);
|
|
1283
1289
|
const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path10.join(gitDir, "index-source") };
|
|
@@ -1290,7 +1296,14 @@ async function pushSource(cwd, ctx, log) {
|
|
|
1290
1296
|
const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
|
|
1291
1297
|
await run("git", ["update-ref", "refs/heads/main", commit], base);
|
|
1292
1298
|
const push = await run("git", ["push", "-q", pushUrl, "+refs/heads/main:main"], base);
|
|
1293
|
-
|
|
1299
|
+
if (push.code === 0) return true;
|
|
1300
|
+
if (!managed) {
|
|
1301
|
+
log.error(
|
|
1302
|
+
"Couldn't push to your repo. Check that you have push access (SSH key or git credentials) and that the repo exists."
|
|
1303
|
+
);
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
return failed();
|
|
1294
1307
|
} catch {
|
|
1295
1308
|
return failed();
|
|
1296
1309
|
} finally {
|
|
@@ -1322,7 +1335,7 @@ async function fetchPushUrl(ctx, log) {
|
|
|
1322
1335
|
log.error("The API didn't return a push URL.");
|
|
1323
1336
|
return null;
|
|
1324
1337
|
}
|
|
1325
|
-
return data.pushUrl;
|
|
1338
|
+
return { pushUrl: data.pushUrl, managed: data.managed !== false };
|
|
1326
1339
|
}
|
|
1327
1340
|
async function isDir2(p) {
|
|
1328
1341
|
try {
|
|
@@ -2055,6 +2068,8 @@ ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture`)")}
|
|
|
2055
2068
|
${c.bold("Options for `init`")}
|
|
2056
2069
|
<name> Project name (positional; default: current directory name).
|
|
2057
2070
|
--name <name> Same as the positional name.
|
|
2071
|
+
--repo <url> Host the source in your own git repo (https/ssh) instead of a managed one;
|
|
2072
|
+
preview/publish push there with your git credentials.
|
|
2058
2073
|
--agents <list> Agents to install skills for (claude,codex,cursor; default: auto-detect).
|
|
2059
2074
|
--dir <path> Single destination workspace (overrides --agents).
|
|
2060
2075
|
--env <path> Token env file (default: ~/.genex/env).
|
|
@@ -2136,6 +2151,7 @@ function parseArgs(argv) {
|
|
|
2136
2151
|
"--colyseus-url",
|
|
2137
2152
|
"--agents",
|
|
2138
2153
|
"--name",
|
|
2154
|
+
"--repo",
|
|
2139
2155
|
"--title",
|
|
2140
2156
|
"--description",
|
|
2141
2157
|
"--categories",
|
|
@@ -2235,6 +2251,9 @@ function applyValueFlag(options, flag, value) {
|
|
|
2235
2251
|
case "--name":
|
|
2236
2252
|
options.name = value;
|
|
2237
2253
|
break;
|
|
2254
|
+
case "--repo":
|
|
2255
|
+
options.repo = value;
|
|
2256
|
+
break;
|
|
2238
2257
|
case "--title":
|
|
2239
2258
|
options.title = value;
|
|
2240
2259
|
break;
|
package/package.json
CHANGED
|
@@ -34,9 +34,12 @@ example, the shared-object/ball code, rotation, and host usage. Read
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
npm i @genex-ai/multiplayer
|
|
37
|
+
npm i @genex-ai/multiplayer@^0.8.0
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
> Pin `@^0.8.0` (not a bare `npm i`): `inputs`/`onHostTick`, auto-reconnect, and the `reconnecting`
|
|
41
|
+
> events this skill relies on landed in 0.8. An older resolve would throw `room.onHostTick is not a function` at runtime.
|
|
42
|
+
|
|
40
43
|
This skill targets `@genex-ai/multiplayer` **≥ 0.8.0** (`objects`/`host` since 0.4; `matchmake()` since 0.5; presets + `score()`/`finish()` since 0.6; `createPrivate()`/`joinPrivate()` since 0.7; matchmake auto-retry + `retry()` since 0.7.1; auto-reconnect + `inputs`/`onHostTick` since 0.8).
|
|
41
44
|
|
|
42
45
|
## Trust model (say it plainly in your game's copy)
|
|
@@ -148,6 +151,7 @@ room.on("reconnecting", ({ attempt }) => showOverlay(`Reconnecting… (${attempt
|
|
|
148
151
|
room.on("reconnected", () => hideOverlay());
|
|
149
152
|
room.on("disconnect", (code) => {
|
|
150
153
|
// Terminal: server restart, revoked session, or the link never came back.
|
|
154
|
+
if (code === 4409) flushSaves(); // replaced by this player's OTHER tab/device — flush now
|
|
151
155
|
// To play again, read a FRESH token and connect() anew — never reuse the old auth object.
|
|
152
156
|
showMenu("Connection lost");
|
|
153
157
|
});
|
|
@@ -160,7 +164,9 @@ glide on; don't tear the scene down. A deliberate `room.leave()` never auto-reco
|
|
|
160
164
|
**One seat per player (enforced server-side):** joining the same game again — a second tab,
|
|
161
165
|
another device, or a page reload — instantly evicts the previous session (it gets
|
|
162
166
|
`disconnect`, code 4409). You never need to handle "the same player twice" and a reload
|
|
163
|
-
never leaves a ghost avatar behind.
|
|
167
|
+
never leaves a ghost avatar behind. If the evicted tab was the host and holds unsaved world
|
|
168
|
+
state, flush it in the `disconnect` handler (code 4409, above): that tab is still alive, so an
|
|
169
|
+
async `saveWorldState` completes — otherwise a debounced save in flight is lost.
|
|
164
170
|
|
|
165
171
|
## Which channel for which data
|
|
166
172
|
|
|
@@ -187,7 +193,10 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
187
193
|
`{ id, name, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you); `stateRaw` is
|
|
188
194
|
the raw latest (hit-tests, discrete values).
|
|
189
195
|
- `room.objects` — shared objects nobody owns until claimed (a ball, an NPC):
|
|
190
|
-
- `claim(id)` — take ownership (last claim wins; call on kick/contact).
|
|
196
|
+
- `claim(id)` — take ownership (last claim wins; call on kick/contact). Claiming is optimistic:
|
|
197
|
+
you own it locally the instant you call it, but if another player claimed the same tick the
|
|
198
|
+
server's last-claim-wins verdict can revoke you — a `set()` you sent before losing the race is
|
|
199
|
+
dropped. For contested objects, keep publishing while `isMine` stays true, not just once.
|
|
191
200
|
- `set(id, state)` — publish it (only lands while you own it; full flat object each call).
|
|
192
201
|
- `get(id)` → `{ id, owner, isMine, state, stateRaw }` or `undefined`. `state` is auto-smoothed
|
|
193
202
|
(or live if `isMine`); `stateRaw` is the raw latest.
|
|
@@ -212,6 +221,24 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
212
221
|
- `room.onHostTick(hz, cb)` — run a fixed simulation tick only while you are the host
|
|
213
222
|
(auto-starts/stops across host migration). Returns a disposer.
|
|
214
223
|
|
|
224
|
+
## Your message budget (every publish is one relay message)
|
|
225
|
+
|
|
226
|
+
Every `me.set`, `objects.set`, `objects.claim`, `send`, and `inputs.send` costs **one relay
|
|
227
|
+
message**, and the relay caps each connection at **~120 messages/second sustained** (drops
|
|
228
|
+
above that — you'll see a console warning "the relay dropped N of your messages"). The
|
|
229
|
+
budget math that matters:
|
|
230
|
+
|
|
231
|
+
- Your own state (`me.set`) at 15 Hz + ONE driven/owned moving object at 15 Hz = 30/s. Fine.
|
|
232
|
+
- The pattern that blows the budget: **republishing IDLE objects every tick.** A host that
|
|
233
|
+
owns several parked vehicles/props must NOT `objects.set` each of them at full tick rate —
|
|
234
|
+
publish an object **when it changed**, plus a low-rate keepalive (~1–2 Hz) so late joiners
|
|
235
|
+
converge. Unchanged pose ⇒ no message.
|
|
236
|
+
- If you see the drop warning, count your sends-per-tick: streams × tick-rate must stay well
|
|
237
|
+
under 120/s with headroom for claims and events.
|
|
238
|
+
- Over budget, the relay spreads the loss across ALL your streams (everything gets choppy at
|
|
239
|
+
once) rather than freezing one — so a single stuttering object is your cue to check the whole
|
|
240
|
+
budget, not just that object. The warning is the signal; don't design at the edge of the cap.
|
|
241
|
+
|
|
215
242
|
## The loop you must build (input → local → tick → render)
|
|
216
243
|
|
|
217
244
|
1. **Input mutates a local object only** (`me.x += …`). Never network on keypress.
|
|
@@ -259,7 +286,10 @@ details are in [references/realtime-patterns.md](references/realtime-patterns.md
|
|
|
259
286
|
## Host authority (scores, rounds, enemies)
|
|
260
287
|
|
|
261
288
|
One client is the `host`. Let *only* the host write agreed state and simulate shared enemies, so
|
|
262
|
-
there's a single source of truth:
|
|
289
|
+
there's a single source of truth. **But: a host is only the authority for objects it OWNS.**
|
|
290
|
+
If another player owns/drives an object (their claim landed), the host renders it from the
|
|
291
|
+
stream like everyone else — a host branch that pins "its" objects to a local pose without
|
|
292
|
+
checking `owner`/`isMine` shows every other player's driving as a frozen object:
|
|
263
293
|
|
|
264
294
|
```ts
|
|
265
295
|
if (room.isHost) room.shared.set("round", nextRound); // only the host advances the round
|
|
@@ -364,10 +394,12 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
364
394
|
|
|
365
395
|
## Checklist
|
|
366
396
|
|
|
367
|
-
- [ ] `npm i @genex-ai/multiplayer
|
|
397
|
+
- [ ] `npm i @genex-ai/multiplayer@^0.8.0` (auto-reconnect, `inputs`, `onHostTick` — pin `^0.8.0`, a bare install can resolve older); config wired into the build.
|
|
368
398
|
- [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
|
|
369
399
|
- [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
|
|
370
400
|
- [ ] Contested (sustained-contact) objects use the host-physics pattern, not claim-on-touch.
|
|
401
|
+
- [ ] Idle/unchanged objects republish at ≤2 Hz keepalive, never every tick (message budget).
|
|
402
|
+
- [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
|
|
371
403
|
- [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
|
|
372
404
|
hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —
|
|
373
405
|
see `genex-threejs-embed-auth`).
|
|
@@ -81,29 +81,53 @@ The `genex controller` controllers are **local-only physics** — each player si
|
|
|
81
81
|
OWN rig (self-authoritative, zero latency). Networking them is publish-and-playback, never
|
|
82
82
|
remote simulation:
|
|
83
83
|
|
|
84
|
+
The vendored controllers expose their pose as `currPos` (a `THREE.Vector3`) and `currQuat` (a
|
|
85
|
+
`THREE.Quaternion`) — **not** `.position` / `.quaternion` — plus boolean state getters. Character
|
|
86
|
+
animation is driven by **five booleans**, not a single enum; publish the booleans and let remotes
|
|
87
|
+
reconstruct the animation. There is **no** `rig.animState`, no `remoteAnimator.play(...)`, and no
|
|
88
|
+
vehicle `wheelSpinPhase` getter.
|
|
89
|
+
|
|
84
90
|
```ts
|
|
85
|
-
// You: after your controller's update, on the fixed tick (~15Hz)
|
|
91
|
+
// You: after your controller's update(), on the fixed tick (~15Hz).
|
|
92
|
+
const p = character.currPos; // THREE.Vector3 (Vehicle/Drone: same getters)
|
|
93
|
+
const q = character.currQuat; // THREE.Quaternion
|
|
86
94
|
room.me.set({
|
|
87
|
-
x: r2(
|
|
88
|
-
q:
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
x: r2(p.x), y: r2(p.y), z: r2(p.z),
|
|
96
|
+
q: [r2(q.x), r2(q.y), r2(q.z), r2(q.w)], // quaternion array — never a scalar yaw
|
|
97
|
+
// character animation = 5 booleans (the CharacterController exposes each as a getter):
|
|
98
|
+
g: character.isOnGround, f: character.isFalling, m: character.isMoving,
|
|
99
|
+
r: character.runActive, j: character.jumpActive,
|
|
91
100
|
});
|
|
92
101
|
|
|
93
|
-
// Remote players:
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
// Remote players: a VISUAL-ONLY avatar — NO Rapier body, NO controller instance for remotes.
|
|
103
|
+
// Position/rotation from smoothed state; animation from the synced flags via the avatar's own
|
|
104
|
+
// update(flags, dt). The character-controller skill's animations reference owns the flag set.
|
|
105
|
+
const pl = room.players.get(id)!;
|
|
106
|
+
remoteAvatar.group.position.set(pl.state.x, pl.state.y, pl.state.z);
|
|
107
|
+
remoteAvatar.group.quaternion.fromArray(pl.state.q);
|
|
108
|
+
const raw = pl.stateRaw; // discrete flags: read RAW, never smoothed
|
|
109
|
+
remoteAvatar.update(
|
|
110
|
+
{ isOnGround: !!raw.g, isFalling: !!raw.f, isMoving: !!raw.m, runActive: !!raw.r, jumpActive: !!raw.j },
|
|
111
|
+
dt,
|
|
112
|
+
);
|
|
99
113
|
```
|
|
100
114
|
|
|
101
115
|
What to publish per controller:
|
|
102
116
|
|
|
103
|
-
- **character**: `x/y/z`, `
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
- **character**: `currPos` → `x/y/z`, `currQuat` → `q`, and the five booleans above
|
|
118
|
+
(`isOnGround`/`isFalling`/`isMoving`/`runActive`/`jumpActive`). Remotes rebuild the animation
|
|
119
|
+
with `avatar.update(flags, dt)` — see the `genex-threejs-character-controller` animations
|
|
120
|
+
reference for the flag set (single source of truth; don't invent a `play(anim)` call).
|
|
121
|
+
- **vehicle**: body `currPos` → `x/y/z` + `currQuat` → `q`. For visible steering, publish the
|
|
122
|
+
front wheel's `car.wheels.get(frontWheelId)?.steerAngle`; spin the wheels on remotes
|
|
123
|
+
procedurally from the body's speed (position delta) — there is no spin-phase getter, and you
|
|
124
|
+
never sync per-wheel transforms.
|
|
125
|
+
- **drone**: `currPos` → `x/y/z`, `currQuat` → `q`, and `drone.hoverThrottle` if the rotor visual
|
|
126
|
+
needs it. Remote rotor spin is procedural, like vehicle wheels.
|
|
127
|
+
|
|
128
|
+
> Remote **visuals** for vehicle wheels / drone rotors are author-built (a spinning mesh you drive
|
|
129
|
+
> from synced speed/throttle) — the controllers build those internally from a live Rapier body,
|
|
130
|
+
> which remotes don't have. Only the body pose + the flags/params above come over the wire.
|
|
107
131
|
|
|
108
132
|
Player-vs-player physical contact (bumping cars) stays approximate at this tier — each
|
|
109
133
|
client is authoritative over itself, so contacts are cosmetic. If a game's core loop IS
|