@genex-ai/cli-demo 0.33.0 → 0.35.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/README.md
CHANGED
|
@@ -8,6 +8,7 @@ genex init <name> # authorize + create the draft project
|
|
|
8
8
|
genex link <slug> # re-link this folder to an existing game of yours (recovery)
|
|
9
9
|
genex preview # build + push to the hosted draft URL (unlisted)
|
|
10
10
|
genex publish # build + push, then list the game in the gallery
|
|
11
|
+
genex make-remixable # make this game remixable — migrate a private source to a public repo
|
|
11
12
|
genex model "<prompt>" # generate a 3D model → prints an asset URL
|
|
12
13
|
genex skybox "<prompt>" # generate a 360° sky → prints an asset URL
|
|
13
14
|
genex sfx "<prompt>" # generate a sound fx → prints an asset URL
|
package/dist/index.js
CHANGED
|
@@ -659,7 +659,12 @@ async function createDraftProject(opts) {
|
|
|
659
659
|
"Content-Type": "application/json",
|
|
660
660
|
Authorization: `Bearer ${token}`
|
|
661
661
|
},
|
|
662
|
-
body: JSON.stringify(
|
|
662
|
+
body: JSON.stringify({
|
|
663
|
+
name,
|
|
664
|
+
...opts.repoUrl ? { repoUrl: opts.repoUrl } : {},
|
|
665
|
+
...opts.private ? { private: true } : {},
|
|
666
|
+
...opts.remixedFromSlug ? { remixedFromSlug: opts.remixedFromSlug } : {}
|
|
667
|
+
})
|
|
663
668
|
});
|
|
664
669
|
} catch (err) {
|
|
665
670
|
log.warn(`Couldn't reach the API at ${apiUrl} to create the project.`);
|
|
@@ -990,6 +995,8 @@ async function runInit(opts) {
|
|
|
990
995
|
token,
|
|
991
996
|
name: projectName,
|
|
992
997
|
repoUrl: opts.repo?.trim() || void 0,
|
|
998
|
+
private: opts.private,
|
|
999
|
+
remixedFromSlug: opts.remixedFrom?.trim() || void 0,
|
|
993
1000
|
colyseusUrl,
|
|
994
1001
|
dashboardUrl: authBaseUrl,
|
|
995
1002
|
log
|
|
@@ -1328,10 +1335,12 @@ async function callPublish(ctx, commit, opts, log) {
|
|
|
1328
1335
|
return true;
|
|
1329
1336
|
}
|
|
1330
1337
|
async function pushSource(cwd, ctx, log) {
|
|
1331
|
-
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1332
1338
|
const target = await fetchPushUrl(ctx, log);
|
|
1333
1339
|
if (!target) return false;
|
|
1334
|
-
|
|
1340
|
+
return pushWorktree(cwd, target.pushUrl, target.managed, log);
|
|
1341
|
+
}
|
|
1342
|
+
async function pushWorktree(cwd, pushUrl, managed, log) {
|
|
1343
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1335
1344
|
const failed = () => {
|
|
1336
1345
|
log.error("Couldn't save your game's source \u2014 please try again.");
|
|
1337
1346
|
return false;
|
|
@@ -1440,6 +1449,75 @@ async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
|
|
|
1440
1449
|
log.dim(" (If the previous build shows, hard-refresh in a few seconds.)");
|
|
1441
1450
|
}
|
|
1442
1451
|
|
|
1452
|
+
// src/commands/make-remixable.ts
|
|
1453
|
+
async function runMakeRemixable(opts) {
|
|
1454
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
1455
|
+
log.plain(c.bold("genex make-remixable"));
|
|
1456
|
+
log.plain("");
|
|
1457
|
+
const meta = await readProject();
|
|
1458
|
+
if (!meta) {
|
|
1459
|
+
log.error("No genex project here. Run `genex init` in this directory first.");
|
|
1460
|
+
process.exitCode = 1;
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const token = await readUserToken(opts.envPath);
|
|
1464
|
+
if (!token) {
|
|
1465
|
+
log.error("Not authorized. Run `genex init` first to sign in.");
|
|
1466
|
+
process.exitCode = 1;
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
|
|
1470
|
+
log.step("Preparing a public repo for your game\u2026");
|
|
1471
|
+
let res;
|
|
1472
|
+
try {
|
|
1473
|
+
res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/make-remixable`, {
|
|
1474
|
+
method: "POST",
|
|
1475
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1476
|
+
});
|
|
1477
|
+
} catch (err) {
|
|
1478
|
+
log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
|
|
1479
|
+
process.exitCode = 1;
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
if (res.status === 401) {
|
|
1483
|
+
log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
1484
|
+
process.exitCode = 1;
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (res.status === 404) {
|
|
1488
|
+
log.error("That game wasn't found on your account. Re-run `genex init` or `genex link <slug>`.");
|
|
1489
|
+
process.exitCode = 1;
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (!res.ok) {
|
|
1493
|
+
log.error(`Couldn't make the game public (HTTP ${res.status}).`);
|
|
1494
|
+
process.exitCode = 1;
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
const data = await res.json().catch(() => null);
|
|
1498
|
+
if (!data?.pushUrl) {
|
|
1499
|
+
log.error("The API didn't return a push URL.");
|
|
1500
|
+
process.exitCode = 1;
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
log.step("Copying your game's source to the public repo\u2026");
|
|
1504
|
+
if (!await pushWorktree(process.cwd(), data.pushUrl, true, log)) {
|
|
1505
|
+
process.exitCode = 1;
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
const newCloneUrl = data.project?.cloneUrl;
|
|
1509
|
+
if (newCloneUrl && newCloneUrl !== meta.cloneUrl) {
|
|
1510
|
+
await writeProject({ ...meta, cloneUrl: newCloneUrl });
|
|
1511
|
+
}
|
|
1512
|
+
log.plain("");
|
|
1513
|
+
log.success("Your game is public \u2014 anyone can remix it now. \u{1F310}");
|
|
1514
|
+
const dashboard = meta.dashboardOrigins?.[0];
|
|
1515
|
+
if (dashboard) {
|
|
1516
|
+
const page = meta.status === "published" ? "world" : "draft";
|
|
1517
|
+
log.plain(` your game's page: ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1443
1521
|
// src/lib/detect-features.ts
|
|
1444
1522
|
import fs10 from "fs/promises";
|
|
1445
1523
|
import path11 from "path";
|
|
@@ -2128,6 +2206,8 @@ ${c.bold("Usage")}
|
|
|
2128
2206
|
update the same live game. Never creates a project.
|
|
2129
2207
|
genex preview [options] Build + push to the hosted draft URL (unlisted).
|
|
2130
2208
|
genex publish [options] Build + push, then list the game in the gallery.
|
|
2209
|
+
genex make-remixable [options] Make THIS game remixable by everyone \u2014 migrates a
|
|
2210
|
+
private source onto a public managed genex repo.
|
|
2131
2211
|
genex model "<prompt>" [options] Generate a 3D model (GLB) into public/assets/models.
|
|
2132
2212
|
genex skybox "<prompt>" [options] Generate a skybox (equirect) into public/assets/skybox.
|
|
2133
2213
|
genex sfx "<prompt>" [options] Generate a sound effect (mp3) into public/assets/sfx.
|
|
@@ -2150,6 +2230,8 @@ ${c.bold("Options for `init`")}
|
|
|
2150
2230
|
--name <name> Same as the positional name.
|
|
2151
2231
|
--repo <url> Host the source in your own git repo (https/ssh) instead of a managed one;
|
|
2152
2232
|
preview/publish push there with your git credentials.
|
|
2233
|
+
--private Create the game private \u2014 only you can remix it (managed repos only).
|
|
2234
|
+
--remixed-from <slug> Record the game this one was remixed from (lineage).
|
|
2153
2235
|
--agents <list> Agents to install skills for (claude,codex,cursor; default: auto-detect).
|
|
2154
2236
|
--dir <path> Single destination workspace (overrides --agents).
|
|
2155
2237
|
--env <path> Token env file (default: ~/.genex/env).
|
|
@@ -2207,6 +2289,7 @@ ${c.bold("Examples")}
|
|
|
2207
2289
|
genex preview
|
|
2208
2290
|
genex publish
|
|
2209
2291
|
genex publish --categories games,vfx
|
|
2292
|
+
genex make-remixable
|
|
2210
2293
|
genex publish --no-push --title "My Game"
|
|
2211
2294
|
genex model "weathered wooden barrel with iron bands"
|
|
2212
2295
|
genex skybox "golden hour over a misty mountain range"
|
|
@@ -2232,6 +2315,7 @@ function parseArgs(argv) {
|
|
|
2232
2315
|
"--agents",
|
|
2233
2316
|
"--name",
|
|
2234
2317
|
"--repo",
|
|
2318
|
+
"--remixed-from",
|
|
2235
2319
|
"--title",
|
|
2236
2320
|
"--description",
|
|
2237
2321
|
"--categories",
|
|
@@ -2275,6 +2359,9 @@ function parseArgs(argv) {
|
|
|
2275
2359
|
case "--regenerate-cover":
|
|
2276
2360
|
parsed.options.regenerateCover = true;
|
|
2277
2361
|
break;
|
|
2362
|
+
case "--private":
|
|
2363
|
+
parsed.options.private = true;
|
|
2364
|
+
break;
|
|
2278
2365
|
case "--quiet":
|
|
2279
2366
|
parsed.options.quiet = true;
|
|
2280
2367
|
break;
|
|
@@ -2334,6 +2421,9 @@ function applyValueFlag(options, flag, value) {
|
|
|
2334
2421
|
case "--repo":
|
|
2335
2422
|
options.repo = value;
|
|
2336
2423
|
break;
|
|
2424
|
+
case "--remixed-from":
|
|
2425
|
+
options.remixedFrom = value;
|
|
2426
|
+
break;
|
|
2337
2427
|
case "--title":
|
|
2338
2428
|
options.title = value;
|
|
2339
2429
|
break;
|
|
@@ -2412,6 +2502,9 @@ async function main() {
|
|
|
2412
2502
|
case "link":
|
|
2413
2503
|
await runLink(parsed.options);
|
|
2414
2504
|
break;
|
|
2505
|
+
case "make-remixable":
|
|
2506
|
+
await runMakeRemixable(parsed.options);
|
|
2507
|
+
break;
|
|
2415
2508
|
case "controller":
|
|
2416
2509
|
await runController({ ...parsed.options, kind: parsed.options.name });
|
|
2417
2510
|
break;
|
package/package.json
CHANGED
|
@@ -85,17 +85,71 @@ owns the queue, roles, winner-stays, forfeit, timeout, and the win condition.
|
|
|
85
85
|
```jsonc
|
|
86
86
|
"genex": {
|
|
87
87
|
"matchmaking": {
|
|
88
|
-
"preset": "arena", // duel | arena | teams | private
|
|
88
|
+
"preset": "arena", // open | duel | arena | teams | private
|
|
89
89
|
"winCondition": "firstToScore", // lastStanding | firstToScore | highScoreInTime | firstToFinish
|
|
90
90
|
"config": { "scoreTarget": 20, "maxPlayers": 8 } // numeric knobs, optional
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
**
|
|
98
|
-
**
|
|
95
|
+
**Choosing a preset — your own rules → `open`; a ready-made match loop → `duel`/`arena`/`teams`.**
|
|
96
|
+
Presets: **open** (a room of N players — the server owns only seating/capacity/refill, you write
|
|
97
|
+
everything else), **duel** (1v1 winner-stays), **arena** (N-player FFA, join-anytime), **teams**
|
|
98
|
+
(balanced N-v-N), **private** (invite-code lobby). Win conditions (batteries-included presets only):
|
|
99
|
+
**lastStanding** (last one alive), **firstToScore** (first to the score target), **highScoreInTime**
|
|
100
|
+
(top score at the time cap), **firstToFinish** (first to finish). A round that hits the time cap
|
|
101
|
+
undecided is a draw. (`open` has no win condition — it never ends a round for you.)
|
|
102
|
+
|
|
103
|
+
### `open` — the default building block (bring your own rules)
|
|
104
|
+
|
|
105
|
+
Reach for **`open`** first: a room of up to `maxPlayers` that the queue seats and (optionally)
|
|
106
|
+
refills, and NOTHING else — no rounds, no scores, no win condition, no winner-stays eviction. You
|
|
107
|
+
build teams/rounds/scoring/win-logic in game code on the primitives you already have (host election,
|
|
108
|
+
`shared`, per-player state, the `players` list). Config knobs (all numbers):
|
|
109
|
+
|
|
110
|
+
```jsonc
|
|
111
|
+
"genex": { "matchmaking": { "preset": "open", "config": {
|
|
112
|
+
"maxPlayers": 10, // seat cap (clamped 2..64)
|
|
113
|
+
"minPlayers": 2, // quorum to flip waiting→playing (default 1)
|
|
114
|
+
"fill": 1 // 1 = keep full: grow to max + refill freed seats (default); 0 = lock for good once simultaneously full (no substitutes)
|
|
115
|
+
} } } }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
With `open`, `mm.matchmaking.status` only goes `searching`→`waiting`→`playing` (never `countdown`/
|
|
119
|
+
`ended`), `players` is the live roster, and `scores`/`winnerId` stay empty — watch `players` and
|
|
120
|
+
`status`, not `matchStart`/`matchEnded` (they never fire). Nobody is ever evicted. Build the rest:
|
|
121
|
+
|
|
122
|
+
| Want… | Do it in game code on top of `open` |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| Room formation & refill | Nothing — the server owns it via `minPlayers`/`maxPlayers`/`fill`. |
|
|
125
|
+
| Duel (1v1) | `maxPlayers: 2, minPlayers: 2`, then start when `mm.matchmaking.players.length === 2`. |
|
|
126
|
+
| Team assignment | The **host** splits `players` deterministically (sort ids, round-robin) and writes the map to `shared` (`session.shared.set('teams', …)`); everyone renders from it. Survives host migration because it lives in shared state. |
|
|
127
|
+
| Rounds / countdown | Host writes `{ phase, deadline }` into `shared`; clients render the countdown from the timestamp (no server clock — approximate fairness is fine at this trust tier). |
|
|
128
|
+
| Scores | A host-owned entry in `shared` (or per-player state); you define what a point means. |
|
|
129
|
+
| Win condition | Your code checks its own condition (you already compute the signals) and the host writes the result to `shared`. |
|
|
130
|
+
| Winner-stays / rotation | The losing **client** leaves voluntarily — `mm.cancel()` then `matchmake()` again; the freed seat refills (`fill: 1`). There is NO forced kick (a server-only power, deliberately not exposed) — a modified client can squat its seat, so if you need *enforced* rotation use `duel`/`arena`/`teams` instead. |
|
|
131
|
+
| Forfeit on disconnect | React to the `players` list shrinking (the SDK surfaces leaves after the reconnect grace). |
|
|
132
|
+
| Spectators | A game-level role: keep a "dead"/observing player seated and just render them as a watcher. There is no server spectator concept — everyone in a room is a player. |
|
|
133
|
+
|
|
134
|
+
#### Waiting room / lobby — two patterns
|
|
135
|
+
|
|
136
|
+
`open` seats you into a LIVE shared room the moment you're matched (`session` goes live, players sync)
|
|
137
|
+
but doesn't "start" anything — so the pre-game lobby is simply **your room before it's grown to the
|
|
138
|
+
size you want**. Set `minPlayers` to your target (so `status` stays `waiting` until enough arrive) and
|
|
139
|
+
let the **host** own the "go" moment by writing a start signal to `shared` (it survives host migration).
|
|
140
|
+
The lobby and the game are ONE `open` room — never spin up a second room for it. Two ways to present it:
|
|
141
|
+
|
|
142
|
+
- **A) UI lobby (Dota-style).** While waiting, render an OVERLAY from `mm.matchmaking` instead of the
|
|
143
|
+
game: the roster + count (`players.length` / target), each player's name, and optionally a per-player
|
|
144
|
+
"ready" toggle (store it in per-player state or a `shared` map). The host begins when all are ready
|
|
145
|
+
(or `players.length >= N`) by writing e.g. `session.shared.set('phase', { started: true, at: <ts> })`;
|
|
146
|
+
every client sees it and swaps the overlay for the game.
|
|
147
|
+
- **B) Physical lobby (Roblox-style).** The waiting area IS a 3D scene in the SAME room: render a lobby
|
|
148
|
+
and let players walk their avatars around, syncing position with `me.set` on the tick exactly like
|
|
149
|
+
in-game. Show a "N / target — starting soon" sign driven by `players.length`. A "ready pad" is a nice
|
|
150
|
+
affordance: players stand on it, the host counts how many are on it (from their synced positions) and
|
|
151
|
+
writes a `shared` countdown; when it elapses everyone moves their camera/scene into the match — no
|
|
152
|
+
re-matchmaking, they're already together.
|
|
99
153
|
|
|
100
154
|
**Private lobbies** (for `preset: 'private'`) don't use `matchmake()` — a host makes an invite code
|
|
101
155
|
and friends join it; the lobby is persistent (rounds replay, nobody is evicted):
|