@linxin666/dsh-pet 0.3.20 → 0.3.21

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 (50) hide show
  1. package/lib/client.js +127 -26
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +99 -4
  4. package/lib/types/client/gameplay-hud.d.ts +15 -0
  5. package/lib/types/client/gameplay-hud.d.ts.map +1 -1
  6. package/lib/types/client/gameplay-hud.js +48 -6
  7. package/lib/types/client/index.d.ts.map +1 -1
  8. package/lib/types/client/index.js +21 -13
  9. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -1
  10. package/lib/types/client/renderers/Frames2dVisualMount.js +5 -0
  11. package/lib/types/client/renderers/frames2d.d.ts +5 -4
  12. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  13. package/lib/types/client/renderers/frames2d.js +27 -18
  14. package/lib/types/client/work-tick-gate.d.ts +40 -0
  15. package/lib/types/client/work-tick-gate.d.ts.map +1 -0
  16. package/lib/types/client/work-tick-gate.js +49 -0
  17. package/lib/types/gameplay.d.ts +4 -0
  18. package/lib/types/gameplay.d.ts.map +1 -1
  19. package/lib/types/gameplay.js +11 -2
  20. package/lib/types/ledger.d.ts +8 -0
  21. package/lib/types/ledger.d.ts.map +1 -1
  22. package/lib/types/ledger.js +25 -0
  23. package/lib/types/persist.d.ts +6 -0
  24. package/lib/types/persist.d.ts.map +1 -1
  25. package/lib/types/persist.js +25 -1
  26. package/lib/types/routes.d.ts.map +1 -1
  27. package/lib/types/routes.js +6 -0
  28. package/lib/types/service.d.ts +24 -0
  29. package/lib/types/service.d.ts.map +1 -1
  30. package/lib/types/service.js +32 -0
  31. package/package.json +1 -1
  32. package/src/client/PetDockEntry.test.tsx +1 -0
  33. package/src/client/gameplay-hud.test.tsx +165 -2
  34. package/src/client/gameplay-hud.tsx +62 -6
  35. package/src/client/index.ts +23 -13
  36. package/src/client/pet.module.css +1 -1
  37. package/src/client/renderers/Frames2dVisualMount.test.tsx +73 -0
  38. package/src/client/renderers/Frames2dVisualMount.tsx +4 -0
  39. package/src/client/renderers/frames2d.test.ts +38 -6
  40. package/src/client/renderers/frames2d.ts +27 -19
  41. package/src/client/work-tick-gate.test.ts +53 -0
  42. package/src/client/work-tick-gate.ts +63 -0
  43. package/src/gameplay.test.ts +36 -0
  44. package/src/gameplay.ts +14 -2
  45. package/src/ledger.test.ts +19 -0
  46. package/src/ledger.ts +24 -0
  47. package/src/persist.test.ts +13 -0
  48. package/src/persist.ts +29 -1
  49. package/src/routes.ts +5 -0
  50. package/src/service.ts +38 -0
package/lib/client.js CHANGED
@@ -75,6 +75,55 @@ window.__ModuleLoader__.load({
75
75
  return previous !== null && JSON.stringify(previous) === JSON.stringify(next);
76
76
  }
77
77
  //#endregion
78
+ //#region src/client/work-tick-gate.ts
79
+ /**
80
+ * Page-wide work-tick gate for the pet's work mode. Hot reloads can leave
81
+ * several GameplayHud instances alive, each running its own work interval;
82
+ * without a shared gate every interval would call workTick and each stale call
83
+ * re-rolls, re-grants treats and re-plays the success/fail track, so the
84
+ * outcome appears to play several times per window. The gate accepts the first
85
+ * adjudication of a window and silently suppresses the duplicates that follow.
86
+ *
87
+ * The window is the active pet's own `gameplay.work.tickMs`, not a constant: a
88
+ * pet configured with a shorter cadence must actually adjudicate that often,
89
+ * while a fixed window would downgrade it without saying so (#1494).
90
+ * @module @linxin666/dsh-pet/client/work-tick-gate
91
+ */
92
+ /** Window used when the active pet declares no work cadence. */
93
+ const DEFAULT_WORK_TICK_MS = 1e4;
94
+ /** Manifest bounds for `gameplay.work.tickMs` (src/gameplay.ts). */
95
+ const MIN_WORK_TICK_MS = 1e3;
96
+ const MAX_WORK_TICK_MS = 6e4;
97
+ /**
98
+ * The gate window for one pet: its configured cadence, clamped to the manifest's
99
+ * own bounds so a malformed registry entry cannot disable or flood the gate.
100
+ * @param tickMs - the active definition's `gameplay.work.tickMs`, when it has one.
101
+ * @returns the window in milliseconds.
102
+ */
103
+ function workTickWindowMs(tickMs) {
104
+ if (typeof tickMs !== "number" || !Number.isFinite(tickMs)) return DEFAULT_WORK_TICK_MS;
105
+ return Math.min(MAX_WORK_TICK_MS, Math.max(MIN_WORK_TICK_MS, tickMs));
106
+ }
107
+ /**
108
+ * Create a gate.
109
+ * @param now - the clock, injectable so tests control the window.
110
+ * @returns the gate over that clock.
111
+ */
112
+ function createWorkTickGate(now = Date.now) {
113
+ let lastAdjudicatedAt = 0;
114
+ return {
115
+ allow: (tickMs) => {
116
+ const at = now();
117
+ if (at - lastAdjudicatedAt < workTickWindowMs(tickMs)) return false;
118
+ lastAdjudicatedAt = at;
119
+ return true;
120
+ },
121
+ reset: () => {
122
+ lastAdjudicatedAt = 0;
123
+ }
124
+ };
125
+ }
126
+ //#endregion
78
127
  //#region ../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/dist/clsx.mjs
79
128
  function r(e) {
80
129
  var t, f, n = "";
@@ -1095,6 +1144,7 @@ window.__ModuleLoader__.load({
1095
1144
  gameplayBus.setIdleTrack = (track) => {
1096
1145
  handleRef.current?.setIdleTrack(track);
1097
1146
  };
1147
+ if (gameplayBus.idleTrack !== void 0) handle.setIdleTrack(gameplayBus.idleTrack);
1098
1148
  cleanups.push(() => {
1099
1149
  gameplayBus.setTrack = void 0;
1100
1150
  gameplayBus.setIdleTrack = void 0;
@@ -1208,6 +1258,7 @@ window.__ModuleLoader__.load({
1208
1258
  const def = definition.gameplay;
1209
1259
  const view = ui.snapshot?.gameplay;
1210
1260
  const phase = ui.snapshot?.phase ?? "idle";
1261
+ const persistedSkin = ui.snapshot?.skin;
1211
1262
  const [open, setOpen] = (0, react.useState)(false);
1212
1263
  const [page, setPage] = (0, react.useState)("root");
1213
1264
  const [skinId, setSkinId] = (0, react.useState)(void 0);
@@ -1400,20 +1451,24 @@ window.__ModuleLoader__.load({
1400
1451
  (0, react.useEffect)(() => {
1401
1452
  const work = def?.work;
1402
1453
  if (def === void 0 || work === void 0 || view?.mode !== "work") return void 0;
1403
- bus.setTrack?.(work.state);
1454
+ const skinGameplay = definition.frames2d?.skins?.find((skin) => skin.id === skinIdRef.current)?.gameplayTracks;
1455
+ /** The state's track, swapped for the skin's override when it declares one. */
1456
+ const trackOf = (state) => skinGameplay?.[state] ?? state;
1457
+ bus.setTrack?.(trackOf(work.state));
1404
1458
  let resultTimer = 0;
1405
1459
  const timer = window.setInterval(() => {
1406
1460
  if (busyRef.current) return;
1407
1461
  busyRef.current = true;
1408
1462
  api.workTick().then((result) => {
1409
1463
  busyRef.current = false;
1464
+ if (modeRef.current !== "work") return;
1410
1465
  applyResult(result);
1411
1466
  if (result.ok !== true || result.outcome === void 0) return;
1412
- const resultTrack = result.outcome === "success" ? work.successState : work.failState;
1467
+ const resultTrack = trackOf(result.outcome === "success" ? work.successState : work.failState);
1413
1468
  const hold = result.outcome === "success" ? work.resultMs?.success ?? 1300 : work.resultMs?.fail ?? 1900;
1414
1469
  bus.setTrack?.(resultTrack);
1415
1470
  resultTimer = window.setTimeout(() => {
1416
- if (modeRef.current === "work") bus.setTrack?.(work.state);
1471
+ if (modeRef.current === "work") bus.setTrack?.(trackOf(work.state));
1417
1472
  }, hold);
1418
1473
  }, () => {
1419
1474
  busyRef.current = false;
@@ -1427,7 +1482,8 @@ window.__ModuleLoader__.load({
1427
1482
  }, [
1428
1483
  definition.id,
1429
1484
  def,
1430
- view?.mode
1485
+ view?.mode,
1486
+ skinId
1431
1487
  ]);
1432
1488
  (0, react.useEffect)(() => {
1433
1489
  const sleep = def?.sleep;
@@ -1464,10 +1520,32 @@ window.__ModuleLoader__.load({
1464
1520
  const setMode = (next) => {
1465
1521
  api.setMode(next).then(applyResult, () => void 0);
1466
1522
  };
1523
+ (0, react.useEffect)(() => {
1524
+ setSkinId(persistedSkin);
1525
+ }, [definition.id, persistedSkin]);
1526
+ (0, react.useEffect)(() => {
1527
+ const skin = definition.frames2d?.skins?.find((candidate) => candidate.id === skinId);
1528
+ bus.idleTrack = skin?.idleTrack;
1529
+ bus.setIdleTrack?.(skin?.idleTrack);
1530
+ }, [
1531
+ definition.id,
1532
+ skinId,
1533
+ persistedSkin
1534
+ ]);
1467
1535
  const skins = definition.frames2d?.skins;
1536
+ /** The base idle track one skin id resolves to (undefined = default look). */
1537
+ const skinTrackOf = (id) => id === void 0 ? void 0 : definition.frames2d?.skins?.find((candidate) => candidate.id === id)?.idleTrack;
1468
1538
  const selectSkin = (skin) => {
1469
1539
  setSkinId(skin?.id);
1470
1540
  bus.setIdleTrack?.(skin?.idleTrack);
1541
+ const restore = () => {
1542
+ setSkinId(persistedSkin);
1543
+ bus.setIdleTrack?.(skinTrackOf(persistedSkin));
1544
+ };
1545
+ api.setSkin(skin?.id).then((result) => {
1546
+ if (result.ok) return;
1547
+ restore();
1548
+ }, restore);
1471
1549
  };
1472
1550
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1473
1551
  ref: hudRef,
@@ -2000,10 +2078,11 @@ window.__ModuleLoader__.load({
2000
2078
  * Frame presentation has two modes picked once at mount by capability
2001
2079
  * probing:
2002
2080
  * - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
2003
- * exist): every frame is decoded exactly once into an ImageBitmap during
2004
- * the warm pass and drawn onto one <canvas> - steady-state playback issues
2005
- * zero DOM mutations and zero re-decodes (measured hotspot: swapping
2006
- * <img>.src per frame drove image decode + invalidation every tick).
2081
+ * exist): frames are decoded once into an ImageBitmap on demand, with a
2082
+ * bounded look-ahead window over the playing track, and drawn onto one
2083
+ * <canvas> - steady-state playback issues zero DOM mutations and zero
2084
+ * re-decodes (measured hotspot: swapping <img>.src per frame drove image
2085
+ * decode + invalidation every tick).
2007
2086
  * - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
2008
2087
  * historical behavior - cache-warm Image elements plus guarded src swaps,
2009
2088
  * so environments without modern decoding keep working unchanged.
@@ -2187,6 +2266,25 @@ window.__ModuleLoader__.load({
2187
2266
  pumpFrames();
2188
2267
  return job;
2189
2268
  };
2269
+ /**
2270
+ * Bounded look-ahead window: decode only the frames playback is about to
2271
+ * need. The historical warm pass decoded every frame of every track up
2272
+ * front - a shipped pet carries ~1.1k 512x683 frames, so that pass pulls
2273
+ * tens of megabytes and retains every decoded bitmap for the life of the
2274
+ * page. Prefetching the playing track's next frames keeps loops and phase
2275
+ * switches warm while unplayed tracks (and every unselected skin's
2276
+ * frames) stay on demand, where playback jumps the queue anyway.
2277
+ */
2278
+ const PREFETCH_AHEAD = 12;
2279
+ const prefetchAhead = (trackId, index) => {
2280
+ const def = config.tracks[trackId];
2281
+ if (def === void 0) return;
2282
+ const end = Math.min(def.frames.length, index + 1 + PREFETCH_AHEAD);
2283
+ for (let ahead = index + 1; ahead < end; ahead += 1) {
2284
+ const url = def.frames[ahead];
2285
+ if (url !== void 0) loadFrame(url);
2286
+ }
2287
+ };
2190
2288
  let disposed = false;
2191
2289
  let timer;
2192
2290
  let watchdog;
@@ -2221,6 +2319,7 @@ window.__ModuleLoader__.load({
2221
2319
  const show = (trackId, index) => {
2222
2320
  const url = config.tracks[trackId]?.frames[index];
2223
2321
  if (url === void 0) return;
2322
+ prefetchAhead(trackId, index);
2224
2323
  if (img !== null) {
2225
2324
  if (img.getAttribute("src") !== url) img.src = url;
2226
2325
  return;
@@ -2277,11 +2376,6 @@ window.__ModuleLoader__.load({
2277
2376
  const expected = (def.durations[frameIndex] ?? 200) + WATCHDOG_MS;
2278
2377
  if (Date.now() - lastAdvance > expected) tick();
2279
2378
  }, WATCHDOG_MS);
2280
- const warmTrackIds = [.../* @__PURE__ */ new Set([...Object.values(config.phases), config.phases.idle])];
2281
- for (const warmTrack of [...warmTrackIds, ...Object.keys(config.tracks)].map((id) => config.tracks[id])) {
2282
- if (warmTrack === void 0) continue;
2283
- for (const warmUrl of warmTrack.frames) loadFrame(warmUrl);
2284
- }
2285
2379
  play(track);
2286
2380
  let disposedOnce = false;
2287
2381
  const dispose = () => {
@@ -3656,7 +3750,7 @@ window.__ModuleLoader__.load({
3656
3750
  /** The building package's version, when the bundle carries it. */
3657
3751
  function bakedVersion() {
3658
3752
  try {
3659
- return "0.3.20";
3753
+ return "0.3.21";
3660
3754
  } catch {
3661
3755
  return;
3662
3756
  }
@@ -3739,6 +3833,7 @@ window.__ModuleLoader__.load({
3739
3833
  setConfig: (patch) => petFetch("/api/pet/set-config", patch),
3740
3834
  setName: (name) => petFetch("/api/pet/set-name", { name }),
3741
3835
  setPet: (petId) => petFetch("/api/pet/set-pet", { petId }),
3836
+ setSkin: (skin) => petFetch("/api/pet/set-skin", skin === void 0 ? {} : { skin }),
3742
3837
  gameplayTouch: (zone) => petFetch("/api/pet/gameplay/touch", zone === void 0 ? {} : { zone }),
3743
3838
  gameplaySetMode: (mode) => petFetch("/api/pet/gameplay/mode", { mode }),
3744
3839
  gameplayWorkTick: () => petFetch("/api/pet/gameplay/work-tick", {}),
@@ -3763,16 +3858,17 @@ window.__ModuleLoader__.load({
3763
3858
  * first-level settings section.
3764
3859
  * @param ctx - client root context.
3765
3860
  */
3861
+ /** The page-wide work-tick gate; its window follows the active pet's cadence. */
3862
+ const workTickGate = createWorkTickGate();
3766
3863
  /**
3767
- * Module-wide work-tick throttle (ms). Hot reloads can leave several
3768
- * GameplayHud instances alive, each running its own 10s work interval;
3769
- * without a shared gate every interval would call workTick and each stale
3770
- * call re-rolls, re-grants treats and re-plays the success/fail track, so
3771
- * the outcome appears to play several times per window. This shared marker
3772
- * accepts the first adjudication of a window and silently suppresses the
3773
- * duplicates that follow. Reset when (re-)entering work mode.
3864
+ * The work cadence the active pet declares, when its registry entry is known.
3865
+ * @param store - the pet store holding the host snapshot and the registry list.
3866
+ * @returns the configured `gameplay.work.tickMs`, or undefined when unknown.
3774
3867
  */
3775
- let lastWorkTickAt = 0;
3868
+ function activeWorkTickMs(store) {
3869
+ const state = store.getSnapshot();
3870
+ return state.pets.find((entry) => entry.id === state.snapshot?.pet.id)?.gameplay?.work?.tickMs;
3871
+ }
3776
3872
  function apply(ctx) {
3777
3873
  reportDailyHeartbeat([{ name: "@linxin666/dsh-pet" }]);
3778
3874
  ctx.effect(() => {
@@ -3934,14 +4030,19 @@ window.__ModuleLoader__.load({
3934
4030
  },
3935
4031
  gameplay: {
3936
4032
  touch: (zone) => petApi.gameplayTouch(zone),
4033
+ setSkin: (skin) => petApi.setSkin(skin).then((result) => {
4034
+ if (result.ok) pollNow();
4035
+ return result;
4036
+ }, () => ({
4037
+ ok: false,
4038
+ error: "transport"
4039
+ })),
3937
4040
  setMode: async (mode) => {
3938
- if (mode === "work") lastWorkTickAt = 0;
4041
+ if (mode === "work") workTickGate.reset();
3939
4042
  return petApi.gameplaySetMode(mode);
3940
4043
  },
3941
4044
  workTick: async () => {
3942
- const now = Date.now();
3943
- if (now - lastWorkTickAt < 8500) return { ok: true };
3944
- lastWorkTickAt = now;
4045
+ if (!workTickGate.allow(activeWorkTickMs(petStore))) return { ok: true };
3945
4046
  return petApi.gameplayWorkTick();
3946
4047
  },
3947
4048
  buy: (item) => petApi.gameplayBuy(item)