@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,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
|
+
}
|