@linxin666/dsh-pet 0.3.5 → 0.3.6

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/contracts/voice-pack-v1.schema.json +30 -0
  2. package/cordis.patch.yml +1 -1
  3. package/lib/client.js +144 -16
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +56 -17
  6. package/lib/invariant.js +1 -1
  7. package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
  8. package/lib/types/access.d.ts +5 -4
  9. package/lib/types/access.d.ts.map +1 -1
  10. package/lib/types/access.js +2 -12
  11. package/lib/types/affinity.d.ts +2 -0
  12. package/lib/types/affinity.d.ts.map +1 -1
  13. package/lib/types/affinity.js +12 -0
  14. package/lib/types/client/PetSprite.d.ts.map +1 -1
  15. package/lib/types/client/PetSprite.js +7 -1
  16. package/lib/types/client/locales.d.ts +18 -0
  17. package/lib/types/client/locales.d.ts.map +1 -1
  18. package/lib/types/client/locales.js +18 -0
  19. package/lib/types/client/renderers/frames2d.d.ts +12 -0
  20. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  21. package/lib/types/client/renderers/frames2d.js +143 -18
  22. package/lib/types/ledger.d.ts +4 -2
  23. package/lib/types/ledger.d.ts.map +1 -1
  24. package/lib/types/ledger.js +4 -4
  25. package/lib/types/pair-access.d.ts +35 -0
  26. package/lib/types/pair-access.d.ts.map +1 -0
  27. package/lib/types/pair-access.js +19 -0
  28. package/lib/types/remarks.d.ts +1 -1
  29. package/lib/types/remarks.d.ts.map +1 -1
  30. package/lib/types/remarks.js +11 -2
  31. package/lib/types/service.d.ts.map +1 -1
  32. package/lib/types/service.js +4 -1
  33. package/lib/types/voice-pack.d.ts +5 -0
  34. package/lib/types/voice-pack.d.ts.map +1 -1
  35. package/lib/types/voice-pack.js +36 -2
  36. package/package.json +1 -1
  37. package/src/access.ts +7 -27
  38. package/src/affinity.ts +13 -0
  39. package/src/client/PetSprite.test.tsx +18 -0
  40. package/src/client/PetSprite.tsx +9 -1
  41. package/src/client/locales.ts +18 -0
  42. package/src/client/renderers/frames2d.test.ts +101 -0
  43. package/src/client/renderers/frames2d.ts +138 -18
  44. package/src/ledger.ts +6 -4
  45. package/src/pair-access.ts +49 -0
  46. package/src/remarks.test.ts +10 -0
  47. package/src/remarks.ts +9 -2
  48. package/src/service.ts +4 -1
  49. package/src/voice-pack.test.ts +39 -0
  50. package/src/voice-pack.ts +37 -2
@@ -8,6 +8,18 @@
8
8
  * phase-mapped track. Rendering never throws: a broken track list degrades
9
9
  * to the first decodable frame, and the 1.2 s stall watchdog re-kicks the
10
10
  * playback chain after timer throttling.
11
+ *
12
+ * Frame presentation has two modes picked once at mount by capability
13
+ * probing:
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).
19
+ * - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
20
+ * historical behavior - cache-warm Image elements plus guarded src swaps,
21
+ * so environments without modern decoding keep working unchanged.
22
+ *
11
23
  * @module @linxin666/dsh-pet/client/renderers/frames2d
12
24
  */
13
25
 
@@ -69,6 +81,12 @@ function validateFrames2dConfig(config: unknown): PetFrames2dConfig {
69
81
  return { tracks, phases: phases as PetFrames2dConfig['phases'] }
70
82
  }
71
83
 
84
+ interface DecodedFrame {
85
+ source: ImageBitmap | HTMLImageElement
86
+ width: number
87
+ height: number
88
+ }
89
+
72
90
  export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
73
91
  id: 'frames2d',
74
92
  apiVersion: PET_RENDERER_API_VERSION,
@@ -79,23 +97,78 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
79
97
  && typeof window.matchMedia === 'function'
80
98
  && window.matchMedia('(prefers-reduced-motion: reduce)').matches
81
99
 
82
- const img = document.createElement('img')
83
- img.dataset.dshPetFrames2d = ctx.petId
84
- img.alt = ''
85
- img.draggable = false
86
- img.style.width = '100%'
87
- img.style.height = '100%'
88
- img.style.objectFit = 'contain'
89
- img.style.pointerEvents = 'none'
90
- ctx.container.appendChild(img)
91
-
92
- // Warm the browser cache for every frame up front (same-origin, small
93
- // webp files); playback itself swaps img.src without React state.
94
- for (const track of Object.values(config.tracks)) {
95
- for (const url of track.frames) {
96
- const pre = new Image()
97
- pre.src = url
100
+ // Capability probe: the canvas path needs decode-to-bitmap, same-origin
101
+ // fetch, and a real 2D context (jsdom returns null and falls back below).
102
+ let canvas: HTMLCanvasElement | null = null
103
+ let context2d: CanvasRenderingContext2D | null = null
104
+ let img: HTMLImageElement | null = null
105
+ try {
106
+ if (typeof createImageBitmap === 'function' && typeof fetch === 'function') {
107
+ const probe = document.createElement('canvas')
108
+ const c2d = probe.getContext('2d')
109
+ if (c2d !== null) {
110
+ canvas = probe
111
+ context2d = c2d
112
+ }
98
113
  }
114
+ } catch {
115
+ canvas = null
116
+ context2d = null
117
+ }
118
+
119
+ if (canvas !== null && context2d !== null) {
120
+ canvas.dataset.dshPetFrames2d = ctx.petId
121
+ canvas.draggable = false
122
+ canvas.style.width = '100%'
123
+ canvas.style.height = '100%'
124
+ canvas.style.objectFit = 'contain'
125
+ canvas.style.pointerEvents = 'none'
126
+ ctx.container.appendChild(canvas)
127
+ } else {
128
+ img = document.createElement('img')
129
+ img.dataset.dshPetFrames2d = ctx.petId
130
+ img.alt = ''
131
+ img.draggable = false
132
+ img.style.width = '100%'
133
+ img.style.height = '100%'
134
+ img.style.objectFit = 'contain'
135
+ img.style.pointerEvents = 'none'
136
+ ctx.container.appendChild(img)
137
+ }
138
+
139
+ // Decode cache: one concurrent decode per URL, memoized forever (frames
140
+ // are tiny same-origin webp files; failures resolve undefined and keep
141
+ // the last painted frame instead of breaking playback).
142
+ const decoding = new Map<string, Promise<DecodedFrame | undefined>>()
143
+ const decodedAll: Promise<void>[] = []
144
+ const loadFrame = (url: string): Promise<DecodedFrame | undefined> => {
145
+ const cached = decoding.get(url)
146
+ if (cached !== undefined) return cached
147
+ const job: Promise<DecodedFrame | undefined> = (async () => {
148
+ try {
149
+ const response = await fetch(url)
150
+ if (!response.ok) throw new Error('http ' + response.status)
151
+ const bitmap = await createImageBitmap(await response.blob())
152
+ return { source: bitmap, width: bitmap.width, height: bitmap.height }
153
+ } catch {
154
+ // Fail-open: classic Image decode keeps non-modern runtimes alive.
155
+ return await new Promise<DecodedFrame | undefined>((resolve) => {
156
+ try {
157
+ const pre = new Image()
158
+ pre.onload = (): void => {
159
+ resolve(pre.naturalWidth > 0 ? { source: pre, width: pre.naturalWidth, height: pre.naturalHeight } : undefined)
160
+ }
161
+ pre.onerror = (): void => resolve(undefined)
162
+ pre.src = url
163
+ } catch {
164
+ resolve(undefined)
165
+ }
166
+ })
167
+ }
168
+ })()
169
+ decoding.set(url, job)
170
+ decodedAll.push(job.then(() => undefined, () => undefined))
171
+ return job
99
172
  }
100
173
 
101
174
  let disposed = false
@@ -105,16 +178,42 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
105
178
  let frameIndex = 0
106
179
  let lastAdvance = Date.now()
107
180
  let override: string | undefined
181
+ let drawToken = 0
182
+ let lastDrawnUrl: string | undefined
108
183
 
109
184
  const trackForPhase = (phase: ActivityPhase): string => {
110
185
  const mapped = config.phases[phase]
111
186
  return mapped !== undefined && config.tracks[mapped] !== undefined ? mapped : config.phases.idle
112
187
  }
113
188
 
189
+ /** Canvas path: paints the newest requested frame; stale draws drop out. */
190
+ const paintCanvas = (url: string): void => {
191
+ if (context2d === null || canvas === null) return
192
+ const myToken = ++drawToken
193
+ void loadFrame(url).then((frame) => {
194
+ if (disposed || frame === undefined || myToken !== drawToken) return
195
+ if (lastDrawnUrl === url) return
196
+ lastDrawnUrl = url
197
+ // Resizing clears the canvas, so size only when it actually differs.
198
+ if (canvas.width !== frame.width || canvas.height !== frame.height) {
199
+ canvas.width = frame.width
200
+ canvas.height = frame.height
201
+ } else {
202
+ context2d.clearRect(0, 0, canvas.width, canvas.height)
203
+ }
204
+ context2d.drawImage(frame.source as CanvasImageSource, 0, 0)
205
+ }).catch(() => { /* keep last frame */ })
206
+ }
207
+
114
208
  const show = (trackId: string, index: number): void => {
115
209
  const def = config.tracks[trackId]
116
210
  const url = def?.frames[index]
117
- if (url !== undefined && img.getAttribute('src') !== url) img.src = url
211
+ if (url === undefined) return
212
+ if (img !== null) {
213
+ if (img.getAttribute('src') !== url) img.src = url
214
+ return
215
+ }
216
+ paintCanvas(url)
118
217
  }
119
218
 
120
219
  const schedule = (ms: number): void => {
@@ -180,6 +279,13 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
180
279
  }, WATCHDOG_MS)
181
280
  }
182
281
 
282
+ // Warm pass: decode every frame up front (tiny same-origin webp files)
283
+ // so loops and phase switches never wait on a first decode - same intent
284
+ // as the historical Image-cache warm loop, now feeding the decode cache.
285
+ for (const warmTrack of Object.values(config.tracks)) {
286
+ for (const warmUrl of warmTrack.frames) void loadFrame(warmUrl)
287
+ }
288
+
183
289
  play(track)
184
290
 
185
291
  let disposedOnce = false
@@ -190,7 +296,21 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
190
296
  unsubscribe()
191
297
  if (timer !== undefined) clearTimeout(timer)
192
298
  if (watchdog !== undefined) clearInterval(watchdog)
193
- img.remove()
299
+ // Release decoded bitmaps after pending decodes settle; close() is
300
+ // browser-only, so guard it for exotic hosts.
301
+ void Promise.allSettled(decodedAll).then(() => {
302
+ for (const job of decoding.values()) {
303
+ void job.then((frame) => {
304
+ try {
305
+ const maybeClose = (frame?.source as { close?: () => void } | undefined)?.close
306
+ if (typeof maybeClose === 'function' && frame !== undefined) maybeClose.call(frame.source)
307
+ } catch { /* already released */ }
308
+ }).catch(() => { /* never rejects anyway */ })
309
+ }
310
+ decoding.clear()
311
+ })
312
+ canvas?.remove()
313
+ img?.remove()
194
314
  }
195
315
  ctx.onCleanup(dispose)
196
316
 
package/src/ledger.ts CHANGED
@@ -32,6 +32,8 @@ export interface LedgerConfig {
32
32
  treats?: Partial<TreatConfig>
33
33
  /** Per-pet remark pools for the selected pet (custom slots override built-ins). */
34
34
  remarks?: PetRemarks
35
+ /** Voice-pack fallback remark pools (global or pet voice pack). */
36
+ voiceRemarks?: PetRemarks
35
37
  }
36
38
 
37
39
  /** Result of one ledger interaction (the shape the pet RPC returns). */
@@ -63,7 +65,7 @@ export class PetLedger {
63
65
  constructor(persist: PetPersist, config: LedgerConfig = {}) {
64
66
  this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
65
67
  this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
66
- this.picker = new RemarkPicker(config.remarks)
68
+ this.picker = new RemarkPicker(config.remarks, config.voiceRemarks)
67
69
  this.current = persist
68
70
  }
69
71
 
@@ -124,10 +126,10 @@ export class PetLedger {
124
126
 
125
127
  /**
126
128
  * Swap the reaction pools to another pet's custom remarks (called on pet
127
- * selection). Slots the pet does not declare fall back to built-ins.
129
+ * selection). Slots the pet does not declare fall back to voice packs or built-ins.
128
130
  */
129
- setRemarks(remarks?: PetRemarks): void {
130
- this.picker = new RemarkPicker(remarks)
131
+ setRemarks(remarks?: PetRemarks, voiceRemarks?: PetRemarks): void {
132
+ this.picker = new RemarkPicker(remarks, voiceRemarks)
131
133
  }
132
134
 
133
135
  /**
@@ -0,0 +1,49 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Pairing trust fence shared by plugins that expose host routes: loopback
4
+ * (the desktop) always passes; a live paired-device cookie is an additional
5
+ * allow path when remote-web-ui is loaded. The consuming plugin never
6
+ * depends on that plugin — without the service the fence stays
7
+ * loopback-only.
8
+ *
9
+ * Per-package wrappers (access.ts) call this with their own name so each
10
+ * plugin keeps a self-describing export; the security decision lives only
11
+ * here.
12
+ */
13
+ import type { IncomingMessage } from 'node:http'
14
+ import { isLoopbackRequest } from './loopback.ts'
15
+
16
+ /** Structural pairing lookup (no package dependency on remote-web-ui). */
17
+ interface PairingAccess {
18
+ isPairedDevice(request: IncomingMessage): boolean
19
+ }
20
+
21
+ /**
22
+ * Structural host-context shape: shared sources carry no @deepseek-ai
23
+ * dependency (the shared package must typecheck standalone), so the fence
24
+ * reads only the two members it needs; cordis Context satisfies this.
25
+ * ctx.get is optional on the test harness; production Context always has it.
26
+ */
27
+ interface LookupCtx {
28
+ get?(name: string, strict?: boolean): unknown
29
+ remoteWebUiPairing?: PairingAccess
30
+ }
31
+
32
+ /**
33
+ * Whether this request may enter the plugin's host routes.
34
+ * @param ctx - host context; may expose remoteWebUiPairing.
35
+ * @param request - the incoming HTTP request.
36
+ * @returns true for loopback, or a live paired-device cookie.
37
+ */
38
+ export function isPairedOrLoopbackAllowed(ctx: LookupCtx, request: IncomingMessage): boolean {
39
+ if (isLoopbackRequest(request)) return true
40
+ const fromGet = typeof ctx.get === 'function' ? ctx.get('remoteWebUiPairing', false) : undefined
41
+ const pairing = (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)
42
+ return pairing?.isPairedDevice(request) === true
43
+ }
44
+
45
+ function isPairingAccess(value: unknown): value is PairingAccess {
46
+ return value !== undefined
47
+ && value !== null
48
+ && typeof (value as PairingAccess).isPairedDevice === 'function'
49
+ }
@@ -54,6 +54,16 @@ describe('RemarkPicker', () => {
54
54
  expect(picker.pick('pet')).toBe('专属摸头台词')
55
55
  expect(picker.pick('feed')).toBe(BUILTIN_REMARKS.feed[0])
56
56
  })
57
+
58
+ it('layers pet custom lines over voice pack lines, falling back to built-ins (#1226)', () => {
59
+ const picker = new RemarkPicker(
60
+ { pet: ['Pet-level line'] },
61
+ { pet: ['Voice-level pet line'], feed: ['Voice-level feed line'] },
62
+ )
63
+ expect(picker.pick('pet')).toBe('Pet-level line')
64
+ expect(picker.pick('feed')).toBe('Voice-level feed line')
65
+ expect(picker.pick('noTreats')).toBe(BUILTIN_REMARKS.noTreats[0])
66
+ })
57
67
  })
58
68
 
59
69
  describe('normalizePetRemarks', () => {
package/src/remarks.ts CHANGED
@@ -184,11 +184,18 @@ export class RemarkPicker {
184
184
  private readonly counters = new Map<RemarkKind, number>()
185
185
  private readonly pools: Record<RemarkKind, readonly string[]>
186
186
 
187
- constructor(overrides?: PetRemarks) {
187
+ constructor(overrides?: PetRemarks, voiceOverrides?: PetRemarks) {
188
188
  this.pools = {} as Record<RemarkKind, readonly string[]>
189
189
  for (const kind of REMARK_KINDS) {
190
190
  const custom = overrides?.[kind]
191
- this.pools[kind] = custom !== undefined && custom.length > 0 ? custom : BUILTIN_REMARKS[kind]
191
+ const voice = voiceOverrides?.[kind]
192
+ if (custom !== undefined && custom.length > 0) {
193
+ this.pools[kind] = custom
194
+ } else if (voice !== undefined && voice.length > 0) {
195
+ this.pools[kind] = voice
196
+ } else {
197
+ this.pools[kind] = BUILTIN_REMARKS[kind]
198
+ }
192
199
  }
193
200
  }
194
201
 
package/src/service.ts CHANGED
@@ -297,10 +297,12 @@ export class PetService extends Service {
297
297
  persist = { ...persist, petId: this.registry.defaultEntry().id }
298
298
  }
299
299
  const selected = this.registry.byId(persist.petId) ?? this.registry.defaultEntry()
300
+ const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, selected.voice)?.remarks
300
301
  const ledgerConfig: LedgerConfig = {
301
302
  affinity: config.affinity,
302
303
  treats: config.treats,
303
304
  remarks: selected.remarks,
305
+ voiceRemarks,
304
306
  }
305
307
  this.ledger = new PetLedger(persist, ledgerConfig)
306
308
  this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
@@ -390,7 +392,8 @@ export class PetService extends Service {
390
392
  const entry = this.registry.byId(petId)
391
393
  if (entry === undefined) return { ok: false, error: 'unknown-pet' }
392
394
  this.ledger.setPetId(entry.id)
393
- this.ledger.setRemarks(entry.remarks)
395
+ const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.remarks
396
+ this.ledger.setRemarks(entry.remarks, voiceRemarks)
394
397
  this.flush()
395
398
  this.syncSettingsFromPet()
396
399
  return { ok: true, petId: entry.id }
@@ -53,6 +53,45 @@ describe('normalizeVoicePack structure', () => {
53
53
  expect(warnings.join('\n')).toContain('mystery')
54
54
  expect(warnings.join('\n')).toContain('voicePackVersion')
55
55
  })
56
+
57
+ it('normalizes top-level remarks and ranks (#1226)', () => {
58
+ const { pack, warnings } = collectWarnings({
59
+ remarks: {
60
+ pet: ['Purr~ So comfortable!'],
61
+ feed: 'Yummy fish!',
62
+ },
63
+ ranks: {
64
+ '0': 'Baby Whale',
65
+ '幼鲸': 'Little Whale',
66
+ },
67
+ })
68
+ expect(pack).toBeDefined()
69
+ expect(pack?.remarks?.pet).toEqual(['Purr~ So comfortable!'])
70
+ expect(pack?.remarks?.feed).toEqual(['Yummy fish!'])
71
+ expect(pack?.ranks).toEqual({
72
+ '0': 'Baby Whale',
73
+ '幼鲸': 'Little Whale',
74
+ })
75
+ expect(warnings).toHaveLength(0)
76
+ })
77
+
78
+ it('merges remarks and ranks across layers (#1226)', () => {
79
+ const base = normalizeVoicePack({
80
+ remarks: { pet: ['Base pet'], feed: ['Base feed'] },
81
+ ranks: { '0': 'Base 0', '25': 'Base 25' },
82
+ })
83
+ const layer = normalizeVoicePack({
84
+ remarks: { pet: ['Layer pet'] },
85
+ ranks: { '0': 'Layer 0' },
86
+ })
87
+ const merged = mergeVoicePacks(base, layer)
88
+ expect(merged?.remarks?.pet).toEqual(['Layer pet'])
89
+ expect(merged?.remarks?.feed).toEqual(['Base feed'])
90
+ expect(merged?.ranks).toEqual({
91
+ '0': 'Layer 0',
92
+ '25': 'Base 25',
93
+ })
94
+ })
56
95
  })
57
96
 
58
97
  describe('normalizePool', () => {
package/src/voice-pack.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  type WhisperCategory,
38
38
  type WhisperResult,
39
39
  } from './chatter.ts'
40
+ import { normalizePetRemarks, type PetRemarks } from './remarks.ts'
40
41
 
41
42
  /** Schema version this module normalizes (optional field; missing = 1). */
42
43
  export const VOICE_PACK_V1 = 1 as const
@@ -69,6 +70,10 @@ export interface VoicePack {
69
70
  overrides: VoicePackOverrides
70
71
  /** Hover-panel chrome, when the pack declares any. */
71
72
  panel?: PetPanelView
73
+ /** Pat/feed interaction remark pools (issue #1226). */
74
+ remarks?: PetRemarks
75
+ /** Affinity rank name overrides (issue #1226). */
76
+ ranks?: Record<string, string>
72
77
  }
73
78
 
74
79
  /** Hard caps shared by every pool slot (mirrors the remarks discipline). */
@@ -265,7 +270,7 @@ export function normalizePanel(
265
270
  }
266
271
 
267
272
  /** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
268
- export const VOICE_PACK_KEYS = new Set(['$schema', 'voicePackVersion', 'status', 'tools', 'toolRemaining', 'whispers', 'panel'])
273
+ export const VOICE_PACK_KEYS = new Set(['$schema', 'voicePackVersion', 'status', 'tools', 'toolRemaining', 'whispers', 'panel', 'remarks', 'ranks'])
269
274
 
270
275
  /** Allowed whisper-section fields (drift-locked in tests). */
271
276
  export const WHISPER_KEYS = new Set(['categories', 'results'])
@@ -356,16 +361,36 @@ export function normalizeVoicePack(
356
361
  }
357
362
  }
358
363
  const panel = raw.panel === undefined ? undefined : normalizePanel(raw.panel, onWarning)
364
+ const remarks = raw.remarks === undefined ? undefined : normalizePetRemarks(raw.remarks, onWarning)
365
+ const ranksRaw = raw.ranks
366
+ let ranks: Record<string, string> | undefined
367
+ if (ranksRaw !== undefined) {
368
+ if (!isRecord(ranksRaw)) {
369
+ onWarning('ranks must be an object')
370
+ } else {
371
+ const out: Record<string, string> = {}
372
+ for (const [key, value] of Object.entries(ranksRaw)) {
373
+ if (typeof value === 'string' && value.trim() !== '') {
374
+ out[key] = value.trim().slice(0, VOICE_STAT_MAX)
375
+ } else {
376
+ onWarning('invalid rank name for ' + key)
377
+ }
378
+ }
379
+ if (Object.keys(out).length > 0) ranks = out
380
+ }
381
+ }
359
382
  if (
360
383
  overrides.status === undefined && overrides.tools === undefined
361
384
  && overrides.toolRemaining === undefined && overrides.whispers === undefined
362
- && panel === undefined
385
+ && panel === undefined && remarks === undefined && ranks === undefined
363
386
  ) {
364
387
  return undefined
365
388
  }
366
389
  return {
367
390
  overrides,
368
391
  ...(panel === undefined ? {} : { panel }),
392
+ ...(remarks === undefined ? {} : { remarks }),
393
+ ...(ranks === undefined ? {} : { ranks }),
369
394
  }
370
395
  }
371
396
 
@@ -379,6 +404,8 @@ export function mergeVoicePacks(...layers: (VoicePack | undefined)[]): VoicePack
379
404
  const overrides: VoicePackOverrides = {}
380
405
  const labels: NonNullable<PetPanelView['labels']> = {}
381
406
  const stats: NonNullable<PetPanelView['stats']> = {}
407
+ let remarks: PetRemarks | undefined
408
+ let ranks: Record<string, string> | undefined
382
409
  let actions: PanelAction[] | undefined
383
410
  let panelSeen = false
384
411
  let any = false
@@ -401,6 +428,12 @@ export function mergeVoicePacks(...layers: (VoicePack | undefined)[]): VoicePack
401
428
  if (layer.panel.stats !== undefined) Object.assign(stats, layer.panel.stats)
402
429
  if (layer.panel.actions !== undefined) actions = layer.panel.actions
403
430
  }
431
+ if (layer.remarks !== undefined) {
432
+ remarks = { ...(remarks ?? {}), ...layer.remarks }
433
+ }
434
+ if (layer.ranks !== undefined) {
435
+ ranks = { ...(ranks ?? {}), ...layer.ranks }
436
+ }
404
437
  }
405
438
  if (!any) return undefined
406
439
  const panel: PetPanelView = {
@@ -412,5 +445,7 @@ export function mergeVoicePacks(...layers: (VoicePack | undefined)[]): VoicePack
412
445
  return {
413
446
  overrides,
414
447
  ...(panelSeen && !panelEmpty ? { panel } : {}),
448
+ ...(remarks !== undefined ? { remarks } : {}),
449
+ ...(ranks !== undefined ? { ranks } : {}),
415
450
  }
416
451
  }