@linxin666/dsh-pet 0.3.13 → 0.3.15

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/lib/index.js CHANGED
@@ -4928,13 +4928,28 @@ function extensionOf(file) {
4928
4928
  return dot < 0 ? "" : file.slice(dot).toLowerCase();
4929
4929
  }
4930
4930
  /**
4931
+ * Realpaths of containment bases, resolved once per base instead of once per
4932
+ * request: every asset/runtime/decoration request used to pay a base
4933
+ * realpathSync on the hot path. Bases are registry entry directories and the
4934
+ * runtime roots — an immutable, registry-bounded set. Only successes are
4935
+ * cached (a missing base keeps failing per request), and a symlinked base
4936
+ * re-pointed mid-process fails containment until restart, which is the
4937
+ * deny-safe direction.
4938
+ */
4939
+ const REAL_BASE_CACHE = /* @__PURE__ */ new Map();
4940
+ /**
4931
4941
  * realpath containment: resolve both sides and require the candidate to stay
4932
4942
  * inside the base directory. A pet directory (or an atlas/preview inside it)
4933
- * that is a symlink escaping its root is rejected, never followed.
4943
+ * that is a symlink escaping its root is rejected, never followed. The
4944
+ * candidate is realpath'ed live on every call; only the base side is cached.
4934
4945
  */
4935
4946
  function containedRealpath(base, candidate) {
4936
4947
  try {
4937
- const realBase = realpathSync(base);
4948
+ let realBase = REAL_BASE_CACHE.get(base);
4949
+ if (realBase === void 0) {
4950
+ realBase = realpathSync(base);
4951
+ REAL_BASE_CACHE.set(base, realBase);
4952
+ }
4938
4953
  const realCandidate = realpathSync(candidate);
4939
4954
  return realCandidate === realBase || realCandidate.startsWith(realBase + sep) ? realCandidate : void 0;
4940
4955
  } catch {
@@ -4955,6 +4970,20 @@ function mimeFor(file) {
4955
4970
  if (dot < 0) return "application/octet-stream";
4956
4971
  return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? "application/octet-stream";
4957
4972
  }
4973
+ /** Weak validator from size + mtime, shared by the three file routes. */
4974
+ function weakEtag(stat) {
4975
+ return "\"" + stat.size.toString(16) + "-" + Math.round(stat.mtimeMs).toString(16) + "\"";
4976
+ }
4977
+ /** Answer 304 when If-None-Match matches the etag; true when handled. */
4978
+ function revalidated(req, res, etag) {
4979
+ if (req.headers["if-none-match"] !== etag) return false;
4980
+ res.writeHead(304, {
4981
+ etag,
4982
+ "cache-control": "no-cache"
4983
+ });
4984
+ res.end();
4985
+ return true;
4986
+ }
4958
4987
  /** Require the method or answer 405. */
4959
4988
  function requireMethod(req, res, method) {
4960
4989
  if (req.method === method) return true;
@@ -5036,6 +5065,7 @@ function dirAliases(registry) {
5036
5065
  */
5037
5066
  function assetHandler(ctx, registry, caps) {
5038
5067
  const aliases = dirAliases(registry);
5068
+ const servableById = new Map(registry.entries.map((entry) => [entry.id, new Set(entry.servable)]));
5039
5069
  return ((req, res) => {
5040
5070
  if (!guard(ctx, req, res)) return;
5041
5071
  if (req.method !== "GET" && req.method !== "HEAD") {
@@ -5090,7 +5120,7 @@ function assetHandler(ctx, registry, caps) {
5090
5120
  const manifestFile = join(entry.dir, MANIFEST_FILE);
5091
5121
  file = existsSync(manifestFile) ? manifestFile : void 0;
5092
5122
  if (file === void 0) synthesized = true;
5093
- } else if (rest.length > 0 && entry.servable.includes(rel)) file = join(entry.dir, rel);
5123
+ } else if (rest.length > 0 && servableById.get(entry.id)?.has(rel)) file = join(entry.dir, rel);
5094
5124
  else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1])) {
5095
5125
  const preview = join(entry.dir, PREVIEW_DIR, rest[1]);
5096
5126
  file = existsSync(preview) ? preview : void 0;
@@ -5121,8 +5151,10 @@ function assetHandler(ctx, registry, caps) {
5121
5151
  return;
5122
5152
  }
5123
5153
  const cap = rest.length === 1 && rest[0] === MANIFEST_FILE ? caps.manifest : IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model;
5154
+ let stat;
5124
5155
  try {
5125
- if (statSync(resolved).size > cap) {
5156
+ stat = statSync(resolved);
5157
+ if (stat.size > cap) {
5126
5158
  res.writeHead(413);
5127
5159
  res.end();
5128
5160
  return;
@@ -5132,11 +5164,14 @@ function assetHandler(ctx, registry, caps) {
5132
5164
  res.end();
5133
5165
  return;
5134
5166
  }
5167
+ const etag = weakEtag(stat);
5168
+ if (revalidated(req, res, etag)) return;
5135
5169
  return readFile(resolved).then((body) => {
5136
5170
  res.writeHead(200, {
5137
5171
  "content-type": mimeFor(resolved),
5138
5172
  "content-length": String(body.byteLength),
5139
- "cache-control": "no-cache"
5173
+ "cache-control": "no-cache",
5174
+ etag
5140
5175
  });
5141
5176
  if (req.method === "HEAD") {
5142
5177
  res.end();
@@ -5216,8 +5251,10 @@ function runtimeHandler(ctx, roots) {
5216
5251
  res.end();
5217
5252
  return;
5218
5253
  }
5254
+ let stat;
5219
5255
  try {
5220
- if (statSync(resolved).size > 16777216) {
5256
+ stat = statSync(resolved);
5257
+ if (stat.size > 16777216) {
5221
5258
  res.writeHead(413);
5222
5259
  res.end();
5223
5260
  return;
@@ -5227,11 +5264,14 @@ function runtimeHandler(ctx, roots) {
5227
5264
  res.end();
5228
5265
  return;
5229
5266
  }
5267
+ const etag = weakEtag(stat);
5268
+ if (revalidated(req, res, etag)) return;
5230
5269
  return readFile(resolved).then((body) => {
5231
5270
  res.writeHead(200, {
5232
5271
  "content-type": name.endsWith(".map") ? "application/json" : "application/javascript; charset=utf-8",
5233
5272
  "content-length": String(body.byteLength),
5234
- "cache-control": "no-cache"
5273
+ "cache-control": "no-cache",
5274
+ etag
5235
5275
  });
5236
5276
  if (req.method === "HEAD") {
5237
5277
  res.end();
@@ -5332,15 +5372,8 @@ function decorationHandler(ctx, registry, caps) {
5332
5372
  res.end();
5333
5373
  return;
5334
5374
  }
5335
- const etag = "\"" + stat.size.toString(16) + "-" + Math.round(stat.mtimeMs).toString(16) + "\"";
5336
- if (req.headers["if-none-match"] === etag) {
5337
- res.writeHead(304, {
5338
- etag,
5339
- "cache-control": "no-cache"
5340
- });
5341
- res.end();
5342
- return;
5343
- }
5375
+ const etag = weakEtag(stat);
5376
+ if (revalidated(req, res, etag)) return;
5344
5377
  readFile(resolved).then((body) => {
5345
5378
  res.writeHead(200, {
5346
5379
  "content-type": mimeFor(resolved),
@@ -5543,17 +5576,30 @@ function applyImpl(ctx, config = {}) {
5543
5576
  }
5544
5577
  };
5545
5578
  ctx.inject(["settings"], (settingsCtx) => {
5546
- settingsCtx.settings.installSection(ctx, "pet", makePetSettingsSchema(service.selectedPetId()), base, {
5547
- setSource: (source) => {
5548
- current = source;
5549
- },
5550
- onChange: () => {
5551
- const section = current();
5552
- service.applySettingsSection(section);
5553
- service.setEnabled(section.enabled ?? true);
5554
- syncRoutes();
5579
+ try {
5580
+ const schema = makePetSettingsSchema(service.selectedPetId());
5581
+ if (typeof settingsCtx.settings?.installSection === "function") settingsCtx.settings.installSection(ctx, "pet", schema, base, {
5582
+ setSource: (source) => {
5583
+ current = source;
5584
+ },
5585
+ onChange: () => {
5586
+ const section = current();
5587
+ service.applySettingsSection(section);
5588
+ service.setEnabled(section.enabled ?? true);
5589
+ syncRoutes();
5590
+ }
5591
+ });
5592
+ else if (typeof settingsCtx.settings?.register === "function") {
5593
+ const scope = settingsCtx.settings.register("pet", schema, { base });
5594
+ current = () => scope?.get?.() ?? base;
5595
+ scope?.watch?.(() => {
5596
+ const section = current();
5597
+ service.applySettingsSection(section);
5598
+ service.setEnabled(section.enabled ?? true);
5599
+ syncRoutes();
5600
+ });
5555
5601
  }
5556
- });
5602
+ } catch {}
5557
5603
  });
5558
5604
  syncRoutes();
5559
5605
  }
@@ -1 +1 @@
1
- {"version":3,"file":"PetSprite.d.ts","sourceRoot":"","sources":["../../../src/client/PetSprite.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAkE,SAAS,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAGnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAEjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAIjD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,8EAA8E;IAC9E,UAAU,EAAE,aAAa,CAAA;IACzB,qDAAqD;IACrD,OAAO,EAAE,gBAAgB,CAAA;IACzB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;IAC5B,8BAA8B;IAC9B,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClD,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAA;IAC9C,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,4DAA4D;IAC5D,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC1C,2DAA2D;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB;;;;OAIG;IACH,GAAG,CAAC,EAAE,SAAS,CAAA;IACf;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9D;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAC3B,qEAAqE;IACrE,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,+CAA+C;IAC/C,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;CAC1B;AA8ID;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,WAAW,CAqjB5D"}
1
+ {"version":3,"file":"PetSprite.d.ts","sourceRoot":"","sources":["../../../src/client/PetSprite.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAkE,SAAS,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAGnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAEjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAIjD,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAGjC,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,8EAA8E;IAC9E,UAAU,EAAE,aAAa,CAAA;IACzB,qDAAqD;IACrD,OAAO,EAAE,gBAAgB,CAAA;IACzB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;IAC5B,8BAA8B;IAC9B,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,sCAAsC;IACtC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,+BAA+B;IAC/B,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClD,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAA;IAC9C,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAChC,4DAA4D;IAC5D,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC1C,2DAA2D;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB;;;;OAIG;IACH,GAAG,CAAC,EAAE,SAAS,CAAA;IACf;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9D;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAC3B,qEAAqE;IACrE,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,+CAA+C;IAC/C,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,CAAC,CAAA;CAC1B;AA8ID;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,WAAW,CA+jB5D"}
@@ -14,7 +14,7 @@ import { createPortal } from 'react-dom';
14
14
  import clsx from 'clsx';
15
15
  import { announcementFresh } from "../announce.js";
16
16
  import { framePosition, rowOfTrack, trimTrack } from "./spritesheet.js";
17
- import { sequenceFrameAt } from "./sequences.js";
17
+ import { createSequenceTimeline } from "./sequences.js";
18
18
  import { animationForPhase } from "../state.js";
19
19
  import styles from './pet.module.css';
20
20
  /** Clamp a drag offset inside the viewport with a margin. */
@@ -230,6 +230,18 @@ export function PetSprite(props) {
230
230
  const reduceMotion = typeof window !== 'undefined'
231
231
  && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
232
232
  const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined;
233
+ // Sequence state hoisted into the effect scope: the cumulative duration
234
+ // table and each item's trimmed track would otherwise be recomputed every
235
+ // tick (map/reduce plus two slices per frame), the same waste the
236
+ // single-track branch below avoids.
237
+ const timeline = sequence === undefined ? undefined : createSequenceTimeline(sequence, tracks);
238
+ const sequenceItems = sequence === undefined ? undefined : new Map(sequence.map(itemAnimation => {
239
+ const itemRow = rowOfTrack(itemAnimation);
240
+ return [itemAnimation, {
241
+ row: itemRow,
242
+ track: trimTrack(tracks[itemAnimation], rows[itemRow] ?? tracks[itemAnimation].frames.length),
243
+ }];
244
+ }));
233
245
  const leadAnimation = sequence?.[0] ?? animation;
234
246
  const row = rowOfTrack(leadAnimation);
235
247
  const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length);
@@ -249,13 +261,12 @@ export function PetSprite(props) {
249
261
  const tick = (ts) => {
250
262
  const delta = ts - last;
251
263
  last = ts;
252
- if (sequence !== undefined) {
264
+ if (timeline !== undefined && sequenceItems !== undefined) {
253
265
  sequenceElapsed += delta;
254
- const current = sequenceFrameAt(sequence, tracks, sequenceElapsed);
255
- const currentRow = rowOfTrack(current.animation);
256
- const currentTrack = trimTrack(tracks[current.animation], rows[currentRow] ?? tracks[current.animation].frames.length);
257
- const col = currentTrack.frames[current.frameIndex];
258
- const pos = framePosition(cell, currentRow, col, scaleRef.current);
266
+ const current = timeline.frameAt(sequenceElapsed);
267
+ const item = sequenceItems.get(current.animation);
268
+ const col = item.track.frames[current.frameIndex];
269
+ const pos = framePosition(cell, item.row, col, scaleRef.current);
259
270
  const posStr = pos.x + 'px ' + pos.y + 'px';
260
271
  if (posStr !== lastPosStr) {
261
272
  lastPosStr = posStr;
@@ -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;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAoT9C"}
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;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAqT9C"}
@@ -342,6 +342,7 @@ export function apply(ctx) {
342
342
  petRoot.unmount();
343
343
  container.remove();
344
344
  disposePoll();
345
+ disposeSessionWatch();
345
346
  disposeUi = undefined;
346
347
  };
347
348
  // The slot teardown is the takeover hook a later apply body runs; it
@@ -1 +1 @@
1
- {"version":3,"file":"pet-store.d.ts","sourceRoot":"","sources":["../../../src/client/pet-store.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAA;AAC3F,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACvE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,gDAAgD;AAChD,MAAM,WAAW,WAAW;IAC1B,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,uDAAuD;IACvD,IAAI,EAAE,cAAc,GAAG,MAAM,CAAA;IAC7B,sDAAsD;IACtD,EAAE,EAAE,MAAM,CAAA;CACX;AAED,wCAAwC;AACxC,MAAM,WAAW,UAAU;IACzB,oEAAoE;IACpE,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,0EAA0E;IAC1E,IAAI,EAAE,aAAa,EAAE,CAAA;IACrB,uBAAuB;IACvB,KAAK,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAA;IACpC,iEAAiE;IACjE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;CAC7B;AAED,uBAAuB;AACvB,MAAM,MAAM,YAAY,GAAG;IACzB,+CAA+C;IAC/C,WAAW,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAA;IAChE,iCAAiC;IACjC,OAAO,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,IAAI,CAAA;IAC3D,gCAAgC;IAChC,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IACvF,8BAA8B;IAC9B,WAAW,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAA;IACtE;;;;OAIG;IACH,eAAe,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,oBAAoB,KAAK,IAAI,CAAA;CACzE,CAAA;AAED,0EAA0E;AAC1E,wBAAgB,cAAc,IAAI,iBAAiB,CAAC,UAAU,EAAE,YAAY,CAAC,CA8B5E;AAED,YAAY,EAAE,cAAc,EAAE,CAAA;AAE9B;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG,mBAAmB,CAAC,UAAU,EAAE,YAAY,CAAC,CAAA"}
1
+ {"version":3,"file":"pet-store.d.ts","sourceRoot":"","sources":["../../../src/client/pet-store.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAA;AAC3F,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACvE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,gDAAgD;AAChD,MAAM,WAAW,WAAW;IAC1B,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,uDAAuD;IACvD,IAAI,EAAE,cAAc,GAAG,MAAM,CAAA;IAC7B,sDAAsD;IACtD,EAAE,EAAE,MAAM,CAAA;CACX;AAED,wCAAwC;AACxC,MAAM,WAAW,UAAU;IACzB,oEAAoE;IACpE,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAA;IAC7B,0EAA0E;IAC1E,IAAI,EAAE,aAAa,EAAE,CAAA;IACrB,uBAAuB;IACvB,KAAK,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAA;IACpC,iEAAiE;IACjE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,sCAAsC;IACtC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAA;CAC7B;AAED,uBAAuB;AACvB,MAAM,MAAM,YAAY,GAAG;IACzB,+CAA+C;IAC/C,WAAW,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAA;IAChE,iCAAiC;IACjC,OAAO,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,IAAI,CAAA;IAC3D,gCAAgC;IAChC,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IACvF,8BAA8B;IAC9B,WAAW,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAA;IACtE;;;;OAIG;IACH,eAAe,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,oBAAoB,KAAK,IAAI,CAAA;CACzE,CAAA;AAED,0EAA0E;AAC1E,wBAAgB,cAAc,IAAI,iBAAiB,CAAC,UAAU,EAAE,YAAY,CAAC,CAsC5E;AAED,YAAY,EAAE,cAAc,EAAE,CAAA;AAY9B;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG,mBAAmB,CAAC,UAAU,EAAE,YAAY,CAAC,CAAA"}
@@ -18,6 +18,15 @@ export function createPetStore() {
18
18
  }),
19
19
  actions: {
20
20
  setSnapshot: (draft, snapshot) => {
21
+ // The 2 s poll republishes the full snapshot even while the pet is
22
+ // idle. Skipping an unchanged payload keeps immer's produce at zero
23
+ // modifications, so zustand never notifies and the whole sprite tree
24
+ // skips the re-render. Equality is JSON-based and skip-only: both
25
+ // sides come from the same host serializer, and any mismatch falls
26
+ // through to the normal publish — the failure mode is one extra
27
+ // render, never a stale pet.
28
+ if (draft.state === 'ready' && draft.error === null && sameSnapshot(draft.snapshot, snapshot))
29
+ return;
21
30
  draft.snapshot = snapshot;
22
31
  draft.state = 'ready';
23
32
  draft.error = null;
@@ -39,3 +48,12 @@ export function createPetStore() {
39
48
  },
40
49
  });
41
50
  }
51
+ /**
52
+ * Content equality for consecutive poll snapshots. JSON compare, not field
53
+ * enumeration: an exact string match is the only way to skip the publish, so
54
+ * a missed field can never freeze the UI — it can only cost the render the
55
+ * optimization exists to save.
56
+ */
57
+ function sameSnapshot(previous, next) {
58
+ return previous !== null && JSON.stringify(previous) === JSON.stringify(next);
59
+ }
@@ -5,6 +5,16 @@ export interface SequenceFrame {
5
5
  animation: PetAnimation;
6
6
  frameIndex: number;
7
7
  }
8
+ /**
9
+ * Precomputed timeline for one looping sequence: the per-item duration table
10
+ * is built once instead of per query. The sprite frame loop asks at
11
+ * animation rate (~60 Hz), where the per-call map/reduce is pure waste.
12
+ */
13
+ export interface SequenceTimeline {
14
+ frameAt(elapsedMs: number): SequenceFrame;
15
+ }
16
+ /** Build a {@link SequenceTimeline} over one manifest sequence. */
17
+ export declare function createSequenceTimeline(sequence: readonly PetAnimation[], tracks: Record<PetAnimation, PetTrackDef>): SequenceTimeline;
8
18
  /** Resolve the active track and frame after elapsed milliseconds of a looping sequence. */
9
19
  export declare function sequenceFrameAt(sequence: readonly PetAnimation[], tracks: Record<PetAnimation, PetTrackDef>, elapsedMs: number): SequenceFrame;
10
20
  //# sourceMappingURL=sequences.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sequences.d.ts","sourceRoot":"","sources":["../../../src/client/sequences.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAE1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,YAAY,CAAA;IACvB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,2FAA2F;AAC3F,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,SAAS,YAAY,EAAE,EACjC,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,WAAW,CAAC,EACzC,SAAS,EAAE,MAAM,GAChB,aAAa,CAiBf"}
1
+ {"version":3,"file":"sequences.d.ts","sourceRoot":"","sources":["../../../src/client/sequences.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAE1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,YAAY,CAAA;IACvB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,CAAA;CAC1C;AAED,mEAAmE;AACnE,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS,YAAY,EAAE,EACjC,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,WAAW,CAAC,GACxC,gBAAgB,CAqBlB;AAED,2FAA2F;AAC3F,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,SAAS,YAAY,EAAE,EACjC,MAAM,EAAE,MAAM,CAAC,YAAY,EAAE,WAAW,CAAC,EACzC,SAAS,EAAE,MAAM,GAChB,aAAa,CAEf"}
@@ -1,20 +1,28 @@
1
1
  /** Pure timing helpers for manifest-defined scene animation sequences. */
2
- /** Resolve the active track and frame after elapsed milliseconds of a looping sequence. */
3
- export function sequenceFrameAt(sequence, tracks, elapsedMs) {
2
+ /** Build a {@link SequenceTimeline} over one manifest sequence. */
3
+ export function createSequenceTimeline(sequence, tracks) {
4
4
  const itemDurations = sequence.map(animation => tracks[animation].durations.reduce((sum, value) => sum + value, 0));
5
5
  const sequenceDuration = itemDurations.reduce((sum, value) => sum + value, 0);
6
- let offset = Math.max(0, elapsedMs) % sequenceDuration;
7
- let itemIndex = 0;
8
- while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]) {
9
- offset -= itemDurations[itemIndex];
10
- itemIndex += 1;
11
- }
12
- const animation = sequence[itemIndex];
13
- const track = tracks[animation];
14
- let frameIndex = 0;
15
- while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]) {
16
- offset -= track.durations[frameIndex];
17
- frameIndex += 1;
18
- }
19
- return { animation, frameIndex };
6
+ return {
7
+ frameAt(elapsedMs) {
8
+ let offset = Math.max(0, elapsedMs) % sequenceDuration;
9
+ let itemIndex = 0;
10
+ while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]) {
11
+ offset -= itemDurations[itemIndex];
12
+ itemIndex += 1;
13
+ }
14
+ const animation = sequence[itemIndex];
15
+ const track = tracks[animation];
16
+ let frameIndex = 0;
17
+ while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]) {
18
+ offset -= track.durations[frameIndex];
19
+ frameIndex += 1;
20
+ }
21
+ return { animation, frameIndex };
22
+ },
23
+ };
24
+ }
25
+ /** Resolve the active track and frame after elapsed milliseconds of a looping sequence. */
26
+ export function sequenceFrameAt(sequence, tracks, elapsedMs) {
27
+ return createSequenceTimeline(sequence, tracks).frameAt(elapsedMs);
20
28
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAG7C,OAAO,CAAC,MAAM,aAAa,CAAA;AAC3B,OAAO,EAAsC,KAAK,SAAS,EAA2B,MAAM,cAAc,CAAA;AAM1G,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAC9D,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,kBAAkB,EAClB,cAAc,EACd,YAAY,GACb,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,MAAM,GACP,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,cAAc,EACd,aAAa,EACb,kBAAkB,EAClB,cAAc,GACf,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,KAAK,GACN,MAAM,YAAY,CAAA;AACnB,YAAY,EACV,aAAa,EACb,YAAY,EACZ,cAAc,EACd,aAAa,EACb,gBAAgB,GACjB,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,YAAY,EACZ,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAC5E,OAAO,EACL,eAAe,EACf,YAAY,EACZ,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,aAAa,EACb,mBAAmB,GACpB,MAAM,cAAc,CAAA;AACrB,YAAY,EAAE,UAAU,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAC9E,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,mBAAmB,EACnB,oBAAoB,EACpB,YAAY,EACZ,cAAc,EACd,UAAU,EACV,cAAc,GACf,MAAM,cAAc,CAAA;AACrB,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAChE,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,aAAa,EACb,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,cAAc,EACd,kBAAkB,GACnB,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,aAAa,EACb,QAAQ,EACR,WAAW,EACX,WAAW,EACX,kBAAkB,EAClB,WAAW,EACX,gBAAgB,GACjB,MAAM,eAAe,CAAA;AAEtB,OAAO,EACL,aAAa,EACb,cAAc,EACd,gBAAgB,GACjB,MAAM,aAAa,CAAA;AAEpB,sEAAsE;AACtE,eAAO,MAAM,IAAI,QAAQ,CAAA;AAEzB,+DAA+D;AAC/D,eAAO,MAAM,MAAM,UAAgB,CAAA;AAEnC;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,aAAa,EAAE,MAAM;;;;;;;;;;;;;;;;IAU1D;AAED,0EAA0E;AAC1E,eAAO,MAAM,KAAK,kBAA6C,CAAA;AAE/D,iBAAS,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,SAAc,GAAG,IAAI,CAkE7D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAG7C,OAAO,CAAC,MAAM,aAAa,CAAA;AAC3B,OAAO,EAAsC,KAAK,SAAS,EAA2B,MAAM,cAAc,CAAA;AAM1G,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAC9D,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,kBAAkB,EAClB,cAAc,EACd,YAAY,GACb,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,MAAM,GACP,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,cAAc,EACd,aAAa,EACb,kBAAkB,EAClB,cAAc,GACf,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,KAAK,GACN,MAAM,YAAY,CAAA;AACnB,YAAY,EACV,aAAa,EACb,YAAY,EACZ,cAAc,EACd,aAAa,EACb,gBAAgB,GACjB,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,YAAY,EACZ,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAC5E,OAAO,EACL,eAAe,EACf,YAAY,EACZ,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,aAAa,EACb,mBAAmB,GACpB,MAAM,cAAc,CAAA;AACrB,YAAY,EAAE,UAAU,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAC9E,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,mBAAmB,EACnB,oBAAoB,EACpB,YAAY,EACZ,cAAc,EACd,UAAU,EACV,cAAc,GACf,MAAM,cAAc,CAAA;AACrB,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAChE,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,aAAa,EACb,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,cAAc,EACd,kBAAkB,GACnB,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,aAAa,EACb,QAAQ,EACR,WAAW,EACX,WAAW,EACX,kBAAkB,EAClB,WAAW,EACX,gBAAgB,GACjB,MAAM,eAAe,CAAA;AAEtB,OAAO,EACL,aAAa,EACb,cAAc,EACd,gBAAgB,GACjB,MAAM,aAAa,CAAA;AAEpB,sEAAsE;AACtE,eAAO,MAAM,IAAI,QAAQ,CAAA;AAEzB,+DAA+D;AAC/D,eAAO,MAAM,MAAM,UAAgB,CAAA;AAEnC;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,aAAa,EAAE,MAAM;;;;;;;;;;;;;;;;IAU1D;AAED,0EAA0E;AAC1E,eAAO,MAAM,KAAK,kBAA6C,CAAA;AAE/D,iBAAS,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,SAAc,GAAG,IAAI,CAkF7D"}
@@ -96,15 +96,33 @@ function applyImpl(ctx, config = {}) {
96
96
  }
97
97
  };
98
98
  ctx.inject(['settings'], (settingsCtx) => {
99
- settingsCtx.settings.installSection(ctx, PET_SETTINGS_NAMESPACE, makePetSettingsSchema(service.selectedPetId()), base, {
100
- setSource: (source) => { current = source; },
101
- onChange: () => {
102
- const section = current();
103
- service.applySettingsSection(section);
104
- service.setEnabled(section.enabled ?? true);
105
- syncRoutes();
106
- },
107
- });
99
+ try {
100
+ const schema = makePetSettingsSchema(service.selectedPetId());
101
+ if (typeof settingsCtx.settings?.installSection === 'function') {
102
+ settingsCtx.settings.installSection(ctx, PET_SETTINGS_NAMESPACE, schema, base, {
103
+ setSource: (source) => { current = source; },
104
+ onChange: () => {
105
+ const section = current();
106
+ service.applySettingsSection(section);
107
+ service.setEnabled(section.enabled ?? true);
108
+ syncRoutes();
109
+ },
110
+ });
111
+ }
112
+ else if (typeof settingsCtx.settings?.register === 'function') {
113
+ const scope = settingsCtx.settings.register(PET_SETTINGS_NAMESPACE, schema, { base });
114
+ current = () => scope?.get?.() ?? base;
115
+ scope?.watch?.(() => {
116
+ const section = current();
117
+ service.applySettingsSection(section);
118
+ service.setEnabled(section.enabled ?? true);
119
+ syncRoutes();
120
+ });
121
+ }
122
+ }
123
+ catch {
124
+ // Defensive fallback against settings registration differences
125
+ }
108
126
  });
109
127
  syncRoutes();
110
128
  }
@@ -40,7 +40,8 @@ export interface PetAssetCaps {
40
40
  /**
41
41
  * realpath containment: resolve both sides and require the candidate to stay
42
42
  * inside the base directory. A pet directory (or an atlas/preview inside it)
43
- * that is a symlink escaping its root is rejected, never followed.
43
+ * that is a symlink escaping its root is rejected, never followed. The
44
+ * candidate is realpath'ed live on every call; only the base side is cached.
44
45
  */
45
46
  export declare function containedRealpath(base: string, candidate: string): string | undefined;
46
47
  /** Browser-facing base path of the plugin runtime files (pet-center M3). */
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAO9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAMtC;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB,yBAAyB;;IAEzB,iDAAiD;;IAEjD,8EAA8E;;CAEtE,CAAA;AAEV,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAWD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUrF;AAwND,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA8B,CAAA;AAe7D,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QAAmB,CAAA;AAE/C,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAwMD,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,YAAY,CAAA;CAAE,GAAG,eAAe,GAAG,QAAQ,EAAE,CAgFjI;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAO9C,+CAA+C;AAC/C,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAAS,CAAA;AAMtC;;;;GAIG;AACH,eAAO,MAAM,cAAc;IACzB,yBAAyB;;IAEzB,iDAAiD;;IAEjD,8EAA8E;;CAEtE,CAAA;AAEV,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAsBD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAcrF;AAqPD,4EAA4E;AAC5E,eAAO,MAAM,kBAAkB,QAA8B,CAAA;AAe7D,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QAAmB,CAAA;AAE/C,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AA4MD,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,YAAY,CAAA;CAAE,GAAG,eAAe,GAAG,QAAQ,EAAE,CAgFjI;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA"}
@@ -45,14 +45,29 @@ function extensionOf(file) {
45
45
  const dot = file.lastIndexOf('.');
46
46
  return dot < 0 ? '' : file.slice(dot).toLowerCase();
47
47
  }
48
+ /**
49
+ * Realpaths of containment bases, resolved once per base instead of once per
50
+ * request: every asset/runtime/decoration request used to pay a base
51
+ * realpathSync on the hot path. Bases are registry entry directories and the
52
+ * runtime roots — an immutable, registry-bounded set. Only successes are
53
+ * cached (a missing base keeps failing per request), and a symlinked base
54
+ * re-pointed mid-process fails containment until restart, which is the
55
+ * deny-safe direction.
56
+ */
57
+ const REAL_BASE_CACHE = new Map();
48
58
  /**
49
59
  * realpath containment: resolve both sides and require the candidate to stay
50
60
  * inside the base directory. A pet directory (or an atlas/preview inside it)
51
- * that is a symlink escaping its root is rejected, never followed.
61
+ * that is a symlink escaping its root is rejected, never followed. The
62
+ * candidate is realpath'ed live on every call; only the base side is cached.
52
63
  */
53
64
  export function containedRealpath(base, candidate) {
54
65
  try {
55
- const realBase = realpathSync(base);
66
+ let realBase = REAL_BASE_CACHE.get(base);
67
+ if (realBase === undefined) {
68
+ realBase = realpathSync(base);
69
+ REAL_BASE_CACHE.set(base, realBase);
70
+ }
56
71
  const realCandidate = realpathSync(candidate);
57
72
  return realCandidate === realBase || realCandidate.startsWith(realBase + sep)
58
73
  ? realCandidate
@@ -77,6 +92,18 @@ function mimeFor(file) {
77
92
  return 'application/octet-stream';
78
93
  return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream';
79
94
  }
95
+ /** Weak validator from size + mtime, shared by the three file routes. */
96
+ function weakEtag(stat) {
97
+ return '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"';
98
+ }
99
+ /** Answer 304 when If-None-Match matches the etag; true when handled. */
100
+ function revalidated(req, res, etag) {
101
+ if (req.headers['if-none-match'] !== etag)
102
+ return false;
103
+ res.writeHead(304, { etag, 'cache-control': 'no-cache' });
104
+ res.end();
105
+ return true;
106
+ }
80
107
  /** Require the method or answer 405. */
81
108
  function requireMethod(req, res, method) {
82
109
  if (req.method === method)
@@ -155,6 +182,11 @@ function dirAliases(registry) {
155
182
  */
156
183
  function assetHandler(ctx, registry, caps) {
157
184
  const aliases = dirAliases(registry);
185
+ // The servable match is a Set probe instead of a linear scan: a full live2d
186
+ // closure routinely exceeds a hundred files, each fetched through this
187
+ // handler per mount. The registry is an immutable snapshot, so the sets are
188
+ // built once per handler.
189
+ const servableById = new Map(registry.entries.map(entry => [entry.id, new Set(entry.servable)]));
158
190
  return ((req, res) => {
159
191
  if (!guard(ctx, req, res))
160
192
  return;
@@ -215,7 +247,7 @@ function assetHandler(ctx, registry, caps) {
215
247
  if (file === undefined)
216
248
  synthesized = true;
217
249
  }
218
- else if (rest.length > 0 && entry.servable.includes(rel)) {
250
+ else if (rest.length > 0 && servableById.get(entry.id)?.has(rel)) {
219
251
  file = join(entry.dir, rel);
220
252
  }
221
253
  else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1])) {
@@ -252,8 +284,10 @@ function assetHandler(ctx, registry, caps) {
252
284
  const cap = rest.length === 1 && rest[0] === MANIFEST_FILE
253
285
  ? caps.manifest
254
286
  : IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model;
287
+ let stat;
255
288
  try {
256
- if (statSync(resolved).size > cap) {
289
+ stat = statSync(resolved);
290
+ if (stat.size > cap) {
257
291
  res.writeHead(413);
258
292
  res.end();
259
293
  return;
@@ -264,11 +298,19 @@ function assetHandler(ctx, registry, caps) {
264
298
  res.end();
265
299
  return;
266
300
  }
301
+ // 'no-cache' forces revalidation before every reuse, and the validator
302
+ // lets repeat requests settle as 304 — atlases and frames are the largest
303
+ // payloads the plugin serves (up to the 20 MB image cap), and without a
304
+ // validator every remount or page load would re-download them in full.
305
+ const etag = weakEtag(stat);
306
+ if (revalidated(req, res, etag))
307
+ return;
267
308
  return readFile(resolved).then((body) => {
268
309
  res.writeHead(200, {
269
310
  'content-type': mimeFor(resolved),
270
311
  'content-length': String(body.byteLength),
271
312
  'cache-control': 'no-cache',
313
+ etag,
272
314
  });
273
315
  if (req.method === 'HEAD') {
274
316
  res.end();
@@ -350,8 +392,10 @@ function runtimeHandler(ctx, roots) {
350
392
  res.end();
351
393
  return;
352
394
  }
395
+ let stat;
353
396
  try {
354
- if (statSync(resolved).size > PET_RUNTIME_CAP) {
397
+ stat = statSync(resolved);
398
+ if (stat.size > PET_RUNTIME_CAP) {
355
399
  res.writeHead(413);
356
400
  res.end();
357
401
  return;
@@ -362,11 +406,18 @@ function runtimeHandler(ctx, roots) {
362
406
  res.end();
363
407
  return;
364
408
  }
409
+ // Same validator as the asset routes: the Cubism Core and vendor bundle
410
+ // ride every page load of a live2d pet, so revalidation should settle as
411
+ // 304 instead of re-downloading the full bodies.
412
+ const etag = weakEtag(stat);
413
+ if (revalidated(req, res, etag))
414
+ return;
365
415
  return readFile(resolved).then((body) => {
366
416
  res.writeHead(200, {
367
417
  'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
368
418
  'content-length': String(body.byteLength),
369
419
  'cache-control': 'no-cache',
420
+ etag,
370
421
  });
371
422
  if (req.method === 'HEAD') {
372
423
  res.end();
@@ -478,12 +529,9 @@ function decorationHandler(ctx, registry, caps) {
478
529
  // validator lets repeat requests settle as 304 — the ornament remounts
479
530
  // on whisper and display-session flips, and without a validator each
480
531
  // remount would re-download the full strip body.
481
- const etag = '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"';
482
- if (req.headers['if-none-match'] === etag) {
483
- res.writeHead(304, { etag, 'cache-control': 'no-cache' });
484
- res.end();
532
+ const etag = weakEtag(stat);
533
+ if (revalidated(req, res, etag))
485
534
  return;
486
- }
487
535
  readFile(resolved).then((body) => {
488
536
  res.writeHead(200, {
489
537
  'content-type': mimeFor(resolved),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-pet",
3
3
  "description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
4
- "version": "0.3.13",
4
+ "version": "0.3.15",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -40,7 +40,7 @@
40
40
  "license": "Apache-2.0",
41
41
  "dsh": {
42
42
  "engines": {
43
- "dsh": ">=0.1.2-alpha.4"
43
+ "dsh": ">=0.1.2-rc.1"
44
44
  },
45
45
  "bundle": {
46
46
  "patch": "./cordis.patch.yml"
@@ -65,18 +65,18 @@
65
65
  },
66
66
  "devDependencies": {
67
67
  "@deepseek-ai/cordis": "^4.0.2",
68
- "@deepseek-ai/dsh-api-remotes": "^0.1.2-alpha.4",
69
- "@deepseek-ai/dsh-api-session-controller": "^0.1.2-alpha.4",
70
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-alpha.4",
71
- "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.4",
72
- "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.4",
73
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.4",
74
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.4",
75
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.4",
76
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.4",
77
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.4",
78
- "@deepseek-ai/dsh-session": "^0.1.2-alpha.4",
79
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.4",
68
+ "@deepseek-ai/dsh-api-remotes": "^0.1.2-rc.1",
69
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.2-rc.1",
70
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-rc.1",
71
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
72
+ "@deepseek-ai/dsh-client-store": "^0.1.2-rc.1",
73
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1",
74
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
75
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
76
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
77
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
78
+ "@deepseek-ai/dsh-session": "^0.1.2-rc.1",
79
+ "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
80
80
  "@testing-library/dom": "^10.4.1",
81
81
  "@testing-library/react": "^16.3.2",
82
82
  "@types/node": "^22.20.0",