@linxin666/dsh-pet 0.3.3 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +17 -3
- package/README.zh.md +17 -3
- package/assets/ouo-neko/pet.json +31 -0
- package/assets/ouo-neko/previews/failed.gif +0 -0
- package/assets/ouo-neko/previews/idle.gif +0 -0
- package/assets/ouo-neko/previews/jumping.gif +0 -0
- package/assets/ouo-neko/previews/review.gif +0 -0
- package/assets/ouo-neko/previews/running-left.gif +0 -0
- package/assets/ouo-neko/previews/running-right.gif +0 -0
- package/assets/ouo-neko/previews/running.gif +0 -0
- package/assets/ouo-neko/previews/waiting.gif +0 -0
- package/assets/ouo-neko/previews/waving.gif +0 -0
- package/assets/ouo-neko/spritesheet.webp +0 -0
- package/contracts/pet-manifest-v2.schema.json +102 -1
- package/lib/client.js +759 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +1224 -22
- package/lib/types/client/PetDockEntry.d.ts +3 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +18 -2
- package/lib/types/client/PetSprite.d.ts +23 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +34 -29
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/drag-stream.d.ts +19 -0
- package/lib/types/client/drag-stream.d.ts.map +1 -0
- package/lib/types/client/drag-stream.js +26 -0
- package/lib/types/client/gameplay-hud.d.ts +50 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -0
- package/lib/types/client/gameplay-hud.js +274 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +12 -0
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/pet-store.d.ts +7 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
- package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
- package/lib/types/client/renderers/frames2d.d.ts +36 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
- package/lib/types/client/renderers/frames2d.js +196 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/defaults.js +5 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/gameplay.d.ts +182 -0
- package/lib/types/gameplay.d.ts.map +1 -0
- package/lib/types/gameplay.js +618 -0
- package/lib/types/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- package/lib/types/manifest-v2.d.ts +38 -1
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +182 -2
- package/lib/types/persist.d.ts +4 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +41 -4
- package/lib/types/registry.d.ts +29 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +170 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +22 -0
- package/lib/types/service.d.ts +56 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +181 -0
- package/package.json +5 -2
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +36 -6
- package/src/client/PetSprite.tsx +42 -21
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/drag-stream.ts +35 -0
- package/src/client/gameplay-hud.test.tsx +338 -0
- package/src/client/gameplay-hud.tsx +403 -0
- package/src/client/index.ts +17 -1
- package/src/client/locales.ts +36 -0
- package/src/client/pet-store.ts +10 -1
- package/src/client/pet.module.css +192 -29
- package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
- package/src/client/renderers/PetRendererSwitch.tsx +28 -1
- package/src/client/renderers/frames2d.test.ts +137 -0
- package/src/client/renderers/frames2d.ts +216 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/persist.test.ts +3 -0
- package/src/persist.ts +43 -6
- package/src/registry.test.ts +168 -1
- package/src/registry.ts +199 -1
- package/src/routes.ts +19 -0
- 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
|
|
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
|
|
1391
|
+
return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
|
|
1342
1392
|
}
|
|
1343
|
-
return
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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 = [
|
|
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 = {
|