@genex-ai/cli-demo 1.6.0 → 1.6.1-dev.409

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
@@ -8,7 +8,7 @@ import fs from "fs";
8
8
  import os from "os";
9
9
  import path from "path";
10
10
  import { fileURLToPath } from "url";
11
- var RAW_CHANNEL = "latest";
11
+ var RAW_CHANNEL = "dev";
12
12
  var CLI_CHANNEL = RAW_CHANNEL === "dev" ? "dev" : "latest";
13
13
  var STANDS = {
14
14
  prod: { api: "https://api.genex.games", dashboard: "https://genex.games" },
@@ -1014,6 +1014,7 @@ Important note: put soul into your creations, with many details and love. Aim to
1014
1014
  16. Never add debug-only code to the game to check your own work \u2014 no hidden test modes, no special URL parameters, no forced-visible flags, no auth mocks, no pixel-sampling hooks. \`?genex_local_test=1\` is the platform's own supported mode and is fine; your own bypass is not. (The multiplayer skill's small build identifier, token-free status line, and connected-quorum watchdog are production supportability, not a bypass \u2014 keep those.)
1015
1015
  17. Input directions match their labels: A/\u2190 moves or turns the player screen-LEFT, D/\u2192 screen-RIGHT, mouse-up looks up, and drag-pan axes share ONE convention. The cursor is either the gameplay tool (RTS, card, builder) or locked away during play \u2014 keyboard-only games included. Check it in every milestone's smoke pass.
1016
1016
  18. NEVER delete, empty, move, rename, or overwrite anything you did not create yourself. This folder may hold the player's own reference images, notes, sketches, or an earlier attempt \u2014 files that exist nowhere else and have no undo, no trash, no backup. A non-empty folder is normal and is NEVER something to clean up, and "start clean" is never a reason. That rules out \`rm\`/\`rm -rf\`, \`git clean\`, \`git checkout -- .\`, \`git reset --hard\` over their work, deleting to resolve a conflict or a stuck interactive prompt, and every setup tool's offer to empty a directory (\`--force\`, \`--overwrite\`, "Remove existing files") \u2014 scaffold into a fresh subfolder and copy in instead. You may add files and edit the ones you wrote. If a step genuinely cannot continue without removing something of theirs, STOP and ask, naming the exact files, and wait for a yes \u2014 "it looks like junk" is never that yes. This binds hardest during setup, where it runs fast and automatically before the player has asked for anything at all.
1017
+ 19. If the game sells anything, it sells it for coin at a fixed, visible price, and NEVER sells chance. Run this test on any purchasable thing before building it: does the player pay (with coin, or with anything coin bought, directly or indirectly), is the outcome uncertain when they pay, and is there a prize they wanted \u2014 all three yes means it is paid randomness, and you build the deterministic version instead. That rules out loot boxes, gacha, mystery boxes, crates, card packs, prize wheels and raffles; wagering, staking, betting, coinflips and casino or slot mechanics denominated in coin; and donation prompts, tip jars or any player-to-player coin transfer, because coin buys goods and never just moves. Randomness the player EARNS by playing is gameplay, not commerce \u2014 an enemy dropping a random item, a chest found in the level, a procedural layout, a crit roll \u2014 and is completely fine. Every coin price renders its real-money equivalent beside it (the server sends one with every item), item prices sit on the platform's price grid so no player is left holding change they cannot spend, and nothing in a shop carries a countdown, "limited time", or a stock counter. When a request crosses one of these lines, name the mechanic, give the one-sentence reason, propose a specific compliant alternative, and build that \u2014 never the banned version "as an option", never a partial one, and never after asking the player to confirm they want it. Load \`$genex-monetization\` before building a shop.
1017
1018
  ${CONTRACT_END}
1018
1019
  `;
1019
1020
  var CLAUDE_IMPORT_LINE = "@AGENTS.md";
@@ -4963,6 +4964,318 @@ async function runMakeRemixable(opts) {
4963
4964
  }
4964
4965
  }
4965
4966
 
4967
+ // src/commands/domain.ts
4968
+ var SUBCOMMANDS = ["add", "list", "verify", "remove"];
4969
+ function statusWord(d) {
4970
+ if (d.status === "active") return c.green("live");
4971
+ if (d.status === "failed") return c.red("stopped");
4972
+ return c.dim("waiting for DNS");
4973
+ }
4974
+ async function runDomain(opts) {
4975
+ const log = createLogger({ quiet: opts.quiet });
4976
+ const sub = (opts.name ?? "list").trim();
4977
+ if (!SUBCOMMANDS.includes(sub)) {
4978
+ log.error(`Unknown subcommand \`${sub}\`. Use: ${SUBCOMMANDS.join(", ")}.`);
4979
+ process.exitCode = 1;
4980
+ return;
4981
+ }
4982
+ const needsHost = sub !== "list";
4983
+ const hostname = opts.hostname?.trim();
4984
+ if (needsHost && !hostname) {
4985
+ log.error(`\`genex domain ${sub}\` needs a hostname, e.g. ${c.cyan(`genex domain ${sub} play.yourdomain.com`)}.`);
4986
+ process.exitCode = 1;
4987
+ return;
4988
+ }
4989
+ const meta = await readProject();
4990
+ if (!meta?.id) {
4991
+ log.error("This folder isn't linked to a game.");
4992
+ log.dim(` Run ${c.cyan("genex link")} (or ${c.cyan("genex list")} to find the slug) first.`);
4993
+ process.exitCode = 1;
4994
+ return;
4995
+ }
4996
+ const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
4997
+ let token = opts.token ?? await readUserToken(opts.envPath);
4998
+ if (!token) {
4999
+ if (opts.noAuth) {
5000
+ log.error("Not signed in. Re-run without --no-auth to connect.");
5001
+ process.exitCode = 1;
5002
+ return;
5003
+ }
5004
+ log.plain("Not signed in \u2014 connecting\u2026");
5005
+ try {
5006
+ token = await authorize(apiUrl, getAuthUrl(opts.authUrl), {
5007
+ log,
5008
+ inlineWaitMs: opts.timeoutSec ? opts.timeoutSec * 1e3 : void 0
5009
+ });
5010
+ } catch (err) {
5011
+ if (err instanceof AuthPendingError) {
5012
+ printAuthHandoff(log, err);
5013
+ log.dim(`Then re-run ${c.cyan(`genex domain ${sub}`)}.`);
5014
+ return;
5015
+ }
5016
+ log.error(`Sign-in didn't complete: ${err instanceof Error ? err.message : String(err)}`);
5017
+ process.exitCode = 1;
5018
+ return;
5019
+ }
5020
+ await writeUserToken(token, opts.envPath);
5021
+ }
5022
+ const base = `${apiUrl}/api/projects/${encodeURIComponent(meta.id)}/domains`;
5023
+ const auth = { Authorization: `Bearer ${token}` };
5024
+ const call = (url, init2) => apiFetch(url, { ...init2, headers: { ...auth, "Content-Type": "application/json", ...init2?.headers ?? {} } });
5025
+ let res;
5026
+ try {
5027
+ if (sub === "list") res = await call(base);
5028
+ else if (sub === "add") res = await call(base, { method: "POST", body: JSON.stringify({ hostname }) });
5029
+ else if (sub === "verify")
5030
+ res = await call(`${base}/${encodeURIComponent(hostname)}/verify`, { method: "POST" });
5031
+ else res = await call(`${base}/${encodeURIComponent(hostname)}`, { method: "DELETE" });
5032
+ } catch (err) {
5033
+ log.error(`Couldn't reach the API at ${apiUrl}.`);
5034
+ log.dim(` ${String(err)}`);
5035
+ process.exitCode = 1;
5036
+ return;
5037
+ }
5038
+ if (!res.ok) {
5039
+ if (await printedStructuredError(res)) {
5040
+ process.exitCode = 1;
5041
+ return;
5042
+ }
5043
+ const body = await res.json().catch(() => null);
5044
+ if (res.status === 503) {
5045
+ log.error("Custom domains aren't available on this Genex environment yet.");
5046
+ } else if (res.status === 404) {
5047
+ log.error(sub === "list" || sub === "add" ? "That game wasn't found on this account." : `${hostname} isn't connected to this game.`);
5048
+ } else {
5049
+ log.error(body?.message ?? body?.error ?? `Request failed (${res.status}).`);
5050
+ }
5051
+ process.exitCode = 1;
5052
+ return;
5053
+ }
5054
+ const data = await res.json();
5055
+ if (opts.json) {
5056
+ log.plain(JSON.stringify(data, null, 2));
5057
+ return;
5058
+ }
5059
+ if (sub === "list") {
5060
+ const rows = data.domains ?? [];
5061
+ if (rows.length === 0) {
5062
+ log.plain("No domains connected to this game.");
5063
+ log.dim(` ${c.cyan("genex domain add play.yourdomain.com")} to connect one.`);
5064
+ return;
5065
+ }
5066
+ for (const d of rows) log.plain(` ${d.hostname.padEnd(34)} ${statusWord(d)}`);
5067
+ return;
5068
+ }
5069
+ if (sub === "add") {
5070
+ if (data.supported === false) {
5071
+ log.plain(String(data.message ?? "That domain's DNS host doesn't support one-click setup."));
5072
+ const manual = data.manual;
5073
+ const records = Array.isArray(manual?.records) ? manual.records : [];
5074
+ if (records.length > 0) {
5075
+ log.plain("");
5076
+ log.plain(" Add these two records at your DNS host:");
5077
+ log.plain("");
5078
+ for (const r of records) {
5079
+ log.plain(` ${c.cyan(String(r.type ?? ""))} ${String(r.name ?? "")}`);
5080
+ log.plain(` ${String(r.value ?? "")}`);
5081
+ }
5082
+ log.plain("");
5083
+ if (manual?.apexHint) {
5084
+ log.dim(" At a root domain your host may call the first one ALIAS or ANAME.");
5085
+ }
5086
+ log.dim(` Then run: genex domain verify ${String(data.hostname ?? "")}`);
5087
+ log.dim(" DNS can take a few minutes to spread.");
5088
+ return;
5089
+ }
5090
+ log.dim(" Your game stays reachable at its usual address.");
5091
+ return;
5092
+ }
5093
+ const applyUrl = String(data.applyUrl ?? "");
5094
+ const providerName = String(data.providerName ?? "your DNS host");
5095
+ log.success(`${data.hostname} can be connected through ${providerName}.`);
5096
+ log.plain("");
5097
+ log.plain(` Approve the DNS change here: ${c.cyan(applyUrl)}`);
5098
+ log.plain("");
5099
+ log.dim(" One click writes the records. Nothing to copy or paste.");
5100
+ if (!applyUrl.startsWith("https://")) {
5101
+ log.warn("That DNS host returned a setup link we could not verify \u2014 not opening it.");
5102
+ } else if (!opts.noOpen) {
5103
+ openBrowser(applyUrl, () => log.dim(" (Couldn't open a browser \u2014 use the link above.)"));
5104
+ }
5105
+ log.dim(` Then ${c.cyan(`genex domain verify ${String(data.hostname)}`)} once you have approved it.`);
5106
+ return;
5107
+ }
5108
+ if (sub === "verify") {
5109
+ const status = String(data.status ?? "");
5110
+ if (status === "active") log.success(`${hostname} is live.`);
5111
+ else if (status === "failed") log.error(`${hostname} is stopped \u2014 its verification record is missing.`);
5112
+ else log.plain(`${hostname} isn't verified yet \u2014 DNS changes can take a few minutes to spread.`);
5113
+ return;
5114
+ }
5115
+ log.success(`${hostname} disconnected.`);
5116
+ }
5117
+
5118
+ // src/commands/shop.ts
5119
+ var SUBS = ["list", "add", "set", "remove", "test"];
5120
+ function money(cents) {
5121
+ return cents === void 0 ? "" : ` ($${(cents / 100).toFixed(2)})`;
5122
+ }
5123
+ async function runShop(opts) {
5124
+ const log = createLogger({ quiet: opts.quiet });
5125
+ const sub = opts.name?.trim() || "list";
5126
+ if (!SUBS.includes(sub)) {
5127
+ log.error(`Unknown subcommand ${c.cyan(sub)}. Use: ${SUBS.join(", ")}.`);
5128
+ process.exitCode = 1;
5129
+ return;
5130
+ }
5131
+ const meta = await readProject();
5132
+ if (!meta?.id) {
5133
+ log.error("This folder isn't linked to a game.");
5134
+ log.dim(` Run ${c.cyan("genex link")} (or ${c.cyan("genex list")} to find the slug) first.`);
5135
+ process.exitCode = 1;
5136
+ return;
5137
+ }
5138
+ const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
5139
+ let token = opts.token ?? await readUserToken(opts.envPath);
5140
+ if (!token) {
5141
+ if (opts.noAuth) {
5142
+ log.error("Not signed in. Re-run without --no-auth to connect.");
5143
+ process.exitCode = 1;
5144
+ return;
5145
+ }
5146
+ log.plain("Not signed in \u2014 connecting\u2026");
5147
+ try {
5148
+ token = await authorize(apiUrl, getAuthUrl(opts.authUrl), {
5149
+ log,
5150
+ inlineWaitMs: opts.timeoutSec ? opts.timeoutSec * 1e3 : void 0
5151
+ });
5152
+ } catch (err) {
5153
+ if (err instanceof AuthPendingError) {
5154
+ printAuthHandoff(log, err);
5155
+ log.dim(`Then re-run ${c.cyan(`genex shop ${sub}`)}.`);
5156
+ return;
5157
+ }
5158
+ log.error(`Sign-in didn't complete: ${err instanceof Error ? err.message : String(err)}`);
5159
+ process.exitCode = 1;
5160
+ return;
5161
+ }
5162
+ await writeUserToken(token, opts.envPath);
5163
+ }
5164
+ const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
5165
+ const call = (url, init2) => apiFetch(url, { ...init2, headers: { ...auth, ...init2?.headers ?? {} } });
5166
+ const projectSkus = `${apiUrl}/api/coin/projects/${encodeURIComponent(meta.id)}/skus`;
5167
+ async function fail3(res2, what) {
5168
+ if (printedStructuredError(res2)) {
5169
+ process.exitCode = 1;
5170
+ return;
5171
+ }
5172
+ process.exitCode = 1;
5173
+ const body = await res2.json().catch(() => ({}));
5174
+ if (res2.status === 404 && body.error === "not_found") {
5175
+ log.error("In-game purchases aren't enabled on this environment yet.");
5176
+ return;
5177
+ }
5178
+ log.error(body.message ?? `Couldn't ${what} (HTTP ${res2.status}).`);
5179
+ if (body.error === "price_off_grid") {
5180
+ log.dim(` See the full list any time: ${c.cyan("genex shop list")}`);
5181
+ }
5182
+ }
5183
+ if (sub === "list") {
5184
+ const res2 = await call(projectSkus);
5185
+ if (!res2.ok) return fail3(res2, "read your shop");
5186
+ const body = await res2.json();
5187
+ if (opts.json) {
5188
+ log.plain(JSON.stringify(body, null, 2));
5189
+ return;
5190
+ }
5191
+ if (body.items.length === 0) {
5192
+ log.plain("This game sells nothing yet.");
5193
+ log.dim(` Add something: ${c.cyan('genex shop add "Iron Key" --price 100')}`);
5194
+ log.dim(` Prices available: ${body.priceGrid.join(", ")} coin`);
5195
+ return;
5196
+ }
5197
+ log.plain(`${body.items.length} item${body.items.length === 1 ? "" : "s"}:`);
5198
+ for (const s of body.items) {
5199
+ const state = s.active ? "" : c.dim(" (retired)");
5200
+ log.plain(
5201
+ ` ${c.cyan(s.id)} ${s.name} \u2014 ${s.priceCoins} coin${money(s.priceDisplayUsdCents)} ${c.dim(s.type)}${state}`
5202
+ );
5203
+ }
5204
+ log.dim(`Prices available: ${body.priceGrid.join(", ")} coin`);
5205
+ return;
5206
+ }
5207
+ if (sub === "add") {
5208
+ const name = opts.hostname?.trim();
5209
+ if (!name) {
5210
+ log.error(`\`genex shop add\` needs a name, e.g. ${c.cyan('genex shop add "Iron Key" --price 100')}.`);
5211
+ process.exitCode = 1;
5212
+ return;
5213
+ }
5214
+ if (!opts.price) {
5215
+ log.error(`\`genex shop add\` needs ${c.cyan("--price")}, in coin.`);
5216
+ process.exitCode = 1;
5217
+ return;
5218
+ }
5219
+ const res2 = await call(projectSkus, {
5220
+ method: "POST",
5221
+ body: JSON.stringify({
5222
+ name,
5223
+ priceCoins: opts.price,
5224
+ type: opts.type === "durable" ? "durable" : "consumable",
5225
+ ...opts.icon ? { iconUrl: opts.icon } : {}
5226
+ })
5227
+ });
5228
+ if (!res2.ok) return fail3(res2, "add that item");
5229
+ const sku = await res2.json();
5230
+ if (opts.json) {
5231
+ log.plain(JSON.stringify(sku, null, 2));
5232
+ return;
5233
+ }
5234
+ log.success(`Added ${c.cyan(sku.name)} \u2014 ${sku.priceCoins} coin.`);
5235
+ log.plain(` id: ${c.cyan(sku.id)}`);
5236
+ log.dim(` Use it in the game: buy({ skuId: "${sku.id}" })`);
5237
+ return;
5238
+ }
5239
+ const skuId = opts.hostname?.trim();
5240
+ if (!skuId) {
5241
+ log.error(`\`genex shop ${sub}\` needs an item id \u2014 see ${c.cyan("genex shop list")}.`);
5242
+ process.exitCode = 1;
5243
+ return;
5244
+ }
5245
+ const skuUrl = `${apiUrl}/api/coin/skus/${encodeURIComponent(skuId)}`;
5246
+ if (sub === "test") {
5247
+ const res2 = await call(`${skuUrl}/test-grant`, { method: "POST" });
5248
+ if (!res2.ok) return fail3(res2, "grant yourself a test copy");
5249
+ const got = await res2.json();
5250
+ log.success(`You now own ${c.cyan(got.name)} in this game.`);
5251
+ log.dim(" It cost nothing and no sale was recorded \u2014 it is a test copy.");
5252
+ log.dim(
5253
+ got.skuType === "durable" ? " Reload the game: it should appear in your inventory." : " Reload the game: it should be delivered and consumed once."
5254
+ );
5255
+ return;
5256
+ }
5257
+ if (sub === "set") {
5258
+ const patch = {};
5259
+ if (opts.rename) patch.name = opts.rename;
5260
+ if (opts.price) patch.priceCoins = opts.price;
5261
+ if (opts.icon) patch.iconUrl = opts.icon;
5262
+ if (Object.keys(patch).length === 0) {
5263
+ log.error(`Nothing to change. Pass ${c.cyan("--rename")}, ${c.cyan("--price")} or ${c.cyan("--icon")}.`);
5264
+ process.exitCode = 1;
5265
+ return;
5266
+ }
5267
+ const res2 = await call(skuUrl, { method: "PATCH", body: JSON.stringify(patch) });
5268
+ if (!res2.ok) return fail3(res2, "update that item");
5269
+ const sku = await res2.json();
5270
+ log.success(`Updated ${c.cyan(sku.name)} \u2014 ${sku.priceCoins} coin.`);
5271
+ return;
5272
+ }
5273
+ const res = await call(skuUrl, { method: "DELETE" });
5274
+ if (!res.ok) return fail3(res, "remove that item");
5275
+ log.success("Removed from the shop.");
5276
+ log.dim(" Players who already bought it keep it.");
5277
+ }
5278
+
4966
5279
  // src/lib/promote.ts
4967
5280
  async function promoteBuild(apiUrl, projectId, token, log) {
4968
5281
  let res;
@@ -19414,6 +19727,18 @@ ${c.bold("Usage")}
19414
19727
  genex publish [options] Build + push + make live, then list it in the gallery.
19415
19728
  genex make-remixable [options] Make THIS game remixable by everyone \u2014 migrates a
19416
19729
  private source onto a public managed genex repo.
19730
+ genex domain <sub> [host] Play this game on a domain you own:
19731
+ add | list | verify | remove. "add" opens a
19732
+ one-click approval at your DNS host \u2014 no
19733
+ records to copy or paste.
19734
+ genex shop <sub> [name|id] What this game sells: list | add | set | remove |
19735
+ test. "add" prints the item id your game passes
19736
+ to buy({ skuId }) \u2014 the platform owns the
19737
+ catalog, so this is the only way one exists.
19738
+ Prices come off a fixed grid, printed on any
19739
+ refusal. "test <id>" gives you a free copy of
19740
+ your own item: you can't buy from your own game,
19741
+ so this is how you check the shop works.
19417
19742
  genex model "<prompt>" [options] Generate a 3D model (GLB); prints a public asset URL.
19418
19743
  genex sfx "<prompt>" [options] Generate a sound effect (mp3); prints a public asset URL.
19419
19744
  genex music "<prompt>" [options] Generate an instrumental music track (mp3); prints a
@@ -19661,6 +19986,13 @@ ${c.bold("Examples")}
19661
19986
  genex publish
19662
19987
  genex publish --categories games,vfx
19663
19988
  genex make-remixable
19989
+ genex domain add play.mygame.com
19990
+ genex domain list
19991
+ genex shop list
19992
+ genex shop add "Iron Key" --price 100 --type durable
19993
+ genex shop set sku_123 --price 200
19994
+ genex shop test sku_123
19995
+ genex shop remove sku_123
19664
19996
  genex publish --no-push --title "My Game"
19665
19997
  genex model "weathered wooden barrel with iron bands"
19666
19998
  genex sfx "punchy laser zap" --duration 2
@@ -19712,6 +20044,10 @@ function parseArgs(argv) {
19712
20044
  "--name",
19713
20045
  "--repo",
19714
20046
  "--remixed-from",
20047
+ "--price",
20048
+ "--icon",
20049
+ "--rename",
20050
+ "--type",
19715
20051
  "--title",
19716
20052
  "--description",
19717
20053
  "--categories",
@@ -19789,6 +20125,9 @@ function parseArgs(argv) {
19789
20125
  case "--no-auth":
19790
20126
  parsed.options.noAuth = true;
19791
20127
  break;
20128
+ case "--no-open":
20129
+ parsed.options.noOpen = true;
20130
+ break;
19792
20131
  case "--no-push":
19793
20132
  parsed.options.noPush = true;
19794
20133
  break;
@@ -19923,6 +20262,18 @@ function parseArgs(argv) {
19923
20262
  } else {
19924
20263
  (parsed.options.selectors ??= []).push(arg);
19925
20264
  }
20265
+ } else if (parsed.command === "shop") {
20266
+ if (!parsed.options.hostname) parsed.options.hostname = arg;
20267
+ else {
20268
+ parsed.error = `Unexpected argument: ${arg}`;
20269
+ return parsed;
20270
+ }
20271
+ } else if (parsed.command === "domain") {
20272
+ if (!parsed.options.hostname) parsed.options.hostname = arg;
20273
+ else {
20274
+ parsed.error = `Unexpected argument: ${arg}`;
20275
+ return parsed;
20276
+ }
19926
20277
  } else if (parsed.command === "animations") {
19927
20278
  parsed.options.query = parsed.options.query ? `${parsed.options.query} ${arg}` : arg;
19928
20279
  } else if (parsed.command === "asset") {
@@ -20115,6 +20466,27 @@ function applyValueFlag(options, flag, value) {
20115
20466
  options.duration = n;
20116
20467
  break;
20117
20468
  }
20469
+ case "--price": {
20470
+ const n = Number(value);
20471
+ if (!Number.isInteger(n) || n <= 0) {
20472
+ throw new Error(`Invalid --price value: ${value} (whole coin, e.g. 100)`);
20473
+ }
20474
+ options.price = n;
20475
+ break;
20476
+ }
20477
+ case "--icon":
20478
+ options.icon = value;
20479
+ break;
20480
+ case "--type": {
20481
+ if (value !== "consumable" && value !== "durable") {
20482
+ throw new Error(`Invalid --type value: ${value} (consumable or durable)`);
20483
+ }
20484
+ options.type = value;
20485
+ break;
20486
+ }
20487
+ case "--rename":
20488
+ options.rename = value;
20489
+ break;
20118
20490
  case "--aspect":
20119
20491
  options.aspect = value;
20120
20492
  break;
@@ -20295,6 +20667,12 @@ async function main() {
20295
20667
  case "make-remixable":
20296
20668
  await runMakeRemixable(parsed.options);
20297
20669
  break;
20670
+ case "shop":
20671
+ await runShop(parsed.options);
20672
+ break;
20673
+ case "domain":
20674
+ await runDomain(parsed.options);
20675
+ break;
20298
20676
  case "controller":
20299
20677
  await runController({ ...parsed.options, kind: parsed.options.name });
20300
20678
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "1.6.0",
3
+ "version": "1.6.1-dev.409",
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": {
@@ -4,7 +4,7 @@
4
4
  "index.html": "6c33ab9fa792aa213a59db325227aae8b7086a10820f9d3428941e1ead846024",
5
5
  "vite.config.js": "255f13f0e8c202aa7c11d6e67397b9079ed771d4d2b3dd4771ce7fd2ae0d13e7",
6
6
  "src/main.js": "ac7e2c4da2f0bac605425b7146580d59eb7f93a029ea6c5bfc7c6e1697fb0f72",
7
- "src/viewer/stage.js": "a7f718fb1b60e131b73bb9669870cb085c06a85740a6e6965545b9a810135d22",
7
+ "src/viewer/stage.js": "dae8f765379d45a9836df901466d7376b94ae1a19107c5dcd611ff80fc09b75c",
8
8
  "src/viewer/hud.js": "fe896457c8765357555d93af2d302b80fbb53f303b01ce1e21361a4862b4df60",
9
9
  "src/viewer/gates-overlay.js": "95a429326f0bb584b56f835d10fc281c0d119dac68260a83a85fa949bb9b2fa1",
10
10
  "tools/emit-manifest.mjs": "8e8590694421ee62300d4b1680f86693d264b08b91de0ea91721ab571983c525",
@@ -33,8 +33,15 @@ const MAX_DPR = 1.5;
33
33
  // values are final sRGB - the backdrop is chrome, not lit geometry, so it is
34
34
  // written straight to the output buffer with no tone mapping and no colour
35
35
  // conversion applied to it.
36
- const POOL = [0x34 / 255, 0x39 / 255, 0x40 / 255];
37
- const CORNER = [0x16 / 255, 0x18 / 255, 0x1d / 255];
36
+ // Raised 2026-08-13 (owner: "make the whole scene a little brighter"). The
37
+ // previous pool measured L* 23.7 - about nine points BELOW the L* 32.5 this
38
+ // comment already names as the outline-safe value, so brightening moves it onto
39
+ // that measurement rather than away from it: #444a54 measures L* 31.5. The
40
+ // corners come up with it so the whole field reads lighter, while the pool-to-
41
+ // corner ratio is kept close to what it was, which is what preserves the
42
+ // vignette instead of flattening the backdrop into one grey.
43
+ const POOL = [0x44 / 255, 0x4a / 255, 0x54 / 255];
44
+ const CORNER = [0x22 / 255, 0x25 / 255, 0x2b / 255];
38
45
  // The pool sits above centre: the camera aims a little above the centroid, so
39
46
  // this puts the brightest part of the backdrop behind the subject's mass.
40
47
  const POOL_CENTRE_Y = 0.56;
@@ -383,7 +390,13 @@ export function createStage({ container, deterministic = false, groundShadow = t
383
390
  const scene = new THREE.Scene();
384
391
  scene.background = null;
385
392
  scene.environment = buildEnvironment(renderer);
386
- scene.environmentIntensity = 0.78;
393
+ // Raised 0.78 -> 0.94 for the 2026-08-13 brightness pass. The environment is
394
+ // the knob to reach for here rather than toneMappingExposure: exposure scales
395
+ // the specular highlight too, and 1.0 is the value already measured to blow
396
+ // the espresso machine's brushed steel (see below). Environment intensity
397
+ // lifts the diffuse and the ambient occlusion-facing sides - the parts that
398
+ // actually read as "dark" - and leaves the highlight roll-off where it is.
399
+ scene.environmentIntensity = 0.94;
387
400
 
388
401
  const backdrop = createBackdrop();
389
402
  const ground = groundShadow ? createGroundShadow() : null;
@@ -399,7 +412,10 @@ export function createStage({ container, deterministic = false, groundShadow = t
399
412
  // standing in for a room, and the environment has a room in it - a top panel,
400
413
  // a floor bounce and a cool wall - so keeping both would just be adding the
401
414
  // approximation back on top of the thing that replaced it.
402
- const key = new THREE.DirectionalLight(0xfff3e2, 1.65);
415
+ // 1.65 -> 1.85 with the 2026-08-13 brightness pass: a small bump so the key
416
+ // keeps its lead over the raised environment. Lifting the environment alone
417
+ // flattens the form, because the lit side and the shaded side rise together.
418
+ const key = new THREE.DirectionalLight(0xfff3e2, 1.85);
403
419
  key.castShadow = true;
404
420
  key.shadow.mapSize.set(2048, 2048);
405
421
  scene.add(key);
@@ -225,7 +225,7 @@ first one is the one a screenshot of the whole arena will not show you.
225
225
 
226
226
  ## Troubleshooting
227
227
 
228
- - **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
228
+ - **"Not authorized"** — run `npx @genex-ai/cli-demo@dev init` first (it writes your `GENEX_TOKEN`).
229
229
  - **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
230
230
  this texture generation. Tell the user the facts the CLI printed: their balance,
231
231
  this generation's cost, and when their credits refill. Then offer to continue the
@@ -151,7 +151,7 @@ set belongs to `$genex-ai-hud` — both build on `npx genex image`/`video`.
151
151
 
152
152
  ## Troubleshooting
153
153
 
154
- - **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
154
+ - **"Not authorized"** — run `npx @genex-ai/cli-demo@dev init` first (it writes your `GENEX_TOKEN`).
155
155
  - **"Prompt rejected"** — the provider's content-safety filter blocked the prompt.
156
156
  This is non-retryable; retrying the same wording fails again. Rewrite the prompt.
157
157
  - **Nothing plays / black surface** — the first `video.play()` must run inside a user
@@ -59,6 +59,7 @@ copy demo architecture.
59
59
  | in-world motion art or another requested video | `$genex-ai-video` |
60
60
  | sound effect, one looping music bed, or a short spoken line | `$genex-ai-sfx`, `$genex-ai-music`, or `$genex-ai-voice` |
61
61
  | requested UI/HUD/menu/interface work, a visible UI problem, or an interface you decided this game wants built with generated art | `$genex-threejs-game-ui` |
62
+ | selling anything for platform coin: a shop, an item catalog, boosts, cosmetics, "make it earn"; also any request for a loot box, gacha, wager, casino mechanic or donation prompt, which that skill refuses and replaces | `$genex-monetization` |
62
63
  | cinematic menu/title/pause/victory/defeat/lobby/credits video treatment | `$genex-ai-menu` |
63
64
  | drawn HUD chrome the game's style wants—one element or a matched set of frames, masks, and icons | `$genex-ai-hud` |
64
65
  | the game works but feels flat, floaty, or unresponsive: input response, camera, impacts, cooldowns, difficulty, fail/retry | `$genex-threejs-game-feel` |
@@ -161,7 +161,7 @@ and re-link the clone to the same live game:
161
161
  ```bash
162
162
  git clone <the game's repo url> my-game && cd my-game
163
163
  npm install
164
- npx @genex-ai/cli-demo@latest link <slug> # slug = the name in the play URL
164
+ npx @genex-ai/cli-demo@dev link <slug> # slug = the name in the play URL
165
165
  ```
166
166
 
167
167
  Don't know the slug? **`npx genex list`** prints every game on your account —
@@ -188,7 +188,7 @@ Safe to run any time — genex-owned skills are refreshed to the latest version,
188
188
  and your own files are never touched:
189
189
 
190
190
  ```bash
191
- npx @genex-ai/cli-demo@latest init
191
+ npx @genex-ai/cli-demo@dev init
192
192
  ```
193
193
 
194
194
  Use `--force` only if you intentionally want your own existing files overwritten
@@ -0,0 +1,293 @@
1
+ ---
2
+ name: genex-monetization
3
+ description: Build an in-game shop that sells for platform coin — item catalog, purchase flow, delivery, and the per-game soft-currency economy a purchase attaches to. Use when the player asks to sell things, add a shop, monetize, or make the game earn. Carries the hard rules: no paid randomness, no gambling in coin, no donation mechanics, and a real-money price beside every coin price.
4
+ ---
5
+
6
+ # Genex Monetization
7
+
8
+ Games on Genex can sell things for **coin**, the platform currency. The player
9
+ buys coin with real money once; spending it inside a game is a ledger movement
10
+ the game never touches. You design what is for sale; the platform owns the
11
+ wallet, the confirmation, and the money.
12
+
13
+ Load this when the game should sell something. Ask first if it should — a game
14
+ with no loop worth monetizing is better without a shop (see §1).
15
+
16
+ ## The hard rules, and the test that generalizes them
17
+
18
+ Before you build ANY purchasable thing, run this test:
19
+
20
+ > **Does the player pay?** (with coin, or with anything bought with coin —
21
+ > directly or indirectly, including a per-game token or key that coin bought.)
22
+ > **Is the outcome uncertain when they pay?**
23
+ > **Is there a prize** — an item, currency, or advantage they wanted?
24
+ >
25
+ > **All three yes = paid randomness. Build the deterministic version instead.**
26
+
27
+ That triad is the test used by every app store to identify gambling, and it
28
+ catches mechanics that do not exist yet — which a list of banned names cannot.
29
+
30
+ Four things are never built, whatever the request:
31
+
32
+ 1. **No paid randomness.** No loot boxes, gacha, mystery boxes, crates, packs,
33
+ prize wheels, raffles, "spin for a bonus", "chance to double your coins".
34
+ Directly or indirectly.
35
+ 2. **No gambling in coin.** No wagering, staking, betting, coinflips, casino or
36
+ slot mechanics denominated in coin or in anything coin buys.
37
+ 3. **No donation or begging mechanics.** No "donate to me" prompts, tip jars, or
38
+ player-to-player coin transfers. Coin buys goods; it never just moves.
39
+ 4. **No pressure.** No countdown timers, "ends in", "limited time", "only N
40
+ left", or stock counters anywhere in the shop.
41
+
42
+ **Randomness the player EARNS by playing is gameplay, not commerce, and is
43
+ completely fine**: an enemy dropping a random item, a chest you found in the
44
+ level, a procedural layout, a critical-hit roll, a shuffled deck. The line is
45
+ what triggered the roll — play, or payment. Build those freely.
46
+
47
+ Genex refuses paid randomness outright rather than allowing it with disclosed
48
+ odds. That is stricter than any app store, and it is why no Genex game needs an
49
+ odds table, an age gate, or a per-country check.
50
+
51
+ ### When a request crosses a line
52
+
53
+ Answer in exactly three parts, then build:
54
+
55
+ 1. **Name it.** "A loot crate is paid randomness — the player pays before
56
+ knowing what they get."
57
+ 2. **Why.** One sentence. "Genex doesn't sell chance; it's a purchase the player
58
+ can't price, and it's what regulators fine studios over."
59
+ 3. **Offer the alternative,** concretely enough to start on, and build that.
60
+
61
+ Never build the banned version "as an option", never build a partial one, and
62
+ never ask the user to confirm they want it. If they insist, restate the rule
63
+ once and build the compliant version. There is no escalation path.
64
+
65
+ **What to build instead:**
66
+
67
+ | They asked for | Build |
68
+ | --- | --- |
69
+ | Loot box, crate, mystery box, card pack | A direct-purchase shop: every item listed at a fixed price, contents visible. For the collecting feel, add a **visible catalog with a completion track** — any purchase advances a meter to a stated milestone reward. |
70
+ | Gacha, banner, pull, summon | A **deterministic unlock**: the character costs a fixed price, or unlocks at a stated number of runs. Coin may buy a stated, visible number of those points. |
71
+ | Prize wheel, spin-to-win, slot machine | A **free spin earned by finishing a run** (never bought), or a **"pick one of three"** screen where all three are visible and the player chooses. Keeps the moment, drops the wager. |
72
+ | Casino game, blackjack, poker, roulette | The same game with **chips that are granted free each session, reset on restart, cannot be bought and cannot become coin**. It becomes a card game. Sell cosmetics — table felt, card backs — for coin. |
73
+ | Coinflip, double-or-nothing, wager my coins | A **skill-based risk/reward inside the run**: a harder route with a bigger payout, staking the run's own score, which was never purchasable. |
74
+ | Betting on matches, PvP wagers | **Leaderboards with a fixed cosmetic reward for placement**, paid by the game. Nobody's balance goes down. |
75
+ | Donate button, tip jar, "pls donate" | A **gift that is a purchase**: they buy a specific item at a stated price and give it. Or a **supporter cosmetic** — a badge or aura at a normal price, where what's delivered is visible. |
76
+ | Pay to remove a wait / energy gate | **Delete the gate** and sell a permanent upgrade or a cosmetic. Pace with difficulty, not with a timer. |
77
+ | Limited-time offer, flash sale | A **permanent tiered ladder** — the value comes from volume, not from a clock. |
78
+ | Pay-to-win stat boost in a competitive game | **Cosmetics**, or a boost that only applies in single-player content. |
79
+
80
+ ## Designing a shop worth buying from
81
+
82
+ Nine checks. Each one is answerable about your actual design.
83
+
84
+ 1. **The shop attaches to a progression that already exists.** Name the screen
85
+ it opens from and the meter a purchase moves. Build the loop first; a shop in
86
+ a game with nothing to want is furniture.
87
+ 2. **A boost shortens a grind the player has already felt.** State it in one
88
+ sentence: "this skips the ore-gathering they've done four times." If you
89
+ can't, it isn't a boost, it's a number.
90
+ 3. **Nothing sold invalidates the core loop.** If a paying and a non-paying
91
+ player both reach the end, the payer must not have skipped the part that IS
92
+ the game.
93
+ 4. **No manufactured friction.** If the annoyance wouldn't exist without the
94
+ shop, remove the annoyance instead of selling the cure.
95
+ 5. **Everything sold is reachable free.** Spending is a shortcut or a
96
+ decoration, never the only path.
97
+ 6. **Prices land on the grid.** Item prices use 50 / 100 / 200 / 500 / 1000 coin
98
+ so every coin pack divides evenly into them and nobody is left holding change
99
+ they cannot spend.
100
+ 7. **Every price shows real money next to it.** The server sends
101
+ `priceDisplayUsdCents` with every item — render it. `250 coins ($2.49)`.
102
+ 8. **One currency layer between money and goods.** Coin buys items. A per-game
103
+ earned currency buys per-game upgrades. They never convert into each other.
104
+ 9. **Purchases never expire and survive a reinstall.** Entitlements live on the
105
+ server; the game re-reads them on every boot.
106
+
107
+ For a per-game earned currency, the load-bearing number is **minutes of play per
108
+ unit earned**. Set it, then price the cheapest meaningful item at one to three
109
+ sessions of earning. Everything else follows. Spend sinks come in three kinds —
110
+ permanent upgrades, refills, cosmetics — and cosmetics are what absorbs late-game
111
+ currency without touching balance.
112
+
113
+ ## Stocking the shop
114
+
115
+ Items live on the platform, not in the game's code. You create them with the CLI,
116
+ and the game names them by id — which is what stops a game inventing its own
117
+ items or repricing them.
118
+
119
+ ```bash
120
+ npx genex shop add "Iron Key" --price 100 --type durable
121
+ # → id: sku_a1b2c3 ← what the game passes to buy()
122
+
123
+ npx genex shop list # what this game sells, and the valid prices
124
+ npx genex shop set sku_a1b2c3 --price 200
125
+ npx genex shop remove sku_a1b2c3 # retires it; players who bought it keep it
126
+ ```
127
+
128
+ `--type consumable` (default) is spent on use; `durable` is owned permanently.
129
+
130
+ **Prices come off a fixed grid** — `genex shop list` prints it, and an off-grid
131
+ price is refused. The grid exists so every coin pack divides evenly by the
132
+ cheapest item, which is what stops a player being left holding change too small
133
+ to spend. Pick the nearest grid price rather than working around it.
134
+
135
+ Record the ids in `DESIGN.md` next to what each item does. They are the one
136
+ thing the game's code cannot regenerate for itself.
137
+
138
+ ## The API
139
+
140
+ From `@genex-ai/embed-sdk`, already installed. `initEmbed()` must have run.
141
+
142
+ ```ts
143
+ import { getShop, buy, getEntitlements, consumeEntitlement } from '@genex-ai/embed-sdk';
144
+
145
+ const items = await getShop();
146
+ // [{ id, type, name, iconUrl, priceCoins, priceDisplayUsdCents }]
147
+ ```
148
+
149
+ Render `name`, `iconUrl`, `priceCoins` **and** `priceDisplayUsdCents`. Never
150
+ hardcode a price: the server charges what its own catalog says, so a hardcoded
151
+ number can silently disagree with what the player is charged.
152
+
153
+ `getShop()` works for a **guest** and inside a **preview** build, so the shop
154
+ window renders for everyone — that is the point of showing it to a signed-out
155
+ player at all. Buying is what needs an account.
156
+
157
+ **Your game cannot read the player's coin balance, and no HUD should show one.**
158
+ The wallet spans every game on the platform, so an untrusted game is not told how
159
+ much a player can spend. Show what you *can* know — what they own, from
160
+ `getEntitlements()` — and let `buy()` report `insufficient_balance` if it comes
161
+ to that.
162
+
163
+ ### Buying
164
+
165
+ ```ts
166
+ buyButton.addEventListener('click', async () => { // must be a real click
167
+ const result = await buy({ skuId: item.id });
168
+ if (result.status === 'canceled') return; // normal — say nothing
169
+ if (result.status !== 'succeeded') {
170
+ showMessage(result.message ?? 'That did not go through.');
171
+ return;
172
+ }
173
+ await deliverPending();
174
+ });
175
+ ```
176
+
177
+ **Call `buy()` synchronously from the click handler.** On the game's own origin
178
+ the confirmation is a popup, and browsers only allow one while a user gesture is
179
+ live — an `await` before it loses the gesture and nothing opens.
180
+
181
+ `buy()` resolves when the SERVER says what happened, not when a window closes.
182
+ Statuses: `succeeded`, `canceled`, `expired`, `insufficient_balance`, `failed`.
183
+
184
+ The player confirms on a Genex-drawn surface — your game does not render the
185
+ price sheet, cannot skin it, and cannot complete a purchase itself. That is
186
+ deliberate: it is what lets a player trust a purchase in a game they have never
187
+ played before.
188
+
189
+ ### Delivering
190
+
191
+ ```ts
192
+ async function deliverPending() {
193
+ for (const e of await getEntitlements({ excludeConsumed: true })) {
194
+ // Branch on skuType (consumable | durable), NEVER on type — `type` is how
195
+ // it was acquired (purchase/gift/test/free) and can't answer this.
196
+ if (e.skuType === 'durable') {
197
+ wear(e.name); // ownership, re-applied on every boot. Do not consume.
198
+ continue;
199
+ }
200
+ const { alreadyConsumed } = await consumeEntitlement(e.id);
201
+ if (alreadyConsumed) continue; // someone got there first
202
+ applyItem(e.skuId); // AFTER the consume
203
+ await savePlayerState(currentSave());
204
+ }
205
+ }
206
+ ```
207
+
208
+ **Consume first, apply second, and run `deliverPending()` on every boot.**
209
+
210
+ That order is not stylistic. If the game dies between consuming and applying,
211
+ the player loses one item — a support ticket. If you apply first and die before
212
+ consuming, every boot re-delivers it forever — an exploit. Re-listing on boot is
213
+ what makes a purchase survive a crash, a refresh, or a closed tab.
214
+
215
+ **`e.skuType` is the field that decides this, not `e.type`.** `type` says how the
216
+ player got it (`purchase`/`gift`/`test`/`free`); `skuType` says what it is
217
+ (`consumable`/`durable`).
218
+
219
+ A **consumable** is spent: consume it, then apply the effect once.
220
+
221
+ A **durable** is owned forever, and you have two honest ways to handle it. If the
222
+ effect is ownership — a cosmetic, a skin, an unlock — do **not** consume it: the
223
+ row's presence in the list IS the ownership, it survives reinstalls, and there is
224
+ no local save to drift out of sync. Only consume a durable when it grants
225
+ something once and non-idempotently (a permanent +100 gold), and then record it
226
+ in the player's save, because re-applying that on every boot would be a bug.
227
+
228
+ ### Testing the shop you just built
229
+
230
+ You cannot buy from your own game — the server refuses it as self-dealing, which
231
+ is the rule that stops someone laundering their own coin into earnings. So do
232
+ not try to verify a shop by buying from it while signed in as its owner; you
233
+ will get a refusal and it is not a bug.
234
+
235
+ ```bash
236
+ npx genex shop test <sku-id> # a free copy, for the owner only
237
+ ```
238
+
239
+ That grants the entitlement without a sale — no coin moves — so the delivery
240
+ path, the inventory and the effect all run exactly as they would after a real
241
+ purchase. Reload the game and it should be there.
242
+
243
+ ## Checklist
244
+
245
+ - [ ] Items exist (`npx genex shop list`) before the shop UI is written
246
+ - [ ] Delivery was verified with `npx genex shop test`, not by trying to buy your own item
247
+ - [ ] The game has a loop and a progression before it has a shop
248
+ - [ ] Every item price is on the 50/100/200/500/1000 grid
249
+ - [ ] Every price renders `priceDisplayUsdCents` beside the coin figure
250
+ - [ ] `buy()` is called synchronously inside a click/tap handler
251
+ - [ ] `canceled` is silent; only real failures show a message
252
+ - [ ] `deliverPending()` runs on every boot, before the player can act
253
+ - [ ] `consumeEntitlement()` is awaited BEFORE the effect is applied
254
+ - [ ] `alreadyConsumed: true` skips the effect
255
+ - [ ] Durable purchases are written to the player's save
256
+ - [ ] No timer, "limited", "ends in", or stock counter anywhere
257
+ - [ ] Nothing sold is unreachable without paying
258
+ - [ ] No paid randomness, no coin wagering, no donation prompt
259
+
260
+ ## Troubleshooting
261
+
262
+ **`buy()` returns `failed` with "the confirmation window was blocked"** — `buy()`
263
+ was not called inside a user gesture, or an `await` ran before it. Move it to the
264
+ first line of the click handler.
265
+
266
+ **The purchase succeeded but the player got nothing** — the game applied the
267
+ effect without consuming, or never ran `deliverPending()` on boot. The
268
+ entitlement is still there; re-list it.
269
+
270
+ **The player got the item twice** — the effect was applied before consuming, or
271
+ `alreadyConsumed` was ignored. Both are the same bug.
272
+
273
+ **`unauthorized` from `getShop()`** — no player identity yet. `initEmbed()` must
274
+ have run and `waitForPlayer()` resolved. See `$genex-threejs-embed-auth`.
275
+
276
+ **`guest_no_wallet`** — guests play but hold no wallet. Show the shop as
277
+ sign-in-to-buy rather than hiding it.
278
+
279
+ **`staging_no_purchase`** — a `genex preview` build cannot spend real coin. Test
280
+ the shop's layout on staging; test a purchase after `genex promote`.
281
+
282
+ **`getShop()` returns nothing** — the game has no items yet. `npx genex shop add
283
+ "<name>" --price <coin>` and use the id it prints.
284
+
285
+ **`price_off_grid`** — that price isn't on the platform's grid. `npx genex shop
286
+ list` prints the valid ones; pick the nearest.
287
+
288
+ **Everything coin-related 404s** — in-game purchases aren't enabled on this
289
+ environment. Nothing to fix in the game; say so and build the rest.
290
+
291
+ **Purchases do nothing in local testing** — local test mode has no wallet and no
292
+ server. `buy()` returns `failed` immediately by design. Test purchases on a
293
+ preview or published build.
@@ -134,9 +134,13 @@ everything twice.
134
134
  visible; the lock may only ever engage from the Play/Resume click or a
135
135
  gameplay canvas click (the phase binding `setPaused(phase !== "playing")` is
136
136
  what guarantees this — check it rides `setPhase`, not the render loop).
137
- Headless caveat: `requestPointerLock` throws in headless Chromium —
138
- assert the wiring and the unlocked cue in a screenshot, and say plainly that
139
- the lock itself needs one manual click (do the both-axes look check there).
137
+ Headless caveat, measured on Chromium 151: `requestPointerLock` does NOT
138
+ throw it locks, with or without a user gesture, so the lock and the
139
+ unlocked cue ARE yours to assert headless. What does not survive is the
140
+ both-axes look check: synthesised mouse movement cancels to a net zero
141
+ delta, so turning right then left proves nothing about direction. Assert the
142
+ wiring and the cue in a screenshot, and say plainly that confirming which way
143
+ the view turns needs one manual pass with a real mouse.
140
144
  7. **Ask the scene the three things the screenshot cannot answer** (below). Run it
141
145
  once, in the same browser you already have open.
142
146
 
@@ -38,7 +38,7 @@ update, so update immediately.)
38
38
  Run exactly the command the nudge printed, from the game project root:
39
39
 
40
40
  ```bash
41
- npm i -D @genex-ai/cli-demo@latest # the genex CLI (a dev dependency)
41
+ npm i -D @genex-ai/cli-demo@dev # the genex CLI (a dev dependency)
42
42
  npm i @genex-ai/embed-sdk@latest # identity/saves SDK (ships inside the game)
43
43
  npm i @genex-ai/multiplayer@latest # multiplayer SDK (only if the game uses it)
44
44
  ```