@genex-ai/cli-demo 0.88.0-dev.219 → 0.91.0-dev.228

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
@@ -183,7 +183,10 @@ already-installed actions are not charged again. The controller command writes
183
183
  `public/assets/meshy-character.json`, whose permanent R2 URLs point at the
184
184
  current rigged model and compact same-signature animation GLBs. Re-run it after
185
185
  adding actions to refresh the manifest without overwriting existing controller
186
- source. The default `genex controller character` behavior remains VRM + UAL.
186
+ source. The game's own generated character is the player's body wherever a human
187
+ body appears on screen; the profile VRM avatar is the fallback, and the vendored
188
+ `character/player-character.ts` resolves both behind one call, so installing the
189
+ manifest needs no code change in the game.
187
190
 
188
191
  ## Install / run
189
192
 
package/dist/index.js CHANGED
@@ -265,13 +265,16 @@ Your capabilities (all via \`npx genex \u2026\`): generate \`model\` \xB7 \`skyb
265
265
  9. Never draw a rectangular backing plate behind bars, digits, or icons \u2014 in sprites or CSS. Ornament lives on the widget's own silhouette; a truly needed shaped plate comes from \`npx genex ui plate\`.
266
266
  10. Fonts: the brief's display + body pair comes from the menu skill's genre table (or carries a one-line stated reason) and is LOADED for real in \`index.html\`.
267
267
  11. Never park ready work behind a question, and never stall on an unanswered one \u2014 decide, state the decision in chat, record it, keep building.
268
- 12. Wire-in floors before any publish and before ending a session: sprite HUD wired (or the recorded CSS lane finished), menu video playing (or its recorded still fallback), logotype placed, fonts loaded, Escape pause, branded loader. Run \`npx genex wait\` on every generation you enqueued and wire what landed \u2014 never park landed assets.
269
- 13. Characters: Meshy/Mixamo/VRM rigs rest facing +Z. Set yaw explicitly when placing a rig; never mirror a SkinnedMesh with negative scale. In any two-character scene, verify in a capture that they face each other, not the camera.
270
- 14. Verify by looking: one smoke check per milestone, after that milestone's preview push, in local test mode (\`?genex_local_test=1\`) with a real gameplay screenshot. A claim without a capture is not verification.
268
+ 12. Wire-in floors before any publish and before ending a session: sprite HUD wired (or the recorded CSS lane finished), menu video playing (or its recorded still fallback), logotype placed, fonts loaded, Escape pause, branded loader, the player wearing the game's own generated character (or DESIGN.md recording why it isn't). Run \`npx genex wait\` on every generation you enqueued and wire what landed \u2014 never park landed assets.
269
+ 13. The player's body is the game's own generated character (\`npx genex character "<look>"\` \u2192 \`npx genex controller character --character <id>\`), enqueued with your first art actions, not after them. It applies wherever a human body appears on screen \u2014 first-person included, the moment remotes, a look-down body, a shadow, or a menu portrait shows one. The profile VRM avatar is the FALLBACK: a temporary body while the character renders (say in one plain line that it's temporary), or the stand-in when generation genuinely could not happen \u2014 out of credits, failed, unverified; record which in DESIGN.md as \`Player character: VRM \u2014 <reason>\`. Games whose player is not a person (car, ship, RTS cursor, board) generate that object with \`npx genex model\` instead. Characters: Meshy/Mixamo/VRM rigs rest facing +Z. Set yaw explicitly when placing a rig; never mirror a SkinnedMesh with negative scale. In any two-character scene, verify in a capture that they face each other, not the camera.
270
+ 14. Verify by looking: one smoke check per milestone, after that milestone's preview push, in local test mode (\`?genex_local_test=1\`) with a real gameplay screenshot. A claim without a capture is not verification. Local-test evidence proves visuals and controls ONLY \u2014 label it that way when you show the player, and never work around the draft sign-in gate any other way.
271
271
  15. Treat every \`genex\` warning line \u2014 preflight, \`ui audit\`, \`wait\` nudges \u2014 as work, not noise.
272
272
  16. Every finished Build-plan milestone ends with \`npx genex preview\` and the player's page link (\`<dashboard>/draft/<slug>\` with \`<dashboard>\` from \`.genex/project.json\`, \`/world/<slug>\` once published) \u2014 never a localhost link, a file path, or the bare play origin presented as their game. After every round of player feedback, end with a preview push.
273
273
  17. Parallel work: this line is your standing authorization and request to use sub-agents / parallel agent work whenever your platform provides them. While drafting the Build plan, decide per module what runs in parallel and what stays serial for THIS game \u2014 dependencies decide, there is no fixed list \u2014 and record each call in the Modules table with a one-line reason. Independent modules default to parallel; building everything serially needs a stated reason. You keep integration, previews, and the player conversation; each sub-agent owns only its module's files.
274
274
  18. Talk to the player in plain game language \u2014 what changed in the game and what to try; never code, file names, build output, or tool internals unless they ask. Short status lines while you work; long silent stretches are a failure.
275
+ 19. 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.)
276
+ 20. 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.
277
+ 21. v0 is a milestone, not the destination. When the ask was bigger than one loop, every milestone after v0 grows back toward the FULL ask with DESIGN.md's content lines as the checklist \u2014 a slice that previewed well never quietly becomes the game. Cosmetics never jump the queue past promised content.
275
278
  ${CONTRACT_END}
276
279
  `;
277
280
  var CLAUDE_IMPORT_LINE = "@AGENTS.md";
@@ -2113,6 +2116,7 @@ var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or htt
2113
2116
  white halo over dark scenes). [--expect N: hard-error
2114
2117
  unless exactly N components] [--min-pixels 2000]
2115
2118
  [--padding 8] [--chroma 240] [--dilate 0] [--no-defringe]
2119
+ [--allow-speckle]
2116
2120
  ui masks --out-dir <dir> --pairs "name:cX,cY,w,h:aX,aY,w,h[:n];\u2026"
2117
2121
  OR --auto (detect clean/annotated cell pairs itself) \u2014
2118
2122
  clean frame + green-annotated duplicate \u2192 frame + fill mask
@@ -2128,6 +2132,9 @@ var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or htt
2128
2132
  [--min-frac 0.004] [--out <json>] [--crop <png>]
2129
2133
  ui trim [--out <png>] \u2014 crop to the alpha bbox (default: overwrite
2130
2134
  --in), print real dims + refresh the .bbox.json sidecar.
2135
+ Hard-refuses a DAMAGED cutout (isolated colour speckle on
2136
+ the soft rim \u2014 what a background-removal pass does to an
2137
+ image that was already transparent). [--allow-speckle]
2131
2138
  ui seams measure a tiling texture's wrap seam (opposite edges vs
2132
2139
  interior detail) \u2192 ratio + verdict as JSON; nonzero exit on
2133
2140
  a visible seam. [--seam-tolerance 3]
@@ -2139,7 +2146,9 @@ var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or htt
2139
2146
  ui audit mechanical HUD-wiring scan (no --in): mask\u2194frame pairing
2140
2147
  mismatches (recorded crops disagree \u2014 PROVABLE, exit 1),
2141
2148
  white-painted meter troughs (baked sheet-white inside a
2142
- fill channel \u2014 PROVABLE, exit 1), loosened-check stamps,
2149
+ fill channel \u2014 PROVABLE, exit 1), damaged cutouts (colour
2150
+ speckle on a wired sprite's rim \u2014 PROVABLE, exit 1),
2151
+ loosened-check stamps,
2143
2152
  extracted-but-never-wired sprites, generated-but-
2144
2153
  unreferenced masks, naive JS %-width fills, phones-
2145
2154
  preload-video heuristic. Everything but the two provable
@@ -2422,6 +2431,15 @@ async function uiExtract(opts, log) {
2422
2431
  if (inputMode === "chroma" && opts.defringe !== false) {
2423
2432
  defringed = defringeAgainstMatte(out) > 0;
2424
2433
  }
2434
+ const speckle = rimSpeckleRatio(out);
2435
+ if (speckle && speckle.ratio > SPECKLE_MAX_RATIO && !opts.allowSpeckle) {
2436
+ fail(speckleRefusal(name, speckle.ratio), {
2437
+ input,
2438
+ sprite: name,
2439
+ speckleRatio: Number(speckle.ratio.toFixed(4)),
2440
+ rimPixels: speckle.sampled
2441
+ });
2442
+ }
2425
2443
  const outPath = path11.join(outDir, `${name}.png`);
2426
2444
  await writePng(outPath, out);
2427
2445
  const sidecar = {
@@ -3180,13 +3198,30 @@ async function uiTrim(opts, log) {
3180
3198
  h: bbox.maxY - bbox.minY + 1
3181
3199
  };
3182
3200
  const trimmed = cropPng(png, cropBox);
3201
+ const speckle = rimSpeckleRatio(trimmed);
3202
+ if (speckle && speckle.ratio > SPECKLE_MAX_RATIO && !opts.allowSpeckle) {
3203
+ fail(speckleRefusal("this image", speckle.ratio), {
3204
+ input,
3205
+ speckleRatio: Number(speckle.ratio.toFixed(4)),
3206
+ rimPixels: speckle.sampled
3207
+ });
3208
+ }
3183
3209
  await writePng(outPath, trimmed);
3184
3210
  const sidecar = computeBBoxes(trimmed);
3211
+ const speckleAllowed = !!(speckle && speckle.ratio > SPECKLE_MAX_RATIO);
3185
3212
  const sidecarPath = outPath.replace(/\.png$/i, "") + ".bbox.json";
3186
- await fs11.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2));
3213
+ await fs11.writeFile(
3214
+ sidecarPath,
3215
+ JSON.stringify(speckleAllowed ? { ...sidecar, speckleAllowed: true } : sidecar, null, 2)
3216
+ );
3187
3217
  log.success(
3188
3218
  `Trimmed ${png.width}x${png.height} \u2192 ${trimmed.width}x${trimmed.height} (${outPath}).`
3189
3219
  );
3220
+ if (speckleAllowed) {
3221
+ log.warn(
3222
+ "Shipped with --allow-speckle: this cutout is damaged and the override is stamped in its sidecar. Eyeball it over a bright background before it reaches the player."
3223
+ );
3224
+ }
3190
3225
  process.stdout.write(
3191
3226
  `${JSON.stringify({
3192
3227
  out: outPath,
@@ -3195,7 +3230,9 @@ async function uiTrim(opts, log) {
3195
3230
  aspectRatio: sidecar.aspectRatio,
3196
3231
  outerBBox: sidecar.outerBBox,
3197
3232
  innerBBox: sidecar.innerBBox,
3198
- sidecar: sidecarPath
3233
+ sidecar: sidecarPath,
3234
+ ...speckle ? { speckleRatio: Number(speckle.ratio.toFixed(4)) } : {},
3235
+ ...speckleAllowed ? { speckleAllowed: true } : {}
3199
3236
  })}
3200
3237
  `
3201
3238
  );
@@ -3292,6 +3329,51 @@ async function uiPlate(opts, log) {
3292
3329
  `
3293
3330
  );
3294
3331
  }
3332
+ var SPECKLE_RIM_ALPHA_MIN = 16;
3333
+ var SPECKLE_RIM_ALPHA_MAX = 220;
3334
+ var SPECKLE_CHROMA_EXCESS = 60;
3335
+ var SPECKLE_MIN_SAMPLE = 200;
3336
+ var SPECKLE_MAX_RATIO = 0.02;
3337
+ function rimSpeckleRatio(png) {
3338
+ const { width: W, height: H, data } = png;
3339
+ if (W < 3 || H < 3) return null;
3340
+ const chroma = new Uint8Array(W * H);
3341
+ for (let i = 0; i < W * H; i++) {
3342
+ const o = i * 4;
3343
+ const r = data[o];
3344
+ const g = data[o + 1];
3345
+ const b = data[o + 2];
3346
+ chroma[i] = Math.max(r, g, b) - Math.min(r, g, b);
3347
+ }
3348
+ const neighbourhood = [];
3349
+ let sampled = 0;
3350
+ let speckled = 0;
3351
+ for (let y = 1; y < H - 1; y++) {
3352
+ for (let x = 1; x < W - 1; x++) {
3353
+ const i = y * W + x;
3354
+ const a = data[i * 4 + 3];
3355
+ if (a <= SPECKLE_RIM_ALPHA_MIN || a >= SPECKLE_RIM_ALPHA_MAX) continue;
3356
+ sampled++;
3357
+ neighbourhood.length = 0;
3358
+ for (let dy = -1; dy <= 1; dy++) {
3359
+ for (let dx = -1; dx <= 1; dx++) neighbourhood.push(chroma[(y + dy) * W + (x + dx)]);
3360
+ }
3361
+ neighbourhood.sort((p, q) => p - q);
3362
+ if (chroma[i] - neighbourhood[4] > SPECKLE_CHROMA_EXCESS) speckled++;
3363
+ }
3364
+ }
3365
+ if (sampled < SPECKLE_MIN_SAMPLE) return null;
3366
+ return { ratio: speckled / sampled, sampled };
3367
+ }
3368
+ function speckleRefusal(label, ratio) {
3369
+ return [
3370
+ `Damaged cutout \u2014 ${(ratio * 100).toFixed(1)}% of ${label}'s soft edge is isolated colour speckle (want <= ${(SPECKLE_MAX_RATIO * 100).toFixed(0)}%).`,
3371
+ "A background-removal pass over an image that was ALREADY transparent re-keys every edge and sprays colour noise along it. Trimming or defringing cannot fix this.",
3372
+ " -> From `genex image --clean`? Ship the `--transparent` original instead.",
3373
+ " -> Source really had a background? Re-clean with `--bg-mode matte` (soft edges).",
3374
+ " Override (stamped in the output): --allow-speckle"
3375
+ ].join("\n");
3376
+ }
3295
3377
  var DEFRINGE_BAND_PX = 2;
3296
3378
  function defringeAgainstMatte(png, matte = { r: 255, g: 255, b: 255 }) {
3297
3379
  const { width: W, height: H, data } = png;
@@ -3374,7 +3456,8 @@ function defringeAgainstMatte(png, matte = { r: 255, g: 255, b: 255 }) {
3374
3456
  }
3375
3457
  var HARD_AUDIT_KINDS = /* @__PURE__ */ new Set([
3376
3458
  "mask-frame-mismatch",
3377
- "white-trough"
3459
+ "white-trough",
3460
+ "cutout-speckle"
3378
3461
  ]);
3379
3462
  var TROUGH_OPAQUE_MIN_ALPHA = 200;
3380
3463
  var TROUGH_WHITE_MIN = 235;
@@ -3535,6 +3618,22 @@ async function collectUiAuditFindings(assetDir, srcDir, cwd = process.cwd()) {
3535
3618
  }
3536
3619
  }
3537
3620
  }
3621
+ for (const [base, p] of pngByBase) {
3622
+ if (/-mask\.png$/.test(base)) continue;
3623
+ if (!referenced(base)) continue;
3624
+ let png;
3625
+ try {
3626
+ png = PNG2.sync.read(await fs11.readFile(p));
3627
+ } catch {
3628
+ continue;
3629
+ }
3630
+ const speckle = rimSpeckleRatio(png);
3631
+ if (!speckle || speckle.ratio <= SPECKLE_MAX_RATIO) continue;
3632
+ findings.push({
3633
+ kind: "cutout-speckle",
3634
+ message: `${base}: ${(speckle.ratio * 100).toFixed(1)}% of the soft edge is isolated colour speckle (want <= ${(SPECKLE_MAX_RATIO * 100).toFixed(0)}%) \u2014 a background-removal pass re-keyed edges that were already transparent and sprayed colour noise along them. Nothing downstream repairs it: ship the \`--transparent\` original, or re-clean the true source with \`--bg-mode matte\`. Do not ship this sprite.`
3635
+ });
3636
+ }
3538
3637
  const loosenedNames = [...metaByName.entries()].filter(([, meta]) => meta.loosened).map(([name]) => name);
3539
3638
  if (loosenedNames.length > 0) {
3540
3639
  findings.push({
@@ -3866,13 +3965,109 @@ async function inlineLocalImage(filePath, flag) {
3866
3965
  const mime = IMAGE_MIME_BY_EXT[path13.extname(filePath).toLowerCase()] ?? "image/png";
3867
3966
  return { ok: true, dataUri: `data:${mime};base64,${bytes.toString("base64")}` };
3868
3967
  }
3869
- var SKYBOX_ENVIRONMENT_SUFFIX = ". Pure environment: sky, clouds, atmosphere, distant horizon haze. No buildings, no structures, no trees, no objects, no ground detail.";
3968
+ var SKYBOX_ENVIRONMENT_SUFFIX = ". The image contains ONLY sky: cloud, atmosphere, light, weather and distant haze at the horizon. Every structure, object, plant and ground surface is outside the frame.";
3870
3969
  var SKYBOX_PROMPT_MAX = 2e3;
3970
+ var SKYBOX_STRUCTURE_NOUNS = [
3971
+ "building",
3972
+ "buildings",
3973
+ "house",
3974
+ "houses",
3975
+ "hut",
3976
+ "huts",
3977
+ "cabin",
3978
+ "cabins",
3979
+ "tower",
3980
+ "towers",
3981
+ "castle",
3982
+ "castles",
3983
+ "cathedral",
3984
+ "cathedrals",
3985
+ "church",
3986
+ "churches",
3987
+ "temple",
3988
+ "temples",
3989
+ "shrine",
3990
+ "shrines",
3991
+ "monastery",
3992
+ "palace",
3993
+ "fortress",
3994
+ "citadel",
3995
+ "ruin",
3996
+ "ruins",
3997
+ "city",
3998
+ "cityscape",
3999
+ "skyline",
4000
+ "town",
4001
+ "village",
4002
+ "settlement",
4003
+ "street",
4004
+ "streets",
4005
+ "road",
4006
+ "roads",
4007
+ "courtyard",
4008
+ "plaza",
4009
+ "bridge",
4010
+ "bridges",
4011
+ "wall",
4012
+ "walls",
4013
+ "gate",
4014
+ "gates",
4015
+ "fence",
4016
+ "fences",
4017
+ "pillar",
4018
+ "pillars",
4019
+ "column",
4020
+ "columns",
4021
+ "statue",
4022
+ "statues",
4023
+ "monument",
4024
+ "obelisk",
4025
+ "spire",
4026
+ "spires",
4027
+ "rooftop",
4028
+ "rooftops",
4029
+ "tree",
4030
+ "trees",
4031
+ "forest",
4032
+ "jungle",
4033
+ "ship",
4034
+ "ships",
4035
+ "boat",
4036
+ "boats",
4037
+ "car",
4038
+ "cars",
4039
+ "vehicle",
4040
+ "vehicles",
4041
+ "person",
4042
+ "people",
4043
+ "figure",
4044
+ "figures",
4045
+ "character",
4046
+ "characters",
4047
+ "creature",
4048
+ "creatures",
4049
+ "dragon",
4050
+ "banner",
4051
+ "banners",
4052
+ "flag",
4053
+ "flags",
4054
+ "torch",
4055
+ "torches",
4056
+ "lamp",
4057
+ "lamps"
4058
+ ];
4059
+ var SKYBOX_NOUN_RE = new RegExp(`\\b(${SKYBOX_STRUCTURE_NOUNS.join("|")})\\b`, "gi");
4060
+ function skyboxStructureNouns(prompt) {
4061
+ const found = /* @__PURE__ */ new Set();
4062
+ for (const m of prompt.matchAll(SKYBOX_NOUN_RE)) found.add(m[1].toLowerCase());
4063
+ return [...found];
4064
+ }
3871
4065
  function applySkyboxGuard(prompt, raw) {
3872
- if (raw) return { prompt, guarded: false };
4066
+ if (raw) return { prompt, guarded: false, nouns: [] };
4067
+ const nouns = skyboxStructureNouns(prompt);
3873
4068
  const guarded = `${prompt}${SKYBOX_ENVIRONMENT_SUFFIX}`;
3874
- if (guarded.length > SKYBOX_PROMPT_MAX) return { prompt, guarded: false };
3875
- return { prompt: guarded, guarded: true };
4069
+ if (guarded.length > SKYBOX_PROMPT_MAX) return { prompt, guarded: false, nouns };
4070
+ return { prompt: guarded, guarded: true, nouns };
3876
4071
  }
3877
4072
  function buildGenOptions(kind, opts) {
3878
4073
  const options = { ...opts.generationOptions };
@@ -3927,6 +4122,23 @@ async function runGenerate(kind, opts) {
3927
4122
  return;
3928
4123
  }
3929
4124
  const skyboxGuard = kind === "skybox" ? applySkyboxGuard(typedPrompt, opts.raw) : void 0;
4125
+ if (skyboxGuard && skyboxGuard.nouns.length > 0) {
4126
+ log.error(
4127
+ `A skybox is sky only \u2014 this prompt names ${skyboxGuard.nouns.join(", ")}.`
4128
+ );
4129
+ log.plain(
4130
+ [
4131
+ " Structures painted into a sky render at infinite distance: they never get closer",
4132
+ " as the player moves, and they read as broken the moment the camera strafes.",
4133
+ ` ${c.cyan("\u2192")} Build them as scene geometry (${c.cyan("npx genex model")}); describe only light, colour,`,
4134
+ " cloud, weather and time of day here.",
4135
+ ` ${c.cyan("\u2192")} Genuinely want it painted into the sky (a space-station panorama for a scene`,
4136
+ ` with no world geometry)? Re-run with ${c.cyan("--raw")}.`
4137
+ ].join("\n")
4138
+ );
4139
+ process.exitCode = 1;
4140
+ return;
4141
+ }
3930
4142
  let prompt = skyboxGuard?.prompt ?? typedPrompt;
3931
4143
  if (kind === "image" && opts.glass) {
3932
4144
  const conflict = opts.transparent ? "--transparent" : opts.removeBackground ? "--remove-bg" : opts.editUrl ? "--edit" : opts.cleanUrl ? "--clean" : opts.upscaleUrl ? "--upscale" : opts.noWait ? "--no-wait" : void 0;
@@ -4660,6 +4872,14 @@ async function printPipelineStatePreflight(log, cwd = process.cwd()) {
4660
4872
  );
4661
4873
  }
4662
4874
  }
4875
+ if (!/player character:/i.test(design)) {
4876
+ const hasCharacter = await fs14.access(path14.join(cwd, "public", "assets", "meshy-character.json")).then(() => true, () => false);
4877
+ if (!hasCharacter && await loadsPlayerBody(cwd)) {
4878
+ warnings.push(
4879
+ `Player character is the stock avatar \u2014 no generated character is wired. The game's own generated character is the player's body wherever a human body appears on screen, first-person included (genex-ai-character). Generate it, or record "Player character: VRM \u2014 <reason>" in DESIGN.md (no human body in this game / out of credits / player declined).`
4880
+ );
4881
+ }
4882
+ }
4663
4883
  for (const w of warnings) log.warn(w);
4664
4884
  } catch {
4665
4885
  }
@@ -4687,6 +4907,21 @@ async function hasHudLikeDom(cwd) {
4687
4907
  }
4688
4908
  return false;
4689
4909
  }
4910
+ async function loadsPlayerBody(cwd) {
4911
+ const BODY_LOADERS = /\bloadPlayerCharacter\s*\(|\bloadVrm(?:Clone)?\s*\(/;
4912
+ try {
4913
+ const entries = await fs14.readdir(path14.join(cwd, "src"), { recursive: true });
4914
+ for (const rel of entries) {
4915
+ if (rel.includes("node_modules")) continue;
4916
+ if (rel.split(path14.sep)[0] === "controllers" || rel.startsWith("controllers/")) continue;
4917
+ if (!/\.(ts|tsx|js|jsx)$/.test(rel)) continue;
4918
+ const text = await fs14.readFile(path14.join(cwd, "src", rel), "utf8").catch(() => "");
4919
+ if (BODY_LOADERS.test(text)) return true;
4920
+ }
4921
+ } catch {
4922
+ }
4923
+ return false;
4924
+ }
4690
4925
  async function hasExtractionSidecars(cwd) {
4691
4926
  try {
4692
4927
  const entries = await fs14.readdir(path14.join(cwd, "public"), { recursive: true });
@@ -4727,6 +4962,17 @@ async function deployGame(ctx, opts, log) {
4727
4962
  log.error(`No index.html in ${rel} \u2014 a game needs one to serve. Nothing deployed.`);
4728
4963
  return false;
4729
4964
  }
4965
+ const lfsPointers = files.filter(
4966
+ (f) => f.bytes.length < 1024 && f.bytes.subarray(0, 64).toString("latin1").startsWith("version https://git-lfs.github.com/spec/v")
4967
+ );
4968
+ if (lfsPointers.length > 0) {
4969
+ log.error(
4970
+ `${lfsPointers.length} file${lfsPointers.length > 1 ? "s" : ""} in ${rel} ${lfsPointers.length > 1 ? "are" : "is"} an unresolved Git LFS pointer, not the real asset \u2014 the game would fail to load at runtime. Run \`git lfs pull\` and rebuild. Nothing deployed.`
4971
+ );
4972
+ for (const p of lfsPointers.slice(0, 8)) log.dim(` ${p.relPath}`);
4973
+ if (lfsPointers.length > 8) log.dim(` \u2026and ${lfsPointers.length - 8} more`);
4974
+ return false;
4975
+ }
4730
4976
  printMobilePreflight(files, log);
4731
4977
  await printUiAuditPreflight(log);
4732
4978
  await printPipelineStatePreflight(log);
@@ -15361,9 +15607,14 @@ var CONTROLLER_FILE_SETS = {
15361
15607
  "character/animation-packs.ts",
15362
15608
  "character/motion-actions.ts",
15363
15609
  "character/meshy/meshy-loader.ts",
15610
+ // The player's body, resolved once: this game's own generated character
15611
+ // when it has one, the visiting player's avatar when it doesn't. ONE
15612
+ // shape for both lanes, so a character that lands at hour 2 is a file
15613
+ // drop and not a rewrite of the boot path written at hour 0.
15614
+ "character/player-character.ts",
15364
15615
  "character/presets.ts",
15365
15616
  // VRM avatar support (three-vrm): load + retarget the UAL clips + auto-fit
15366
- // the capsule + optional foot IK. Owner's avatar replaces the old mannequin.
15617
+ // the capsule + optional foot IK the FALLBACK body's lane.
15367
15618
  "character/vrm/vrm-loader.ts",
15368
15619
  "character/vrm/vrm-retarget.ts",
15369
15620
  "character/vrm/capsule-fit.ts",
@@ -15372,22 +15623,27 @@ var CONTROLLER_FILE_SETS = {
15372
15623
  NOTICE
15373
15624
  ],
15374
15625
  // The FALLBACK VRM is written to public/assets/avatar.vrm at install time by
15375
- // installOwnerAvatar (owner's avatar, or the bundled default) — so it is NOT
15376
- // a static manifest asset; at runtime the game loads the playing user's own
15377
- // avatar (user.avatarUrl from the embed identity, AG-804) and this file
15378
- // covers local dev + load failures. animation-library.glb (the 12-clip core)
15379
- // still is a manifest asset; extra clips arrive via `genex controller anims`
15380
- // into public/assets/anims/.
15626
+ // installFallbackAvatar (the bundled CC0 default) — so it is NOT a static
15627
+ // manifest asset; at runtime the game loads the playing user's own avatar
15628
+ // (user.avatarUrl from the embed identity, AG-804) and this file covers
15629
+ // local dev + load failures. It is only ever the FALLBACK body: the player
15630
+ // is this game's own generated character whenever it has one.
15631
+ // animation-library.glb (the 12-clip core) still is a manifest asset; extra
15632
+ // clips arrive via `genex controller anims` into public/assets/anims/.
15381
15633
  assets: ["assets/animation-library.glb"],
15382
15634
  skill: "genex-threejs-character-controller",
15635
+ // ONE sketch, with or without --character. The lane is a runtime decision
15636
+ // inside loadPlayerCharacter, never a code edit the agent has to remember
15637
+ // to make once the generated character lands.
15383
15638
  sketch: [
15384
15639
  `const physics = await PhysicsWorld.create();`,
15385
- `const { user } = await waitForPlayer(); // @genex-ai/embed-sdk \u2014 the player's own avatar`,
15386
- `const { scene, vrm } = await loadVrm(user.avatarUrl ?? "./assets/avatar.vrm").catch(() => loadVrm("./assets/avatar.vrm"));`,
15387
- `const clips = await loadCharacterClips(vrm); // core library + every genex-controller-anims pack`,
15388
- `const character = new CharacterController(physics.world, camera, { ...characterPresets["default"].options, ...capsuleFromModel(scene), position: { x: 0, y: 2, z: 0 } });`,
15389
- `character.root.add(scene); const anims = new CharacterAnimations(scene, clips);`,
15390
- `addEventListener("pointerdown", () => anims.playOneShot("Punch_Jab")); // per frame: anims.update(character, dt); vrm.update(dt);`
15640
+ `const { user } = await waitForPlayer(); // @genex-ai/embed-sdk`,
15641
+ `const player = await loadPlayerCharacter({ avatarUrl: user.avatarUrl }); // this game's character, else the avatar fallback`,
15642
+ `const fit = capsuleFromModel(player.scene);`,
15643
+ `const character = new CharacterController(physics.world, camera, { ...characterPresets["default"].options, ...fit, position: { x: 0, y: 2, z: 0 } });`,
15644
+ `character.root.add(player.scene); player.scene.position.y = fit.modelOffsetY;`,
15645
+ `const anims = new CharacterAnimations(player.scene, player.clips, { locomotionProfile: player.locomotionProfile });`,
15646
+ `addEventListener("pointerdown", () => anims.playOneShot("Punch_Jab")); // per frame: anims.update(character, dt); player.update(dt);`
15391
15647
  ]
15392
15648
  },
15393
15649
  car: {
@@ -15546,8 +15802,7 @@ async function runController(opts) {
15546
15802
  return;
15547
15803
  }
15548
15804
  } else {
15549
- const token = opts.token !== void 0 ? opts.token : await readUserToken();
15550
- await installOwnerAvatar({ root, srcDir, apiUrl: getApiUrl(opts.apiUrl), token, log });
15805
+ await installFallbackAvatar({ root, srcDir, log });
15551
15806
  }
15552
15807
  log.plain("");
15553
15808
  }
@@ -15566,16 +15821,18 @@ async function runController(opts) {
15566
15821
  log.plain(
15567
15822
  kind === "touch" ? ` ${stepOffset + 2}. Wiring sketch (create behind a touch check; read per frame):` : ` ${stepOffset + 2}. Wiring sketch (controllers update BEFORE the physics step):`
15568
15823
  );
15569
- const sketch = kind === "character" && opts.character ? [
15570
- `const physics = await PhysicsWorld.create();`,
15571
- `const native = await loadMeshyCharacter("./assets/meshy-character.json");`,
15572
- `const fit = capsuleFromModel(native.scene); const character = new CharacterController(physics.world, camera, { ...characterPresets["default"].options, ...fit, position: { x: 0, y: 2, z: 0 } });`,
15573
- `character.root.add(native.scene); const anims = new CharacterAnimations(native.scene, native.clips, { locomotionProfile: native.locomotionProfile });`,
15574
- `physics.onBeforeStep(() => character.update(dt, input)); // Rapier owns movement; then anims.update(character, dt)`
15575
- ] : set.sketch;
15576
- for (const line of sketch) {
15824
+ for (const line of set.sketch) {
15577
15825
  log.dim(` ${line}`);
15578
15826
  }
15827
+ if (kind === "character" && !await exists3(path17.join(root, ASSETS_DEST, "meshy-character.json"))) {
15828
+ log.plain("");
15829
+ log.plain(
15830
+ ` ${stepOffset + 3}. This game has no generated character yet \u2014 ${c.cyan(
15831
+ 'npx genex character "<look>"'
15832
+ )} makes the player's real body;`
15833
+ );
15834
+ log.plain(" the avatar above is only the fallback until it lands. No code change when it does.");
15835
+ }
15579
15836
  }
15580
15837
  async function installMeshyCharacterManifest(args) {
15581
15838
  const response = await apiFetch(
@@ -15607,6 +15864,7 @@ async function installMeshyCharacterManifest(args) {
15607
15864
  `
15608
15865
  );
15609
15866
  args.log.dim(` public/assets/meshy-character.json (${args.characterId}, current revision)`);
15867
+ args.log.success("This game's generated character is now the player's body \u2014 picked up on the next reload, no code change needed.");
15610
15868
  const pack2 = manifest.controllerPack;
15611
15869
  if (typeof pack2?.key === "string" && typeof pack2.version === "number") {
15612
15870
  args.log.success(`Meshy controller pack ${pack2.key} v${pack2.version}`);
@@ -15736,36 +15994,12 @@ function assertCompleteMeshyControllerPack(manifest) {
15736
15994
  incompletePack(`${pack2.key} v${pack2.version} fingerprint does not match its action bindings.`);
15737
15995
  }
15738
15996
  }
15739
- async function installOwnerAvatar(args) {
15740
- const { root, srcDir, apiUrl, token, log } = args;
15997
+ async function installFallbackAvatar(args) {
15998
+ const { root, srcDir, log } = args;
15741
15999
  const dest = path17.join(root, ASSETS_DEST, "avatar.vrm");
15742
16000
  await fs17.mkdir(path17.dirname(dest), { recursive: true });
15743
- if (token) {
15744
- try {
15745
- const res = await apiFetch(`${apiUrl}/api/avatars/me`, {
15746
- headers: { Authorization: `Bearer ${token}` }
15747
- });
15748
- if (res.ok) {
15749
- const me = await res.json();
15750
- if (me.vrmUrl) {
15751
- const vrmRes = await fetch(me.vrmUrl);
15752
- if (vrmRes.ok) {
15753
- const buf = Buffer.from(await vrmRes.arrayBuffer());
15754
- await fs17.writeFile(dest, buf);
15755
- log.dim(` public/assets/avatar.vrm (your avatar \u2014 ${(buf.length / 1e6).toFixed(1)} MB)`);
15756
- return;
15757
- }
15758
- }
15759
- }
15760
- log.dim(" couldn't fetch your avatar; using the bundled default.");
15761
- } catch {
15762
- log.dim(" avatar fetch failed (offline?); using the bundled default.");
15763
- }
15764
- }
15765
16001
  await fs17.copyFile(path17.join(srcDir, "assets", "default-avatar.vrm"), dest);
15766
- log.dim(
15767
- token ? " public/assets/avatar.vrm (bundled default)" : " public/assets/avatar.vrm (bundled default \u2014 sign in and re-run for your own)"
15768
- );
16002
+ log.dim(" public/assets/avatar.vrm (fallback avatar \u2014 bundled CC0 default)");
15769
16003
  }
15770
16004
  async function exists3(p) {
15771
16005
  try {
@@ -16394,6 +16628,15 @@ import path18 from "path";
16394
16628
 
16395
16629
  // src/lib/motion/npz.ts
16396
16630
  import zlib from "zlib";
16631
+ function decodeUcs4(b) {
16632
+ let out = "";
16633
+ for (let i = 0; i + 4 <= b.length; i += 4) {
16634
+ const cp = b.readUInt32LE(i);
16635
+ if (cp === 0) break;
16636
+ out += cp > 1114111 ? "\uFFFD" : String.fromCodePoint(cp);
16637
+ }
16638
+ return out;
16639
+ }
16397
16640
  function readNpy(b) {
16398
16641
  if (b.toString("latin1", 1, 6) !== "NUMPY") throw new Error("not an .npy member");
16399
16642
  const major = b[6];
@@ -16411,7 +16654,7 @@ function readNpy(b) {
16411
16654
  if (descr === "<f8") return { shape, descr, data: new Float64Array(data.buffer, data.byteOffset, data.length / 8) };
16412
16655
  if (descr === "<i8") return { shape, descr, data: new BigInt64Array(data.buffer, data.byteOffset, data.length / 8) };
16413
16656
  if (descr === "|b1") return { shape, descr, data: new Uint8Array(data.buffer, data.byteOffset, data.length) };
16414
- if (descr.startsWith("<U")) return { shape, descr, data: data.toString("utf16le").replace(/\0+$/, "") };
16657
+ if (descr.startsWith("<U")) return { shape, descr, data: decodeUcs4(data) };
16415
16658
  if (descr === "|O") throw new Error(".npz member is a pickled object array \u2014 regenerate the take with a current ARDY");
16416
16659
  throw new Error(`unsupported .npy dtype ${descr}`);
16417
16660
  }
@@ -17986,9 +18229,11 @@ ${c.bold("Usage")}
17986
18229
  without a physics controller. "quality" = the
17987
18230
  adaptive-quality kit (device tier, runtime governor,
17988
18231
  per-tier asset rungs) every game wires at boot.
17989
- Character defaults to the player's VRM + UAL pack.
18232
+ Character's player body is the game's own generated
18233
+ character, with a VRM avatar as the fallback.
17990
18234
  genex controller character --character <id>
17991
- Install the Meshy-native controller variant.
18235
+ Point the game at its generated character (writes the
18236
+ manifest; no code change \u2014 the loader picks it up).
17992
18237
  genex controller anims <sel \u2026> Install legacy VRM + UAL animation clips by tag/name
17993
18238
  into public/assets/anims/. --list shows the catalog;
17994
18239
  --reset forgets the previous selection first.
@@ -18000,7 +18245,9 @@ ${c.bold("Usage")}
18000
18245
  ${c.bold("Options for the generators (`model` `skybox` `sfx` `music` `voice` `texture` `image` `video`)")}
18001
18246
  --terrain (texture) seamless tiling surface for terrain/ground.
18002
18247
  --raw (skybox) send the prompt exactly as written \u2014 skip the
18003
- appended environment-only guard.
18248
+ appended environment-only clause AND the refusal on
18249
+ prompts that name structures/objects (a sky is sky only;
18250
+ landforms and weather always pass).
18004
18251
  --duration <sec> (sfx, video, music) target clip length in seconds
18005
18252
  (music: 10-300, default 90 \u2014 a looping gameplay track).
18006
18253
  --transparent (image) transparent background (alpha) \u2014 for decals/stickers.
@@ -18116,8 +18363,8 @@ ${c.bold("Options for `character` / `animations search`")}
18116
18363
  controller slots, requirements, review status, and estimated cost.
18117
18364
 
18118
18365
  ${c.bold("Options for `controller character` / legacy `controller anims`")}
18119
- --character <id> (character) install the owned Meshy character manifest and
18120
- same-rig native loader instead of the player's VRM.
18366
+ --character <id> (character) install the owned Meshy character manifest \u2014 the
18367
+ game's own generated character becomes the player's body.
18121
18368
  --list [selectors] (anims) show the bundled VRM + UAL tag/name catalog.
18122
18369
  --reset (anims) forget the previous game-local UAL selection.
18123
18370
 
@@ -18303,6 +18550,9 @@ function parseArgs(argv) {
18303
18550
  case "--no-defringe":
18304
18551
  parsed.options.defringe = false;
18305
18552
  break;
18553
+ case "--allow-speckle":
18554
+ parsed.options.allowSpeckle = true;
18555
+ break;
18306
18556
  case "--loop":
18307
18557
  parsed.options.loop = true;
18308
18558
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.88.0-dev.219",
3
+ "version": "0.91.0-dev.228",
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": {
@@ -46,11 +46,13 @@ license (SPDX: `CC0-1.0`). No attribution is required by the license; this
46
46
  notice is provided as a courtesy — supporting Quaternius on Patreon is a nice
47
47
  way to say thanks.
48
48
 
49
- ## Default avatar — CC0 1.0 Universal
49
+ ## Fallback avatar — CC0 1.0 Universal
50
50
 
51
- The character controller plays as your VRM avatar. When you have not chosen one
52
- (or run offline), the bundled default `assets/default-avatar.vrm` is copied to
53
- `public/assets/avatar.vrm`. It is **Wizzir** from the **100 Avatars** project by
51
+ The player's body is the game's own generated character; a VRM avatar is the
52
+ fallback lane. `genex controller character` copies the bundled default
53
+ `assets/default-avatar.vrm` to `public/assets/avatar.vrm`, which covers local
54
+ dev and load failures when that lane runs. It is **Wizzir** from the
55
+ **100 Avatars** project by
54
56
  **Polygonal Mind** (polygonalmind.com), dedicated to the public domain under
55
57
  CC0 1.0 Universal (SPDX: `CC0-1.0`). Attribution is a courtesy, not required.
56
58