@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/index.js CHANGED
@@ -1171,6 +1171,37 @@ var PetLedger = class {
1171
1171
  this.dirty = true;
1172
1172
  }
1173
1173
  /**
1174
+ * Select one pet's frames2d skin; `undefined` clears the choice back to the
1175
+ * pet's default look. Manifest validation stays a caller concern, exactly
1176
+ * like setPetName's length check.
1177
+ */
1178
+ setPetSkin(petId, skinId) {
1179
+ const skins = this.current.skins;
1180
+ if (skinId === void 0) {
1181
+ if (skins[petId] === void 0) return;
1182
+ const next = { ...skins };
1183
+ delete next[petId];
1184
+ this.current = {
1185
+ ...this.current,
1186
+ skins: next
1187
+ };
1188
+ } else {
1189
+ if (skins[petId] === skinId) return;
1190
+ this.current = {
1191
+ ...this.current,
1192
+ skins: {
1193
+ ...skins,
1194
+ [petId]: skinId
1195
+ }
1196
+ };
1197
+ }
1198
+ this.dirty = true;
1199
+ }
1200
+ /** The persisted skin id for one pet (undefined = the pet's default look). */
1201
+ petSkin(petId) {
1202
+ return this.current.skins[petId];
1203
+ }
1204
+ /**
1174
1205
  * Swap the reaction pools to another pet's custom remarks (called on pet
1175
1206
  * selection). Slots the pet does not declare fall back to voice packs or built-ins.
1176
1207
  */
@@ -1370,6 +1401,7 @@ function emptyPersist() {
1370
1401
  return {
1371
1402
  petId: DEFAULT_PET_ID,
1372
1403
  names: {},
1404
+ skins: {},
1373
1405
  affinity: emptyAffinity(),
1374
1406
  treats: emptyTreatLedger(),
1375
1407
  display: { ...defaultDisplayConfig },
@@ -1400,6 +1432,18 @@ function loadPetNames(parsed) {
1400
1432
  }
1401
1433
  return names;
1402
1434
  }
1435
+ /** Sanitize the per-pet skin selection map (string keys, non-empty trimmed values). */
1436
+ function loadPetSkins(parsed) {
1437
+ const skins = {};
1438
+ if (typeof parsed.skins !== "object" || parsed.skins === null) return skins;
1439
+ for (const [id, value] of Object.entries(parsed.skins)) {
1440
+ if (id === "" || typeof value !== "string") continue;
1441
+ const skin = value.trim();
1442
+ if (skin === "") continue;
1443
+ skins[id] = skin;
1444
+ }
1445
+ return skins;
1446
+ }
1403
1447
  /** Clamp one count/score into [0, max]. */
1404
1448
  function clamp(value, max) {
1405
1449
  return Math.min(max, Math.max(0, value));
@@ -1424,12 +1468,15 @@ function loadGameplay(parsed) {
1424
1468
  if (key === "" || typeof value !== "number" || !Number.isFinite(value)) continue;
1425
1469
  currencies[key] = Math.min(GAMEPLAY_LOAD_CURRENCY_CAP, Math.max(0, Math.floor(value)));
1426
1470
  }
1427
- result[petId] = {
1471
+ const item = {
1428
1472
  stats,
1429
1473
  currencies,
1430
1474
  mode: record.mode === "work" || record.mode === "sleep" ? record.mode : null,
1431
1475
  settledAt: clamp(finiteNum(record.settledAt, 0), Number.MAX_SAFE_INTEGER)
1432
1476
  };
1477
+ if (typeof record.incomeCarryMs === "number" && Number.isFinite(record.incomeCarryMs)) item.incomeCarryMs = Math.max(0, record.incomeCarryMs);
1478
+ if (typeof record.restoreCarryMs === "number" && Number.isFinite(record.restoreCarryMs)) item.restoreCarryMs = Math.max(0, record.restoreCarryMs);
1479
+ result[petId] = item;
1433
1480
  }
1434
1481
  return result;
1435
1482
  }
@@ -1469,6 +1516,7 @@ function loadPetPersist(dir = petHomeDir()) {
1469
1516
  return {
1470
1517
  petId,
1471
1518
  names,
1519
+ skins: loadPetSkins(parsed),
1472
1520
  affinity,
1473
1521
  treats,
1474
1522
  display,
@@ -2335,7 +2383,10 @@ function settleGameplay(state, manifest, now, options) {
2335
2383
  }
2336
2384
  }
2337
2385
  if (manifest.passiveIncome !== void 0) {
2338
- const ticks = Math.floor(elapsedMs / manifest.passiveIncome.intervalMs);
2386
+ const incomeElapsed = elapsedMs + (state.incomeCarryMs ?? 0);
2387
+ const interval = manifest.passiveIncome.intervalMs;
2388
+ const ticks = Math.floor(incomeElapsed / interval);
2389
+ state.incomeCarryMs = incomeElapsed % interval;
2339
2390
  if (ticks > 0) {
2340
2391
  const currency = manifest.passiveIncome.currency;
2341
2392
  state.currencies[currency] = (state.currencies[currency] ?? 0) + ticks * manifest.passiveIncome.amount;
@@ -2343,13 +2394,16 @@ function settleGameplay(state, manifest, now, options) {
2343
2394
  }
2344
2395
  }
2345
2396
  if (state.mode === "sleep" && manifest.sleep !== void 0) {
2346
- const ticks = Math.floor(elapsedMs / manifest.sleep.restore.intervalMs);
2397
+ const restoreElapsed = elapsedMs + (state.restoreCarryMs ?? 0);
2398
+ const interval = manifest.sleep.restore.intervalMs;
2399
+ const ticks = Math.floor(restoreElapsed / interval);
2400
+ state.restoreCarryMs = restoreElapsed % interval;
2347
2401
  if (ticks > 0) {
2348
2402
  const stat = manifest.sleep.restore.stat;
2349
2403
  state.stats[stat] = (state.stats[stat] ?? 0) + ticks * manifest.sleep.restore.amount;
2350
2404
  changed = true;
2351
2405
  }
2352
- }
2406
+ } else state.restoreCarryMs = 0;
2353
2407
  state.settledAt = now;
2354
2408
  clampGameplay(state, manifest);
2355
2409
  return changed;
@@ -4794,6 +4848,40 @@ var PetService = class extends Service {
4794
4848
  display: this.ledger.snapshot.display
4795
4849
  };
4796
4850
  }
4851
+ /**
4852
+ * The persisted skin for one entry, when the manifest still declares it: a
4853
+ * stale id (skin removed from the manifest, pet swapped) reads as "default"
4854
+ * rather than pinning a track the browser half cannot resolve.
4855
+ */
4856
+ persistedSkin(entry) {
4857
+ const stored = this.ledger.petSkin(entry.id);
4858
+ if (stored === void 0) return void 0;
4859
+ return entry.frames2d?.skins?.some((skin) => skin.id === stored) === true ? stored : void 0;
4860
+ }
4861
+ /**
4862
+ * RPC: select the current pet's frames2d skin (`undefined` restores the
4863
+ * pet's default look). The choice is stored per pet, so every later state
4864
+ * view (reload, client restart, pet re-selection) serves it back.
4865
+ */
4866
+ async setSkin(skin) {
4867
+ const entry = this.activeEntry();
4868
+ const declared = entry.frames2d?.skins ?? [];
4869
+ if (skin === void 0) {
4870
+ this.ledger.setPetSkin(entry.id, void 0);
4871
+ this.flush();
4872
+ return { ok: true };
4873
+ }
4874
+ if (!declared.some((candidate) => candidate.id === skin)) return {
4875
+ ok: false,
4876
+ error: "unknown-skin"
4877
+ };
4878
+ this.ledger.setPetSkin(entry.id, skin);
4879
+ this.flush();
4880
+ return {
4881
+ ok: true,
4882
+ skin
4883
+ };
4884
+ }
4797
4885
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
4798
4886
  async setConfig(patch) {
4799
4887
  const next = {
@@ -4903,6 +4991,7 @@ var PetService = class extends Service {
4903
4991
  gameplay = this.gameplayViewOf(state);
4904
4992
  }
4905
4993
  const announcement = this.announcement !== void 0 && announcementFresh(this.announcement, Date.now()) ? this.announcement : void 0;
4994
+ const skin = this.persistedSkin(entry);
4906
4995
  return {
4907
4996
  animation: snapshot.animation,
4908
4997
  ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
@@ -4919,6 +5008,7 @@ var PetService = class extends Service {
4919
5008
  description: entry.description
4920
5009
  },
4921
5010
  name: this.petName(),
5011
+ ...skin === void 0 ? {} : { skin },
4922
5012
  treats: {
4923
5013
  stocked: this.ledger.snapshot.treats.treats,
4924
5014
  max: this.ledger.treatMax
@@ -5605,6 +5695,11 @@ function makePetRoutes(deps) {
5605
5695
  if (typeof name !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-name"));
5606
5696
  return service.setName(name);
5607
5697
  }),
5698
+ postRoute(ctx, "/api/pet/set-skin", (body) => {
5699
+ const skin = body.skin;
5700
+ if (skin !== void 0 && typeof skin !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-skin"));
5701
+ return service.setSkin(skin === void 0 || skin === "" ? void 0 : skin);
5702
+ }),
5608
5703
  postRoute(ctx, "/api/pet/set-pet", (body) => {
5609
5704
  const petId = body.petId;
5610
5705
  if (typeof petId !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-pet"));
@@ -21,6 +21,14 @@ export interface GameplayApi {
21
21
  setMode: (mode: 'work' | 'sleep' | null) => Promise<PetGameplayVerbResult>;
22
22
  workTick: () => Promise<PetGameplayVerbResult>;
23
23
  buy: (item: string) => Promise<PetGameplayVerbResult>;
24
+ /**
25
+ * Persist the selected skin for the current pet (host-authoritative;
26
+ * `undefined` restores the pet's default look).
27
+ */
28
+ setSkin: (skin: string | undefined) => Promise<{
29
+ ok: boolean;
30
+ error?: string;
31
+ }>;
24
32
  }
25
33
  /**
26
34
  * The per-pet coordination bus. The frames2d visual mount registers the
@@ -32,6 +40,13 @@ export interface GameplayBus {
32
40
  setTrack?: (track?: string) => void;
33
41
  /** Swap the pet's base idle track (skin switch); undefined restores default. */
34
42
  setIdleTrack?: (track?: string) => void;
43
+ /**
44
+ * The base idle track the HUD wants right now, latched on the bus so a
45
+ * renderer that registers late (or remounts: hidden/summoned, StrictMode's
46
+ * double mount) still applies the restored skin instead of snapping back to
47
+ * the default look. Mutating it never requires a re-render.
48
+ */
49
+ idleTrack?: string;
35
50
  tap?: (fx: number, fy: number) => void;
36
51
  /**
37
52
  * Card open/close request from the chrome (the hover panel's 玩法 action):
@@ -1 +1 @@
1
- {"version":3,"file":"gameplay-hud.d.ts","sourceRoot":"","sources":["../../../src/client/gameplay-hud.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAsE,KAAK,YAAY,EAAE,MAAM,OAAO,CAAA;AAC7G,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,aAAa,EAAqB,MAAM,gBAAgB,CAAA;AACtE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAE1D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAClD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGtC,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;IACxD,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;IAC1E,QAAQ,EAAE,MAAM,OAAO,CAAC,qBAAqB,CAAC,CAAA;IAC9C,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;CACtD;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACnC,gFAAgF;IAChF,YAAY,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACvC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;IACtC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;CACpC;AAYD,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,KAAK,EAAE;IACjC,UAAU,EAAE,aAAa,CAAA;IACzB,KAAK,EAAE,gBAAgB,CAAA;IACvB,GAAG,EAAE,WAAW,CAAA;IAChB,GAAG,EAAE,WAAW,CAAA;IAChB,IAAI,EAAE,UAAU,CAAA;IAChB,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAA;CAC/B,GAAG,YAAY,GAAG,IAAI,CAwatB"}
1
+ {"version":3,"file":"gameplay-hud.d.ts","sourceRoot":"","sources":["../../../src/client/gameplay-hud.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAsE,KAAK,YAAY,EAAE,MAAM,OAAO,CAAA;AAC7G,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,aAAa,EAAqB,MAAM,gBAAgB,CAAA;AACtE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAE1D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAClD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGtC,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;IACxD,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;IAC1E,QAAQ,EAAE,MAAM,OAAO,CAAC,qBAAqB,CAAC,CAAA;IAC9C,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;IACrD;;;OAGG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAChF;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACnC,gFAAgF;IAChF,YAAY,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACvC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;IACtC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;CACpC;AAYD,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,KAAK,EAAE;IACjC,UAAU,EAAE,aAAa,CAAA;IACzB,KAAK,EAAE,gBAAgB,CAAA;IACvB,GAAG,EAAE,WAAW,CAAA;IAChB,GAAG,EAAE,WAAW,CAAA;IAChB,IAAI,EAAE,UAAU,CAAA;IAChB,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAA;CAC/B,GAAG,YAAY,GAAG,IAAI,CAodtB"}
@@ -20,6 +20,8 @@ export function GameplayHud(props) {
20
20
  const def = definition.gameplay;
21
21
  const view = ui.snapshot?.gameplay;
22
22
  const phase = ui.snapshot?.phase ?? 'idle';
23
+ // Host-persisted skin selection for this pet (undefined = default look).
24
+ const persistedSkin = ui.snapshot?.skin;
23
25
  const [open, setOpen] = useState(false);
24
26
  const [page, setPage] = useState('root');
25
27
  // Currently selected skin id (base idle swap); undefined = default look.
@@ -247,12 +249,16 @@ export function GameplayHud(props) {
247
249
  }, [definition.id, def]);
248
250
  // Work loop: hold the work track, adjudicate one round per tick, play the
249
251
  // result track for its hold window, then resume. Leaving the mode
250
- // releases the override so the phase mapping takes over.
252
+ // releases the override so the phase mapping takes over. A skin that
253
+ // declares gameplayTracks for the work states plays its own art instead.
251
254
  useEffect(() => {
252
255
  const work = def?.work;
253
256
  if (def === undefined || work === undefined || view?.mode !== 'work')
254
257
  return undefined;
255
- bus.setTrack?.(work.state);
258
+ const skinGameplay = definition.frames2d?.skins?.find(skin => skin.id === skinIdRef.current)?.gameplayTracks;
259
+ /** The state's track, swapped for the skin's override when it declares one. */
260
+ const trackOf = (state) => skinGameplay?.[state] ?? state;
261
+ bus.setTrack?.(trackOf(work.state));
256
262
  let resultTimer = 0;
257
263
  const timer = window.setInterval(() => {
258
264
  if (busyRef.current)
@@ -260,15 +266,20 @@ export function GameplayHud(props) {
260
266
  busyRef.current = true;
261
267
  void api.workTick().then((result) => {
262
268
  busyRef.current = false;
269
+ // Leaving work mode while the adjudication is in flight drops the late
270
+ // result: writing it back would show work rewards and play the result
271
+ // track for a mode the user has already left (#1495).
272
+ if (modeRef.current !== 'work')
273
+ return;
263
274
  applyResult(result);
264
275
  if (result.ok !== true || result.outcome === undefined)
265
276
  return;
266
- const resultTrack = result.outcome === 'success' ? work.successState : work.failState;
277
+ const resultTrack = trackOf(result.outcome === 'success' ? work.successState : work.failState);
267
278
  const hold = result.outcome === 'success' ? work.resultMs?.success ?? 1300 : work.resultMs?.fail ?? 1900;
268
279
  bus.setTrack?.(resultTrack);
269
280
  resultTimer = window.setTimeout(() => {
270
281
  if (modeRef.current === 'work')
271
- bus.setTrack?.(work.state);
282
+ bus.setTrack?.(trackOf(work.state));
272
283
  }, hold);
273
284
  }, () => { busyRef.current = false; });
274
285
  }, work.tickMs);
@@ -277,8 +288,8 @@ export function GameplayHud(props) {
277
288
  window.clearTimeout(resultTimer);
278
289
  bus.setTrack?.(undefined);
279
290
  };
280
- // eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode value
281
- }, [definition.id, def, view?.mode]);
291
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode and the selected skin
292
+ }, [definition.id, def, view?.mode, skinId]);
282
293
  // Sleep loop: hold the sleep track; restore is host-side (lazy settle).
283
294
  // While a skin with a gameplayTracks.sleep override is selected, the skin's
284
295
  // own track replaces the default sleep track (e.g. a skin-specific doze).
@@ -315,10 +326,41 @@ export function GameplayHud(props) {
315
326
  const setMode = (next) => {
316
327
  void api.setMode(next).then(applyResult, () => undefined);
317
328
  };
329
+ // Skin selection is persisted host-side (per pet): re-seed the menu from
330
+ // every fresh state view, so a page reload or client restart keeps the last
331
+ // choice instead of snapping back to the default look.
332
+ useEffect(() => {
333
+ setSkinId(persistedSkin);
334
+ }, [definition.id, persistedSkin]);
335
+ // Push the resolved base idle track into the renderer whenever the pet or
336
+ // the selection changes. The value is latched on the bus first: the visual
337
+ // may register later, or remount later (hidden/summoned), and reads the
338
+ // latch back on activation so a restored skin never falls back to default.
339
+ useEffect(() => {
340
+ const skin = definition.frames2d?.skins?.find(candidate => candidate.id === skinId);
341
+ bus.idleTrack = skin?.idleTrack;
342
+ bus.setIdleTrack?.(skin?.idleTrack);
343
+ // persistedSkin rides the deps so the host's value also re-pushes on
344
+ // arrival (a renderer that mounted early still converges).
345
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one push per selection
346
+ }, [definition.id, skinId, persistedSkin]);
318
347
  const skins = definition.frames2d?.skins;
348
+ /** The base idle track one skin id resolves to (undefined = default look). */
349
+ const skinTrackOf = (id) => id === undefined ? undefined : definition.frames2d?.skins?.find(candidate => candidate.id === id)?.idleTrack;
319
350
  const selectSkin = (skin) => {
320
351
  setSkinId(skin?.id);
321
352
  bus.setIdleTrack?.(skin?.idleTrack);
353
+ // The host owns the choice: a refusal (unknown skin) restores both the
354
+ // menu highlight and the renderer to the value it still serves.
355
+ const restore = () => {
356
+ setSkinId(persistedSkin);
357
+ bus.setIdleTrack?.(skinTrackOf(persistedSkin));
358
+ };
359
+ void api.setSkin(skin?.id).then((result) => {
360
+ if (result.ok)
361
+ return;
362
+ restore();
363
+ }, restore);
322
364
  };
323
365
  return (_jsxs("div", { ref: hudRef, className: styles.gameplayHud, "data-dsh-pet-gameplay": definition.id, children: [floats.map(entry => (_jsx("div", { className: styles.gameplayFloat, children: entry.text }, entry.id))), mode !== null && (_jsx("div", { className: styles.gameplayModeChip, children: tr(mode === 'work' ? 'pet.gameplay.working' : 'pet.gameplay.sleeping') })), open && (_jsxs("div", { ref: cardRef, className: styles.gameplayCard, "data-page": page, children: [page === 'root' && (_jsxs(_Fragment, { children: [_jsx("div", { className: styles.gameplayBars, children: Object.entries(stats).map(([name, stat]) => {
324
366
  const value = view.stats[name] ?? 0;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAGnE,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,4CAA4C,CAAA;AA8ElG,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAoBD,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAiU9C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAGnE,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,4CAA4C,CAAA;AAiFlG,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAuBD,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAqU9C"}
@@ -15,6 +15,7 @@
15
15
  import { createElement } from 'react';
16
16
  import { createRoot } from 'react-dom/client';
17
17
  import { createPetStore } from "./pet-store.js";
18
+ import { createWorkTickGate } from "./work-tick-gate.js";
18
19
  import { PetDockEntry } from "./PetDockEntry.js";
19
20
  import { defaultPetRendererRegistry } from "./renderers/registry.js";
20
21
  import { live2dRenderer } from "./renderers/live2d.js";
@@ -47,6 +48,7 @@ const petApi = {
47
48
  setConfig: (patch) => petFetch('/api/pet/set-config', patch),
48
49
  setName: (name) => petFetch('/api/pet/set-name', { name }),
49
50
  setPet: (petId) => petFetch('/api/pet/set-pet', { petId }),
51
+ setSkin: (skin) => petFetch('/api/pet/set-skin', skin === undefined ? {} : { skin }),
50
52
  gameplayTouch: (zone) => petFetch('/api/pet/gameplay/touch', zone === undefined ? {} : { zone }),
51
53
  gameplaySetMode: (mode) => petFetch('/api/pet/gameplay/mode', { mode }),
52
54
  gameplayWorkTick: () => petFetch('/api/pet/gameplay/work-tick', {}),
@@ -64,16 +66,18 @@ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote
64
66
  * first-level settings section.
65
67
  * @param ctx - client root context.
66
68
  */
69
+ /** The page-wide work-tick gate; its window follows the active pet's cadence. */
70
+ const workTickGate = createWorkTickGate();
67
71
  /**
68
- * Module-wide work-tick throttle (ms). Hot reloads can leave several
69
- * GameplayHud instances alive, each running its own 10s work interval;
70
- * without a shared gate every interval would call workTick and each stale
71
- * call re-rolls, re-grants treats and re-plays the success/fail track, so
72
- * the outcome appears to play several times per window. This shared marker
73
- * accepts the first adjudication of a window and silently suppresses the
74
- * duplicates that follow. Reset when (re-)entering work mode.
72
+ * The work cadence the active pet declares, when its registry entry is known.
73
+ * @param store - the pet store holding the host snapshot and the registry list.
74
+ * @returns the configured `gameplay.work.tickMs`, or undefined when unknown.
75
75
  */
76
- let lastWorkTickAt = 0;
76
+ function activeWorkTickMs(store) {
77
+ const state = store.getSnapshot();
78
+ const definition = state.pets.find((entry) => entry.id === state.snapshot?.pet.id);
79
+ return definition?.gameplay?.work?.tickMs;
80
+ }
77
81
  export function apply(ctx) {
78
82
  // Anonymous install heartbeat (docs/telemetry.md): one beat per browser per
79
83
  // UTC day, package name only, silent failure.
@@ -309,19 +313,23 @@ export function apply(ctx) {
309
313
  },
310
314
  gameplay: {
311
315
  touch: (zone) => petApi.gameplayTouch(zone),
316
+ setSkin: (skin) => petApi.setSkin(skin).then((result) => {
317
+ if (result.ok)
318
+ pollNow();
319
+ return result;
320
+ }, () => ({ ok: false, error: 'transport' })),
312
321
  setMode: async (mode) => {
313
322
  if (mode === 'work')
314
- lastWorkTickAt = 0;
323
+ workTickGate.reset();
315
324
  return petApi.gameplaySetMode(mode);
316
325
  },
317
326
  workTick: async () => {
318
327
  // One adjudication per tick window, page-wide: suppress stale
319
- // duplicate intervals (HMR) re-playing the result track.
320
- const now = Date.now();
321
- if (now - lastWorkTickAt < 8500) {
328
+ // duplicate intervals (HMR) re-playing the result track. The window
329
+ // is the pet's own cadence, so a shorter tickMs is not downgraded.
330
+ if (!workTickGate.allow(activeWorkTickMs(petStore))) {
322
331
  return { ok: true };
323
332
  }
324
- lastWorkTickAt = now;
325
333
  return petApi.gameplayWorkTick();
326
334
  },
327
335
  buy: (item) => petApi.gameplayBuy(item),
@@ -1 +1 @@
1
- {"version":3,"file":"Frames2dVisualMount.d.ts","sourceRoot":"","sources":["../../../../src/client/renderers/Frames2dVisualMount.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAA+B,KAAK,YAAY,EAAE,MAAM,OAAO,CAAA;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAInD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,eAAe,CAAA;AAEvC,8EAA8E;AAC9E,wBAAgB,mBAAmB,CAAC,KAAK,EAAE;IACzC,UAAU,EAAE,aAAa,CAAA;IACzB,KAAK,EAAE,aAAa,CAAA;IACpB,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,gEAAgE;IAChE,IAAI,EAAE,UAAU,CAAA;IAChB,yEAAyE;IACzE,GAAG,CAAC,EAAE,WAAW,CAAA;IACjB,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAA;CAC/B,GAAG,YAAY,CAiFf"}
1
+ {"version":3,"file":"Frames2dVisualMount.d.ts","sourceRoot":"","sources":["../../../../src/client/renderers/Frames2dVisualMount.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAA+B,KAAK,YAAY,EAAE,MAAM,OAAO,CAAA;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAInD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,eAAe,CAAA;AAEvC,8EAA8E;AAC9E,wBAAgB,mBAAmB,CAAC,KAAK,EAAE;IACzC,UAAU,EAAE,aAAa,CAAA;IACzB,KAAK,EAAE,aAAa,CAAA;IACpB,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,gEAAgE;IAChE,IAAI,EAAE,UAAU,CAAA;IAChB,yEAAyE;IACzE,GAAG,CAAC,EAAE,WAAW,CAAA;IACjB,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAA;CAC/B,GAAG,YAAY,CAqFf"}
@@ -52,6 +52,11 @@ export function Frames2dVisualMount(props) {
52
52
  const gameplayBus = props.bus;
53
53
  gameplayBus.setTrack = (track) => { handleRef.current?.setState(track); };
54
54
  gameplayBus.setIdleTrack = (track) => { handleRef.current?.setIdleTrack(track); };
55
+ // The HUD latches the wanted base idle (skin selection, restored from the
56
+ // host snapshot): apply it on activation, so a late or repeated mount
57
+ // never repaints the pet with the default look.
58
+ if (gameplayBus.idleTrack !== undefined)
59
+ handle.setIdleTrack(gameplayBus.idleTrack);
55
60
  cleanups.push(() => {
56
61
  gameplayBus.setTrack = undefined;
57
62
  gameplayBus.setIdleTrack = undefined;
@@ -12,10 +12,11 @@
12
12
  * Frame presentation has two modes picked once at mount by capability
13
13
  * probing:
14
14
  * - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
15
- * exist): every frame is decoded exactly once into an ImageBitmap during
16
- * the warm pass and drawn onto one <canvas> - steady-state playback issues
17
- * zero DOM mutations and zero re-decodes (measured hotspot: swapping
18
- * <img>.src per frame drove image decode + invalidation every tick).
15
+ * exist): frames are decoded once into an ImageBitmap on demand, with a
16
+ * bounded look-ahead window over the playing track, and drawn onto one
17
+ * <canvas> - steady-state playback issues zero DOM mutations and zero
18
+ * re-decodes (measured hotspot: swapping <img>.src per frame drove image
19
+ * decode + invalidation every tick).
19
20
  * - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
20
21
  * historical behavior - cache-warm Image elements plus guarded src swaps,
21
22
  * so environments without modern decoding keep working unchanged.
@@ -1 +1 @@
1
- {"version":3,"file":"frames2d.d.ts","sourceRoot":"","sources":["../../../../src/client/renderers/frames2d.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAA4B,KAAK,WAAW,EAA2B,KAAK,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;AACzI,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,oEAAoE;AACpE,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,IAAI,EAAE,OAAO,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAA;IAC3C,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACjE,wEAAwE;IACxE,KAAK,CAAC,EAAE,kBAAkB,EAAE,CAAA;CAC7B;AAED,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,0EAA0E;IAC1E,SAAS,EAAE,MAAM,CAAA;IACjB,sFAAsF;IACtF,YAAY,CAAC,EAAE,6BAA6B,EAAE,CAAA;IAC9C,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACxC;AAED,4DAA4D;AAC5D,MAAM,WAAW,6BAA6B;IAC5C,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,MAAM,WAAW,sBAAuB,SAAQ,iBAAiB;IAC/D,gFAAgF;IAChF,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;IACzC;;;;OAIG;IACH,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;IAC7C,2DAA2D;IAC3D,YAAY,IAAI,MAAM,CAAA;CACvB;AAuFD,eAAO,MAAM,gBAAgB,EAAE,WAAW,CAAC,iBAAiB,CA6T3D,CAAA"}
1
+ {"version":3,"file":"frames2d.d.ts","sourceRoot":"","sources":["../../../../src/client/renderers/frames2d.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAA4B,KAAK,WAAW,EAA2B,KAAK,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;AACzI,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,oEAAoE;AACpE,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,IAAI,EAAE,OAAO,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAA;IAC3C,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACjE,wEAAwE;IACxE,KAAK,CAAC,EAAE,kBAAkB,EAAE,CAAA;CAC7B;AAED,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,0EAA0E;IAC1E,SAAS,EAAE,MAAM,CAAA;IACjB,sFAAsF;IACtF,YAAY,CAAC,EAAE,6BAA6B,EAAE,CAAA;IAC9C,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACxC;AAED,4DAA4D;AAC5D,MAAM,WAAW,6BAA6B;IAC5C,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,MAAM,WAAW,sBAAuB,SAAQ,iBAAiB;IAC/D,gFAAgF;IAChF,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;IACzC;;;;OAIG;IACH,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;IAC7C,2DAA2D;IAC3D,YAAY,IAAI,MAAM,CAAA;CACvB;AAuFD,eAAO,MAAM,gBAAgB,EAAE,WAAW,CAAC,iBAAiB,CAoU3D,CAAA"}
@@ -12,10 +12,11 @@
12
12
  * Frame presentation has two modes picked once at mount by capability
13
13
  * probing:
14
14
  * - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
15
- * exist): every frame is decoded exactly once into an ImageBitmap during
16
- * the warm pass and drawn onto one <canvas> - steady-state playback issues
17
- * zero DOM mutations and zero re-decodes (measured hotspot: swapping
18
- * <img>.src per frame drove image decode + invalidation every tick).
15
+ * exist): frames are decoded once into an ImageBitmap on demand, with a
16
+ * bounded look-ahead window over the playing track, and drawn onto one
17
+ * <canvas> - steady-state playback issues zero DOM mutations and zero
18
+ * re-decodes (measured hotspot: swapping <img>.src per frame drove image
19
+ * decode + invalidation every tick).
19
20
  * - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
20
21
  * historical behavior - cache-warm Image elements plus guarded src swaps,
21
22
  * so environments without modern decoding keep working unchanged.
@@ -230,6 +231,27 @@ export const frames2dRenderer = {
230
231
  pumpFrames();
231
232
  return job;
232
233
  };
234
+ /**
235
+ * Bounded look-ahead window: decode only the frames playback is about to
236
+ * need. The historical warm pass decoded every frame of every track up
237
+ * front - a shipped pet carries ~1.1k 512x683 frames, so that pass pulls
238
+ * tens of megabytes and retains every decoded bitmap for the life of the
239
+ * page. Prefetching the playing track's next frames keeps loops and phase
240
+ * switches warm while unplayed tracks (and every unselected skin's
241
+ * frames) stay on demand, where playback jumps the queue anyway.
242
+ */
243
+ const PREFETCH_AHEAD = 12;
244
+ const prefetchAhead = (trackId, index) => {
245
+ const def = config.tracks[trackId];
246
+ if (def === undefined)
247
+ return;
248
+ const end = Math.min(def.frames.length, index + 1 + PREFETCH_AHEAD);
249
+ for (let ahead = index + 1; ahead < end; ahead += 1) {
250
+ const url = def.frames[ahead];
251
+ if (url !== undefined)
252
+ void loadFrame(url);
253
+ }
254
+ };
233
255
  let disposed = false;
234
256
  let timer;
235
257
  let watchdog;
@@ -277,6 +299,7 @@ export const frames2dRenderer = {
277
299
  const url = def?.frames[index];
278
300
  if (url === undefined)
279
301
  return;
302
+ prefetchAhead(trackId, index);
280
303
  if (img !== null) {
281
304
  if (img.getAttribute('src') !== url)
282
305
  img.src = url;
@@ -358,20 +381,6 @@ export const frames2dRenderer = {
358
381
  tick();
359
382
  }, WATCHDOG_MS);
360
383
  }
361
- // Warm pass: decode every frame up front (tiny same-origin webp files)
362
- // so loops and phase switches never wait on a first decode - same intent
363
- // as the historical Image-cache warm loop, now feeding the decode cache.
364
- // Phase-reachable tracks enqueue first so early switches never trail the
365
- // full warm backlog; demand loads jump the queue regardless.
366
- const warmTrackIds = [
367
- ...new Set([...Object.values(config.phases), config.phases.idle]),
368
- ];
369
- for (const warmTrack of [...warmTrackIds, ...Object.keys(config.tracks)].map((id) => config.tracks[id])) {
370
- if (warmTrack === undefined)
371
- continue;
372
- for (const warmUrl of warmTrack.frames)
373
- void loadFrame(warmUrl);
374
- }
375
384
  play(track);
376
385
  let disposedOnce = false;
377
386
  const dispose = () => {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Page-wide work-tick gate for the pet's work mode. Hot reloads can leave
3
+ * several GameplayHud instances alive, each running its own work interval;
4
+ * without a shared gate every interval would call workTick and each stale call
5
+ * re-rolls, re-grants treats and re-plays the success/fail track, so the
6
+ * outcome appears to play several times per window. The gate accepts the first
7
+ * adjudication of a window and silently suppresses the duplicates that follow.
8
+ *
9
+ * The window is the active pet's own `gameplay.work.tickMs`, not a constant: a
10
+ * pet configured with a shorter cadence must actually adjudicate that often,
11
+ * while a fixed window would downgrade it without saying so (#1494).
12
+ * @module @linxin666/dsh-pet/client/work-tick-gate
13
+ */
14
+ /** Window used when the active pet declares no work cadence. */
15
+ export declare const DEFAULT_WORK_TICK_MS = 10000;
16
+ /**
17
+ * The gate window for one pet: its configured cadence, clamped to the manifest's
18
+ * own bounds so a malformed registry entry cannot disable or flood the gate.
19
+ * @param tickMs - the active definition's `gameplay.work.tickMs`, when it has one.
20
+ * @returns the window in milliseconds.
21
+ */
22
+ export declare function workTickWindowMs(tickMs: number | undefined): number;
23
+ /** The shared work-tick gate. */
24
+ export interface WorkTickGate {
25
+ /**
26
+ * Admit one adjudication for the current window.
27
+ * @param tickMs - the active pet's configured work cadence.
28
+ * @returns true when this call may adjudicate, false for a duplicate.
29
+ */
30
+ allow: (tickMs: number | undefined) => boolean;
31
+ /** Forget the last adjudication (used when work mode is entered). */
32
+ reset: () => void;
33
+ }
34
+ /**
35
+ * Create a gate.
36
+ * @param now - the clock, injectable so tests control the window.
37
+ * @returns the gate over that clock.
38
+ */
39
+ export declare function createWorkTickGate(now?: () => number): WorkTickGate;
40
+ //# sourceMappingURL=work-tick-gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"work-tick-gate.d.ts","sourceRoot":"","sources":["../../../src/client/work-tick-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,QAAS,CAAA;AAM1C;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAGnE;AAED,iCAAiC;AACjC,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,KAAK,OAAO,CAAA;IAC9C,qEAAqE;IACrE,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,GAAE,MAAM,MAAiB,GAAG,YAAY,CAa7E"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Page-wide work-tick gate for the pet's work mode. Hot reloads can leave
3
+ * several GameplayHud instances alive, each running its own work interval;
4
+ * without a shared gate every interval would call workTick and each stale call
5
+ * re-rolls, re-grants treats and re-plays the success/fail track, so the
6
+ * outcome appears to play several times per window. The gate accepts the first
7
+ * adjudication of a window and silently suppresses the duplicates that follow.
8
+ *
9
+ * The window is the active pet's own `gameplay.work.tickMs`, not a constant: a
10
+ * pet configured with a shorter cadence must actually adjudicate that often,
11
+ * while a fixed window would downgrade it without saying so (#1494).
12
+ * @module @linxin666/dsh-pet/client/work-tick-gate
13
+ */
14
+ /** Window used when the active pet declares no work cadence. */
15
+ export const DEFAULT_WORK_TICK_MS = 10_000;
16
+ /** Manifest bounds for `gameplay.work.tickMs` (src/gameplay.ts). */
17
+ const MIN_WORK_TICK_MS = 1_000;
18
+ const MAX_WORK_TICK_MS = 60_000;
19
+ /**
20
+ * The gate window for one pet: its configured cadence, clamped to the manifest's
21
+ * own bounds so a malformed registry entry cannot disable or flood the gate.
22
+ * @param tickMs - the active definition's `gameplay.work.tickMs`, when it has one.
23
+ * @returns the window in milliseconds.
24
+ */
25
+ export function workTickWindowMs(tickMs) {
26
+ if (typeof tickMs !== 'number' || !Number.isFinite(tickMs))
27
+ return DEFAULT_WORK_TICK_MS;
28
+ return Math.min(MAX_WORK_TICK_MS, Math.max(MIN_WORK_TICK_MS, tickMs));
29
+ }
30
+ /**
31
+ * Create a gate.
32
+ * @param now - the clock, injectable so tests control the window.
33
+ * @returns the gate over that clock.
34
+ */
35
+ export function createWorkTickGate(now = Date.now) {
36
+ let lastAdjudicatedAt = 0;
37
+ return {
38
+ allow: (tickMs) => {
39
+ const at = now();
40
+ if (at - lastAdjudicatedAt < workTickWindowMs(tickMs))
41
+ return false;
42
+ lastAdjudicatedAt = at;
43
+ return true;
44
+ },
45
+ reset: () => {
46
+ lastAdjudicatedAt = 0;
47
+ },
48
+ };
49
+ }
@@ -150,6 +150,10 @@ export interface PetGameplayState {
150
150
  mode: 'work' | 'sleep' | null;
151
151
  /** Epoch ms of the last lazy settle. */
152
152
  settledAt: number;
153
+ /** Accumulated remainder ms towards the next passive income tick. */
154
+ incomeCarryMs?: number;
155
+ /** Accumulated remainder ms towards the next sleep restore tick. */
156
+ restoreCarryMs?: number;
153
157
  }
154
158
  /** Fresh state for one pet: stats at their initial (default max), no currency. */
155
159
  export declare function initialGameplayState(manifest: PetGameplayManifest, now: number): PetGameplayState;