@linxin666/dsh-pet 0.2.3 → 0.2.4
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/README.i18n.yaml +2 -2
- package/README.md +64 -1
- package/README.zh.md +64 -1
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +144 -18
- package/lib/client.js.map +1 -1
- package/lib/index.js +979 -70
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetSettingsCard.d.ts +4 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +3 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +103 -4
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.js +13 -1
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -0
- package/lib/types/registry.d.ts +55 -2
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +198 -16
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +131 -3
- package/lib/types/service.d.ts +33 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +42 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +11 -10
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetSettingsCard.tsx +18 -0
- package/src/client/PetSprite.test.tsx +254 -12
- package/src/client/PetSprite.tsx +142 -25
- package/src/client/locales.ts +4 -0
- package/src/client/renderers/live2d.test.ts +23 -2
- package/src/client/renderers/live2d.ts +14 -1
- package/src/client/settings-form.ts +8 -6
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/index.ts +2 -0
- package/src/registry.test.ts +223 -0
- package/src/registry.ts +235 -15
- package/src/routes.ts +128 -3
- package/src/service.ts +59 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-decoration manifest v1 — fail-closed structure + warn-and-drop
|
|
3
|
+
* content, mirroring the pet manifest-v2 discipline (issue #623 M5,
|
|
4
|
+
* protocol #567). Unknown top-level fields, unsafe entry paths, out-of-
|
|
5
|
+
* range geometry and unknown renderer content reject the descriptor with
|
|
6
|
+
* human-readable diagnostics; per-phase binding issues drop that binding
|
|
7
|
+
* only. The JSON Schema twin lives at
|
|
8
|
+
* contracts/status-decoration-v1.schema.json; this hand-rolled parser is
|
|
9
|
+
* authoritative. Keep this file erasable-syntax-only (scripts/ import it
|
|
10
|
+
* under node's strip-only mode).
|
|
11
|
+
* @module @linxin666/dsh-pet/decoration
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isAbsolute } from 'node:path'
|
|
15
|
+
import type { ActivityPhase } from './state.ts'
|
|
16
|
+
import { PET_ACTIVITY_PHASES } from './manifest-v2.ts'
|
|
17
|
+
import type {
|
|
18
|
+
DecorationDiagnostic,
|
|
19
|
+
DecorationManifest,
|
|
20
|
+
DecorationManifestParse,
|
|
21
|
+
PhaseBindings,
|
|
22
|
+
PhaseSegment,
|
|
23
|
+
} from './contracts/status-decoration.ts'
|
|
24
|
+
|
|
25
|
+
/** Geometry and content caps (the adopted PNG/WebP sprite-strip bounds). */
|
|
26
|
+
export const DECORATION_CELL_MAX = 256
|
|
27
|
+
export const DECORATION_COLUMNS_MAX = 16
|
|
28
|
+
export const DECORATION_DURATION_MAX_MS = 2000
|
|
29
|
+
export const DECORATION_ENTRY_EXTENSIONS = ['.webp', '.png'] as const
|
|
30
|
+
export const DECORATION_DISPLAY_NAME_MAX = 64
|
|
31
|
+
|
|
32
|
+
/** Field allow-list (drift-locked to the schema twin in tests). */
|
|
33
|
+
export const KNOWN_DECORATION_TOP_LEVEL = new Set([
|
|
34
|
+
'$schema', 'decorationManifestVersion', 'id', 'displayName', 'license',
|
|
35
|
+
'entry', 'cell', 'columns', 'frameMs', 'durations', 'loop', 'phases',
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
39
|
+
const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/
|
|
40
|
+
|
|
41
|
+
class Diagnostics {
|
|
42
|
+
readonly list: DecorationDiagnostic[] = []
|
|
43
|
+
private readonly source: string
|
|
44
|
+
constructor(source: string) { this.source = source }
|
|
45
|
+
error(message: string): void { this.list.push({ level: 'error', message: this.source + ': ' + message }) }
|
|
46
|
+
warn(message: string): void { this.list.push({ level: 'warning', message: this.source + ': ' + message }) }
|
|
47
|
+
get hasErrors(): boolean { return this.list.some(d => d.level === 'error') }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
51
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function unknownKeys(source: Record<string, unknown>, known: Set<string>): string[] {
|
|
55
|
+
return Object.keys(source).filter(key => !known.has(key))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Positive integer in [min, max], else undefined. */
|
|
59
|
+
function finiteInt(value: unknown, min: number, max: number): number | undefined {
|
|
60
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max
|
|
61
|
+
? value
|
|
62
|
+
: undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate a descriptor-relative entry path: no absolute paths, no
|
|
67
|
+
* backslashes, no traversal, plain safe segments only, and an exact
|
|
68
|
+
* lowercase PNG/WebP extension (the adopted entry discipline — SVG/CSS are
|
|
69
|
+
* not accepted). The extension match is case-sensitive on purpose: the
|
|
70
|
+
* asset route serves the declared path verbatim, so a case-mismatched
|
|
71
|
+
* suffix (frames.PNG vs frames.png) would pass a lenient check but 403 on
|
|
72
|
+
* case-sensitive filesystems.
|
|
73
|
+
* Returns the normalized path or undefined.
|
|
74
|
+
*/
|
|
75
|
+
export function safeDecorationEntry(raw: unknown): string | undefined {
|
|
76
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
77
|
+
const value = raw.trim()
|
|
78
|
+
if (value.length > 256) return undefined
|
|
79
|
+
if (isAbsolute(value) || value.includes('\\') || /^[a-z][a-z0-9+.-]*:/i.test(value)) return undefined
|
|
80
|
+
const segments = value.split('/').filter(segment => segment !== '')
|
|
81
|
+
if (segments.length === 0) return undefined
|
|
82
|
+
if (segments.some(segment => segment === '.' || segment === '..' || !PATH_SEGMENT_PATTERN.test(segment))) return undefined
|
|
83
|
+
const last = segments[segments.length - 1]!
|
|
84
|
+
const dot = last.lastIndexOf('.')
|
|
85
|
+
if (dot <= 0 || !(DECORATION_ENTRY_EXTENSIONS as readonly string[]).includes(last.slice(dot))) return undefined
|
|
86
|
+
return segments.join('/')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Normalize one phase binding value; undefined (warning) on bad content. */
|
|
90
|
+
function normalizeSegment(
|
|
91
|
+
raw: unknown,
|
|
92
|
+
columns: number,
|
|
93
|
+
diag: Diagnostics,
|
|
94
|
+
): PhaseSegment | undefined {
|
|
95
|
+
if (raw === 'hide') return 'hide'
|
|
96
|
+
if (!isRecord(raw)) {
|
|
97
|
+
diag.warn('phase binding must be "hide" or { from, to }; binding dropped')
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
const from = finiteInt(raw.from, 0, columns - 1)
|
|
101
|
+
const to = finiteInt(raw.to, 0, columns - 1)
|
|
102
|
+
if (from === undefined || to === undefined || from > to) {
|
|
103
|
+
diag.warn('phase frame segment out of range; binding dropped')
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
return { from, to }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse and validate one decoration.json document. Fail-closed over the
|
|
111
|
+
* structure (types, key sets, paths, ranges); phase-binding content issues
|
|
112
|
+
* drop that binding only (warn-and-drop, the registry never-throw rule).
|
|
113
|
+
*/
|
|
114
|
+
export function parseDecorationManifest(
|
|
115
|
+
raw: unknown,
|
|
116
|
+
source: string = 'decoration.json',
|
|
117
|
+
): DecorationManifestParse {
|
|
118
|
+
const diag = new Diagnostics(source)
|
|
119
|
+
if (!isRecord(raw)) {
|
|
120
|
+
diag.error('descriptor must be a JSON object')
|
|
121
|
+
return { ok: false, diagnostics: diag.list }
|
|
122
|
+
}
|
|
123
|
+
for (const key of unknownKeys(raw, KNOWN_DECORATION_TOP_LEVEL)) {
|
|
124
|
+
diag.error('unknown top-level field ' + JSON.stringify(key))
|
|
125
|
+
}
|
|
126
|
+
if (raw.decorationManifestVersion !== 1) {
|
|
127
|
+
diag.error('decorationManifestVersion must be 1')
|
|
128
|
+
}
|
|
129
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : ''
|
|
130
|
+
if (!PET_ID_PATTERN.test(id)) {
|
|
131
|
+
diag.error('id must be a lowercase kebab id')
|
|
132
|
+
}
|
|
133
|
+
if (id.length > 64) {
|
|
134
|
+
diag.error('id must be at most 64 characters')
|
|
135
|
+
}
|
|
136
|
+
const license = typeof raw.license === 'string' ? raw.license.trim() : ''
|
|
137
|
+
if (license === '') diag.error('license is required (asset provenance)')
|
|
138
|
+
if (license.length > 128) {
|
|
139
|
+
diag.error('license must be at most 128 characters')
|
|
140
|
+
}
|
|
141
|
+
const entry = safeDecorationEntry(raw.entry)
|
|
142
|
+
if (entry === undefined) {
|
|
143
|
+
diag.error('entry must be a safe relative PNG/WebP path')
|
|
144
|
+
}
|
|
145
|
+
const rawCell = isRecord(raw.cell) ? raw.cell : {}
|
|
146
|
+
for (const key of Object.keys(rawCell)) {
|
|
147
|
+
if (key !== 'width' && key !== 'height') diag.warn('unknown cell field ' + JSON.stringify(key) + ' ignored')
|
|
148
|
+
}
|
|
149
|
+
const cellWidth = finiteInt(rawCell.width, 1, DECORATION_CELL_MAX)
|
|
150
|
+
const cellHeight = finiteInt(rawCell.height, 1, DECORATION_CELL_MAX)
|
|
151
|
+
if (cellWidth === undefined || cellHeight === undefined) {
|
|
152
|
+
diag.error('cell width/height must be integers in [1, ' + DECORATION_CELL_MAX + ']')
|
|
153
|
+
}
|
|
154
|
+
const columns = finiteInt(raw.columns, 1, DECORATION_COLUMNS_MAX)
|
|
155
|
+
if (columns === undefined) {
|
|
156
|
+
diag.error('columns must be an integer in [1, ' + DECORATION_COLUMNS_MAX + ']')
|
|
157
|
+
}
|
|
158
|
+
if (diag.hasErrors || id === '' || entry === undefined || columns === undefined) {
|
|
159
|
+
return { ok: false, diagnostics: diag.list }
|
|
160
|
+
}
|
|
161
|
+
const displayName = typeof raw.displayName === 'string' && raw.displayName.trim() !== ''
|
|
162
|
+
? raw.displayName.trim().slice(0, DECORATION_DISPLAY_NAME_MAX)
|
|
163
|
+
: id
|
|
164
|
+
let loop: boolean
|
|
165
|
+
if (raw.loop === undefined || typeof raw.loop === 'boolean') {
|
|
166
|
+
loop = raw.loop ?? true
|
|
167
|
+
} else {
|
|
168
|
+
diag.warn('loop must be a boolean; defaulting to true')
|
|
169
|
+
loop = true
|
|
170
|
+
}
|
|
171
|
+
const rawDurations = raw.durations
|
|
172
|
+
let durations: number[]
|
|
173
|
+
if (Array.isArray(rawDurations)) {
|
|
174
|
+
const usable = rawDurations.filter((v): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= DECORATION_DURATION_MAX_MS)
|
|
175
|
+
if (usable.length !== columns) {
|
|
176
|
+
diag.warn('durations length must equal columns; using the constant frameMs instead')
|
|
177
|
+
durations = []
|
|
178
|
+
} else {
|
|
179
|
+
durations = usable
|
|
180
|
+
}
|
|
181
|
+
} else if (rawDurations !== undefined) {
|
|
182
|
+
diag.warn('durations must be an array; using the constant frameMs instead')
|
|
183
|
+
durations = []
|
|
184
|
+
} else {
|
|
185
|
+
durations = []
|
|
186
|
+
}
|
|
187
|
+
if (durations.length === 0) {
|
|
188
|
+
const frameMs = finiteInt(raw.frameMs, 1, DECORATION_DURATION_MAX_MS) ?? 120
|
|
189
|
+
durations = Array.from({ length: columns }, () => frameMs)
|
|
190
|
+
}
|
|
191
|
+
const phases: PhaseBindings = {}
|
|
192
|
+
const rawPhases = raw.phases
|
|
193
|
+
if (isRecord(rawPhases)) {
|
|
194
|
+
for (const [key, value] of Object.entries(rawPhases)) {
|
|
195
|
+
if (!(PET_ACTIVITY_PHASES as readonly string[]).includes(key)) {
|
|
196
|
+
diag.warn('unknown phase ' + JSON.stringify(key) + '; binding ignored')
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const segment = normalizeSegment(value, columns, diag)
|
|
200
|
+
if (segment !== undefined) phases[key as ActivityPhase] = segment
|
|
201
|
+
}
|
|
202
|
+
} else if (rawPhases !== undefined) {
|
|
203
|
+
diag.warn('phases must be an object; all phases hide')
|
|
204
|
+
}
|
|
205
|
+
const visible = Object.values(phases).some(segment => segment !== 'hide')
|
|
206
|
+
if (!visible) diag.warn('no phase shows the ornament; the decoration stays hidden')
|
|
207
|
+
const manifest: DecorationManifest = {
|
|
208
|
+
decorationManifestVersion: 1,
|
|
209
|
+
id,
|
|
210
|
+
displayName,
|
|
211
|
+
license,
|
|
212
|
+
entry,
|
|
213
|
+
cell: { width: cellWidth!, height: cellHeight! },
|
|
214
|
+
columns,
|
|
215
|
+
durations,
|
|
216
|
+
loop,
|
|
217
|
+
phases,
|
|
218
|
+
}
|
|
219
|
+
return { ok: true, manifest, diagnostics: diag.list }
|
|
220
|
+
}
|
package/src/event-projection.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
15
15
|
import type { PetStateInput } from './state.ts'
|
|
16
|
-
import { StatusVoice, toolArgHint, WhisperEngine } from './chatter.ts'
|
|
16
|
+
import { StatusVoice, toolArgHint, WhisperEngine, type VoicePoolsProvider } from './chatter.ts'
|
|
17
17
|
|
|
18
18
|
/** Runtime shape of the optional legacy activity event. */
|
|
19
19
|
export interface ActivityStatusEventLike {
|
|
@@ -41,14 +41,19 @@ export interface PetActivityTransition {
|
|
|
41
41
|
whisper?: string
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Fresh projection runtime for a newly seen session. The optional voice-pack
|
|
46
|
+
* provider (pet-center M4, issue #677) hands both chatter engines their
|
|
47
|
+
* pools; engines resolve overrides at draw time, so swapping the provider's
|
|
48
|
+
* pack re-voices live runtimes without rebuilding them.
|
|
49
|
+
*/
|
|
50
|
+
export function emptyProjectionRuntime(pools?: VoicePoolsProvider): ProjectionRuntime {
|
|
46
51
|
return {
|
|
47
52
|
activeTools: new Set(),
|
|
48
53
|
officialEventsSeen: false,
|
|
49
54
|
stepHadFailure: false,
|
|
50
|
-
voice: new StatusVoice(),
|
|
51
|
-
whispers: new WhisperEngine(),
|
|
55
|
+
voice: new StatusVoice(pools),
|
|
56
|
+
whispers: new WhisperEngine(pools),
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
|
package/src/index.ts
CHANGED
|
@@ -134,6 +134,7 @@ export function makePetSettingsSchema(fallbackPetId: string) {
|
|
|
134
134
|
bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
|
|
135
135
|
petId: z.string().default(fallbackPetId),
|
|
136
136
|
enabled: z.boolean().default(true),
|
|
137
|
+
decorationEnabled: z.boolean().default(true),
|
|
137
138
|
})
|
|
138
139
|
}
|
|
139
140
|
|
|
@@ -163,6 +164,7 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
|
|
|
163
164
|
bottom: service.display().bottom,
|
|
164
165
|
petId: service.selectedPetId(),
|
|
165
166
|
enabled: config.enabled ?? true,
|
|
167
|
+
decorationEnabled: config.decorationEnabled ?? true,
|
|
166
168
|
}
|
|
167
169
|
// The browser half talks to the pet through same-origin JSON endpoints and
|
|
168
170
|
// loads each pet's atlas from the registry's own media route (RPC domains
|
package/src/registry.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_FRAME_COUNTS,
|
|
7
7
|
DEFAULT_PET_CELL,
|
|
8
|
+
PET_SCAN_JSON_CAP,
|
|
8
9
|
codexPetsDir,
|
|
9
10
|
loadPetRegistry,
|
|
10
11
|
petAtlasFile,
|
|
@@ -465,3 +466,225 @@ describe('loadPetRegistry pet-center v2 (issue #623)', () => {
|
|
|
465
466
|
}
|
|
466
467
|
})
|
|
467
468
|
})
|
|
469
|
+
|
|
470
|
+
describe('voice packs (pet-center M4, issue #677)', () => {
|
|
471
|
+
function writeVoice(dir: string, name: string, pack: unknown): void {
|
|
472
|
+
mkdirSync(join(dir, name), { recursive: true })
|
|
473
|
+
writeFileSync(join(dir, name, 'pet.json'), JSON.stringify({ id: name, displayName: name, spritesheetPath: 'spritesheet.webp' }), 'utf8')
|
|
474
|
+
writeFileSync(join(dir, name, 'spritesheet.webp'), 'webp', 'utf8')
|
|
475
|
+
writeFileSync(join(dir, name, 'voice.json'), JSON.stringify(pack), 'utf8')
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
it('loads a pet voice.json and serves its panel slice to the browser view', () => {
|
|
479
|
+
const root = tempDir()
|
|
480
|
+
try {
|
|
481
|
+
const petsDir = join(root, 'pets')
|
|
482
|
+
writeVoice(petsDir, 'talker', {
|
|
483
|
+
status: { done: ['自定义完工'] },
|
|
484
|
+
panel: { labels: { feed: '投喂' }, stats: { rank: '好感 {rank}' }, actions: ['feed'] },
|
|
485
|
+
})
|
|
486
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
|
|
487
|
+
const entry = registry.byId('talker')!
|
|
488
|
+
expect(entry.voice?.overrides.status?.done).toEqual(['自定义完工'])
|
|
489
|
+
const view = petEntryView(entry)
|
|
490
|
+
expect(view.panel).toEqual({
|
|
491
|
+
labels: { feed: '投喂' },
|
|
492
|
+
stats: { rank: '好感 {rank}' },
|
|
493
|
+
actions: ['feed'],
|
|
494
|
+
})
|
|
495
|
+
// Host-only voice content never reaches the browser half.
|
|
496
|
+
expect('voice' in view).toBe(false)
|
|
497
|
+
// A healthy voice pack records no diagnostics of its own.
|
|
498
|
+
expect(registry.diagnostics.filter(d => d.message.includes('voice'))).toEqual([])
|
|
499
|
+
} finally {
|
|
500
|
+
rmSync(root, { recursive: true, force: true })
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('warns and drops a broken voice.json without rejecting the pet', () => {
|
|
505
|
+
const root = tempDir()
|
|
506
|
+
try {
|
|
507
|
+
const petsDir = join(root, 'pets')
|
|
508
|
+
mkdirSync(join(petsDir, 'mumbler'), { recursive: true })
|
|
509
|
+
writeFileSync(join(petsDir, 'mumbler', 'pet.json'), JSON.stringify({ id: 'mumbler', displayName: 'Mumbler', spritesheetPath: 'spritesheet.webp' }), 'utf8')
|
|
510
|
+
writeFileSync(join(petsDir, 'mumbler', 'spritesheet.webp'), 'webp', 'utf8')
|
|
511
|
+
writeFileSync(join(petsDir, 'mumbler', 'voice.json'), '{ not json', 'utf8')
|
|
512
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
|
|
513
|
+
expect(registry.byId('mumbler')).toBeDefined()
|
|
514
|
+
expect(registry.byId('mumbler')!.voice).toBeUndefined()
|
|
515
|
+
expect(registry.diagnostics.some(d => d.level === 'warning' && d.message.includes('voice pack is not valid JSON'))).toBe(true)
|
|
516
|
+
} finally {
|
|
517
|
+
rmSync(root, { recursive: true, force: true })
|
|
518
|
+
}
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('loads the global .voice.json override from the DSH_HOME pets dir', () => {
|
|
522
|
+
const root = tempDir()
|
|
523
|
+
try {
|
|
524
|
+
const petsDir = join(root, 'pets')
|
|
525
|
+
mkdirSync(join(petsDir, 'plain'), { recursive: true })
|
|
526
|
+
writeFileSync(join(petsDir, 'plain', 'pet.json'), JSON.stringify({ id: 'plain', displayName: 'Plain', spritesheetPath: 'spritesheet.webp' }), 'utf8')
|
|
527
|
+
writeFileSync(join(petsDir, 'plain', 'spritesheet.webp'), 'webp', 'utf8')
|
|
528
|
+
writeFileSync(join(petsDir, '.voice.json'), JSON.stringify({
|
|
529
|
+
status: { done: ['全局完工'] },
|
|
530
|
+
panel: { labels: { hide: '全局藏' } },
|
|
531
|
+
}), 'utf8')
|
|
532
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: petsDir })
|
|
533
|
+
expect(registry.globalVoice?.overrides.status?.done).toEqual(['全局完工'])
|
|
534
|
+
expect(registry.globalVoice?.panel?.labels).toEqual({ hide: '全局藏' })
|
|
535
|
+
// The dotfile itself is never scanned as a pet directory.
|
|
536
|
+
expect(registry.entries.map(e => e.id)).toEqual(['plain'])
|
|
537
|
+
} finally {
|
|
538
|
+
rmSync(root, { recursive: true, force: true })
|
|
539
|
+
}
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('serves the merged panel chrome (per-pet over global, per slot)', () => {
|
|
543
|
+
const root = tempDir()
|
|
544
|
+
try {
|
|
545
|
+
const petsDir = join(root, 'pets')
|
|
546
|
+
mkdirSync(join(petsDir, 'plain'), { recursive: true })
|
|
547
|
+
writeFileSync(join(petsDir, 'plain', 'pet.json'), JSON.stringify({ id: 'plain', displayName: 'Plain', spritesheetPath: 'spritesheet.webp' }), 'utf8')
|
|
548
|
+
writeFileSync(join(petsDir, 'plain', 'spritesheet.webp'), 'webp', 'utf8')
|
|
549
|
+
writeFileSync(join(petsDir, 'plain', 'voice.json'), JSON.stringify({
|
|
550
|
+
panel: { labels: { feed: '宠物投喂' }, stats: { treats: '宠物鱼干 {n}' } },
|
|
551
|
+
}), 'utf8')
|
|
552
|
+
writeFileSync(join(petsDir, '.voice.json'), JSON.stringify({
|
|
553
|
+
panel: { labels: { feed: '全局投喂', hide: '全局藏' } },
|
|
554
|
+
}), 'utf8')
|
|
555
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: petsDir })
|
|
556
|
+
const entry = registry.byId('plain')
|
|
557
|
+
expect(entry).toBeDefined()
|
|
558
|
+
const view = petEntryView(entry!, registry.globalVoice)
|
|
559
|
+
// The pet's own slot wins; untouched global slots layer underneath.
|
|
560
|
+
expect(view.panel?.labels).toEqual({ feed: '宠物投喂', hide: '全局藏' })
|
|
561
|
+
expect(view.panel?.stats?.treats).toBe('宠物鱼干 {n}')
|
|
562
|
+
// A pack-less pet would receive the global panel as-is.
|
|
563
|
+
const bare = resolvePetManifest({ id: 'bare', displayName: 'Bare', spritesheetPath: 'spritesheet.webp' }, join(tmpdir(), 'bare'))
|
|
564
|
+
expect(petEntryView(bare!, registry.globalVoice).panel?.labels).toEqual({ feed: '全局投喂', hide: '全局藏' })
|
|
565
|
+
} finally {
|
|
566
|
+
rmSync(root, { recursive: true, force: true })
|
|
567
|
+
}
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('skips an oversized voice.json with a warning instead of reading it', () => {
|
|
571
|
+
const root = tempDir()
|
|
572
|
+
try {
|
|
573
|
+
const petsDir = join(root, 'pets')
|
|
574
|
+
mkdirSync(join(petsDir, 'loud'), { recursive: true })
|
|
575
|
+
writeFileSync(join(petsDir, 'loud', 'pet.json'), JSON.stringify({ id: 'loud', displayName: 'Loud', spritesheetPath: 'spritesheet.webp' }), 'utf8')
|
|
576
|
+
writeFileSync(join(petsDir, 'loud', 'voice.json'), '{ ' + 'x'.repeat(PET_SCAN_JSON_CAP) + ' }', 'utf8')
|
|
577
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
|
|
578
|
+
expect(registry.byId('loud')!.voice).toBeUndefined()
|
|
579
|
+
expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
|
|
580
|
+
} finally {
|
|
581
|
+
rmSync(root, { recursive: true, force: true })
|
|
582
|
+
}
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('skips a non-regular voice.json with a warning', () => {
|
|
586
|
+
const root = tempDir()
|
|
587
|
+
try {
|
|
588
|
+
const petsDir = join(root, 'pets')
|
|
589
|
+
mkdirSync(join(petsDir, 'odd', 'voice.json'), { recursive: true })
|
|
590
|
+
writeFileSync(join(petsDir, 'odd', 'pet.json'), JSON.stringify({ id: 'odd', displayName: 'Odd', spritesheetPath: 'spritesheet.webp' }), 'utf8')
|
|
591
|
+
const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
|
|
592
|
+
expect(registry.byId('odd')!.voice).toBeUndefined()
|
|
593
|
+
expect(registry.warnings.some(w => w.includes('not a regular file'))).toBe(true)
|
|
594
|
+
} finally {
|
|
595
|
+
rmSync(root, { recursive: true, force: true })
|
|
596
|
+
}
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
describe('status decorations (pet-center M5, #567)', () => {
|
|
600
|
+
function writeDecoration(dir: string, name: string, manifest: Record<string, unknown>, strip = 'whale-frames.png'): void {
|
|
601
|
+
mkdirSync(join(dir, name), { recursive: true })
|
|
602
|
+
writeFileSync(join(dir, name, 'decoration.json'), JSON.stringify(manifest), 'utf8')
|
|
603
|
+
writeFileSync(join(dir, name, strip), 'png', 'utf8')
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const baseManifest = () => ({
|
|
607
|
+
decorationManifestVersion: 1,
|
|
608
|
+
id: 'whale',
|
|
609
|
+
displayName: '喷水鲸鱼',
|
|
610
|
+
license: 'MIT',
|
|
611
|
+
entry: 'whale-frames.png',
|
|
612
|
+
cell: { width: 64, height: 48 },
|
|
613
|
+
columns: 4,
|
|
614
|
+
phases: { idle: 'hide', thinking: { from: 0, to: 3 } },
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('scans built-in decorations and exposes the browser view fields', () => {
|
|
618
|
+
const root = tempDir()
|
|
619
|
+
try {
|
|
620
|
+
const assets = join(root, 'assets')
|
|
621
|
+
writeDecoration(join(assets, 'decorations'), 'whale', baseManifest())
|
|
622
|
+
const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
|
|
623
|
+
const entry = registry.decorationById?.('whale')
|
|
624
|
+
expect(entry).toBeDefined()
|
|
625
|
+
expect(entry!.entryUrl).toBe('/api/pet/decoration/whale/whale-frames.png')
|
|
626
|
+
expect(entry!.servable).toEqual(['decoration.json', 'whale-frames.png'])
|
|
627
|
+
expect(entry!.phases.thinking).toEqual({ from: 0, to: 3 })
|
|
628
|
+
// The pet entries list is untouched by decorations.
|
|
629
|
+
expect(registry.entries).toEqual([])
|
|
630
|
+
} finally {
|
|
631
|
+
rmSync(root, { recursive: true, force: true })
|
|
632
|
+
}
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
it('lets a user decoration override the built-in by id', () => {
|
|
636
|
+
const root = tempDir()
|
|
637
|
+
try {
|
|
638
|
+
writeDecoration(join(root, 'assets', 'decorations'), 'whale', baseManifest())
|
|
639
|
+
const dsh = join(root, 'dsh')
|
|
640
|
+
writeDecoration(join(dsh, 'decorations'), 'whale', { ...baseManifest(), displayName: '家用鲸鱼' })
|
|
641
|
+
const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: dsh })
|
|
642
|
+
expect(registry.decorationById?.('whale')?.id).toBe('whale')
|
|
643
|
+
expect(registry.warnings.some(w => w.includes('user decoration whale overrides'))).toBe(true)
|
|
644
|
+
expect(registry.decorationById?.('whale')?.entryUrl).toBe('/api/pet/decoration/whale/whale-frames.png')
|
|
645
|
+
} finally {
|
|
646
|
+
rmSync(root, { recursive: true, force: true })
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it('warns and skips a broken descriptor without disturbing pets', () => {
|
|
651
|
+
const root = tempDir()
|
|
652
|
+
try {
|
|
653
|
+
mkdirSync(join(root, 'assets', 'decorations', 'broken'), { recursive: true })
|
|
654
|
+
writeFileSync(join(root, 'assets', 'decorations', 'broken', 'decoration.json'), '{ not json', 'utf8')
|
|
655
|
+
const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
|
|
656
|
+
expect(registry.decorations).toEqual([])
|
|
657
|
+
expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('broken'))).toBe(true)
|
|
658
|
+
} finally {
|
|
659
|
+
rmSync(root, { recursive: true, force: true })
|
|
660
|
+
}
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it('skips an oversized decoration.json with a warning instead of reading it', () => {
|
|
664
|
+
const root = tempDir()
|
|
665
|
+
try {
|
|
666
|
+
mkdirSync(join(root, 'assets', 'decorations', 'huge'), { recursive: true })
|
|
667
|
+
writeFileSync(join(root, 'assets', 'decorations', 'huge', 'decoration.json'), '{ ' + 'x'.repeat(PET_SCAN_JSON_CAP) + ' }', 'utf8')
|
|
668
|
+
const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
|
|
669
|
+
expect(registry.decorations).toEqual([])
|
|
670
|
+
expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
|
|
671
|
+
} finally {
|
|
672
|
+
rmSync(root, { recursive: true, force: true })
|
|
673
|
+
}
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('lists a decoration with a missing strip and warns about the file', () => {
|
|
677
|
+
const root = tempDir()
|
|
678
|
+
try {
|
|
679
|
+
const dir = join(root, 'assets', 'decorations', 'ghost')
|
|
680
|
+
mkdirSync(dir, { recursive: true })
|
|
681
|
+
writeFileSync(join(dir, 'decoration.json'), JSON.stringify(baseManifest()), 'utf8')
|
|
682
|
+
const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
|
|
683
|
+
// The entry id comes from the descriptor (the directory name is free).
|
|
684
|
+
expect(registry.decorationById?.('whale')).toBeDefined()
|
|
685
|
+
expect(registry.warnings.some(w => w.includes('strip file missing'))).toBe(true)
|
|
686
|
+
} finally {
|
|
687
|
+
rmSync(root, { recursive: true, force: true })
|
|
688
|
+
}
|
|
689
|
+
})
|
|
690
|
+
})
|