@linxin666/dsh-pet 0.3.2 → 0.3.4

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.
Files changed (127) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +25 -7
  3. package/README.zh.md +25 -7
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/lib/client.js +830 -26
  17. package/lib/client.js.map +1 -1
  18. package/lib/index.js +1226 -24
  19. package/lib/types/client/PetDockEntry.d.ts +3 -0
  20. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  21. package/lib/types/client/PetDockEntry.js +18 -2
  22. package/lib/types/client/PetSprite.d.ts +23 -0
  23. package/lib/types/client/PetSprite.d.ts.map +1 -1
  24. package/lib/types/client/PetSprite.js +34 -29
  25. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  26. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PluginSettingsCard.js +2 -2
  28. package/lib/types/client/drag-stream.d.ts +19 -0
  29. package/lib/types/client/drag-stream.d.ts.map +1 -0
  30. package/lib/types/client/drag-stream.js +26 -0
  31. package/lib/types/client/gameplay-hud.d.ts +50 -0
  32. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  33. package/lib/types/client/gameplay-hud.js +274 -0
  34. package/lib/types/client/index.d.ts +1 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/lib/types/client/index.js +16 -0
  37. package/lib/types/client/locales.d.ts +34 -0
  38. package/lib/types/client/locales.d.ts.map +1 -1
  39. package/lib/types/client/locales.js +36 -0
  40. package/lib/types/client/pet-store.d.ts +7 -1
  41. package/lib/types/client/pet-store.d.ts.map +1 -1
  42. package/lib/types/client/pet-store.js +4 -0
  43. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  44. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  45. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  46. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  47. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  48. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  49. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  50. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  51. package/lib/types/client/renderers/frames2d.js +196 -0
  52. package/lib/types/client/settings-form.d.ts +1 -1
  53. package/lib/types/client/settings-form.js +1 -1
  54. package/lib/types/client/telemetry.d.ts +27 -0
  55. package/lib/types/client/telemetry.d.ts.map +1 -0
  56. package/lib/types/client/telemetry.js +97 -0
  57. package/lib/types/defaults.d.ts +6 -0
  58. package/lib/types/defaults.d.ts.map +1 -0
  59. package/lib/types/defaults.js +5 -0
  60. package/lib/types/dsh-home.d.ts.map +1 -1
  61. package/lib/types/dsh-home.js +9 -3
  62. package/lib/types/gameplay.d.ts +182 -0
  63. package/lib/types/gameplay.d.ts.map +1 -0
  64. package/lib/types/gameplay.js +618 -0
  65. package/lib/types/http.d.ts +1 -1
  66. package/lib/types/http.js +1 -1
  67. package/lib/types/index.d.ts +1 -1
  68. package/lib/types/index.js +1 -1
  69. package/lib/types/ledger.d.ts +14 -0
  70. package/lib/types/ledger.d.ts.map +1 -1
  71. package/lib/types/ledger.js +30 -0
  72. package/lib/types/loopback.d.ts +1 -1
  73. package/lib/types/loopback.js +1 -1
  74. package/lib/types/manifest-v2.d.ts +38 -1
  75. package/lib/types/manifest-v2.d.ts.map +1 -1
  76. package/lib/types/manifest-v2.js +182 -2
  77. package/lib/types/mount-once.d.ts +1 -1
  78. package/lib/types/mount-once.js +2 -2
  79. package/lib/types/persist.d.ts +4 -4
  80. package/lib/types/persist.d.ts.map +1 -1
  81. package/lib/types/persist.js +41 -4
  82. package/lib/types/registry.d.ts +29 -0
  83. package/lib/types/registry.d.ts.map +1 -1
  84. package/lib/types/registry.js +170 -1
  85. package/lib/types/routes.d.ts.map +1 -1
  86. package/lib/types/routes.js +22 -0
  87. package/lib/types/service.d.ts +56 -0
  88. package/lib/types/service.d.ts.map +1 -1
  89. package/lib/types/service.js +181 -0
  90. package/package.json +6 -3
  91. package/src/client/PetDockEntry.test.tsx +6 -0
  92. package/src/client/PetDockEntry.tsx +32 -1
  93. package/src/client/PetSprite.test.tsx +36 -6
  94. package/src/client/PetSprite.tsx +42 -21
  95. package/src/client/PluginSettingsCard.tsx +4 -2
  96. package/src/client/drag-stream.ts +35 -0
  97. package/src/client/gameplay-hud.test.tsx +338 -0
  98. package/src/client/gameplay-hud.tsx +403 -0
  99. package/src/client/index.ts +23 -2
  100. package/src/client/locales.ts +36 -0
  101. package/src/client/pet-store.ts +10 -1
  102. package/src/client/pet.module.css +192 -29
  103. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  104. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  105. package/src/client/renderers/frames2d.test.ts +137 -0
  106. package/src/client/renderers/frames2d.ts +216 -0
  107. package/src/client/settings-card.module.css +5 -0
  108. package/src/client/settings-form.ts +1 -1
  109. package/src/client/telemetry.ts +105 -0
  110. package/src/defaults.ts +7 -0
  111. package/src/dsh-home.ts +9 -3
  112. package/src/gameplay.test.ts +239 -0
  113. package/src/gameplay.ts +692 -0
  114. package/src/http.ts +1 -1
  115. package/src/index.ts +1 -1
  116. package/src/ledger.test.ts +16 -0
  117. package/src/ledger.ts +31 -0
  118. package/src/loopback.ts +1 -1
  119. package/src/manifest-v2.test.ts +130 -1
  120. package/src/manifest-v2.ts +181 -2
  121. package/src/mount-once.ts +2 -2
  122. package/src/persist.test.ts +3 -0
  123. package/src/persist.ts +43 -6
  124. package/src/registry.test.ts +168 -1
  125. package/src/registry.ts +199 -1
  126. package/src/routes.ts +19 -0
  127. package/src/service.ts +208 -0
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { Service } from "@deepseek-ai/cordis";
5
5
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
7
7
  import { homedir } from "node:os";
8
+ import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { readFile } from "node:fs/promises";
10
11
  //#region src/chatter.ts
@@ -1202,6 +1203,17 @@ var PetLedger = class {
1202
1203
  };
1203
1204
  this.dirty = true;
1204
1205
  }
1206
+ /** Replace one pet's gameplay state (validation/clamping stays a caller concern). */
1207
+ setGameplay(petId, gameplay) {
1208
+ this.current = {
1209
+ ...this.current,
1210
+ gameplay: {
1211
+ ...this.current.gameplay,
1212
+ [petId]: gameplay
1213
+ }
1214
+ };
1215
+ this.dirty = true;
1216
+ }
1205
1217
  /** Replace one pet's display name (validation stays a caller concern). */
1206
1218
  setPetName(petId, name) {
1207
1219
  this.current = {
@@ -1237,6 +1249,40 @@ var PetLedger = class {
1237
1249
  return true;
1238
1250
  }
1239
1251
  /**
1252
+ * Grant gameplay treats into the shared stock (capped by the treat cap).
1253
+ * This is the unified gameplay currency (wallet removed): work rewards,
1254
+ * passive income and lottery prizes land here so one balance feeds the
1255
+ * shop and the feeding economy. Returns true when the snapshot changed.
1256
+ */
1257
+ grantTreats(amount) {
1258
+ if (amount <= 0) return false;
1259
+ const capped = Math.min(this.treatConfig.maxTreats, this.current.treats.treats + amount);
1260
+ if (capped === this.current.treats.treats) return false;
1261
+ this.current = {
1262
+ ...this.current,
1263
+ treats: {
1264
+ ...this.current.treats,
1265
+ treats: capped
1266
+ }
1267
+ };
1268
+ this.dirty = true;
1269
+ return true;
1270
+ }
1271
+ /** Spend gameplay treats from the shared stock; refuses when unaffordable. */
1272
+ spendTreats(amount) {
1273
+ const stock = this.current.treats.treats;
1274
+ if (amount <= 0 || stock < amount) return { ok: false };
1275
+ this.current = {
1276
+ ...this.current,
1277
+ treats: {
1278
+ ...this.current.treats,
1279
+ treats: stock - amount
1280
+ }
1281
+ };
1282
+ this.dirty = true;
1283
+ return { ok: true };
1284
+ }
1285
+ /**
1240
1286
  * Award the completed-turn reward once per session+turn (idempotent) and
1241
1287
  * run the treat settlement that work output feeds. Returns true when the
1242
1288
  * snapshot changed.
@@ -1324,8 +1370,9 @@ var PetLedger = class {
1324
1370
  */
1325
1371
  /** Expand a leading ~ (or ~user) in a path, platform-style. */
1326
1372
  function expandHome(path, home = homedir()) {
1373
+ const j = home.startsWith("/") ? join$1 : join;
1327
1374
  if (path === "~") return home;
1328
- if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
1375
+ if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
1329
1376
  return path;
1330
1377
  }
1331
1378
  /**
@@ -1335,18 +1382,28 @@ function expandHome(path, home = homedir()) {
1335
1382
  * @returns the absolute DSH home path.
1336
1383
  */
1337
1384
  function resolveDshHome(env = process.env, home = homedir()) {
1385
+ const isPosix = home.startsWith("/");
1386
+ const j = isPosix ? join$1 : join;
1387
+ const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
1338
1388
  const raw = env.DSH_HOME;
1339
1389
  if (raw !== void 0 && raw.trim() !== "") {
1340
1390
  const expanded = expandHome(raw.trim(), home);
1341
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
1391
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
1342
1392
  }
1343
- return join(home, ".dsh");
1393
+ return j(home, ".dsh");
1344
1394
  }
1345
1395
  /** Resolve the DSH home directory from the live environment. */
1346
1396
  function dshHome() {
1347
1397
  return resolveDshHome();
1348
1398
  }
1349
1399
  //#endregion
1400
+ //#region src/defaults.ts
1401
+ /** Shared defaults used by registry selection and persisted-state migration. */
1402
+ /** Pet id that legacy single-pet installs and fresh registries resolve to. */
1403
+ const DEFAULT_PET_ID = "whale-girl";
1404
+ /** Default pet name used only when a manifest carries no displayName. */
1405
+ const DEFAULT_PET_NAME = "鲸鱼娘";
1406
+ //#endregion
1350
1407
  //#region src/persist.ts
1351
1408
  /**
1352
1409
  * Pet persistence — tiny JSON store for affinity + display config, written
@@ -1361,10 +1418,6 @@ const defaultDisplayConfig = {
1361
1418
  bottom: 120
1362
1419
  };
1363
1420
  const DISPLAY_INSET_MAX = 1e4;
1364
- /** Pet id the legacy single-pet installs resolve to on migration. */
1365
- const DEFAULT_PET_ID = "whale-girl";
1366
- /** Default pet name (used only when a manifest carries no displayName). */
1367
- const DEFAULT_PET_NAME = "鲸鱼娘";
1368
1421
  /** Name constraints. */
1369
1422
  const PET_NAME_MAX_LENGTH = 20;
1370
1423
  function emptyPersist() {
@@ -1373,7 +1426,8 @@ function emptyPersist() {
1373
1426
  names: {},
1374
1427
  affinity: emptyAffinity(),
1375
1428
  treats: emptyTreatLedger(),
1376
- display: { ...defaultDisplayConfig }
1429
+ display: { ...defaultDisplayConfig },
1430
+ gameplay: {}
1377
1431
  };
1378
1432
  }
1379
1433
  /**
@@ -1404,6 +1458,35 @@ function loadPetNames(parsed) {
1404
1458
  function clamp(value, max) {
1405
1459
  return Math.min(max, Math.max(0, value));
1406
1460
  }
1461
+ /** Absolute numeric ceilings applied at load (manifest clamps refine these). */
1462
+ const GAMEPLAY_LOAD_STAT_CAP = 1e6;
1463
+ const GAMEPLAY_LOAD_CURRENCY_CAP = 9999999;
1464
+ /** Sanitize the persisted per-pet gameplay map. */
1465
+ function loadGameplay(parsed) {
1466
+ const result = {};
1467
+ if (typeof parsed.gameplay !== "object" || parsed.gameplay === null) return result;
1468
+ for (const [petId, raw] of Object.entries(parsed.gameplay)) {
1469
+ if (petId === "" || typeof raw !== "object" || raw === null) continue;
1470
+ const record = raw;
1471
+ const stats = {};
1472
+ if (typeof record.stats === "object" && record.stats !== null) for (const [key, value] of Object.entries(record.stats)) {
1473
+ if (key === "" || typeof value !== "number" || !Number.isFinite(value)) continue;
1474
+ stats[key] = Math.min(GAMEPLAY_LOAD_STAT_CAP, Math.max(0, value));
1475
+ }
1476
+ const currencies = {};
1477
+ if (typeof record.currencies === "object" && record.currencies !== null) for (const [key, value] of Object.entries(record.currencies)) {
1478
+ if (key === "" || typeof value !== "number" || !Number.isFinite(value)) continue;
1479
+ currencies[key] = Math.min(GAMEPLAY_LOAD_CURRENCY_CAP, Math.max(0, Math.floor(value)));
1480
+ }
1481
+ result[petId] = {
1482
+ stats,
1483
+ currencies,
1484
+ mode: record.mode === "work" || record.mode === "sleep" ? record.mode : null,
1485
+ settledAt: clamp(finiteNum(record.settledAt, 0), Number.MAX_SAFE_INTEGER)
1486
+ };
1487
+ }
1488
+ return result;
1489
+ }
1407
1490
  /** Load persisted state; missing or corrupt files fall back to defaults. */
1408
1491
  function loadPetPersist(dir = petHomeDir()) {
1409
1492
  try {
@@ -1442,7 +1525,8 @@ function loadPetPersist(dir = petHomeDir()) {
1442
1525
  names,
1443
1526
  affinity,
1444
1527
  treats,
1445
- display
1528
+ display,
1529
+ gameplay: loadGameplay(parsed)
1446
1530
  };
1447
1531
  } catch {
1448
1532
  return emptyPersist();
@@ -1487,7 +1571,7 @@ const PLACEHOLDER_WHITELIST = {
1487
1571
  "{points}"
1488
1572
  ]
1489
1573
  };
1490
- function isRecord$2(value) {
1574
+ function isRecord$3(value) {
1491
1575
  return typeof value === "object" && value !== null && !Array.isArray(value);
1492
1576
  }
1493
1577
  /** Trim, length-cap and placeholder-check one copy line; undefined to drop. */
@@ -1548,7 +1632,7 @@ function normalizeWhisperRules(raw, onWarning = () => {}) {
1548
1632
  if (raw.length > 32) onWarning("whispers.rules has more than 32 rules; extra rules are ignored");
1549
1633
  const rules = [];
1550
1634
  for (const item of raw.slice(0, 32)) {
1551
- if (!isRecord$2(item)) {
1635
+ if (!isRecord$3(item)) {
1552
1636
  onWarning("whisper rule must be an object");
1553
1637
  continue;
1554
1638
  }
@@ -1580,13 +1664,13 @@ function normalizeWhisperRules(raw, onWarning = () => {}) {
1580
1664
  }
1581
1665
  /** Normalize the panel block (labels / stats / actions; warn-and-drop). */
1582
1666
  function normalizePanel(raw, onWarning = () => {}) {
1583
- if (!isRecord$2(raw)) {
1667
+ if (!isRecord$3(raw)) {
1584
1668
  onWarning("panel must be an object");
1585
1669
  return;
1586
1670
  }
1587
1671
  const panel = {};
1588
1672
  const labelsRaw = raw.labels;
1589
- if (labelsRaw !== void 0) if (!isRecord$2(labelsRaw)) onWarning("panel.labels must be an object");
1673
+ if (labelsRaw !== void 0) if (!isRecord$3(labelsRaw)) onWarning("panel.labels must be an object");
1590
1674
  else {
1591
1675
  const labels = {};
1592
1676
  for (const key of PANEL_LABEL_KEYS) {
@@ -1602,7 +1686,7 @@ function normalizePanel(raw, onWarning = () => {}) {
1602
1686
  if (Object.keys(labels).length > 0) panel.labels = labels;
1603
1687
  }
1604
1688
  const statsRaw = raw.stats;
1605
- if (statsRaw !== void 0) if (!isRecord$2(statsRaw)) onWarning("panel.stats must be an object");
1689
+ if (statsRaw !== void 0) if (!isRecord$3(statsRaw)) onWarning("panel.stats must be an object");
1606
1690
  else {
1607
1691
  const stats = {};
1608
1692
  for (const key of PANEL_STAT_KEYS) {
@@ -1652,7 +1736,7 @@ const WHISPER_KEYS = /* @__PURE__ */ new Set(["generic", "rules"]);
1652
1736
  */
1653
1737
  function normalizeVoicePack(raw, onWarning = () => {}) {
1654
1738
  if (raw === void 0) return void 0;
1655
- if (!isRecord$2(raw)) {
1739
+ if (!isRecord$3(raw)) {
1656
1740
  onWarning("voice.json must be a JSON object; the file is ignored");
1657
1741
  return;
1658
1742
  }
@@ -1661,7 +1745,7 @@ function normalizeVoicePack(raw, onWarning = () => {}) {
1661
1745
  if (version !== void 0 && (typeof version !== "number" || version !== 1)) onWarning("voicePackVersion " + String(version) + " is not supported; reading as v1 best-effort");
1662
1746
  const overrides = {};
1663
1747
  const statusRaw = raw.status;
1664
- if (statusRaw !== void 0) if (!isRecord$2(statusRaw)) onWarning("status must be an object");
1748
+ if (statusRaw !== void 0) if (!isRecord$3(statusRaw)) onWarning("status must be an object");
1665
1749
  else for (const key of Object.keys(statusRaw)) {
1666
1750
  if (!STATUS_SCENES.includes(key)) {
1667
1751
  onWarning("unknown status scene " + key + " ignored");
@@ -1674,7 +1758,7 @@ function normalizeVoicePack(raw, onWarning = () => {}) {
1674
1758
  };
1675
1759
  }
1676
1760
  const toolsRaw = raw.tools;
1677
- if (toolsRaw !== void 0) if (!isRecord$2(toolsRaw)) onWarning("tools must be an object");
1761
+ if (toolsRaw !== void 0) if (!isRecord$3(toolsRaw)) onWarning("tools must be an object");
1678
1762
  else for (const key of Object.keys(toolsRaw)) {
1679
1763
  if (!TOOL_CATEGORIES.includes(key)) {
1680
1764
  onWarning("unknown tool family " + key + " ignored");
@@ -1692,7 +1776,7 @@ function normalizeVoicePack(raw, onWarning = () => {}) {
1692
1776
  if (pool !== void 0 && pool.length > 0) overrides.toolRemaining = pool;
1693
1777
  }
1694
1778
  const whispersRaw = raw.whispers;
1695
- if (whispersRaw !== void 0) if (!isRecord$2(whispersRaw)) onWarning("whispers must be an object");
1779
+ if (whispersRaw !== void 0) if (!isRecord$3(whispersRaw)) onWarning("whispers must be an object");
1696
1780
  else {
1697
1781
  for (const key of Object.keys(whispersRaw)) if (!WHISPER_KEYS.has(key)) onWarning("unknown whispers field " + key + " ignored");
1698
1782
  const generic = normalizePool(whispersRaw.generic, "whisperGeneric", onWarning);
@@ -1757,8 +1841,585 @@ function mergeVoicePacks(...layers) {
1757
1841
  ...panelSeen && !panelEmpty ? { panel } : {}
1758
1842
  };
1759
1843
  }
1844
+ //#endregion
1845
+ //#region src/gameplay.ts
1846
+ const KEBAB = /^[a-z0-9][a-z0-9-]*$/;
1847
+ const MAX_STATS = 16;
1848
+ const MAX_ZONES = 8;
1849
+ const MAX_BRANCHES = 8;
1850
+ const MAX_ACTS = 16;
1851
+ const MAX_SHOP_ITEMS = 32;
1852
+ const MAX_LOTTERY_TIERS = 16;
1853
+ const MAX_PHRASES = 64;
1854
+ const PHRASE_MAX_LENGTH = 120;
1855
+ const STAT_VALUE_MAX = 1e6;
1856
+ const CURRENCY_MAX = 9999999;
1857
+ const KNOWN_GAMEPLAY = /* @__PURE__ */ new Set([
1858
+ "idleDirector",
1859
+ "stats",
1860
+ "hitBox",
1861
+ "touch",
1862
+ "work",
1863
+ "sleep",
1864
+ "passiveIncome",
1865
+ "shop",
1866
+ "dragState",
1867
+ "dragEndState"
1868
+ ]);
1869
+ const KNOWN_STAT = /* @__PURE__ */ new Set([
1870
+ "max",
1871
+ "initial",
1872
+ "decayPerMinute",
1873
+ "workingDecayPerMinute",
1874
+ "idleDecayPerMinute"
1875
+ ]);
1876
+ const KNOWN_ZONE = /* @__PURE__ */ new Set([
1877
+ "name",
1878
+ "y0",
1879
+ "y1",
1880
+ "branches"
1881
+ ]);
1882
+ const KNOWN_TOUCH = /* @__PURE__ */ new Set(["zones", "clickBoost"]);
1883
+ const KNOWN_BRANCH = /* @__PURE__ */ new Set([
1884
+ "probability",
1885
+ "effects",
1886
+ "state",
1887
+ "stateMs",
1888
+ "phrases"
1889
+ ]);
1890
+ const KNOWN_EFFECT = /* @__PURE__ */ new Set([
1891
+ "stat",
1892
+ "currency",
1893
+ "amount"
1894
+ ]);
1895
+ const KNOWN_WORK = /* @__PURE__ */ new Set([
1896
+ "state",
1897
+ "successState",
1898
+ "failState",
1899
+ "tickMs",
1900
+ "resultMs",
1901
+ "successProbability",
1902
+ "success",
1903
+ "fail"
1904
+ ]);
1905
+ const KNOWN_SLEEP = /* @__PURE__ */ new Set([
1906
+ "state",
1907
+ "wakeState",
1908
+ "restore"
1909
+ ]);
1910
+ const KNOWN_SHOP_ITEM = /* @__PURE__ */ new Set([
1911
+ "id",
1912
+ "label",
1913
+ "image",
1914
+ "price",
1915
+ "currency",
1916
+ "effects",
1917
+ "lottery"
1918
+ ]);
1919
+ const KNOWN_LOTTERY = /* @__PURE__ */ new Set([
1920
+ "effects",
1921
+ "currency",
1922
+ "tiers"
1923
+ ]);
1924
+ const KNOWN_IDLE_DIRECTOR = /* @__PURE__ */ new Set([
1925
+ "intervalMs",
1926
+ "maxMiss",
1927
+ "idleWeight",
1928
+ "acts"
1929
+ ]);
1930
+ const KNOWN_ACT = /* @__PURE__ */ new Set([
1931
+ "track",
1932
+ "weight",
1933
+ "phrases"
1934
+ ]);
1935
+ function isRecord$2(value) {
1936
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1937
+ }
1938
+ function unknownKeys$2(source, known) {
1939
+ return Object.keys(source).filter((key) => !known.has(key));
1940
+ }
1941
+ function validName(name, max = 32) {
1942
+ return typeof name === "string" && name.length <= max && KEBAB.test(name);
1943
+ }
1944
+ function intIn(value, min, max) {
1945
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
1946
+ }
1947
+ function numIn(value, min, max) {
1948
+ return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
1949
+ }
1950
+ function parseEffects(raw, field, stats, hooks) {
1951
+ if (raw === void 0) return void 0;
1952
+ if (!Array.isArray(raw) || raw.length === 0) {
1953
+ hooks.error(field + " must be a non-empty array of effects");
1954
+ return;
1955
+ }
1956
+ const effects = [];
1957
+ for (const entry of raw) {
1958
+ if (!isRecord$2(entry)) {
1959
+ hooks.error(field + ": every effect must be an object");
1960
+ continue;
1961
+ }
1962
+ const extra = unknownKeys$2(entry, KNOWN_EFFECT);
1963
+ if (extra.length > 0) hooks.error(field + ": unknown effect field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1964
+ const hasStat = typeof entry.stat === "string";
1965
+ const hasCurrency = typeof entry.currency === "string";
1966
+ if (hasStat === hasCurrency) {
1967
+ hooks.error(field + ": an effect needs exactly one of stat or currency");
1968
+ continue;
1969
+ }
1970
+ if (!intIn(entry.amount, -1e6, STAT_VALUE_MAX) || entry.amount === 0) {
1971
+ hooks.error(field + ": effect amount must be a non-zero integer within ±1000000");
1972
+ continue;
1973
+ }
1974
+ if (hasStat && stats[entry.stat] === void 0) {
1975
+ hooks.error(field + ": effect references undeclared stat " + JSON.stringify(entry.stat));
1976
+ continue;
1977
+ }
1978
+ if (hasCurrency && !validName(entry.currency, 24)) {
1979
+ hooks.error(field + ": effect currency must be a kebab id");
1980
+ continue;
1981
+ }
1982
+ effects.push({
1983
+ ...hasStat ? { stat: entry.stat } : {},
1984
+ ...hasCurrency ? { currency: entry.currency } : {},
1985
+ amount: entry.amount
1986
+ });
1987
+ }
1988
+ return effects.length === 0 ? void 0 : effects;
1989
+ }
1990
+ function parsePhrases(raw, field, hooks) {
1991
+ if (raw === void 0) return void 0;
1992
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_PHRASES || raw.some((line) => typeof line !== "string" || line.trim() === "" || line.length > PHRASE_MAX_LENGTH)) {
1993
+ hooks.error(field + " must be 1..64 non-empty lines of at most 120 chars");
1994
+ return;
1995
+ }
1996
+ return raw;
1997
+ }
1998
+ function parseStateRef(raw, field, hooks) {
1999
+ if (raw === void 0) return void 0;
2000
+ if (typeof raw !== "string" || !hooks.stateNames.has(raw)) {
2001
+ hooks.error(field + " must name a declared frames2d track");
2002
+ return;
2003
+ }
2004
+ return raw;
2005
+ }
2006
+ /**
2007
+ * Validate the manifest 'gameplay' block (fail-closed). Only frames2d pets
2008
+ * may declare gameplay today: every state reference checks against the
2009
+ * declared track names.
2010
+ */
2011
+ function parseGameplayManifest(raw, hooks) {
2012
+ const error = (message) => hooks.error(message);
2013
+ if (!isRecord$2(raw)) {
2014
+ error("gameplay must be an object");
2015
+ return;
2016
+ }
2017
+ const extra = unknownKeys$2(raw, KNOWN_GAMEPLAY);
2018
+ if (extra.length > 0) error("gameplay: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
2019
+ let failed = false;
2020
+ const fail = (message) => {
2021
+ failed = true;
2022
+ error(message);
2023
+ };
2024
+ const stats = {};
2025
+ if (raw.stats !== void 0) if (!isRecord$2(raw.stats)) fail("gameplay.stats must be an object keyed by stat id");
2026
+ else {
2027
+ const entries = Object.entries(raw.stats);
2028
+ if (entries.length > MAX_STATS) fail("gameplay.stats declares too many stats (max 16)");
2029
+ for (const [name, value] of entries) {
2030
+ if (!validName(name, 24)) {
2031
+ fail("gameplay.stats: invalid stat id " + JSON.stringify(name));
2032
+ continue;
2033
+ }
2034
+ if (!isRecord$2(value)) {
2035
+ fail("gameplay.stats." + name + " must be an object");
2036
+ continue;
2037
+ }
2038
+ const statExtra = unknownKeys$2(value, KNOWN_STAT);
2039
+ if (statExtra.length > 0) fail("gameplay.stats." + name + ": unknown field(s) " + statExtra.map((k) => JSON.stringify(k)).join(", "));
2040
+ if (!intIn(value.max, 1, STAT_VALUE_MAX)) {
2041
+ fail("gameplay.stats." + name + ".max must be an integer in [1, 1000000]");
2042
+ continue;
2043
+ }
2044
+ const def = { max: value.max };
2045
+ if (value.initial !== void 0) if (!numIn(value.initial, 0, value.max)) fail("gameplay.stats." + name + ".initial must be within [0, max]");
2046
+ else def.initial = value.initial;
2047
+ for (const key of [
2048
+ "decayPerMinute",
2049
+ "workingDecayPerMinute",
2050
+ "idleDecayPerMinute"
2051
+ ]) if (value[key] !== void 0) if (!numIn(value[key], 0, 1e3)) fail("gameplay.stats." + name + "." + key + " must be a number in [0, 1000]");
2052
+ else def[key] = value[key];
2053
+ stats[name] = def;
2054
+ }
2055
+ }
2056
+ const block = {};
2057
+ if (Object.keys(stats).length > 0) block.stats = stats;
2058
+ if (raw.idleDirector !== void 0) if (!isRecord$2(raw.idleDirector) || !Array.isArray(raw.idleDirector.acts)) fail("gameplay.idleDirector must be an object with an acts array");
2059
+ else {
2060
+ const d = raw.idleDirector;
2061
+ const dExtra = unknownKeys$2(d, KNOWN_IDLE_DIRECTOR);
2062
+ if (dExtra.length > 0) fail("gameplay.idleDirector: unknown field(s) " + dExtra.map((k) => JSON.stringify(k)).join(", "));
2063
+ if (d.intervalMs !== void 0 && !intIn(d.intervalMs, 1e3, 6e4)) fail("gameplay.idleDirector.intervalMs must be an integer in [1000, 60000]");
2064
+ if (d.maxMiss !== void 0 && !intIn(d.maxMiss, 0, 10)) fail("gameplay.idleDirector.maxMiss must be an integer in [0, 10]");
2065
+ if (d.idleWeight !== void 0 && !intIn(d.idleWeight, 0, 1e4)) fail("gameplay.idleDirector.idleWeight must be an integer in [0, 10000]");
2066
+ if (d.acts.length === 0 || d.acts.length > MAX_ACTS) fail("gameplay.idleDirector.acts must declare 1..16 acts");
2067
+ const acts = [];
2068
+ for (const act of d.acts) {
2069
+ if (!isRecord$2(act) || !intIn(act.weight, 1, 1e4)) {
2070
+ fail("gameplay.idleDirector.acts entries need a weight integer in [1, 10000]");
2071
+ continue;
2072
+ }
2073
+ const aExtra = unknownKeys$2(act, KNOWN_ACT);
2074
+ if (aExtra.length > 0) fail("gameplay.idleDirector.acts: unknown field(s) " + aExtra.map((k) => JSON.stringify(k)).join(", "));
2075
+ const track = parseStateRef(act.track, "gameplay.idleDirector.acts.track", hooks);
2076
+ if (track === void 0) continue;
2077
+ const entry = {
2078
+ track,
2079
+ weight: act.weight
2080
+ };
2081
+ const phrases = parsePhrases(act.phrases, "gameplay.idleDirector.acts.phrases", hooks);
2082
+ if (phrases !== void 0) entry.phrases = phrases;
2083
+ acts.push(entry);
2084
+ }
2085
+ if (acts.length > 0) block.idleDirector = {
2086
+ intervalMs: intIn(d.intervalMs, 1e3, 6e4) ? d.intervalMs : 5e3,
2087
+ maxMiss: intIn(d.maxMiss, 0, 10) ? d.maxMiss : 2,
2088
+ idleWeight: intIn(d.idleWeight, 0, 1e4) ? d.idleWeight : 0,
2089
+ acts
2090
+ };
2091
+ }
2092
+ if (raw.hitBox !== void 0) {
2093
+ const b = raw.hitBox;
2094
+ if (!isRecord$2(b) || !numIn(b.x0, 0, 1) || !numIn(b.x1, 0, 1) || !numIn(b.y0, 0, 1) || !numIn(b.y1, 0, 1) || !(b.x0 < b.x1) || !(b.y0 < b.y1)) fail("gameplay.hitBox must be { x0, y0, x1, y1 } fractions with x0 < x1 and y0 < y1");
2095
+ else block.hitBox = {
2096
+ x0: b.x0,
2097
+ y0: b.y0,
2098
+ x1: b.x1,
2099
+ y1: b.y1
2100
+ };
2101
+ }
2102
+ if (raw.touch !== void 0) if (!isRecord$2(raw.touch) || !Array.isArray(raw.touch.zones)) fail("gameplay.touch must be an object with a zones array");
2103
+ else {
2104
+ const tExtra = unknownKeys$2(raw.touch, KNOWN_TOUCH);
2105
+ if (tExtra.length > 0) fail("gameplay.touch: unknown field(s) " + tExtra.map((k) => JSON.stringify(k)).join(", "));
2106
+ let clickBoost;
2107
+ if (raw.touch.clickBoost !== void 0) {
2108
+ const cb = raw.touch.clickBoost;
2109
+ if (!isRecord$2(cb) || typeof cb.stat !== "string" || stats[cb.stat] === void 0 || !intIn(cb.min, 0, 1e3) || !intIn(cb.max, 0, 1e3) || cb.min > cb.max) fail("gameplay.touch.clickBoost must be { stat (declared), min, max } integers with 0 <= min <= max <= 1000");
2110
+ else clickBoost = {
2111
+ stat: cb.stat,
2112
+ min: cb.min,
2113
+ max: cb.max
2114
+ };
2115
+ }
2116
+ const zones = [];
2117
+ if (raw.touch.zones.length === 0 || raw.touch.zones.length > MAX_ZONES) fail("gameplay.touch.zones must declare 1..8 zones");
2118
+ for (const zoneRaw of raw.touch.zones) {
2119
+ if (!isRecord$2(zoneRaw) || !validName(zoneRaw.name) || !numIn(zoneRaw.y0, 0, 1) || !numIn(zoneRaw.y1, 0, 1) || !(zoneRaw.y0 < zoneRaw.y1)) {
2120
+ fail("gameplay.touch.zones entries need a kebab name and 0 <= y0 < y1 <= 1");
2121
+ continue;
2122
+ }
2123
+ const zExtra = unknownKeys$2(zoneRaw, KNOWN_ZONE);
2124
+ if (zExtra.length > 0) fail("gameplay.touch." + zoneRaw.name + ": unknown field(s) " + zExtra.map((k) => JSON.stringify(k)).join(", "));
2125
+ if (!Array.isArray(zoneRaw.branches) || zoneRaw.branches.length === 0 || zoneRaw.branches.length > MAX_BRANCHES) {
2126
+ fail("gameplay.touch." + zoneRaw.name + ".branches must declare 1..8 branches");
2127
+ continue;
2128
+ }
2129
+ let probabilitySum = 0;
2130
+ const branches = [];
2131
+ for (const branchRaw of zoneRaw.branches) {
2132
+ if (!isRecord$2(branchRaw) || !numIn(branchRaw.probability, 0, 1) || branchRaw.probability === 0) {
2133
+ fail("gameplay.touch." + zoneRaw.name + ".branches entries need a probability in (0, 1]");
2134
+ continue;
2135
+ }
2136
+ const bExtra = unknownKeys$2(branchRaw, KNOWN_BRANCH);
2137
+ if (bExtra.length > 0) fail("gameplay.touch." + zoneRaw.name + ": unknown branch field(s) " + bExtra.map((k) => JSON.stringify(k)).join(", "));
2138
+ probabilitySum += branchRaw.probability;
2139
+ const branch = { probability: branchRaw.probability };
2140
+ const effects = parseEffects(branchRaw.effects, "gameplay.touch." + zoneRaw.name + ".effects", stats, hooks);
2141
+ if (effects !== void 0) branch.effects = effects;
2142
+ const state = parseStateRef(branchRaw.state, "gameplay.touch." + zoneRaw.name + ".state", hooks);
2143
+ if (state !== void 0) branch.state = state;
2144
+ if (branchRaw.stateMs !== void 0) if (!intIn(branchRaw.stateMs, 200, 1e4)) fail("gameplay.touch." + zoneRaw.name + ".stateMs must be an integer in [200, 10000]");
2145
+ else branch.stateMs = branchRaw.stateMs;
2146
+ const phrases = parsePhrases(branchRaw.phrases, "gameplay.touch." + zoneRaw.name + ".phrases", hooks);
2147
+ if (phrases !== void 0) branch.phrases = phrases;
2148
+ branches.push(branch);
2149
+ }
2150
+ if (probabilitySum > 1.000000001) fail("gameplay.touch." + zoneRaw.name + ": branch probabilities must sum to at most 1");
2151
+ if (branches.length > 0) zones.push({
2152
+ name: zoneRaw.name,
2153
+ y0: zoneRaw.y0,
2154
+ y1: zoneRaw.y1,
2155
+ branches
2156
+ });
2157
+ }
2158
+ if (zones.length > 0 || clickBoost !== void 0) block.touch = {
2159
+ zones,
2160
+ ...clickBoost === void 0 ? {} : { clickBoost }
2161
+ };
2162
+ }
2163
+ if (raw.work !== void 0) {
2164
+ const w = raw.work;
2165
+ if (!isRecord$2(w)) fail("gameplay.work must be an object");
2166
+ else {
2167
+ const wExtra = unknownKeys$2(w, KNOWN_WORK);
2168
+ if (wExtra.length > 0) fail("gameplay.work: unknown field(s) " + wExtra.map((k) => JSON.stringify(k)).join(", "));
2169
+ const state = parseStateRef(w.state, "gameplay.work.state", hooks);
2170
+ const successState = parseStateRef(w.successState, "gameplay.work.successState", hooks);
2171
+ const failState = parseStateRef(w.failState, "gameplay.work.failState", hooks);
2172
+ if (!intIn(w.tickMs, 1e3, 6e4)) fail("gameplay.work.tickMs must be an integer in [1000, 60000]");
2173
+ if (!numIn(w.successProbability, 0, 1)) fail("gameplay.work.successProbability must be a number in [0, 1]");
2174
+ if (state !== void 0 && successState !== void 0 && failState !== void 0 && intIn(w.tickMs, 1e3, 6e4) && numIn(w.successProbability, 0, 1)) {
2175
+ const work = {
2176
+ state,
2177
+ successState,
2178
+ failState,
2179
+ tickMs: w.tickMs,
2180
+ successProbability: w.successProbability
2181
+ };
2182
+ if (w.resultMs !== void 0) if (!isRecord$2(w.resultMs) || !intIn(w.resultMs.success, 200, 1e4) || !intIn(w.resultMs.fail, 200, 1e4)) fail("gameplay.work.resultMs must be { success, fail } integers in [200, 10000]");
2183
+ else work.resultMs = {
2184
+ success: w.resultMs.success,
2185
+ fail: w.resultMs.fail
2186
+ };
2187
+ for (const key of ["success", "fail"]) if (w[key] !== void 0) if (!isRecord$2(w[key])) fail("gameplay.work." + key + " must be an object { effects }");
2188
+ else {
2189
+ const effects = parseEffects(w[key].effects, "gameplay.work." + key + ".effects", stats, hooks);
2190
+ if (effects !== void 0) work[key] = { effects };
2191
+ }
2192
+ block.work = work;
2193
+ }
2194
+ }
2195
+ }
2196
+ if (raw.sleep !== void 0) {
2197
+ const s = raw.sleep;
2198
+ if (!isRecord$2(s) || !isRecord$2(s.restore)) fail("gameplay.sleep must be an object with a restore block");
2199
+ else {
2200
+ const sExtra = unknownKeys$2(s, KNOWN_SLEEP);
2201
+ if (sExtra.length > 0) fail("gameplay.sleep: unknown field(s) " + sExtra.map((k) => JSON.stringify(k)).join(", "));
2202
+ const state = parseStateRef(s.state, "gameplay.sleep.state", hooks);
2203
+ const wakeState = parseStateRef(s.wakeState, "gameplay.sleep.wakeState", hooks);
2204
+ const restoreStat = typeof s.restore.stat === "string" && stats[s.restore.stat] !== void 0 ? s.restore.stat : void 0;
2205
+ if (restoreStat === void 0) fail("gameplay.sleep.restore.stat must reference a declared stat");
2206
+ if (!intIn(s.restore.amount, 1, 1e3)) fail("gameplay.sleep.restore.amount must be an integer in [1, 1000]");
2207
+ if (!intIn(s.restore.intervalMs, 1e3, 6e5)) fail("gameplay.sleep.restore.intervalMs must be an integer in [1000, 600000]");
2208
+ if (state !== void 0 && restoreStat !== void 0 && intIn(s.restore.amount, 1, 1e3) && intIn(s.restore.intervalMs, 1e3, 6e5)) block.sleep = {
2209
+ state,
2210
+ ...wakeState === void 0 ? {} : { wakeState },
2211
+ restore: {
2212
+ stat: restoreStat,
2213
+ amount: s.restore.amount,
2214
+ intervalMs: s.restore.intervalMs
2215
+ }
2216
+ };
2217
+ }
2218
+ }
2219
+ if (raw.passiveIncome !== void 0) {
2220
+ const p = raw.passiveIncome;
2221
+ if (!isRecord$2(p) || !validName(p.currency, 24) || !intIn(p.amount, 1, 1e4) || !intIn(p.intervalMs, 1e3, 864e5)) fail("gameplay.passiveIncome must be { currency (kebab), amount 1..10000, intervalMs 1000..86400000 }");
2222
+ else block.passiveIncome = {
2223
+ currency: p.currency,
2224
+ amount: p.amount,
2225
+ intervalMs: p.intervalMs
2226
+ };
2227
+ }
2228
+ if (raw.shop !== void 0) {
2229
+ const s = raw.shop;
2230
+ if (!isRecord$2(s) || !Array.isArray(s.items) || s.items.length === 0 || s.items.length > MAX_SHOP_ITEMS) fail("gameplay.shop must be an object with 1..32 items");
2231
+ else {
2232
+ const shopState = parseStateRef(s.state, "gameplay.shop.state", hooks);
2233
+ const items = [];
2234
+ const seen = /* @__PURE__ */ new Set();
2235
+ for (const itemRaw of s.items) {
2236
+ if (!isRecord$2(itemRaw) || !validName(itemRaw.id, 24)) {
2237
+ fail("gameplay.shop.items entries need a kebab id");
2238
+ continue;
2239
+ }
2240
+ if (seen.has(itemRaw.id)) {
2241
+ fail("gameplay.shop: duplicate item id " + JSON.stringify(itemRaw.id));
2242
+ continue;
2243
+ }
2244
+ seen.add(itemRaw.id);
2245
+ const iExtra = unknownKeys$2(itemRaw, KNOWN_SHOP_ITEM);
2246
+ if (iExtra.length > 0) fail("gameplay.shop." + itemRaw.id + ": unknown field(s) " + iExtra.map((k) => JSON.stringify(k)).join(", "));
2247
+ if (typeof itemRaw.label !== "string" || itemRaw.label.trim() === "" || itemRaw.label.length > 80) {
2248
+ fail("gameplay.shop." + itemRaw.id + ".label must be a non-empty string of at most 80 chars");
2249
+ continue;
2250
+ }
2251
+ if (!intIn(itemRaw.price, 1, 1e6)) {
2252
+ fail("gameplay.shop." + itemRaw.id + ".price must be an integer in [1, 1000000]");
2253
+ continue;
2254
+ }
2255
+ if (!validName(itemRaw.currency, 24)) {
2256
+ fail("gameplay.shop." + itemRaw.id + ".currency must be a kebab id");
2257
+ continue;
2258
+ }
2259
+ const item = {
2260
+ id: itemRaw.id,
2261
+ label: itemRaw.label.trim(),
2262
+ price: itemRaw.price,
2263
+ currency: itemRaw.currency
2264
+ };
2265
+ if (itemRaw.image !== void 0) if (typeof itemRaw.image !== "string" || itemRaw.image.includes("..") || itemRaw.image.includes("\\") || itemRaw.image.startsWith("/")) fail("gameplay.shop." + itemRaw.id + ".image must be a safe manifest-relative frame path");
2266
+ else item.image = itemRaw.image;
2267
+ const effects = parseEffects(itemRaw.effects, "gameplay.shop." + itemRaw.id + ".effects", stats, hooks);
2268
+ if (effects !== void 0) item.effects = effects;
2269
+ if (itemRaw.lottery !== void 0) {
2270
+ const l = itemRaw.lottery;
2271
+ if (!isRecord$2(l) || !Array.isArray(l.tiers) || l.tiers.length === 0 || l.tiers.length > MAX_LOTTERY_TIERS) fail("gameplay.shop." + itemRaw.id + ".lottery needs 1..16 tiers");
2272
+ else {
2273
+ const lExtra = unknownKeys$2(l, KNOWN_LOTTERY);
2274
+ if (lExtra.length > 0) fail("gameplay.shop." + itemRaw.id + ".lottery: unknown field(s) " + lExtra.map((k) => JSON.stringify(k)).join(", "));
2275
+ if (l.currency !== void 0 && !validName(l.currency, 24)) fail("gameplay.shop." + itemRaw.id + ".lottery.currency must be a kebab id");
2276
+ let tierSum = 0;
2277
+ const tiers = [];
2278
+ for (const tierRaw of l.tiers) {
2279
+ if (!isRecord$2(tierRaw) || !numIn(tierRaw.probability, 0, 1) || tierRaw.probability === 0 || !intIn(tierRaw.prize, 0, 1e9)) {
2280
+ fail("gameplay.shop." + itemRaw.id + ".lottery.tiers entries need probability (0,1] and prize 0..1e9");
2281
+ continue;
2282
+ }
2283
+ tierSum += tierRaw.probability;
2284
+ const tier = {
2285
+ probability: tierRaw.probability,
2286
+ prize: tierRaw.prize
2287
+ };
2288
+ if (tierRaw.currency !== void 0) if (!validName(tierRaw.currency, 24)) fail("gameplay.shop." + itemRaw.id + ".lottery tier currency must be a kebab id");
2289
+ else tier.currency = tierRaw.currency;
2290
+ tiers.push(tier);
2291
+ }
2292
+ if (tierSum > 1.000000001) fail("gameplay.shop." + itemRaw.id + ".lottery tier probabilities must sum to at most 1");
2293
+ if (tiers.length > 0) {
2294
+ const lotteryEffects = parseEffects(l.effects, "gameplay.shop." + itemRaw.id + ".lottery.effects", stats, hooks);
2295
+ item.lottery = {
2296
+ tiers,
2297
+ ...lotteryEffects === void 0 ? {} : { effects: lotteryEffects },
2298
+ ...validName(l.currency, 24) ? { currency: l.currency } : {}
2299
+ };
2300
+ }
2301
+ }
2302
+ }
2303
+ if (item.effects === void 0 && item.lottery === void 0) {
2304
+ fail("gameplay.shop." + itemRaw.id + " needs effects or a lottery");
2305
+ continue;
2306
+ }
2307
+ items.push(item);
2308
+ }
2309
+ if (items.length > 0) block.shop = {
2310
+ ...shopState === void 0 ? {} : { state: shopState },
2311
+ items
2312
+ };
2313
+ }
2314
+ }
2315
+ if (raw.dragState !== void 0) {
2316
+ const state = parseStateRef(raw.dragState, "gameplay.dragState", hooks);
2317
+ if (state !== void 0) block.dragState = state;
2318
+ }
2319
+ if (raw.dragEndState !== void 0) {
2320
+ const state = parseStateRef(raw.dragEndState, "gameplay.dragEndState", hooks);
2321
+ if (state !== void 0) block.dragEndState = state;
2322
+ }
2323
+ return failed ? void 0 : block;
2324
+ }
2325
+ /** Fresh state for one pet: stats at their initial (default max), no currency. */
2326
+ function initialGameplayState(manifest, now) {
2327
+ const stats = {};
2328
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) stats[name] = def.initial ?? def.max;
2329
+ return {
2330
+ stats,
2331
+ currencies: {},
2332
+ mode: null,
2333
+ settledAt: now
2334
+ };
2335
+ }
2336
+ /** Clamp one stat value into [0, max]; currencies into [0, CURRENCY_MAX]. */
2337
+ function clampGameplay(state, manifest) {
2338
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
2339
+ const value = state.stats[name];
2340
+ if (value === void 0) state.stats[name] = def.initial ?? def.max;
2341
+ else state.stats[name] = Math.min(def.max, Math.max(0, value));
2342
+ }
2343
+ for (const [name, value] of Object.entries(state.currencies)) state.currencies[name] = Math.min(CURRENCY_MAX, Math.max(0, Math.floor(value)));
2344
+ }
2345
+ /**
2346
+ * Lazy settle: apply stat decay, passive income and sleep restore for the
2347
+ * elapsed wall time since the last settle. Mirrors the treats.ts discipline
2348
+ * (no host timers; read paths settle). Returns whether anything changed.
2349
+ */
2350
+ function settleGameplay(state, manifest, now, options) {
2351
+ const elapsedMs = now - state.settledAt;
2352
+ if (elapsedMs <= 0) return false;
2353
+ const minutes = elapsedMs / 6e4;
2354
+ let changed = false;
2355
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
2356
+ const current = state.stats[name];
2357
+ if (current === void 0 || current <= 0) continue;
2358
+ let rate = def.decayPerMinute ?? 0;
2359
+ if (state.mode === "work" && def.workingDecayPerMinute !== void 0) rate = def.workingDecayPerMinute;
2360
+ if (!options.sessionActive) rate += def.idleDecayPerMinute ?? 0;
2361
+ if (rate <= 0) continue;
2362
+ const next = Math.max(0, current - rate * minutes);
2363
+ if (next !== current) {
2364
+ state.stats[name] = next;
2365
+ changed = true;
2366
+ }
2367
+ }
2368
+ if (manifest.passiveIncome !== void 0) {
2369
+ const ticks = Math.floor(elapsedMs / manifest.passiveIncome.intervalMs);
2370
+ if (ticks > 0) {
2371
+ const currency = manifest.passiveIncome.currency;
2372
+ state.currencies[currency] = (state.currencies[currency] ?? 0) + ticks * manifest.passiveIncome.amount;
2373
+ changed = true;
2374
+ }
2375
+ }
2376
+ if (state.mode === "sleep" && manifest.sleep !== void 0) {
2377
+ const ticks = Math.floor(elapsedMs / manifest.sleep.restore.intervalMs);
2378
+ if (ticks > 0) {
2379
+ const stat = manifest.sleep.restore.stat;
2380
+ state.stats[stat] = (state.stats[stat] ?? 0) + ticks * manifest.sleep.restore.amount;
2381
+ changed = true;
2382
+ }
2383
+ }
2384
+ state.settledAt = now;
2385
+ clampGameplay(state, manifest);
2386
+ return changed;
2387
+ }
2388
+ /** Apply one effect vector (touch/work/shop), clamped. */
2389
+ function applyGameplayEffects(state, manifest, effects) {
2390
+ for (const effect of effects) if (effect.stat !== void 0) state.stats[effect.stat] = (state.stats[effect.stat] ?? 0) + effect.amount;
2391
+ else if (effect.currency !== void 0) state.currencies[effect.currency] = (state.currencies[effect.currency] ?? 0) + effect.amount;
2392
+ clampGameplay(state, manifest);
2393
+ }
2394
+ /** Roll one touch zone branch; undefined when the roll lands in no-op mass. */
2395
+ function rollTouchBranch(zone, rng) {
2396
+ const roll = rng();
2397
+ let acc = 0;
2398
+ for (const branch of zone.branches) {
2399
+ acc += branch.probability;
2400
+ if (roll < acc) return branch;
2401
+ }
2402
+ }
2403
+ /** Roll one work tick outcome. */
2404
+ function rollWorkOutcome(work, rng) {
2405
+ return rng() < work.successProbability ? "success" : "fail";
2406
+ }
2407
+ /** Draw one lottery prize tier; uncovered mass falls through to the last tier. */
2408
+ function drawLotteryTier(lottery, rng) {
2409
+ const roll = rng();
2410
+ let acc = 0;
2411
+ for (const tier of lottery.tiers) {
2412
+ acc += tier.probability;
2413
+ if (roll < acc) return tier;
2414
+ }
2415
+ return lottery.tiers[lottery.tiers.length - 1];
2416
+ }
1760
2417
  /** Renderer kinds the pet center knows how to dispatch (M1 §2). */
1761
- const PET_RENDERER_KINDS = ["sprite2d", "live2d"];
2418
+ const PET_RENDERER_KINDS = [
2419
+ "sprite2d",
2420
+ "live2d",
2421
+ "frames2d"
2422
+ ];
1762
2423
  /** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
1763
2424
  const PET_ACTIVITY_PHASES = [
1764
2425
  "idle",
@@ -1790,8 +2451,10 @@ const KNOWN_TOP_LEVEL = /* @__PURE__ */ new Set([
1790
2451
  "renderer",
1791
2452
  "sprite2d",
1792
2453
  "live2d",
2454
+ "frames2d",
1793
2455
  "sequences",
1794
- "remarks"
2456
+ "remarks",
2457
+ "gameplay"
1795
2458
  ]);
1796
2459
  /** sprite2d block field allow-list (drift-locked to the schema file). */
1797
2460
  const KNOWN_SPRITE2D = /* @__PURE__ */ new Set([
@@ -1812,6 +2475,20 @@ const KNOWN_LIVE2D = /* @__PURE__ */ new Set([
1812
2475
  "hitAreas",
1813
2476
  "lipSync"
1814
2477
  ]);
2478
+ /** frames2d block field allow-list (drift-locked to the schema file). */
2479
+ const KNOWN_FRAMES2D = /* @__PURE__ */ new Set([
2480
+ "dir",
2481
+ "defaultFrameMs",
2482
+ "tracks",
2483
+ "phases"
2484
+ ]);
2485
+ /** frames2d track field allow-list (drift-locked to the schema file). */
2486
+ const KNOWN_FRAMES2D_TRACK = /* @__PURE__ */ new Set([
2487
+ "frames",
2488
+ "frameMs",
2489
+ "loop",
2490
+ "fallback"
2491
+ ]);
1815
2492
  var Diagnostics$1 = class {
1816
2493
  list = [];
1817
2494
  source;
@@ -1958,6 +2635,106 @@ function parseLive2dBlock(raw, diag) {
1958
2635
  else block.lipSync = raw.lipSync;
1959
2636
  return diag.hasErrors ? void 0 : block;
1960
2637
  }
2638
+ const FRAMES2D_TRACK_NAME = /^[a-z0-9][a-z0-9-]*$/;
2639
+ const FRAMES2D_MAX_TRACKS = 64;
2640
+ const FRAMES2D_MAX_FRAMES = 64;
2641
+ const FRAMES2D_MIN_FRAME_MS = 16;
2642
+ const FRAMES2D_MAX_FRAME_MS = 5e3;
2643
+ const FRAMES2D_IMAGE_EXTENSIONS$1 = /* @__PURE__ */ new Set([
2644
+ ".webp",
2645
+ ".png",
2646
+ ".gif",
2647
+ ".jpg",
2648
+ ".jpeg"
2649
+ ]);
2650
+ /** Validate one frames2d track name (kebab id, at most 32 chars). */
2651
+ function validTrackName(name) {
2652
+ return name.length <= 32 && FRAMES2D_TRACK_NAME.test(name);
2653
+ }
2654
+ /** A frames2d frame entry is a single image file name inside the track directory. */
2655
+ function safeFrameName(raw) {
2656
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
2657
+ const value = raw.trim();
2658
+ if (value.includes("/") || value.includes("\\") || !PATH_SEGMENT_PATTERN$2.test(value)) return void 0;
2659
+ const dot = value.lastIndexOf(".");
2660
+ if (dot <= 0) return void 0;
2661
+ if (!FRAMES2D_IMAGE_EXTENSIONS$1.has(value.slice(dot).toLowerCase())) return void 0;
2662
+ return value;
2663
+ }
2664
+ function parseFrames2dTrack(raw, field, diag) {
2665
+ if (!isRecord$1(raw)) {
2666
+ diag.error(field + " must be an object");
2667
+ return;
2668
+ }
2669
+ const extra = unknownKeys$1(raw, KNOWN_FRAMES2D_TRACK);
2670
+ if (extra.length > 0) diag.error(field + ": unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
2671
+ const track = {};
2672
+ if (raw.frames !== void 0) if (!Array.isArray(raw.frames) || raw.frames.length === 0) diag.error(field + ".frames must be a non-empty array of frame file names");
2673
+ else if (raw.frames.length > FRAMES2D_MAX_FRAMES) diag.error(field + ".frames declares too many frames (" + raw.frames.length + ", max 64)");
2674
+ else {
2675
+ const frames = [];
2676
+ for (const entry of raw.frames) {
2677
+ const safe = safeFrameName(entry);
2678
+ if (safe === void 0) diag.error(field + ".frames entry " + JSON.stringify(String(entry)) + " is not a safe image file name");
2679
+ else frames.push(safe);
2680
+ }
2681
+ if (frames.length === raw.frames.length) track.frames = frames;
2682
+ }
2683
+ if (raw.frameMs !== void 0) if (!Array.isArray(raw.frameMs) || raw.frameMs.length === 0 || raw.frameMs.some((v) => typeof v !== "number" || !Number.isInteger(v) || v < FRAMES2D_MIN_FRAME_MS || v > FRAMES2D_MAX_FRAME_MS)) diag.error(field + ".frameMs must be a non-empty array of integer ms in [16, 5000]");
2684
+ else if (track.frames === void 0) diag.error(field + ".frameMs requires a valid explicit frames list (directory tracks use filename-encoded ms or defaultFrameMs)");
2685
+ else if (raw.frameMs.length !== track.frames.length) diag.error(field + ".frameMs must have the same length as frames");
2686
+ else track.frameMs = raw.frameMs;
2687
+ if (raw.loop !== void 0) if (typeof raw.loop !== "boolean") diag.error(field + ".loop must be a boolean");
2688
+ else track.loop = raw.loop;
2689
+ if (raw.fallback !== void 0) if (typeof raw.fallback !== "string" || !validTrackName(raw.fallback)) diag.error(field + ".fallback must be a track name");
2690
+ else track.fallback = raw.fallback;
2691
+ return diag.hasErrors ? void 0 : track;
2692
+ }
2693
+ function parseFrames2dBlock(raw, diag) {
2694
+ if (!isRecord$1(raw)) {
2695
+ diag.error("renderer frames2d requires a \"frames2d\" block object");
2696
+ return;
2697
+ }
2698
+ const extra = unknownKeys$1(raw, KNOWN_FRAMES2D);
2699
+ if (extra.length > 0) diag.error("frames2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
2700
+ const block = {
2701
+ tracks: {},
2702
+ phases: { idle: "" }
2703
+ };
2704
+ if (raw.dir !== void 0) {
2705
+ const dir = safeManifestPath(raw.dir);
2706
+ if (dir === void 0) diag.error("frames2d.dir must be a safe manifest-relative directory");
2707
+ else block.dir = dir;
2708
+ }
2709
+ if (raw.defaultFrameMs !== void 0) if (typeof raw.defaultFrameMs !== "number" || !Number.isInteger(raw.defaultFrameMs) || raw.defaultFrameMs < FRAMES2D_MIN_FRAME_MS || raw.defaultFrameMs > FRAMES2D_MAX_FRAME_MS) diag.error("frames2d.defaultFrameMs must be an integer in [16, 5000]");
2710
+ else block.defaultFrameMs = raw.defaultFrameMs;
2711
+ const tracks = {};
2712
+ if (!isRecord$1(raw.tracks)) diag.error("frames2d.tracks is required and must be an object keyed by track name");
2713
+ else {
2714
+ const entries = Object.entries(raw.tracks);
2715
+ if (entries.length === 0) diag.error("frames2d.tracks must declare at least one track");
2716
+ if (entries.length > FRAMES2D_MAX_TRACKS) diag.error("frames2d.tracks declares too many tracks (" + entries.length + ", max 64)");
2717
+ for (const [name, value] of entries) {
2718
+ if (!validTrackName(name)) {
2719
+ diag.error("frames2d.tracks: invalid track name " + JSON.stringify(name) + " (lowercase kebab, at most 32 chars)");
2720
+ continue;
2721
+ }
2722
+ const track = parseFrames2dTrack(value, "frames2d.tracks." + name, diag);
2723
+ if (track !== void 0) tracks[name] = track;
2724
+ }
2725
+ }
2726
+ block.tracks = tracks;
2727
+ const phases = parsePhaseStringMap(raw.phases, "frames2d.phases", diag);
2728
+ if (raw.phases === void 0) diag.error("frames2d.phases is required (at least an \"idle\" mapping)");
2729
+ else if (phases !== void 0 && phases.idle === void 0) diag.error("frames2d.phases.idle is required (unmapped phases fall back to it)");
2730
+ const phasesValid = phases !== void 0 && phases.idle !== void 0;
2731
+ if (phasesValid) block.phases = phases;
2732
+ if (phasesValid) {
2733
+ for (const [phase, target] of Object.entries(block.phases)) if (target !== void 0 && tracks[target] === void 0) diag.error("frames2d.phases." + phase + " references unknown track " + JSON.stringify(target));
2734
+ }
2735
+ for (const [name, track] of Object.entries(tracks)) if (track.fallback !== void 0 && tracks[track.fallback] === void 0) diag.error("frames2d.tracks." + name + ".fallback references unknown track " + JSON.stringify(track.fallback));
2736
+ return diag.hasErrors ? void 0 : block;
2737
+ }
1961
2738
  /** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
1962
2739
  function compatV1(source, diag) {
1963
2740
  const id = parseStringBlock(source, "id", diag, true);
@@ -2017,10 +2794,25 @@ function parseV2(source, diag) {
2017
2794
  const block = parseSprite2dBlock(source.sprite2d, diag);
2018
2795
  if (block !== void 0) manifest.sprite2d = block;
2019
2796
  if (source.live2d !== void 0) diag.error("renderer sprite2d must not declare a live2d block");
2797
+ if (source.frames2d !== void 0) diag.error("renderer sprite2d must not declare a frames2d block");
2020
2798
  } else if (renderer === "live2d") {
2021
2799
  const block = parseLive2dBlock(source.live2d, diag);
2022
2800
  if (block !== void 0) manifest.live2d = block;
2023
2801
  if (source.sprite2d !== void 0) diag.error("renderer live2d must not declare a sprite2d block");
2802
+ if (source.frames2d !== void 0) diag.error("renderer live2d must not declare a frames2d block");
2803
+ } else if (renderer === "frames2d") {
2804
+ const block = parseFrames2dBlock(source.frames2d, diag);
2805
+ if (block !== void 0) manifest.frames2d = block;
2806
+ if (source.sprite2d !== void 0) diag.error("renderer frames2d must not declare a sprite2d block");
2807
+ if (source.live2d !== void 0) diag.error("renderer frames2d must not declare a live2d block");
2808
+ }
2809
+ if (source.gameplay !== void 0) if (renderer !== "frames2d") diag.error("gameplay currently requires renderer frames2d (its state references name frames2d tracks)");
2810
+ else {
2811
+ const gameplay = parseGameplayManifest(source.gameplay, {
2812
+ stateNames: new Set(Object.keys(manifest.frames2d?.tracks ?? {})),
2813
+ error: (message) => diag.error("gameplay: " + message)
2814
+ });
2815
+ if (gameplay !== void 0) manifest.gameplay = gameplay;
2024
2816
  }
2025
2817
  const sequences = parseSequences(source.sequences, diag);
2026
2818
  if (sequences !== void 0) manifest.sequences = sequences;
@@ -2824,6 +3616,173 @@ function resolveLive2dEntry(manifest, dir, options) {
2824
3616
  ...remarks === void 0 ? {} : { remarks }
2825
3617
  };
2826
3618
  }
3619
+ /** Filename-encoded frame duration tail ('<base>_<index>_<ms>.webp'). */
3620
+ const FRAMES2D_FILENAME_MS = /_(\d+)\.[^.]+$/;
3621
+ /** Trailing frame index, allowing an optional '_<ms>' duration tail after it. */
3622
+ const FRAMES2D_FRAME_INDEX = /(\d+)(?:_\d+)?\.[^.]+$/;
3623
+ /** Default per-frame duration when neither frameMs nor the filename encodes one. */
3624
+ const FRAMES2D_DEFAULT_FRAME_MS = 200;
3625
+ /** Image extensions a frames2d track directory may list. */
3626
+ const FRAMES2D_IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
3627
+ ".webp",
3628
+ ".png",
3629
+ ".gif",
3630
+ ".jpg",
3631
+ ".jpeg"
3632
+ ]);
3633
+ /**
3634
+ * Resolve a validated frames2d manifest into a renderable entry. Track frame
3635
+ * lists come from the manifest's explicit list or from listing
3636
+ * '<dir>/<track>/' in filename order; durations resolve frameMs[i] >
3637
+ * filename-encoded '_<ms>' tail > defaultFrameMs. Missing frames warn and
3638
+ * skip (the live2d closure discipline); a track left with zero frames is
3639
+ * dropped, and if the idle-mapped track ends up empty the entry is rejected
3640
+ * fail-closed. The sprite fields carry contract defaults: the chrome sizes
3641
+ * frames2d pets off 'display.size', not the atlas.
3642
+ */
3643
+ function resolveFrames2dEntry(manifest, dir, options) {
3644
+ const assetPrefix = options.assetPrefix ?? "/pet";
3645
+ const record = (level, message) => {
3646
+ options.diagnostics?.push({
3647
+ level,
3648
+ source: dir,
3649
+ message
3650
+ });
3651
+ options.warnings?.push(message);
3652
+ };
3653
+ const block = manifest.frames2d;
3654
+ if (block === void 0) {
3655
+ record("error", "pet " + manifest.id + ": renderer frames2d requires a frames2d block");
3656
+ return;
3657
+ }
3658
+ const root = block.dir ?? ".";
3659
+ const defaultMs = block.defaultFrameMs ?? FRAMES2D_DEFAULT_FRAME_MS;
3660
+ const idleTrack = block.phases.idle;
3661
+ const tracks = {};
3662
+ const servable = [];
3663
+ const firstFrameRel = {};
3664
+ for (const [name, track] of Object.entries(block.tracks)) {
3665
+ const trackDir = root === "." ? name : root + "/" + name;
3666
+ let relFrames = [];
3667
+ if (track.frames !== void 0) for (const frame of track.frames) {
3668
+ const rel = trackDir + "/" + frame;
3669
+ if (!existsSync(join(dir, rel))) {
3670
+ record("warning", "pet " + manifest.id + ": frames2d frame missing: " + rel);
3671
+ continue;
3672
+ }
3673
+ relFrames.push(rel);
3674
+ }
3675
+ else {
3676
+ let files = [];
3677
+ try {
3678
+ files = readdirSync(join(dir, trackDir)).filter((file) => {
3679
+ if (file.startsWith(".")) return false;
3680
+ const dot = file.lastIndexOf(".");
3681
+ return dot > 0 && FRAMES2D_IMAGE_EXTENSIONS.has(file.slice(dot).toLowerCase());
3682
+ });
3683
+ } catch {
3684
+ files = [];
3685
+ }
3686
+ files.sort((a, b) => {
3687
+ const ia = FRAMES2D_FRAME_INDEX.exec(a)?.[1];
3688
+ const ib = FRAMES2D_FRAME_INDEX.exec(b)?.[1];
3689
+ if (ia !== void 0 && ib !== void 0 && ia !== ib) return Number(ia) - Number(ib);
3690
+ return a < b ? -1 : a > b ? 1 : 0;
3691
+ });
3692
+ relFrames = files.map((file) => trackDir + "/" + file);
3693
+ }
3694
+ if (relFrames.length === 0) {
3695
+ record("warning", "pet " + manifest.id + ": frames2d track " + JSON.stringify(name) + " has no frames on disk; dropped");
3696
+ continue;
3697
+ }
3698
+ const durations = relFrames.map((rel, index) => {
3699
+ if (track.frameMs !== void 0) return track.frameMs[index] ?? defaultMs;
3700
+ const match = FRAMES2D_FILENAME_MS.exec(rel);
3701
+ if (match !== null) {
3702
+ const ms = Number(match[1]);
3703
+ if (Number.isInteger(ms) && ms >= 16 && ms <= 5e3) return ms;
3704
+ }
3705
+ return defaultMs;
3706
+ });
3707
+ const loop = track.loop ?? true;
3708
+ tracks[name] = {
3709
+ frames: relFrames.map((rel) => assetUrl(assetPrefix, manifest.id, rel)),
3710
+ durations,
3711
+ loop,
3712
+ ...loop ? {} : { fallback: track.fallback ?? idleTrack }
3713
+ };
3714
+ firstFrameRel[name] = relFrames[0];
3715
+ servable.push(...relFrames);
3716
+ }
3717
+ if (tracks[idleTrack] === void 0) {
3718
+ record("error", "pet " + manifest.id + ": frames2d idle track " + JSON.stringify(idleTrack) + " has no frames on disk");
3719
+ return;
3720
+ }
3721
+ const phases = { ...block.phases };
3722
+ for (const [phase, target] of Object.entries(phases)) if (target !== void 0 && tracks[target] === void 0) {
3723
+ record("warning", "pet " + manifest.id + ": frames2d phase " + phase + " maps to dropped track " + JSON.stringify(target) + "; using idle");
3724
+ phases[phase] = idleTrack;
3725
+ }
3726
+ const remarks = normalizePetRemarks(manifest.remarks, (message) => record("warning", "pet " + manifest.id + ": " + message));
3727
+ let gameplay;
3728
+ if (manifest.gameplay !== void 0) {
3729
+ gameplay = manifest.gameplay;
3730
+ if (gameplay.shop !== void 0) {
3731
+ const items = [];
3732
+ for (const item of gameplay.shop.items) {
3733
+ if (item.image === void 0) {
3734
+ items.push(item);
3735
+ continue;
3736
+ }
3737
+ if (!existsSync(join(dir, item.image))) {
3738
+ record("warning", "pet " + manifest.id + ": gameplay shop item " + item.id + " image missing: " + item.image);
3739
+ const { image, ...rest } = item;
3740
+ items.push(rest);
3741
+ continue;
3742
+ }
3743
+ servable.push(item.image);
3744
+ items.push({
3745
+ ...item,
3746
+ image: assetUrl(assetPrefix, manifest.id, item.image)
3747
+ });
3748
+ }
3749
+ gameplay = {
3750
+ ...gameplay,
3751
+ shop: {
3752
+ ...gameplay.shop,
3753
+ items
3754
+ }
3755
+ };
3756
+ }
3757
+ }
3758
+ const flatTracks = buildTracks(DEFAULT_FRAME_COUNTS, 8, {}, (message) => record("warning", "pet " + manifest.id + ": " + message));
3759
+ if (flatTracks === void 0) return void 0;
3760
+ const firstAbs = join(dir, firstFrameRel[idleTrack]);
3761
+ const dims = existsSync(firstAbs) ? readImageDimensions(firstAbs) : void 0;
3762
+ const cell = dims !== void 0 && dims.width >= 1 && dims.height >= 1 ? dims : { ...DEFAULT_PET_CELL };
3763
+ return {
3764
+ id: manifest.id,
3765
+ displayName: manifest.displayName,
3766
+ description: manifest.description ?? "",
3767
+ renderer: "frames2d",
3768
+ frames2d: {
3769
+ tracks,
3770
+ phases
3771
+ },
3772
+ ...gameplay === void 0 ? {} : { gameplay },
3773
+ cell,
3774
+ columns: 8,
3775
+ rows: [...DEFAULT_FRAME_COUNTS],
3776
+ atlasRows: 9,
3777
+ tracks: flatTracks,
3778
+ atlasUrl: tracks[idleTrack].frames[0],
3779
+ manifestUrl: assetUrl(assetPrefix, manifest.id, "pet.json"),
3780
+ dir,
3781
+ spritesheetPath: firstFrameRel[idleTrack],
3782
+ servable,
3783
+ ...remarks === void 0 ? {} : { remarks }
3784
+ };
3785
+ }
2827
3786
  /** Scan one directory of pet folders; entries come back in name order. */
2828
3787
  function scanPetDir(dir, options) {
2829
3788
  if (!existsSync(dir)) return [];
@@ -2853,6 +3812,7 @@ function scanPetDir(dir, options) {
2853
3812
  if (!verdict.ok) continue;
2854
3813
  let entry;
2855
3814
  if (verdict.manifest.renderer === "live2d") entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
3815
+ else if (verdict.manifest.renderer === "frames2d") entry = resolveFrames2dEntry(verdict.manifest, entryDir, options);
2856
3816
  else {
2857
3817
  const legacy = flattenV2Sprite2d(verdict.manifest);
2858
3818
  if (legacy === void 0) {
@@ -3144,7 +4104,7 @@ function loadPetRegistry(options) {
3144
4104
  warnings,
3145
4105
  diagnostics,
3146
4106
  byId: (id) => byId.get(id),
3147
- defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0],
4107
+ defaultEntry: () => entries.find((entry) => entry.id === "whale-girl" && builtinIds.has(entry.id)) ?? entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0],
3148
4108
  ...globalVoice === void 0 ? {} : { globalVoice },
3149
4109
  decorations,
3150
4110
  decorationById: (id) => decorationById.get(id)
@@ -3180,6 +4140,8 @@ function petEntryView(entry, globalVoice) {
3180
4140
  description: entry.description,
3181
4141
  renderer: entry.renderer,
3182
4142
  ...entry.live2d === void 0 ? {} : { live2d: entry.live2d },
4143
+ ...entry.frames2d === void 0 ? {} : { frames2d: entry.frames2d },
4144
+ ...entry.gameplay === void 0 ? {} : { gameplay: entry.gameplay },
3183
4145
  cell: entry.cell,
3184
4146
  columns: entry.columns,
3185
4147
  rows: entry.rows,
@@ -3191,6 +4153,23 @@ function petEntryView(entry, globalVoice) {
3191
4153
  ...panel === void 0 ? {} : { panel }
3192
4154
  };
3193
4155
  }
4156
+ //#endregion
4157
+ //#region src/service.ts
4158
+ /**
4159
+ * Pet host service — the `pet.*` RPC domain. A composition facade: it wires
4160
+ * the pure event projection (`event-projection`) onto the state machine,
4161
+ * delegates the affinity economy to the ledger (`ledger`), and routes
4162
+ * persistence through `persist`. The API gateway maps these methods onto
4163
+ * `pet.state` / `pet.pets` / `pet.interact` / `pet.setVisible` /
4164
+ * `pet.setConfig` / `pet.setName` / `pet.setPet` for browser consumers.
4165
+ *
4166
+ * Concurrent sessions each keep their own machine: the sprite animation
4167
+ * follows the most recent meaningful event (the display session) while the
4168
+ * state view carries one bubble per active session.
4169
+ * @module @linxin666/dsh-pet/service
4170
+ */
4171
+ /** The unified gameplay currency: the shared treat (小鱼干) ledger. */
4172
+ const GAMEPLAY_TREATS_CURRENCY = "treats";
3194
4173
  /** Hard cap on simultaneously displayed session bubbles (most recent first). */
3195
4174
  const MAX_SESSION_BUBBLES = 12;
3196
4175
  /**
@@ -3442,6 +4421,205 @@ var PetService = class extends Service {
3442
4421
  if (this.ledger.takeDirty()) this.flush();
3443
4422
  return result;
3444
4423
  }
4424
+ /** The active pet's gameplay block, if it declares one. */
4425
+ gameplayDef() {
4426
+ return this.activeEntry().gameplay;
4427
+ }
4428
+ /** The persisted (or fresh) gameplay state of the selected pet. */
4429
+ gameplayState(def, now) {
4430
+ const petId = this.selectedPetId();
4431
+ const stored = this.ledger.snapshot.gameplay[petId];
4432
+ return {
4433
+ petId,
4434
+ state: stored === void 0 ? initialGameplayState(def, now) : {
4435
+ stats: { ...stored.stats },
4436
+ currencies: { ...stored.currencies },
4437
+ mode: stored.mode,
4438
+ settledAt: stored.settledAt
4439
+ }
4440
+ };
4441
+ }
4442
+ /** Display view of one gameplay state (rounded stats; treats ride the shared treat ledger). */
4443
+ gameplayViewOf(state) {
4444
+ const stats = {};
4445
+ for (const [name, value] of Object.entries(state.stats)) stats[name] = Math.round(value);
4446
+ return {
4447
+ stats,
4448
+ mode: state.mode
4449
+ };
4450
+ }
4451
+ /**
4452
+ * Move gameplay 'treats' currency (the unified post-wallet currency) from
4453
+ * the engine's settle work area into the shared treat ledger, capped by
4454
+ * the stock cap. The engine keeps its generic currency record for settle
4455
+ * math; this drain is the only bridge to the wallet-free economy.
4456
+ */
4457
+ drainGameplayTreats(state) {
4458
+ const pending = Math.floor(state.currencies[GAMEPLAY_TREATS_CURRENCY] ?? 0);
4459
+ delete state.currencies[GAMEPLAY_TREATS_CURRENCY];
4460
+ if (pending > 0) this.ledger.grantTreats(pending);
4461
+ }
4462
+ /** Persist the mutated gameplay state of one verb call. */
4463
+ commitGameplay(petId, state) {
4464
+ this.ledger.setGameplay(petId, state);
4465
+ if (this.ledger.takeDirty()) this.flush();
4466
+ }
4467
+ /**
4468
+ * RPC: a touch on the pet. 'zone' names a touch zone (roll a branch);
4469
+ * omitted means a plain click while a touch animation holds (clickBoost).
4470
+ */
4471
+ async gameplayTouch(zone) {
4472
+ const def = this.gameplayDef();
4473
+ if (def === void 0) return {
4474
+ ok: false,
4475
+ error: "no-gameplay"
4476
+ };
4477
+ const now = Date.now();
4478
+ const { petId, state } = this.gameplayState(def, now);
4479
+ settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive });
4480
+ if (zone === void 0) {
4481
+ const boost = def.touch?.clickBoost;
4482
+ if (boost === void 0) return {
4483
+ ok: false,
4484
+ error: "no-touch"
4485
+ };
4486
+ const amount = boost.min + Math.floor(Math.random() * (boost.max - boost.min + 1));
4487
+ if (amount > 0) applyGameplayEffects(state, def, [{
4488
+ stat: boost.stat,
4489
+ amount
4490
+ }]);
4491
+ this.drainGameplayTreats(state);
4492
+ this.commitGameplay(petId, state);
4493
+ return {
4494
+ ok: true,
4495
+ hit: false,
4496
+ view: this.gameplayViewOf(state)
4497
+ };
4498
+ }
4499
+ const target = def.touch?.zones.find((entry) => entry.name === zone);
4500
+ if (target === void 0) return {
4501
+ ok: false,
4502
+ error: "unknown-zone"
4503
+ };
4504
+ const branch = rollTouchBranch(target, Math.random);
4505
+ if (branch === void 0) {
4506
+ this.drainGameplayTreats(state);
4507
+ this.commitGameplay(petId, state);
4508
+ return {
4509
+ ok: true,
4510
+ hit: false,
4511
+ view: this.gameplayViewOf(state)
4512
+ };
4513
+ }
4514
+ if (branch.effects !== void 0) applyGameplayEffects(state, def, branch.effects);
4515
+ const phrase = branch.phrases !== void 0 && branch.phrases.length > 0 ? branch.phrases[Math.floor(Math.random() * branch.phrases.length)] : void 0;
4516
+ this.drainGameplayTreats(state);
4517
+ this.commitGameplay(petId, state);
4518
+ return {
4519
+ ok: true,
4520
+ hit: true,
4521
+ ...branch.state === void 0 ? {} : { state: branch.state },
4522
+ ...branch.stateMs === void 0 ? {} : { stateMs: branch.stateMs },
4523
+ ...phrase === void 0 ? {} : { phrase },
4524
+ view: this.gameplayViewOf(state)
4525
+ };
4526
+ }
4527
+ /** RPC: enter or leave a gameplay mode ('work' | 'sleep' | null). */
4528
+ async gameplaySetMode(mode) {
4529
+ const def = this.gameplayDef();
4530
+ if (def === void 0) return {
4531
+ ok: false,
4532
+ error: "no-gameplay"
4533
+ };
4534
+ if (mode === "work" && def.work === void 0) return {
4535
+ ok: false,
4536
+ error: "no-work"
4537
+ };
4538
+ if (mode === "sleep" && def.sleep === void 0) return {
4539
+ ok: false,
4540
+ error: "no-sleep"
4541
+ };
4542
+ const now = Date.now();
4543
+ const { petId, state } = this.gameplayState(def, now);
4544
+ settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive });
4545
+ state.mode = mode;
4546
+ this.drainGameplayTreats(state);
4547
+ this.commitGameplay(petId, state);
4548
+ return {
4549
+ ok: true,
4550
+ view: this.gameplayViewOf(state)
4551
+ };
4552
+ }
4553
+ /** RPC: one work-round adjudication (only while the work mode holds). */
4554
+ async gameplayWorkTick() {
4555
+ const def = this.gameplayDef();
4556
+ if (def?.work === void 0) return {
4557
+ ok: false,
4558
+ error: "no-work"
4559
+ };
4560
+ const now = Date.now();
4561
+ const { petId, state } = this.gameplayState(def, now);
4562
+ if (state.mode !== "work") return {
4563
+ ok: false,
4564
+ error: "not-working"
4565
+ };
4566
+ settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive });
4567
+ const outcome = rollWorkOutcome(def.work, Math.random);
4568
+ const effects = outcome === "success" ? def.work.success?.effects : def.work.fail?.effects;
4569
+ if (effects !== void 0) applyGameplayEffects(state, def, effects);
4570
+ this.drainGameplayTreats(state);
4571
+ this.commitGameplay(petId, state);
4572
+ return {
4573
+ ok: true,
4574
+ outcome,
4575
+ view: this.gameplayViewOf(state)
4576
+ };
4577
+ }
4578
+ /** RPC: buy one shop item (effects, currency swap, or a lottery draw). */
4579
+ async gameplayBuy(itemId) {
4580
+ const def = this.gameplayDef();
4581
+ if (def === void 0) return {
4582
+ ok: false,
4583
+ error: "no-gameplay"
4584
+ };
4585
+ const item = def.shop?.items.find((entry) => entry.id === itemId);
4586
+ if (item === void 0) return {
4587
+ ok: false,
4588
+ error: "unknown-item"
4589
+ };
4590
+ const now = Date.now();
4591
+ const { petId, state } = this.gameplayState(def, now);
4592
+ settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive });
4593
+ const treats = item.currency === GAMEPLAY_TREATS_CURRENCY;
4594
+ const balance = treats ? this.ledger.snapshot.treats.treats : state.currencies[item.currency] ?? 0;
4595
+ if (balance < item.price) return {
4596
+ ok: false,
4597
+ error: "insufficient-funds",
4598
+ view: this.gameplayViewOf(state)
4599
+ };
4600
+ if (treats) this.ledger.spendTreats(item.price);
4601
+ else state.currencies[item.currency] = balance - item.price;
4602
+ if (item.effects !== void 0) applyGameplayEffects(state, def, item.effects);
4603
+ let prize;
4604
+ if (item.lottery !== void 0) {
4605
+ if (item.lottery.effects !== void 0) applyGameplayEffects(state, def, item.lottery.effects);
4606
+ const tier = drawLotteryTier(item.lottery, Math.random);
4607
+ const prizeCurrency = tier.currency ?? item.lottery.currency ?? item.currency;
4608
+ if (prizeCurrency === GAMEPLAY_TREATS_CURRENCY) this.ledger.grantTreats(tier.prize);
4609
+ else state.currencies[prizeCurrency] = (state.currencies[prizeCurrency] ?? 0) + tier.prize;
4610
+ prize = {
4611
+ amount: tier.prize,
4612
+ currency: prizeCurrency
4613
+ };
4614
+ }
4615
+ this.drainGameplayTreats(state);
4616
+ this.commitGameplay(petId, state);
4617
+ return {
4618
+ ok: true,
4619
+ ...prize === void 0 ? {} : { prize },
4620
+ view: this.gameplayViewOf(state)
4621
+ };
4622
+ }
3445
4623
  /** RPC: show or hide the pet. */
3446
4624
  async setVisible(visible) {
3447
4625
  this.ledger.setDisplay({
@@ -3551,6 +4729,13 @@ var PetService = class extends Service {
3551
4729
  const whisper = (this.displaySession === void 0 ? void 0 : this.sessionActivity.get(this.displaySession))?.whisper;
3552
4730
  const freshWhisper = whisper !== void 0 && Date.now() - whisper.at < 8e3 ? whisper.text : void 0;
3553
4731
  const decoration = this.activeDecoration();
4732
+ const gameplayDef = this.gameplayDef();
4733
+ let gameplay;
4734
+ if (gameplayDef !== void 0) {
4735
+ const { state } = this.gameplayState(gameplayDef, Date.now());
4736
+ settleGameplay(state, gameplayDef, Date.now(), { sessionActive: snapshot.sessionActive });
4737
+ gameplay = this.gameplayViewOf(state);
4738
+ }
3554
4739
  return {
3555
4740
  animation: snapshot.animation,
3556
4741
  ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
@@ -3570,7 +4755,8 @@ var PetService = class extends Service {
3570
4755
  treats: {
3571
4756
  stocked: this.ledger.snapshot.treats.treats,
3572
4757
  max: this.ledger.treatMax
3573
- }
4758
+ },
4759
+ ...gameplay === void 0 ? {} : { gameplay }
3574
4760
  };
3575
4761
  }
3576
4762
  flush() {
@@ -4210,6 +5396,22 @@ function makePetRoutes(deps) {
4210
5396
  const petId = body.petId;
4211
5397
  if (typeof petId !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-pet"));
4212
5398
  return service.setPetId(petId);
5399
+ }),
5400
+ postRoute(ctx, "/api/pet/gameplay/touch", (body) => {
5401
+ const zone = body.zone;
5402
+ if (zone !== void 0 && typeof zone !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-zone"));
5403
+ return service.gameplayTouch(zone);
5404
+ }),
5405
+ postRoute(ctx, "/api/pet/gameplay/mode", (body) => {
5406
+ const mode = body.mode;
5407
+ if (mode !== null && mode !== "work" && mode !== "sleep") return Promise.reject(/* @__PURE__ */ new Error("invalid-mode"));
5408
+ return service.gameplaySetMode(mode);
5409
+ }),
5410
+ postRoute(ctx, "/api/pet/gameplay/work-tick", () => service.gameplayWorkTick()),
5411
+ postRoute(ctx, "/api/pet/gameplay/buy", (body) => {
5412
+ const item = body.item;
5413
+ if (typeof item !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-item"));
5414
+ return service.gameplayBuy(item);
4213
5415
  })
4214
5416
  ];
4215
5417
  const assetRoute = {
@@ -4241,7 +5443,7 @@ function makePetRoutes(deps) {
4241
5443
  //#region src/mount-once.ts
4242
5444
  /**
4243
5445
  * Host single-instance guard shared by the plugin family. The family bundle
4244
- * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
5446
+ * (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
4245
5447
  * the loader accepts a standalone install of the same package side by side;
4246
5448
  * without this guard the second instance would still re-register the same
4247
5449
  * webserver routes, tools, settings namespaces, and system-prompt sections
@@ -4254,7 +5456,7 @@ function makePetRoutes(deps) {
4254
5456
  * `ctx.effect` runs its callback immediately and treats the callback's
4255
5457
  * return value as the fiber disposer, so the unmarker is returned, not run.
4256
5458
  */
4257
- const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
5459
+ const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
4258
5460
  function mountedSet() {
4259
5461
  const registry = globalThis;
4260
5462
  return registry[MOUNTED] ??= /* @__PURE__ */ new Set();