@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.
- package/contracts/voice-pack-v1.schema.json +30 -0
- package/cordis.patch.yml +1 -1
- package/lib/client.js +144 -16
- package/lib/client.js.map +1 -1
- package/lib/index.js +56 -17
- package/lib/invariant.js +1 -1
- package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
- package/lib/types/access.d.ts +5 -4
- package/lib/types/access.d.ts.map +1 -1
- package/lib/types/access.js +2 -12
- package/lib/types/affinity.d.ts +2 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +12 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +7 -1
- package/lib/types/client/locales.d.ts +18 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +18 -0
- package/lib/types/client/renderers/frames2d.d.ts +12 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
- package/lib/types/client/renderers/frames2d.js +143 -18
- package/lib/types/ledger.d.ts +4 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +4 -4
- package/lib/types/pair-access.d.ts +35 -0
- package/lib/types/pair-access.d.ts.map +1 -0
- package/lib/types/pair-access.js +19 -0
- package/lib/types/remarks.d.ts +1 -1
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +11 -2
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +4 -1
- package/lib/types/voice-pack.d.ts +5 -0
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +36 -2
- package/package.json +1 -1
- package/src/access.ts +7 -27
- package/src/affinity.ts +13 -0
- package/src/client/PetSprite.test.tsx +18 -0
- package/src/client/PetSprite.tsx +9 -1
- package/src/client/locales.ts +18 -0
- package/src/client/renderers/frames2d.test.ts +101 -0
- package/src/client/renderers/frames2d.ts +138 -18
- package/src/ledger.ts +6 -4
- package/src/pair-access.ts +49 -0
- package/src/remarks.test.ts +10 -0
- package/src/remarks.ts +9 -2
- package/src/service.ts +4 -1
- package/src/voice-pack.test.ts +39 -0
- 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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
img
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/remarks.test.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 }
|
package/src/voice-pack.test.ts
CHANGED
|
@@ -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
|
}
|