@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.
Files changed (70) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +64 -1
  3. package/README.zh.md +64 -1
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/contracts/pet-manifest-v2.schema.json +281 -0
  7. package/contracts/status-decoration-v1.schema.json +144 -0
  8. package/contracts/voice-pack-v1.schema.json +234 -0
  9. package/lib/client.js +144 -18
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +979 -70
  12. package/lib/types/chatter.d.ts +61 -3
  13. package/lib/types/chatter.d.ts.map +1 -1
  14. package/lib/types/chatter.js +71 -12
  15. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  16. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/PetSettingsCard.js +3 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +103 -4
  20. package/lib/types/client/locales.d.ts +4 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +4 -0
  23. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  24. package/lib/types/client/renderers/live2d.js +13 -1
  25. package/lib/types/client/settings-form.d.ts.map +1 -1
  26. package/lib/types/client/settings-form.js +9 -6
  27. package/lib/types/contracts/status-decoration.d.ts +85 -0
  28. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  29. package/lib/types/contracts/status-decoration.js +21 -0
  30. package/lib/types/decoration.d.ts +39 -0
  31. package/lib/types/decoration.d.ts.map +1 -0
  32. package/lib/types/decoration.js +210 -0
  33. package/lib/types/event-projection.d.ts +8 -3
  34. package/lib/types/event-projection.d.ts.map +1 -1
  35. package/lib/types/event-projection.js +9 -4
  36. package/lib/types/index.d.ts +2 -0
  37. package/lib/types/index.d.ts.map +1 -1
  38. package/lib/types/index.js +2 -0
  39. package/lib/types/registry.d.ts +55 -2
  40. package/lib/types/registry.d.ts.map +1 -1
  41. package/lib/types/registry.js +198 -16
  42. package/lib/types/routes.d.ts.map +1 -1
  43. package/lib/types/routes.js +131 -3
  44. package/lib/types/service.d.ts +33 -0
  45. package/lib/types/service.d.ts.map +1 -1
  46. package/lib/types/service.js +42 -3
  47. package/lib/types/voice-pack.d.ts +98 -0
  48. package/lib/types/voice-pack.d.ts.map +1 -0
  49. package/lib/types/voice-pack.js +384 -0
  50. package/package.json +11 -10
  51. package/src/chatter.test.ts +89 -2
  52. package/src/chatter.ts +120 -14
  53. package/src/client/PetSettingsCard.tsx +18 -0
  54. package/src/client/PetSprite.test.tsx +254 -12
  55. package/src/client/PetSprite.tsx +142 -25
  56. package/src/client/locales.ts +4 -0
  57. package/src/client/renderers/live2d.test.ts +23 -2
  58. package/src/client/renderers/live2d.ts +14 -1
  59. package/src/client/settings-form.ts +8 -6
  60. package/src/contracts/status-decoration.ts +78 -0
  61. package/src/decoration.test.ts +178 -0
  62. package/src/decoration.ts +220 -0
  63. package/src/event-projection.ts +10 -5
  64. package/src/index.ts +2 -0
  65. package/src/registry.test.ts +223 -0
  66. package/src/registry.ts +235 -15
  67. package/src/routes.ts +128 -3
  68. package/src/service.ts +59 -3
  69. package/src/voice-pack.test.ts +216 -0
  70. package/src/voice-pack.ts +413 -0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Voice-pack unit tests (pet-center M4, issue #677): normalization rules
3
+ * (structure fail-closed per file, content warn-and-drop per slot), the
4
+ * per-kind placeholder whitelists, and the layer merge precedence
5
+ * (later layers win per slot).
6
+ */
7
+ import { describe, expect, it } from 'vitest'
8
+ import { readFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import {
11
+ mergeVoicePacks,
12
+ normalizePanel,
13
+ normalizePool,
14
+ normalizeVoicePack,
15
+ normalizeWhisperRules,
16
+ PANEL_ACTIONS,
17
+ PANEL_LABEL_KEYS,
18
+ PANEL_STAT_KEYS,
19
+ VOICE_LINE_MAX,
20
+ VOICE_PACK_KEYS,
21
+ VOICE_PACK_V1,
22
+ WHISPER_KEYS,
23
+ } from './voice-pack.ts'
24
+ import { STATUS_SCENES, TOOL_CATEGORIES } from './chatter.ts'
25
+ import { petPackageRoot } from './registry.ts'
26
+
27
+ function collectWarnings(pack: unknown): { pack: ReturnType<typeof normalizeVoicePack>; warnings: string[] } {
28
+ const warnings: string[] = []
29
+ const result = normalizeVoicePack(pack, message => warnings.push(message))
30
+ return { pack: result, warnings }
31
+ }
32
+
33
+ describe('normalizeVoicePack structure', () => {
34
+ it('accepts a minimal pack and drops it when nothing usable remains', () => {
35
+ expect(normalizeVoicePack({})).toBeUndefined()
36
+ expect(normalizeVoicePack({ status: { done: ['收工'] } })).toBeDefined()
37
+ })
38
+
39
+ it('rejects a non-object root with a warning (fail-closed per file)', () => {
40
+ const { pack, warnings } = collectWarnings(['not', 'an', 'object'])
41
+ expect(pack).toBeUndefined()
42
+ expect(warnings[0]).toContain('must be a JSON object')
43
+ })
44
+
45
+ it('warns on unknown top-level fields and unknown voicePackVersion', () => {
46
+ const { pack, warnings } = collectWarnings({
47
+ voicePackVersion: 99,
48
+ mystery: true,
49
+ status: { done: ['收工'] },
50
+ })
51
+ expect(pack).toBeDefined()
52
+ expect(warnings.join('\n')).toContain('mystery')
53
+ expect(warnings.join('\n')).toContain('voicePackVersion')
54
+ })
55
+ })
56
+
57
+ describe('normalizePool', () => {
58
+ it('accepts a single string and arrays, dropping non-string entries', () => {
59
+ const warnings: string[] = []
60
+ expect(normalizePool('一行', 'status', m => warnings.push(m))).toEqual(['一行'])
61
+ expect(normalizePool(['一', 7, '二'], 'status', m => warnings.push(m))).toEqual(['一', '二'])
62
+ expect(warnings.join('\n')).toContain('non-string')
63
+ })
64
+
65
+ it('caps pool length and truncates long lines', () => {
66
+ const lines = Array.from({ length: 80 }, (_, i) => '行' + i)
67
+ const warnings: string[] = []
68
+ const pool = normalizePool(lines, 'status', m => warnings.push(m))
69
+ expect(pool).toHaveLength(64)
70
+ expect(warnings.join('\n')).toContain('extra lines')
71
+ expect(normalizePool(['x'.repeat(500)], 'status')?.[0]).toHaveLength(VOICE_LINE_MAX)
72
+ })
73
+
74
+ it('drops lines carrying placeholders the pool kind does not allow', () => {
75
+ const warnings: string[] = []
76
+ const pool = normalizePool(['好的 {tool}', '干净的'], 'status', m => warnings.push(m))
77
+ expect(pool).toEqual(['干净的'])
78
+ expect(warnings.join('\n')).toContain('unsupported placeholder')
79
+ })
80
+
81
+ it('keeps allowed placeholders per kind', () => {
82
+ expect(normalizePool(['跑 {hint}', '用 {tool}'], 'tools')![0]).toBe('跑 {hint}')
83
+ expect(normalizePool(['还有 {n} 个'], 'toolRemaining')![0]).toBe('还有 {n} 个')
84
+ expect(normalizePool(['好感 {rank}'], 'stat')![0]).toBe('好感 {rank}')
85
+ })
86
+
87
+ it('preserves an explicit empty array (mute semantics) but not absent', () => {
88
+ expect(normalizePool([], 'whisperGeneric')).toEqual([])
89
+ expect(normalizePool(undefined, 'whisperGeneric')).toBeUndefined()
90
+ })
91
+ })
92
+
93
+ describe('normalizeWhisperRules', () => {
94
+ it('lowercases and trims keywords, drops unusable rules', () => {
95
+ const warnings: string[] = []
96
+ const rules = normalizeWhisperRules([
97
+ { keywords: [' 测试通过 ', 7], pool: ['全绿'] },
98
+ { keywords: [], pool: ['没有关键词'] },
99
+ { keywords: ['有词'], pool: [] },
100
+ ], m => warnings.push(m))
101
+ expect(rules).toEqual([{ keywords: ['测试通过'], pool: ['全绿'] }])
102
+ expect(warnings.length).toBeGreaterThan(0)
103
+ })
104
+
105
+ it('caps the rule count and keyword count', () => {
106
+ const many = Array.from({ length: 40 }, (_, i) => ({ keywords: ['k' + i], pool: ['p'] }))
107
+ expect(normalizeWhisperRules(many)).toHaveLength(32)
108
+ expect(normalizeWhisperRules([{ keywords: Array.from({ length: 20 }, (_, i) => 'k' + i), pool: ['p'] }])![0]!.keywords).toHaveLength(16)
109
+ })
110
+
111
+ it('returns an explicit empty array (disables the keyword channel)', () => {
112
+ expect(normalizeWhisperRules([])).toEqual([])
113
+ })
114
+ })
115
+
116
+ describe('normalizePanel', () => {
117
+ it('normalizes labels, stats and the action subset', () => {
118
+ const panel = normalizePanel({
119
+ labels: { feed: '投喂', confirm: '好的', bogus: 'x' },
120
+ stats: { rank: '好感 {rank}', points: '{points} 分' },
121
+ actions: ['hide', 'feed', 'hide', 'bogus'],
122
+ })
123
+ expect(panel?.labels).toEqual({ feed: '投喂', confirm: '好的' })
124
+ expect(panel?.stats).toEqual({ rank: '好感 {rank}', points: '{points} 分' })
125
+ expect(panel?.actions).toEqual(['feed', 'hide'])
126
+ })
127
+
128
+ it('rejects non-string labels and labels carrying placeholders', () => {
129
+ const warnings: string[] = []
130
+ const panel = normalizePanel({ labels: { feed: 3, rename: '改名 {tool}' } }, m => warnings.push(m))
131
+ expect(panel?.labels).toBeUndefined()
132
+ expect(warnings.length).toBe(2)
133
+ })
134
+
135
+ it('keeps an explicit empty action list (hides every action)', () => {
136
+ expect(normalizePanel({ actions: [] })?.actions).toEqual([])
137
+ })
138
+ })
139
+
140
+ describe('mergeVoicePacks', () => {
141
+ it('merges per-key with later layers winning', () => {
142
+ const global = normalizeVoicePack({
143
+ status: { done: ['全局收工'], thinking: ['全局思考'] },
144
+ panel: { labels: { feed: '全局投喂' } },
145
+ })
146
+ const pet = normalizeVoicePack({
147
+ status: { done: ['宠物收工'] },
148
+ panel: { labels: { feed: '宠物投喂', hide: '宠物藏' } },
149
+ })
150
+ const merged = mergeVoicePacks(global, pet)
151
+ expect(merged?.overrides.status?.done).toEqual(['宠物收工'])
152
+ expect(merged?.overrides.status?.thinking).toEqual(['全局思考'])
153
+ expect(merged?.panel?.labels).toEqual({ feed: '宠物投喂', hide: '宠物藏' })
154
+ })
155
+
156
+ it('lets the pet pack replace whisper sections while the global stays', () => {
157
+ const global = normalizeVoicePack({ whispers: { generic: ['全局碎碎念'] } })
158
+ const pet = normalizeVoicePack({ whispers: { rules: [{ keywords: ['测试'], pool: ['宠物全绿'] }] } })
159
+ const merged = mergeVoicePacks(global, pet)
160
+ expect(merged?.overrides.whispers?.generic).toEqual(['全局碎碎念'])
161
+ expect(merged?.overrides.whispers?.rules).toEqual([{ keywords: ['测试'], pool: ['宠物全绿'] }])
162
+ })
163
+
164
+ it('returns undefined when every layer is empty', () => {
165
+ expect(mergeVoicePacks(undefined, undefined)).toBeUndefined()
166
+ })
167
+ })
168
+
169
+ describe('normalizeVoicePack schema twin', () => {
170
+ it('accepts the $schema field without a warning', () => {
171
+ const { pack, warnings } = collectWarnings({
172
+ $schema: 'http://json-schema.org/draft-07/schema#',
173
+ status: { done: ['收工'] },
174
+ })
175
+ expect(pack).toBeDefined()
176
+ expect(warnings).toEqual([])
177
+ })
178
+
179
+ it('drops the tail when the length cap cuts a placeholder token in half', () => {
180
+ const line = 'x'.repeat(155) + '{tool}'
181
+ const { pack, warnings } = collectWarnings({ tools: { shell: [line] } })
182
+ expect(pack?.overrides.tools?.shell).toEqual(['x'.repeat(155)])
183
+ expect(warnings.some(w => w.includes('unterminated placeholder'))).toBe(true)
184
+ })
185
+ })
186
+
187
+ describe('voice-pack schema file drift lock', () => {
188
+ const schema = JSON.parse(readFileSync(
189
+ join(petPackageRoot(import.meta.url), 'contracts', 'voice-pack-v1.schema.json'),
190
+ 'utf8',
191
+ )) as {
192
+ properties: Record<string, { const?: number; properties?: Record<string, unknown>; items?: { enum?: string[] } }>
193
+ }
194
+
195
+ it('locks the schema top-level fields to the validator allow-list', () => {
196
+ expect(new Set(Object.keys(schema.properties))).toEqual(VOICE_PACK_KEYS)
197
+ })
198
+
199
+ it('locks the scene, tool, whisper and panel key sets', () => {
200
+ const props = schema.properties
201
+ expect(new Set(Object.keys(props.status?.properties ?? {}))).toEqual(new Set(STATUS_SCENES))
202
+ expect(new Set(Object.keys(props.tools?.properties ?? {}))).toEqual(new Set(TOOL_CATEGORIES))
203
+ expect(new Set(Object.keys(props.whispers?.properties ?? {}))).toEqual(WHISPER_KEYS)
204
+ const panel = props.panel?.properties ?? {}
205
+ const labels = (panel.labels as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}
206
+ const stats = (panel.stats as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}
207
+ expect(new Set(Object.keys(labels))).toEqual(new Set(PANEL_LABEL_KEYS))
208
+ expect(new Set(Object.keys(stats))).toEqual(new Set(PANEL_STAT_KEYS))
209
+ const actions = (panel.actions as { items?: { enum?: string[] } } | undefined)
210
+ expect(actions?.items?.enum).toEqual([...PANEL_ACTIONS])
211
+ })
212
+
213
+ it('keeps the schema version const in sync', () => {
214
+ expect(schema.properties.voicePackVersion?.const).toBe(VOICE_PACK_V1)
215
+ })
216
+ })
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Voice-pack normalization and merge (pet-center M4, issue #677).
3
+ *
4
+ * A voice pack is the optional 'voice.json' inside a pet directory, or the
5
+ * global '$DSH_HOME/pets/.voice.json' override file — pure JSON content that
6
+ * layers pet copy over the built-in pools. Two halves:
7
+ *
8
+ * - 'overrides': the chatter pools (status / tools / toolRemaining /
9
+ * whispers) handed to the chatter engines through a VoicePoolsProvider;
10
+ * - 'panel': the hover-panel chrome (button labels, stat formats, action
11
+ * subset) served to the browser half through PetDefinition.panel.
12
+ *
13
+ * Discipline split matches the registry: STRUCTURE is fail-closed per file
14
+ * (a non-object root drops the whole pack with a warning), CONTENT is
15
+ * warn-and-drop per slot — one bad line never breaks a pet. The JSON Schema
16
+ * twin lives at contracts/voice-pack-v1.schema.json for documentation and
17
+ * external tooling; this hand-rolled normalizer is authoritative (the
18
+ * repository ships no schema-validator runtime).
19
+ *
20
+ * Placeholder policy (each pool kind whitelists its own tokens):
21
+ * - status / whisper pools / panel labels: no placeholders allowed — a line
22
+ * carrying any '{...}' token is dropped with a warning;
23
+ * - tools: {tool} and {hint}; toolRemaining: {n}; panel stats:
24
+ * {rank} / {n} / {points}.
25
+ *
26
+ * This file is imported directly by scripts/dsh-pet under node's strip-only
27
+ * TypeScript mode: keep it erasable-syntax-only.
28
+ * @module @linxin666/dsh-pet/voice-pack
29
+ */
30
+
31
+ import {
32
+ STATUS_SCENES,
33
+ TOOL_CATEGORIES,
34
+ type VoicePackOverrides,
35
+ type WhisperRule,
36
+ } from './chatter.ts'
37
+
38
+ /** Schema version this module normalizes (optional field; missing = 1). */
39
+ export const VOICE_PACK_V1 = 1 as const
40
+
41
+ /** Hover-panel action buttons a pack can show or hide (canonical order). */
42
+ export const PANEL_ACTIONS = ['feed', 'rename', 'hide'] as const
43
+ export type PanelAction = (typeof PANEL_ACTIONS)[number]
44
+
45
+ /** Panel label slots (unset slots keep the client's i18n dictionary copy). */
46
+ export const PANEL_LABEL_KEYS = ['feed', 'rename', 'hide', 'confirm'] as const
47
+ export type PanelLabelKey = (typeof PANEL_LABEL_KEYS)[number]
48
+
49
+ /** Panel stat slots ({rank}/{n}/{points} interpolate the live values). */
50
+ export const PANEL_STAT_KEYS = ['rank', 'treats', 'points'] as const
51
+ export type PanelStatKey = (typeof PANEL_STAT_KEYS)[number]
52
+
53
+ /** Hover-panel chrome overrides served to the browser half. */
54
+ export interface PetPanelView {
55
+ /** Button / input labels; unset slots keep the i18n dictionary copy. */
56
+ labels?: Partial<Record<PanelLabelKey, string>>
57
+ /** Stat line formats; unset slots keep the i18n dictionary copy. */
58
+ stats?: Partial<Record<PanelStatKey, string>>
59
+ /** Actions to render in canonical order; absent = all three; [] = none. */
60
+ actions?: PanelAction[]
61
+ }
62
+
63
+ /** One normalized voice pack (a pet's voice.json or the global override). */
64
+ export interface VoicePack {
65
+ /** Chatter pool overrides (draw-time merged with the built-in pools). */
66
+ overrides: VoicePackOverrides
67
+ /** Hover-panel chrome, when the pack declares any. */
68
+ panel?: PetPanelView
69
+ }
70
+
71
+ /** Hard caps shared by every pool slot (mirrors the remarks discipline). */
72
+ export const VOICE_POOL_LINES_MAX = 64
73
+ export const VOICE_LINE_MAX = 160
74
+ export const VOICE_KEYWORDS_PER_RULE_MAX = 16
75
+ export const VOICE_KEYWORD_MAX = 40
76
+ export const VOICE_RULES_MAX = 32
77
+ export const VOICE_LABEL_MAX = 40
78
+ export const VOICE_STAT_MAX = 80
79
+
80
+ /** Any '{token}' placeholder (no nesting, no newlines). */
81
+ const PLACEHOLDER_PATTERN = /{[^{}]*}/g
82
+
83
+ /** Allowed placeholder tokens per pool kind (absent kind = none allowed). */
84
+ const PLACEHOLDER_WHITELIST: Partial<Record<PoolKind, readonly string[]>> = {
85
+ tools: ['{tool}', '{hint}'],
86
+ toolRemaining: ['{n}'],
87
+ stat: ['{rank}', '{n}', '{points}'],
88
+ }
89
+
90
+ type PoolKind = 'status' | 'tools' | 'toolRemaining' | 'whisperGeneric' | 'whisperRule' | 'label' | 'stat'
91
+
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
94
+ }
95
+
96
+ /** Trim, length-cap and placeholder-check one copy line; undefined to drop. */
97
+ function normalizeLine(raw: string, kind: PoolKind, onWarning: (message: string) => void): string | undefined {
98
+ const trimmed = raw.trim()
99
+ if (trimmed === '') return undefined
100
+ let capped = trimmed.length > VOICE_LINE_MAX ? trimmed.slice(0, VOICE_LINE_MAX) : trimmed
101
+ // The length cap may cut a placeholder token in half, leaving a dangling
102
+ // '{' that would render literally; drop the tail from the unmatched brace.
103
+ const dangling = capped.lastIndexOf('{')
104
+ if (dangling !== -1 && capped.indexOf('}', dangling) === -1) {
105
+ onWarning('line cut at an unterminated placeholder; tail dropped: ' + capped.slice(0, 40) + '...')
106
+ capped = capped.slice(0, dangling)
107
+ }
108
+ if (capped === '') return undefined
109
+ const allowed = PLACEHOLDER_WHITELIST[kind]
110
+ const tokens = capped.match(PLACEHOLDER_PATTERN) ?? []
111
+ for (const token of tokens) {
112
+ if (allowed?.includes(token) === true) continue
113
+ const preview = capped.length > 40 ? capped.slice(0, 40) + '...' : capped
114
+ onWarning('line dropped (unsupported placeholder ' + token + '): ' + preview)
115
+ return undefined
116
+ }
117
+ return capped
118
+ }
119
+
120
+ /**
121
+ * Normalize one pool slot. Accepts a single line or an array; non-string
122
+ * entries warn and drop, empty lines drop silently, lines over the length
123
+ * cap truncate, illegal placeholders drop the line, and pools over the line
124
+ * cap keep their first lines. An explicit empty pool normalizes to [] (the
125
+ * whisper channels read that as mute) while an absent slot normalizes to
126
+ * undefined (the slot keeps the built-in pool).
127
+ */
128
+ export function normalizePool(
129
+ raw: unknown,
130
+ kind: PoolKind,
131
+ onWarning: (message: string) => void = () => {},
132
+ ): string[] | undefined {
133
+ if (raw === undefined) return undefined
134
+ const entries = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw : undefined
135
+ if (entries === undefined) {
136
+ onWarning('pool must be a string or an array of strings')
137
+ return undefined
138
+ }
139
+ if (entries.length > VOICE_POOL_LINES_MAX) {
140
+ onWarning('pool has more than ' + VOICE_POOL_LINES_MAX + ' lines; extra lines are ignored')
141
+ }
142
+ const pool: string[] = []
143
+ for (const entry of entries.slice(0, VOICE_POOL_LINES_MAX)) {
144
+ if (typeof entry !== 'string') {
145
+ onWarning('non-string pool entry dropped')
146
+ continue
147
+ }
148
+ const line = normalizeLine(entry, kind, onWarning)
149
+ if (line !== undefined) pool.push(line)
150
+ }
151
+ return pool
152
+ }
153
+
154
+ /** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
155
+ export function normalizeWhisperRules(
156
+ raw: unknown,
157
+ onWarning: (message: string) => void = () => {},
158
+ ): WhisperRule[] | undefined {
159
+ if (raw === undefined) return undefined
160
+ if (!Array.isArray(raw)) {
161
+ onWarning('whispers.rules must be an array')
162
+ return undefined
163
+ }
164
+ if (raw.length > VOICE_RULES_MAX) {
165
+ onWarning('whispers.rules has more than ' + VOICE_RULES_MAX + ' rules; extra rules are ignored')
166
+ }
167
+ const rules: WhisperRule[] = []
168
+ for (const item of raw.slice(0, VOICE_RULES_MAX)) {
169
+ if (!isRecord(item)) {
170
+ onWarning('whisper rule must be an object')
171
+ continue
172
+ }
173
+ for (const key of Object.keys(item)) {
174
+ if (key !== 'keywords' && key !== 'pool') onWarning('unknown whisper rule field ' + key + ' ignored')
175
+ }
176
+ const keywordsRaw: unknown = item.keywords
177
+ const keywords: string[] = []
178
+ if (Array.isArray(keywordsRaw)) {
179
+ for (const entry of keywordsRaw.slice(0, VOICE_KEYWORDS_PER_RULE_MAX)) {
180
+ if (typeof entry !== 'string') {
181
+ onWarning('non-string keyword dropped')
182
+ continue
183
+ }
184
+ const trimmed = entry.trim().toLowerCase().slice(0, VOICE_KEYWORD_MAX)
185
+ if (trimmed !== '') keywords.push(trimmed)
186
+ }
187
+ if (keywordsRaw.length > VOICE_KEYWORDS_PER_RULE_MAX) {
188
+ onWarning('rule has more than ' + VOICE_KEYWORDS_PER_RULE_MAX + ' keywords; extra keywords are ignored')
189
+ }
190
+ }
191
+ const pool = normalizePool(item.pool, 'whisperRule', onWarning)
192
+ if (keywords.length === 0 || pool === undefined || pool.length === 0) {
193
+ onWarning('whisper rule dropped (needs keywords and a non-empty pool)')
194
+ continue
195
+ }
196
+ rules.push({ keywords, pool })
197
+ }
198
+ return rules
199
+ }
200
+
201
+ /** Normalize the panel block (labels / stats / actions; warn-and-drop). */
202
+ export function normalizePanel(
203
+ raw: unknown,
204
+ onWarning: (message: string) => void = () => {},
205
+ ): PetPanelView | undefined {
206
+ if (!isRecord(raw)) {
207
+ onWarning('panel must be an object')
208
+ return undefined
209
+ }
210
+ const panel: PetPanelView = {}
211
+ const labelsRaw = raw.labels
212
+ if (labelsRaw !== undefined) {
213
+ if (!isRecord(labelsRaw)) {
214
+ onWarning('panel.labels must be an object')
215
+ } else {
216
+ const labels: Record<string, string> = {}
217
+ for (const key of PANEL_LABEL_KEYS) {
218
+ const value = labelsRaw[key]
219
+ if (value === undefined) continue
220
+ if (typeof value !== 'string') {
221
+ onWarning('panel.labels.' + key + ' must be a string')
222
+ continue
223
+ }
224
+ const line = normalizeLine(value, 'label', onWarning)
225
+ if (line !== undefined) labels[key] = line.slice(0, VOICE_LABEL_MAX)
226
+ }
227
+ if (Object.keys(labels).length > 0) panel.labels = labels
228
+ }
229
+ }
230
+ const statsRaw = raw.stats
231
+ if (statsRaw !== undefined) {
232
+ if (!isRecord(statsRaw)) {
233
+ onWarning('panel.stats must be an object')
234
+ } else {
235
+ const stats: Record<string, string> = {}
236
+ for (const key of PANEL_STAT_KEYS) {
237
+ const value = statsRaw[key]
238
+ if (value === undefined) continue
239
+ if (typeof value !== 'string') {
240
+ onWarning('panel.stats.' + key + ' must be a string')
241
+ continue
242
+ }
243
+ const line = normalizeLine(value, 'stat', onWarning)
244
+ if (line !== undefined) stats[key] = line.slice(0, VOICE_STAT_MAX)
245
+ }
246
+ if (Object.keys(stats).length > 0) panel.stats = stats
247
+ }
248
+ }
249
+ const actionsRaw = raw.actions
250
+ if (actionsRaw !== undefined) {
251
+ if (!Array.isArray(actionsRaw)) {
252
+ onWarning('panel.actions must be an array')
253
+ } else {
254
+ const seen = new Set<PanelAction>()
255
+ for (const entry of actionsRaw) {
256
+ if (typeof entry !== 'string' || !(PANEL_ACTIONS as readonly string[]).includes(entry)) {
257
+ onWarning('unknown panel action dropped: ' + String(entry))
258
+ continue
259
+ }
260
+ seen.add(entry as PanelAction)
261
+ }
262
+ // Canonical order, deduplicated; an explicit empty array hides all.
263
+ panel.actions = PANEL_ACTIONS.filter(action => seen.has(action))
264
+ }
265
+ }
266
+ if (panel.labels === undefined && panel.stats === undefined && panel.actions === undefined) return undefined
267
+ return panel
268
+ }
269
+
270
+ /** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
271
+ export const VOICE_PACK_KEYS = new Set(['$schema', 'voicePackVersion', 'status', 'tools', 'toolRemaining', 'whispers', 'panel'])
272
+
273
+ /** Allowed whisper-section fields (drift-locked in tests). */
274
+ export const WHISPER_KEYS = new Set(['generic', 'rules'])
275
+
276
+ /**
277
+ * Normalize one raw voice.json document into a VoicePack, or undefined when
278
+ * the file cannot serve as a pack at all (non-object root — structure is
279
+ * fail-closed per file). Every slot issue is a warning, never a throw.
280
+ */
281
+ export function normalizeVoicePack(
282
+ raw: unknown,
283
+ onWarning: (message: string) => void = () => {},
284
+ ): VoicePack | undefined {
285
+ if (raw === undefined) return undefined
286
+ if (!isRecord(raw)) {
287
+ onWarning('voice.json must be a JSON object; the file is ignored')
288
+ return undefined
289
+ }
290
+ for (const key of Object.keys(raw)) {
291
+ if (!VOICE_PACK_KEYS.has(key)) onWarning('unknown top-level field ' + key + ' ignored')
292
+ }
293
+ const version = raw.voicePackVersion
294
+ if (version !== undefined && (typeof version !== 'number' || version !== VOICE_PACK_V1)) {
295
+ onWarning('voicePackVersion ' + String(version) + ' is not supported; reading as v1 best-effort')
296
+ }
297
+ const overrides: VoicePackOverrides = {}
298
+ const statusRaw = raw.status
299
+ if (statusRaw !== undefined) {
300
+ if (!isRecord(statusRaw)) {
301
+ onWarning('status must be an object')
302
+ } else {
303
+ for (const key of Object.keys(statusRaw)) {
304
+ if (!(STATUS_SCENES as readonly string[]).includes(key)) {
305
+ onWarning('unknown status scene ' + key + ' ignored')
306
+ continue
307
+ }
308
+ const pool = normalizePool(statusRaw[key], 'status', onWarning)
309
+ if (pool !== undefined && pool.length > 0) {
310
+ overrides.status = { ...overrides.status, [key]: pool }
311
+ }
312
+ }
313
+ }
314
+ }
315
+ const toolsRaw = raw.tools
316
+ if (toolsRaw !== undefined) {
317
+ if (!isRecord(toolsRaw)) {
318
+ onWarning('tools must be an object')
319
+ } else {
320
+ for (const key of Object.keys(toolsRaw)) {
321
+ if (!(TOOL_CATEGORIES as readonly string[]).includes(key)) {
322
+ onWarning('unknown tool family ' + key + ' ignored')
323
+ continue
324
+ }
325
+ const pool = normalizePool(toolsRaw[key], 'tools', onWarning)
326
+ if (pool !== undefined && pool.length > 0) {
327
+ overrides.tools = { ...overrides.tools, [key]: pool }
328
+ }
329
+ }
330
+ }
331
+ }
332
+ const remainingRaw = raw.toolRemaining
333
+ if (remainingRaw !== undefined) {
334
+ const pool = normalizePool(remainingRaw, 'toolRemaining', onWarning)
335
+ if (pool !== undefined && pool.length > 0) overrides.toolRemaining = pool
336
+ }
337
+ const whispersRaw = raw.whispers
338
+ if (whispersRaw !== undefined) {
339
+ if (!isRecord(whispersRaw)) {
340
+ onWarning('whispers must be an object')
341
+ } else {
342
+ for (const key of Object.keys(whispersRaw)) {
343
+ if (!WHISPER_KEYS.has(key)) onWarning('unknown whispers field ' + key + ' ignored')
344
+ }
345
+ const generic = normalizePool(whispersRaw.generic, 'whisperGeneric', onWarning)
346
+ const rules = normalizeWhisperRules(whispersRaw.rules, onWarning)
347
+ if (generic !== undefined || rules !== undefined) {
348
+ overrides.whispers = {
349
+ ...(generic === undefined ? {} : { generic }),
350
+ ...(rules === undefined ? {} : { rules }),
351
+ }
352
+ }
353
+ }
354
+ }
355
+ const panel = raw.panel === undefined ? undefined : normalizePanel(raw.panel, onWarning)
356
+ if (
357
+ overrides.status === undefined && overrides.tools === undefined
358
+ && overrides.toolRemaining === undefined && overrides.whispers === undefined
359
+ && panel === undefined
360
+ ) {
361
+ return undefined
362
+ }
363
+ return {
364
+ overrides,
365
+ ...(panel === undefined ? {} : { panel }),
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Merge voice-pack layers into one pack; later layers win per slot. The
371
+ * built-in pools are NOT a layer here — the chatter engines fall back to
372
+ * them per key at draw time. Merge order for a selected pet:
373
+ * mergeVoicePacks(registry.globalVoice, entry.voice).
374
+ */
375
+ export function mergeVoicePacks(...layers: (VoicePack | undefined)[]): VoicePack | undefined {
376
+ const overrides: VoicePackOverrides = {}
377
+ const labels: NonNullable<PetPanelView['labels']> = {}
378
+ const stats: NonNullable<PetPanelView['stats']> = {}
379
+ let actions: PanelAction[] | undefined
380
+ let panelSeen = false
381
+ let any = false
382
+ for (const layer of layers) {
383
+ if (layer === undefined) continue
384
+ any = true
385
+ if (layer.overrides.status !== undefined) {
386
+ overrides.status = { ...overrides.status, ...layer.overrides.status }
387
+ }
388
+ if (layer.overrides.tools !== undefined) {
389
+ overrides.tools = { ...overrides.tools, ...layer.overrides.tools }
390
+ }
391
+ if (layer.overrides.toolRemaining !== undefined) overrides.toolRemaining = layer.overrides.toolRemaining
392
+ if (layer.overrides.whispers !== undefined) {
393
+ overrides.whispers = { ...overrides.whispers, ...layer.overrides.whispers }
394
+ }
395
+ if (layer.panel !== undefined) {
396
+ panelSeen = true
397
+ if (layer.panel.labels !== undefined) Object.assign(labels, layer.panel.labels)
398
+ if (layer.panel.stats !== undefined) Object.assign(stats, layer.panel.stats)
399
+ if (layer.panel.actions !== undefined) actions = layer.panel.actions
400
+ }
401
+ }
402
+ if (!any) return undefined
403
+ const panel: PetPanelView = {
404
+ ...(Object.keys(labels).length > 0 ? { labels } : {}),
405
+ ...(Object.keys(stats).length > 0 ? { stats } : {}),
406
+ ...(actions === undefined ? {} : { actions }),
407
+ }
408
+ const panelEmpty = panel.labels === undefined && panel.stats === undefined && panel.actions === undefined
409
+ return {
410
+ overrides,
411
+ ...(panelSeen && !panelEmpty ? { panel } : {}),
412
+ }
413
+ }