@linxin666/dsh-pet 0.2.2 → 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 +129 -3
- package/README.zh.md +129 -3
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- 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 +581 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +1684 -59
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -0
- 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/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -1
- package/lib/types/client/PetSettingsCard.d.ts +14 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +24 -1
- package/lib/types/client/PetSprite.d.ts +7 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +120 -12
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -0
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +14 -0
- package/lib/types/client/phase-stream.d.ts +22 -0
- package/lib/types/client/phase-stream.d.ts.map +1 -0
- package/lib/types/client/phase-stream.js +28 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
- package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/runtime.js +63 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
- package/lib/types/client/renderers/live2d.d.ts +52 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d.js +192 -0
- package/lib/types/client/renderers/registry.d.ts +32 -0
- package/lib/types/client/renderers/registry.d.ts.map +1 -0
- package/lib/types/client/renderers/registry.js +49 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/renderer.d.ts +50 -0
- package/lib/types/contracts/renderer.d.ts.map +1 -0
- package/lib/types/contracts/renderer.js +15 -0
- 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/manifest-v2.d.ts +129 -0
- package/lib/types/manifest-v2.d.ts.map +1 -0
- package/lib/types/manifest-v2.js +387 -0
- package/lib/types/model3.d.ts +26 -0
- package/lib/types/model3.d.ts.map +1 -0
- package/lib/types/model3.js +96 -0
- package/lib/types/registry.d.ts +112 -5
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +395 -38
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +312 -13
- package/lib/types/service.d.ts +38 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +46 -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 +13 -10
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +57 -0
- package/src/client/PetSprite.test.tsx +265 -12
- package/src/client/PetSprite.tsx +165 -33
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +14 -0
- package/src/client/phase-stream.test.ts +76 -0
- package/src/client/phase-stream.ts +39 -0
- package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
- package/src/client/renderers/PetRendererSwitch.tsx +48 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
- package/src/client/renderers/live2d/runtime.test.ts +61 -0
- package/src/client/renderers/live2d/runtime.ts +119 -0
- package/src/client/renderers/live2d/vendor-entry.ts +14 -0
- package/src/client/renderers/live2d.test.ts +263 -0
- package/src/client/renderers/live2d.ts +231 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-form.ts +8 -6
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- 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/manifest-v2.test.ts +251 -0
- package/src/manifest-v2.ts +414 -0
- package/src/model3.test.ts +79 -0
- package/src/model3.ts +91 -0
- package/src/registry.test.ts +438 -2
- package/src/registry.ts +470 -38
- package/src/routes.ts +328 -14
- package/src/service.ts +65 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
import { type VoicePackOverrides, type WhisperRule } from './chatter.ts';
|
|
31
|
+
/** Schema version this module normalizes (optional field; missing = 1). */
|
|
32
|
+
export declare const VOICE_PACK_V1: 1;
|
|
33
|
+
/** Hover-panel action buttons a pack can show or hide (canonical order). */
|
|
34
|
+
export declare const PANEL_ACTIONS: readonly ['feed', 'rename', 'hide'];
|
|
35
|
+
export type PanelAction = (typeof PANEL_ACTIONS)[number];
|
|
36
|
+
/** Panel label slots (unset slots keep the client's i18n dictionary copy). */
|
|
37
|
+
export declare const PANEL_LABEL_KEYS: readonly ['feed', 'rename', 'hide', 'confirm'];
|
|
38
|
+
export type PanelLabelKey = (typeof PANEL_LABEL_KEYS)[number];
|
|
39
|
+
/** Panel stat slots ({rank}/{n}/{points} interpolate the live values). */
|
|
40
|
+
export declare const PANEL_STAT_KEYS: readonly ['rank', 'treats', 'points'];
|
|
41
|
+
export type PanelStatKey = (typeof PANEL_STAT_KEYS)[number];
|
|
42
|
+
/** Hover-panel chrome overrides served to the browser half. */
|
|
43
|
+
export interface PetPanelView {
|
|
44
|
+
/** Button / input labels; unset slots keep the i18n dictionary copy. */
|
|
45
|
+
labels?: Partial<Record<PanelLabelKey, string>>;
|
|
46
|
+
/** Stat line formats; unset slots keep the i18n dictionary copy. */
|
|
47
|
+
stats?: Partial<Record<PanelStatKey, string>>;
|
|
48
|
+
/** Actions to render in canonical order; absent = all three; [] = none. */
|
|
49
|
+
actions?: PanelAction[];
|
|
50
|
+
}
|
|
51
|
+
/** One normalized voice pack (a pet's voice.json or the global override). */
|
|
52
|
+
export interface VoicePack {
|
|
53
|
+
/** Chatter pool overrides (draw-time merged with the built-in pools). */
|
|
54
|
+
overrides: VoicePackOverrides;
|
|
55
|
+
/** Hover-panel chrome, when the pack declares any. */
|
|
56
|
+
panel?: PetPanelView;
|
|
57
|
+
}
|
|
58
|
+
/** Hard caps shared by every pool slot (mirrors the remarks discipline). */
|
|
59
|
+
export declare const VOICE_POOL_LINES_MAX = 64;
|
|
60
|
+
export declare const VOICE_LINE_MAX = 160;
|
|
61
|
+
export declare const VOICE_KEYWORDS_PER_RULE_MAX = 16;
|
|
62
|
+
export declare const VOICE_KEYWORD_MAX = 40;
|
|
63
|
+
export declare const VOICE_RULES_MAX = 32;
|
|
64
|
+
export declare const VOICE_LABEL_MAX = 40;
|
|
65
|
+
export declare const VOICE_STAT_MAX = 80;
|
|
66
|
+
type PoolKind = 'status' | 'tools' | 'toolRemaining' | 'whisperGeneric' | 'whisperRule' | 'label' | 'stat';
|
|
67
|
+
/**
|
|
68
|
+
* Normalize one pool slot. Accepts a single line or an array; non-string
|
|
69
|
+
* entries warn and drop, empty lines drop silently, lines over the length
|
|
70
|
+
* cap truncate, illegal placeholders drop the line, and pools over the line
|
|
71
|
+
* cap keep their first lines. An explicit empty pool normalizes to [] (the
|
|
72
|
+
* whisper channels read that as mute) while an absent slot normalizes to
|
|
73
|
+
* undefined (the slot keeps the built-in pool).
|
|
74
|
+
*/
|
|
75
|
+
export declare function normalizePool(raw: unknown, kind: PoolKind, onWarning?: (message: string) => void): string[] | undefined;
|
|
76
|
+
/** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
|
|
77
|
+
export declare function normalizeWhisperRules(raw: unknown, onWarning?: (message: string) => void): WhisperRule[] | undefined;
|
|
78
|
+
/** Normalize the panel block (labels / stats / actions; warn-and-drop). */
|
|
79
|
+
export declare function normalizePanel(raw: unknown, onWarning?: (message: string) => void): PetPanelView | undefined;
|
|
80
|
+
/** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
|
|
81
|
+
export declare const VOICE_PACK_KEYS: Set<string>;
|
|
82
|
+
/** Allowed whisper-section fields (drift-locked in tests). */
|
|
83
|
+
export declare const WHISPER_KEYS: Set<string>;
|
|
84
|
+
/**
|
|
85
|
+
* Normalize one raw voice.json document into a VoicePack, or undefined when
|
|
86
|
+
* the file cannot serve as a pack at all (non-object root — structure is
|
|
87
|
+
* fail-closed per file). Every slot issue is a warning, never a throw.
|
|
88
|
+
*/
|
|
89
|
+
export declare function normalizeVoicePack(raw: unknown, onWarning?: (message: string) => void): VoicePack | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Merge voice-pack layers into one pack; later layers win per slot. The
|
|
92
|
+
* built-in pools are NOT a layer here — the chatter engines fall back to
|
|
93
|
+
* them per key at draw time. Merge order for a selected pet:
|
|
94
|
+
* mergeVoicePacks(registry.globalVoice, entry.voice).
|
|
95
|
+
*/
|
|
96
|
+
export declare function mergeVoicePacks(...layers: (VoicePack | undefined)[]): VoicePack | undefined;
|
|
97
|
+
export {};
|
|
98
|
+
//# sourceMappingURL=voice-pack.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-pack.d.ts","sourceRoot":"","sources":["../../src/voice-pack.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,384 @@
|
|
|
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
|
+
import { STATUS_SCENES, TOOL_CATEGORIES, } from './chatter.js';
|
|
31
|
+
/** Schema version this module normalizes (optional field; missing = 1). */
|
|
32
|
+
export const VOICE_PACK_V1 = 1;
|
|
33
|
+
/** Hover-panel action buttons a pack can show or hide (canonical order). */
|
|
34
|
+
export const PANEL_ACTIONS = ['feed', 'rename', 'hide'];
|
|
35
|
+
/** Panel label slots (unset slots keep the client's i18n dictionary copy). */
|
|
36
|
+
export const PANEL_LABEL_KEYS = ['feed', 'rename', 'hide', 'confirm'];
|
|
37
|
+
/** Panel stat slots ({rank}/{n}/{points} interpolate the live values). */
|
|
38
|
+
export const PANEL_STAT_KEYS = ['rank', 'treats', 'points'];
|
|
39
|
+
/** Hard caps shared by every pool slot (mirrors the remarks discipline). */
|
|
40
|
+
export const VOICE_POOL_LINES_MAX = 64;
|
|
41
|
+
export const VOICE_LINE_MAX = 160;
|
|
42
|
+
export const VOICE_KEYWORDS_PER_RULE_MAX = 16;
|
|
43
|
+
export const VOICE_KEYWORD_MAX = 40;
|
|
44
|
+
export const VOICE_RULES_MAX = 32;
|
|
45
|
+
export const VOICE_LABEL_MAX = 40;
|
|
46
|
+
export const VOICE_STAT_MAX = 80;
|
|
47
|
+
/** Any '{token}' placeholder (no nesting, no newlines). */
|
|
48
|
+
const PLACEHOLDER_PATTERN = /{[^{}]*}/g;
|
|
49
|
+
/** Allowed placeholder tokens per pool kind (absent kind = none allowed). */
|
|
50
|
+
const PLACEHOLDER_WHITELIST = {
|
|
51
|
+
tools: ['{tool}', '{hint}'],
|
|
52
|
+
toolRemaining: ['{n}'],
|
|
53
|
+
stat: ['{rank}', '{n}', '{points}'],
|
|
54
|
+
};
|
|
55
|
+
function isRecord(value) {
|
|
56
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
/** Trim, length-cap and placeholder-check one copy line; undefined to drop. */
|
|
59
|
+
function normalizeLine(raw, kind, onWarning) {
|
|
60
|
+
const trimmed = raw.trim();
|
|
61
|
+
if (trimmed === '')
|
|
62
|
+
return undefined;
|
|
63
|
+
let capped = trimmed.length > VOICE_LINE_MAX ? trimmed.slice(0, VOICE_LINE_MAX) : trimmed;
|
|
64
|
+
// The length cap may cut a placeholder token in half, leaving a dangling
|
|
65
|
+
// '{' that would render literally; drop the tail from the unmatched brace.
|
|
66
|
+
const dangling = capped.lastIndexOf('{');
|
|
67
|
+
if (dangling !== -1 && capped.indexOf('}', dangling) === -1) {
|
|
68
|
+
onWarning('line cut at an unterminated placeholder; tail dropped: ' + capped.slice(0, 40) + '...');
|
|
69
|
+
capped = capped.slice(0, dangling);
|
|
70
|
+
}
|
|
71
|
+
if (capped === '')
|
|
72
|
+
return undefined;
|
|
73
|
+
const allowed = PLACEHOLDER_WHITELIST[kind];
|
|
74
|
+
const tokens = capped.match(PLACEHOLDER_PATTERN) ?? [];
|
|
75
|
+
for (const token of tokens) {
|
|
76
|
+
if (allowed?.includes(token) === true)
|
|
77
|
+
continue;
|
|
78
|
+
const preview = capped.length > 40 ? capped.slice(0, 40) + '...' : capped;
|
|
79
|
+
onWarning('line dropped (unsupported placeholder ' + token + '): ' + preview);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return capped;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Normalize one pool slot. Accepts a single line or an array; non-string
|
|
86
|
+
* entries warn and drop, empty lines drop silently, lines over the length
|
|
87
|
+
* cap truncate, illegal placeholders drop the line, and pools over the line
|
|
88
|
+
* cap keep their first lines. An explicit empty pool normalizes to [] (the
|
|
89
|
+
* whisper channels read that as mute) while an absent slot normalizes to
|
|
90
|
+
* undefined (the slot keeps the built-in pool).
|
|
91
|
+
*/
|
|
92
|
+
export function normalizePool(raw, kind, onWarning = () => { }) {
|
|
93
|
+
if (raw === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
const entries = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw : undefined;
|
|
96
|
+
if (entries === undefined) {
|
|
97
|
+
onWarning('pool must be a string or an array of strings');
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
if (entries.length > VOICE_POOL_LINES_MAX) {
|
|
101
|
+
onWarning('pool has more than ' + VOICE_POOL_LINES_MAX + ' lines; extra lines are ignored');
|
|
102
|
+
}
|
|
103
|
+
const pool = [];
|
|
104
|
+
for (const entry of entries.slice(0, VOICE_POOL_LINES_MAX)) {
|
|
105
|
+
if (typeof entry !== 'string') {
|
|
106
|
+
onWarning('non-string pool entry dropped');
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const line = normalizeLine(entry, kind, onWarning);
|
|
110
|
+
if (line !== undefined)
|
|
111
|
+
pool.push(line);
|
|
112
|
+
}
|
|
113
|
+
return pool;
|
|
114
|
+
}
|
|
115
|
+
/** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
|
|
116
|
+
export function normalizeWhisperRules(raw, onWarning = () => { }) {
|
|
117
|
+
if (raw === undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
if (!Array.isArray(raw)) {
|
|
120
|
+
onWarning('whispers.rules must be an array');
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
if (raw.length > VOICE_RULES_MAX) {
|
|
124
|
+
onWarning('whispers.rules has more than ' + VOICE_RULES_MAX + ' rules; extra rules are ignored');
|
|
125
|
+
}
|
|
126
|
+
const rules = [];
|
|
127
|
+
for (const item of raw.slice(0, VOICE_RULES_MAX)) {
|
|
128
|
+
if (!isRecord(item)) {
|
|
129
|
+
onWarning('whisper rule must be an object');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const key of Object.keys(item)) {
|
|
133
|
+
if (key !== 'keywords' && key !== 'pool')
|
|
134
|
+
onWarning('unknown whisper rule field ' + key + ' ignored');
|
|
135
|
+
}
|
|
136
|
+
const keywordsRaw = item.keywords;
|
|
137
|
+
const keywords = [];
|
|
138
|
+
if (Array.isArray(keywordsRaw)) {
|
|
139
|
+
for (const entry of keywordsRaw.slice(0, VOICE_KEYWORDS_PER_RULE_MAX)) {
|
|
140
|
+
if (typeof entry !== 'string') {
|
|
141
|
+
onWarning('non-string keyword dropped');
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const trimmed = entry.trim().toLowerCase().slice(0, VOICE_KEYWORD_MAX);
|
|
145
|
+
if (trimmed !== '')
|
|
146
|
+
keywords.push(trimmed);
|
|
147
|
+
}
|
|
148
|
+
if (keywordsRaw.length > VOICE_KEYWORDS_PER_RULE_MAX) {
|
|
149
|
+
onWarning('rule has more than ' + VOICE_KEYWORDS_PER_RULE_MAX + ' keywords; extra keywords are ignored');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const pool = normalizePool(item.pool, 'whisperRule', onWarning);
|
|
153
|
+
if (keywords.length === 0 || pool === undefined || pool.length === 0) {
|
|
154
|
+
onWarning('whisper rule dropped (needs keywords and a non-empty pool)');
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
rules.push({ keywords, pool });
|
|
158
|
+
}
|
|
159
|
+
return rules;
|
|
160
|
+
}
|
|
161
|
+
/** Normalize the panel block (labels / stats / actions; warn-and-drop). */
|
|
162
|
+
export function normalizePanel(raw, onWarning = () => { }) {
|
|
163
|
+
if (!isRecord(raw)) {
|
|
164
|
+
onWarning('panel must be an object');
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
const panel = {};
|
|
168
|
+
const labelsRaw = raw.labels;
|
|
169
|
+
if (labelsRaw !== undefined) {
|
|
170
|
+
if (!isRecord(labelsRaw)) {
|
|
171
|
+
onWarning('panel.labels must be an object');
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const labels = {};
|
|
175
|
+
for (const key of PANEL_LABEL_KEYS) {
|
|
176
|
+
const value = labelsRaw[key];
|
|
177
|
+
if (value === undefined)
|
|
178
|
+
continue;
|
|
179
|
+
if (typeof value !== 'string') {
|
|
180
|
+
onWarning('panel.labels.' + key + ' must be a string');
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const line = normalizeLine(value, 'label', onWarning);
|
|
184
|
+
if (line !== undefined)
|
|
185
|
+
labels[key] = line.slice(0, VOICE_LABEL_MAX);
|
|
186
|
+
}
|
|
187
|
+
if (Object.keys(labels).length > 0)
|
|
188
|
+
panel.labels = labels;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const statsRaw = raw.stats;
|
|
192
|
+
if (statsRaw !== undefined) {
|
|
193
|
+
if (!isRecord(statsRaw)) {
|
|
194
|
+
onWarning('panel.stats must be an object');
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const stats = {};
|
|
198
|
+
for (const key of PANEL_STAT_KEYS) {
|
|
199
|
+
const value = statsRaw[key];
|
|
200
|
+
if (value === undefined)
|
|
201
|
+
continue;
|
|
202
|
+
if (typeof value !== 'string') {
|
|
203
|
+
onWarning('panel.stats.' + key + ' must be a string');
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const line = normalizeLine(value, 'stat', onWarning);
|
|
207
|
+
if (line !== undefined)
|
|
208
|
+
stats[key] = line.slice(0, VOICE_STAT_MAX);
|
|
209
|
+
}
|
|
210
|
+
if (Object.keys(stats).length > 0)
|
|
211
|
+
panel.stats = stats;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const actionsRaw = raw.actions;
|
|
215
|
+
if (actionsRaw !== undefined) {
|
|
216
|
+
if (!Array.isArray(actionsRaw)) {
|
|
217
|
+
onWarning('panel.actions must be an array');
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const seen = new Set();
|
|
221
|
+
for (const entry of actionsRaw) {
|
|
222
|
+
if (typeof entry !== 'string' || !PANEL_ACTIONS.includes(entry)) {
|
|
223
|
+
onWarning('unknown panel action dropped: ' + String(entry));
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
seen.add(entry);
|
|
227
|
+
}
|
|
228
|
+
// Canonical order, deduplicated; an explicit empty array hides all.
|
|
229
|
+
panel.actions = PANEL_ACTIONS.filter(action => seen.has(action));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (panel.labels === undefined && panel.stats === undefined && panel.actions === undefined)
|
|
233
|
+
return undefined;
|
|
234
|
+
return panel;
|
|
235
|
+
}
|
|
236
|
+
/** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
|
|
237
|
+
export const VOICE_PACK_KEYS = new Set(['$schema', 'voicePackVersion', 'status', 'tools', 'toolRemaining', 'whispers', 'panel']);
|
|
238
|
+
/** Allowed whisper-section fields (drift-locked in tests). */
|
|
239
|
+
export const WHISPER_KEYS = new Set(['generic', 'rules']);
|
|
240
|
+
/**
|
|
241
|
+
* Normalize one raw voice.json document into a VoicePack, or undefined when
|
|
242
|
+
* the file cannot serve as a pack at all (non-object root — structure is
|
|
243
|
+
* fail-closed per file). Every slot issue is a warning, never a throw.
|
|
244
|
+
*/
|
|
245
|
+
export function normalizeVoicePack(raw, onWarning = () => { }) {
|
|
246
|
+
if (raw === undefined)
|
|
247
|
+
return undefined;
|
|
248
|
+
if (!isRecord(raw)) {
|
|
249
|
+
onWarning('voice.json must be a JSON object; the file is ignored');
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
for (const key of Object.keys(raw)) {
|
|
253
|
+
if (!VOICE_PACK_KEYS.has(key))
|
|
254
|
+
onWarning('unknown top-level field ' + key + ' ignored');
|
|
255
|
+
}
|
|
256
|
+
const version = raw.voicePackVersion;
|
|
257
|
+
if (version !== undefined && (typeof version !== 'number' || version !== VOICE_PACK_V1)) {
|
|
258
|
+
onWarning('voicePackVersion ' + String(version) + ' is not supported; reading as v1 best-effort');
|
|
259
|
+
}
|
|
260
|
+
const overrides = {};
|
|
261
|
+
const statusRaw = raw.status;
|
|
262
|
+
if (statusRaw !== undefined) {
|
|
263
|
+
if (!isRecord(statusRaw)) {
|
|
264
|
+
onWarning('status must be an object');
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
for (const key of Object.keys(statusRaw)) {
|
|
268
|
+
if (!STATUS_SCENES.includes(key)) {
|
|
269
|
+
onWarning('unknown status scene ' + key + ' ignored');
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const pool = normalizePool(statusRaw[key], 'status', onWarning);
|
|
273
|
+
if (pool !== undefined && pool.length > 0) {
|
|
274
|
+
overrides.status = { ...overrides.status, [key]: pool };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const toolsRaw = raw.tools;
|
|
280
|
+
if (toolsRaw !== undefined) {
|
|
281
|
+
if (!isRecord(toolsRaw)) {
|
|
282
|
+
onWarning('tools must be an object');
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
for (const key of Object.keys(toolsRaw)) {
|
|
286
|
+
if (!TOOL_CATEGORIES.includes(key)) {
|
|
287
|
+
onWarning('unknown tool family ' + key + ' ignored');
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const pool = normalizePool(toolsRaw[key], 'tools', onWarning);
|
|
291
|
+
if (pool !== undefined && pool.length > 0) {
|
|
292
|
+
overrides.tools = { ...overrides.tools, [key]: pool };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const remainingRaw = raw.toolRemaining;
|
|
298
|
+
if (remainingRaw !== undefined) {
|
|
299
|
+
const pool = normalizePool(remainingRaw, 'toolRemaining', onWarning);
|
|
300
|
+
if (pool !== undefined && pool.length > 0)
|
|
301
|
+
overrides.toolRemaining = pool;
|
|
302
|
+
}
|
|
303
|
+
const whispersRaw = raw.whispers;
|
|
304
|
+
if (whispersRaw !== undefined) {
|
|
305
|
+
if (!isRecord(whispersRaw)) {
|
|
306
|
+
onWarning('whispers must be an object');
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
for (const key of Object.keys(whispersRaw)) {
|
|
310
|
+
if (!WHISPER_KEYS.has(key))
|
|
311
|
+
onWarning('unknown whispers field ' + key + ' ignored');
|
|
312
|
+
}
|
|
313
|
+
const generic = normalizePool(whispersRaw.generic, 'whisperGeneric', onWarning);
|
|
314
|
+
const rules = normalizeWhisperRules(whispersRaw.rules, onWarning);
|
|
315
|
+
if (generic !== undefined || rules !== undefined) {
|
|
316
|
+
overrides.whispers = {
|
|
317
|
+
...(generic === undefined ? {} : { generic }),
|
|
318
|
+
...(rules === undefined ? {} : { rules }),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const panel = raw.panel === undefined ? undefined : normalizePanel(raw.panel, onWarning);
|
|
324
|
+
if (overrides.status === undefined && overrides.tools === undefined
|
|
325
|
+
&& overrides.toolRemaining === undefined && overrides.whispers === undefined
|
|
326
|
+
&& panel === undefined) {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
overrides,
|
|
331
|
+
...(panel === undefined ? {} : { panel }),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Merge voice-pack layers into one pack; later layers win per slot. The
|
|
336
|
+
* built-in pools are NOT a layer here — the chatter engines fall back to
|
|
337
|
+
* them per key at draw time. Merge order for a selected pet:
|
|
338
|
+
* mergeVoicePacks(registry.globalVoice, entry.voice).
|
|
339
|
+
*/
|
|
340
|
+
export function mergeVoicePacks(...layers) {
|
|
341
|
+
const overrides = {};
|
|
342
|
+
const labels = {};
|
|
343
|
+
const stats = {};
|
|
344
|
+
let actions;
|
|
345
|
+
let panelSeen = false;
|
|
346
|
+
let any = false;
|
|
347
|
+
for (const layer of layers) {
|
|
348
|
+
if (layer === undefined)
|
|
349
|
+
continue;
|
|
350
|
+
any = true;
|
|
351
|
+
if (layer.overrides.status !== undefined) {
|
|
352
|
+
overrides.status = { ...overrides.status, ...layer.overrides.status };
|
|
353
|
+
}
|
|
354
|
+
if (layer.overrides.tools !== undefined) {
|
|
355
|
+
overrides.tools = { ...overrides.tools, ...layer.overrides.tools };
|
|
356
|
+
}
|
|
357
|
+
if (layer.overrides.toolRemaining !== undefined)
|
|
358
|
+
overrides.toolRemaining = layer.overrides.toolRemaining;
|
|
359
|
+
if (layer.overrides.whispers !== undefined) {
|
|
360
|
+
overrides.whispers = { ...overrides.whispers, ...layer.overrides.whispers };
|
|
361
|
+
}
|
|
362
|
+
if (layer.panel !== undefined) {
|
|
363
|
+
panelSeen = true;
|
|
364
|
+
if (layer.panel.labels !== undefined)
|
|
365
|
+
Object.assign(labels, layer.panel.labels);
|
|
366
|
+
if (layer.panel.stats !== undefined)
|
|
367
|
+
Object.assign(stats, layer.panel.stats);
|
|
368
|
+
if (layer.panel.actions !== undefined)
|
|
369
|
+
actions = layer.panel.actions;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!any)
|
|
373
|
+
return undefined;
|
|
374
|
+
const panel = {
|
|
375
|
+
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
|
376
|
+
...(Object.keys(stats).length > 0 ? { stats } : {}),
|
|
377
|
+
...(actions === undefined ? {} : { actions }),
|
|
378
|
+
};
|
|
379
|
+
const panelEmpty = panel.labels === undefined && panel.stats === undefined && panel.actions === undefined;
|
|
380
|
+
return {
|
|
381
|
+
overrides,
|
|
382
|
+
...(panelSeen && !panelEmpty ? { panel } : {}),
|
|
383
|
+
};
|
|
384
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-pet",
|
|
3
3
|
"description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"lib/**/*.d.ts.map",
|
|
32
32
|
"src",
|
|
33
33
|
"cordis.patch.yml",
|
|
34
|
-
"assets"
|
|
34
|
+
"assets",
|
|
35
|
+
"contracts"
|
|
35
36
|
],
|
|
36
37
|
"license": "Apache-2.0",
|
|
37
38
|
"dsh": {
|
|
@@ -58,24 +59,26 @@
|
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
61
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.
|
|
62
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.
|
|
63
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.
|
|
65
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.
|
|
66
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.
|
|
67
|
-
"@deepseek-ai/dsh-session": "^0.1.0-rc.
|
|
68
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.
|
|
62
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8",
|
|
63
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
|
|
67
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
|
|
68
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
|
69
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
|
69
70
|
"@testing-library/dom": "^10.4.1",
|
|
70
71
|
"@testing-library/react": "^16.3.2",
|
|
71
72
|
"@types/node": "^22.20.0",
|
|
72
73
|
"@types/react": "~18.3.1",
|
|
73
74
|
"@types/react-dom": "~18.3.0",
|
|
74
75
|
"jsdom": "29.1.1",
|
|
76
|
+
"pixi.js": "^8.19.0",
|
|
75
77
|
"react": "^18.2.0",
|
|
76
78
|
"react-dom": "^18.2.0",
|
|
77
79
|
"tsdown": "^0.22.2",
|
|
78
80
|
"typescript": "^6.0.3",
|
|
81
|
+
"untitled-pixi-live2d-engine": "1.3.5",
|
|
79
82
|
"vite-tsconfig-paths": "^6.1.1",
|
|
80
83
|
"vitest": "^4.1.8"
|
|
81
84
|
},
|
package/src/chatter.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
WHISPER_GENERIC_POOL,
|
|
15
15
|
WHISPER_RULES,
|
|
16
16
|
WhisperEngine,
|
|
17
|
+
type VoicePackOverrides,
|
|
17
18
|
} from './chatter.ts'
|
|
18
19
|
|
|
19
20
|
describe('StatusVoice', () => {
|
|
@@ -139,7 +140,7 @@ describe('WhisperEngine', () => {
|
|
|
139
140
|
})
|
|
140
141
|
|
|
141
142
|
it('earns an ambient whisper from output volume alone', () => {
|
|
142
|
-
const engine = new WhisperEngine(0, 10)
|
|
143
|
+
const engine = new WhisperEngine(undefined, 0, 10)
|
|
143
144
|
expect(engine.feed('aaaaaaaa', 0)).toBeUndefined()
|
|
144
145
|
expect(engine.feed('bbbb', 1)).toBe(WHISPER_GENERIC_POOL[0])
|
|
145
146
|
// The counter resets after speaking: another budget must accumulate.
|
|
@@ -148,7 +149,93 @@ describe('WhisperEngine', () => {
|
|
|
148
149
|
})
|
|
149
150
|
|
|
150
151
|
it('ignores empty chunks', () => {
|
|
151
|
-
const engine = new WhisperEngine(0, 0)
|
|
152
|
+
const engine = new WhisperEngine(undefined, 0, 0)
|
|
152
153
|
expect(engine.feed('', 0)).toBeUndefined()
|
|
153
154
|
})
|
|
154
155
|
})
|
|
156
|
+
|
|
157
|
+
describe('voice-pack overrides (pet-center M4)', () => {
|
|
158
|
+
const PACK = (): VoicePackOverrides => ({
|
|
159
|
+
status: {
|
|
160
|
+
done: ['自定义完工', '第二句完工'],
|
|
161
|
+
thinking: [],
|
|
162
|
+
},
|
|
163
|
+
tools: {
|
|
164
|
+
shell: ['敲命令 {hint}', '再来一次 {tool}'],
|
|
165
|
+
},
|
|
166
|
+
toolRemaining: ['后台还有 {n} 个'],
|
|
167
|
+
whispers: {
|
|
168
|
+
generic: ['自定义碎碎念 A', '自定义碎碎念 B'],
|
|
169
|
+
rules: [{ keywords: ['测试通过'], pool: ['自定义全绿'] }],
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('replaces a scene pool and keeps untouched scenes on the built-in pools', () => {
|
|
174
|
+
const voice = new StatusVoice(PACK)
|
|
175
|
+
expect(voice.scene('done', 0)).toBe('自定义完工')
|
|
176
|
+
expect(voice.scene('done', 5000)).toBe('第二句完工')
|
|
177
|
+
expect(voice.scene('thinking', 0)).toBe(STATUS_POOLS.thinking[0])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('falls back to the built-in pool when the override pool is empty', () => {
|
|
181
|
+
const voice = new StatusVoice(PACK)
|
|
182
|
+
// PACK.thinking is an empty array: a scene line always renders.
|
|
183
|
+
expect(voice.scene('thinking', 0)).toBe(STATUS_POOLS.thinking[0])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('interpolates placeholders from an overridden tool pool', () => {
|
|
187
|
+
const voice = new StatusVoice(PACK)
|
|
188
|
+
expect(voice.tool('bash', 'bash', 'npm test', 0)).toBe('敲命令 npm test')
|
|
189
|
+
const rotated = voice.tool('bash', 'bash', 'npm test', 5000)
|
|
190
|
+
expect(rotated).toBe('再来一次 bash')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('uses an overridden remaining-tools pool', () => {
|
|
194
|
+
const voice = new StatusVoice(PACK)
|
|
195
|
+
expect(voice.toolRemaining(4, 0)).toBe('后台还有 4 个')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('interpolates every occurrence of a repeated placeholder', () => {
|
|
199
|
+
const pack: ReturnType<typeof PACK> = {
|
|
200
|
+
...PACK(),
|
|
201
|
+
tools: { shell: ['{tool} 和 {tool} 一起跑 {hint} {hint}'] },
|
|
202
|
+
toolRemaining: ['{n} 路并进,共 {n} 路'],
|
|
203
|
+
}
|
|
204
|
+
const voice = new StatusVoice(() => pack)
|
|
205
|
+
expect(voice.tool('bash', 'bash', 'npm test', 0)).toBe('bash 和 bash 一起跑 npm test npm test')
|
|
206
|
+
expect(voice.toolRemaining(2, 5000)).toBe('2 路并进,共 2 路')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('follows a provider swap on the next draw without rebuilding the engine', () => {
|
|
210
|
+
let pack: ReturnType<typeof PACK> = PACK()
|
|
211
|
+
const voice = new StatusVoice(() => pack)
|
|
212
|
+
expect(voice.scene('done', 0)).toBe('自定义完工')
|
|
213
|
+
pack = { ...PACK(), status: { done: ['换声了'] } }
|
|
214
|
+
expect(voice.scene('done', 5000)).toBe('换声了')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('replaces the whisper rules as a whole', () => {
|
|
218
|
+
const engine = new WhisperEngine(PACK)
|
|
219
|
+
expect(engine.feed('这里有一个错误', 0)).toBeUndefined()
|
|
220
|
+
// The built-in error rule is gone; only the pack's rule fires.
|
|
221
|
+
expect(engine.feed('测试通过', 1000)).toBe('自定义全绿')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('replaces the ambient generic pool', () => {
|
|
225
|
+
const engine = new WhisperEngine(PACK, 0, 3)
|
|
226
|
+
expect(engine.feed('aaaa', 0)).toBe('自定义碎碎念 A')
|
|
227
|
+
expect(engine.feed('bbbb', 1)).toBe('自定义碎碎念 B')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('mutes ambient whispers when the generic pool is explicitly empty', () => {
|
|
231
|
+
const engine = new WhisperEngine(() => ({ whispers: { generic: [] } }), 0, 3)
|
|
232
|
+
expect(engine.feed('aaaa', 0)).toBeUndefined()
|
|
233
|
+
expect(engine.feed('bbbb', 1)).toBeUndefined()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('disables keyword rules when the rules array is explicitly empty', () => {
|
|
237
|
+
const engine = new WhisperEngine(() => ({ whispers: { rules: [] } }), 0, 3)
|
|
238
|
+
// No keyword rule matches anymore; volume still earns a built-in ambient.
|
|
239
|
+
expect(engine.feed('测试通过', 0)).toBe(WHISPER_GENERIC_POOL[0])
|
|
240
|
+
})
|
|
241
|
+
})
|