@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
package/src/chatter.ts
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* plugin has always shown, so existing installs keep their wording until the
|
|
18
18
|
* scene cycles. No emoji anywhere (repository rule); ~ is the whale-girl's
|
|
19
19
|
* signature.
|
|
20
|
+
*
|
|
21
|
+
* Since pet-center M4 (issue #677) every pool is overridable through a
|
|
22
|
+
* {@link VoicePoolsProvider}: the built-in pools are the fallback layer, and
|
|
23
|
+
* voice packs (per-pet voice.json / the global .voice.json) layer their
|
|
24
|
+
* pools on top at draw time.
|
|
20
25
|
* @module @linxin666/dsh-pet/chatter
|
|
21
26
|
*/
|
|
22
27
|
|
|
@@ -193,6 +198,18 @@ export type ToolCategory =
|
|
|
193
198
|
| 'ask'
|
|
194
199
|
| 'generic'
|
|
195
200
|
|
|
201
|
+
/** Every status scene key, in declaration order (voice-pack key allow-list). */
|
|
202
|
+
export const STATUS_SCENES: readonly StatusScene[] = [
|
|
203
|
+
'prepare', 'waiting', 'thinking', 'review', 'toolResult', 'done',
|
|
204
|
+
'failed', 'toolFailed', 'maxTokens', 'interrupted', 'blocked',
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
/** Every tool-family key, in declaration order (voice-pack key allow-list). */
|
|
208
|
+
export const TOOL_CATEGORIES: readonly ToolCategory[] = [
|
|
209
|
+
'read', 'write', 'edit', 'shell', 'grep', 'find', 'ls', 'webSearch',
|
|
210
|
+
'webFetch', 'mcp', 'memory', 'subagent', 'todo', 'browser', 'git', 'ask', 'generic',
|
|
211
|
+
]
|
|
212
|
+
|
|
196
213
|
/** Map a raw tool name onto its copy family (working-activity style regexes). */
|
|
197
214
|
export function toolCategory(toolName: string): ToolCategory {
|
|
198
215
|
const name = toolName.toLowerCase()
|
|
@@ -433,12 +450,19 @@ export function toolArgHint(toolName: string, argumentsJson: string): string | u
|
|
|
433
450
|
* stretch keeps changing its wording.
|
|
434
451
|
*/
|
|
435
452
|
export class StatusVoice {
|
|
453
|
+
private readonly pools: VoicePoolsProvider
|
|
454
|
+
private readonly rotateMs: number
|
|
436
455
|
private readonly counters = new Map<string, number>()
|
|
437
456
|
private lastScene = ''
|
|
438
457
|
private lastLine = ''
|
|
439
458
|
private lastLineAt = Number.NEGATIVE_INFINITY
|
|
440
459
|
|
|
441
|
-
constructor(
|
|
460
|
+
constructor(pools: VoicePoolsProvider = () => BUILTIN_VOICE_PACK, rotateMs: number = STATUS_ROTATE_MS) {
|
|
461
|
+
// Plain property assignment, not parameter properties: this module is
|
|
462
|
+
// imported by scripts/ under node's strip-only mode (pet-center M4).
|
|
463
|
+
this.pools = pools
|
|
464
|
+
this.rotateMs = rotateMs
|
|
465
|
+
}
|
|
442
466
|
|
|
443
467
|
/** Draw the next line of one pool, advancing its round-robin cursor. */
|
|
444
468
|
private draw(poolKey: string, pool: readonly string[]): string {
|
|
@@ -456,24 +480,38 @@ export class StatusVoice {
|
|
|
456
480
|
return this.lastLine
|
|
457
481
|
}
|
|
458
482
|
|
|
483
|
+
/**
|
|
484
|
+
* A scene's effective pool: the voice-pack override when it carries lines,
|
|
485
|
+
* else the built-in pool. Empty overrides fall back rather than blank the
|
|
486
|
+
* bubble — a scene line always renders.
|
|
487
|
+
*/
|
|
488
|
+
private scenePool(scene: StatusScene): readonly string[] {
|
|
489
|
+
const override = this.pools().status?.[scene]
|
|
490
|
+
return override !== undefined && override.length > 0 ? override : STATUS_POOLS[scene]
|
|
491
|
+
}
|
|
492
|
+
|
|
459
493
|
/** Status line for a phase scene. */
|
|
460
494
|
scene(scene: StatusScene, nowMs: number): string {
|
|
461
|
-
return this.voice('scene:' + scene, 'pool:' + scene,
|
|
495
|
+
return this.voice('scene:' + scene, 'pool:' + scene, this.scenePool(scene), nowMs)
|
|
462
496
|
}
|
|
463
497
|
|
|
464
498
|
/** Status line for a tool call, with the real-argument hint when known. */
|
|
465
499
|
tool(toolName: string, displayName: string, hint: string | undefined, nowMs: number): string {
|
|
466
500
|
const category = toolCategory(toolName)
|
|
467
|
-
const
|
|
501
|
+
const override = this.pools().tools?.[category]
|
|
502
|
+
const pool = override !== undefined && override.length > 0 ? override : TOOL_POOLS[category]
|
|
503
|
+
const line = this.voice('tool:' + category, 'tool:' + category, pool, nowMs)
|
|
468
504
|
return line
|
|
469
|
-
.
|
|
470
|
-
.
|
|
505
|
+
.replaceAll('{tool}', displayName)
|
|
506
|
+
.replaceAll('{hint}', hint ?? displayName)
|
|
471
507
|
}
|
|
472
508
|
|
|
473
509
|
/** Status line while sibling tools still run (always reflects the count). */
|
|
474
510
|
toolRemaining(count: number, nowMs: number): string {
|
|
475
|
-
|
|
476
|
-
|
|
511
|
+
const override = this.pools().toolRemaining
|
|
512
|
+
const pool = override !== undefined && override.length > 0 ? override : TOOL_REMAINING_POOL
|
|
513
|
+
return this.voice('toolRemaining', 'toolRemaining', pool, nowMs)
|
|
514
|
+
.replaceAll('{n}', String(count))
|
|
477
515
|
}
|
|
478
516
|
}
|
|
479
517
|
|
|
@@ -734,24 +772,89 @@ export const WHISPER_RULES: readonly WhisperRule[] = [
|
|
|
734
772
|
},
|
|
735
773
|
]
|
|
736
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Voice-pack overrides (pet-center M4, issue #677): the content a voice
|
|
777
|
+
* pack can replace, one pool at a time. Every field is optional — missing
|
|
778
|
+
* keys inherit the built-in pools. Resolution happens at draw time through
|
|
779
|
+
* a provider function, so swapping pets (or editing the global file) re-
|
|
780
|
+
* voices live engines without rebuilding them.
|
|
781
|
+
*
|
|
782
|
+
* Override semantics:
|
|
783
|
+
* - status/tools/toolRemaining: a non-empty override replaces the built-in
|
|
784
|
+
* pool for that key; an empty override falls back to the built-in pool
|
|
785
|
+
* (a scene line always renders, so it can never be blanked).
|
|
786
|
+
* - whispers.generic / whispers.rules: the override REPLACES the built-in
|
|
787
|
+
* section; an empty array mutes that channel (ambient or keyword).
|
|
788
|
+
*/
|
|
789
|
+
export interface VoicePackOverrides {
|
|
790
|
+
/** Status copy pools by scene; each key replaces that scene's pool. */
|
|
791
|
+
status?: Partial<Record<StatusScene, readonly string[]>>
|
|
792
|
+
/** Tool copy pools by family; each key replaces that family's pool. */
|
|
793
|
+
tools?: Partial<Record<ToolCategory, readonly string[]>>
|
|
794
|
+
/** The parallel-tools count line pool ({n} interpolates the count). */
|
|
795
|
+
toolRemaining?: readonly string[]
|
|
796
|
+
/** Murmur pools; each section replaces the built-in one as a whole. */
|
|
797
|
+
whispers?: {
|
|
798
|
+
/** Ambient inner-whisper pool (empty mutes ambient whispers). */
|
|
799
|
+
generic?: readonly string[]
|
|
800
|
+
/** Ordered keyword rules (empty disables keyword-triggered whispers). */
|
|
801
|
+
rules?: readonly WhisperRule[]
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Read the current effective voice-pack overrides (draw-time resolution). */
|
|
806
|
+
export type VoicePoolsProvider = () => VoicePackOverrides
|
|
807
|
+
|
|
808
|
+
/** The built-in voice pack: the plugin's default copy, unchanged since v1. */
|
|
809
|
+
export const BUILTIN_VOICE_PACK: VoicePackOverrides = {
|
|
810
|
+
status: STATUS_POOLS,
|
|
811
|
+
tools: TOOL_POOLS,
|
|
812
|
+
toolRemaining: TOOL_REMAINING_POOL,
|
|
813
|
+
whispers: { generic: WHISPER_GENERIC_POOL, rules: WHISPER_RULES },
|
|
814
|
+
}
|
|
815
|
+
|
|
737
816
|
/**
|
|
738
817
|
* The murmur engine (碎碎念): watches the model's own output and lets the pet
|
|
739
818
|
* whisper its inner voice. Two ways to earn a whisper:
|
|
740
819
|
* - a keyword rule matches the fresh chunk text (themed whisper);
|
|
741
820
|
* - enough output volume flowed by without one (ambient whisper).
|
|
742
821
|
* A cooldown keeps whispers occasional; all picks are round-robin so tests
|
|
743
|
-
* reproduce exact lines.
|
|
822
|
+
* reproduce exact lines. The voice-pack provider (pet-center M4) swaps the
|
|
823
|
+
* pools at draw time, so a pet switch re-voices live engines in place.
|
|
744
824
|
*/
|
|
745
825
|
export class WhisperEngine {
|
|
826
|
+
private readonly pools: VoicePoolsProvider
|
|
827
|
+
private readonly cooldownMs: number
|
|
828
|
+
private readonly charBudget: number
|
|
746
829
|
private readonly counters = new Map<number, number>()
|
|
747
830
|
private genericCursor = 0
|
|
748
831
|
private lastWhisperAt = Number.NEGATIVE_INFINITY
|
|
749
832
|
private charsSinceWhisper = 0
|
|
750
833
|
|
|
751
834
|
constructor(
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
835
|
+
pools: VoicePoolsProvider = () => BUILTIN_VOICE_PACK,
|
|
836
|
+
cooldownMs: number = WHISPER_COOLDOWN_MS,
|
|
837
|
+
charBudget: number = WHISPER_CHAR_BUDGET,
|
|
838
|
+
) {
|
|
839
|
+
this.pools = pools
|
|
840
|
+
this.cooldownMs = cooldownMs
|
|
841
|
+
this.charBudget = charBudget
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Effective keyword rules: an override replaces the built-in rules as a
|
|
846
|
+
* whole; an explicit empty array disables keyword-triggered whispers.
|
|
847
|
+
*/
|
|
848
|
+
private rules(): readonly WhisperRule[] {
|
|
849
|
+
const override = this.pools().whispers?.rules
|
|
850
|
+
return override === undefined ? WHISPER_RULES : override
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Effective ambient pool (an explicit empty array mutes ambient whispers). */
|
|
854
|
+
private generic(): readonly string[] {
|
|
855
|
+
const override = this.pools().whispers?.generic
|
|
856
|
+
return override === undefined ? WHISPER_GENERIC_POOL : override
|
|
857
|
+
}
|
|
755
858
|
|
|
756
859
|
/**
|
|
757
860
|
* Feed one model-output chunk (reasoning or text). Returns the whisper to
|
|
@@ -765,8 +868,9 @@ export class WhisperEngine {
|
|
|
765
868
|
return undefined
|
|
766
869
|
}
|
|
767
870
|
const haystack = text.toLowerCase()
|
|
768
|
-
|
|
769
|
-
|
|
871
|
+
const rules = this.rules()
|
|
872
|
+
for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
|
|
873
|
+
const rule = rules[ruleIndex]!
|
|
770
874
|
if (!rule.keywords.some(keyword => haystack.includes(keyword))) continue
|
|
771
875
|
const index = (this.counters.get(ruleIndex) ?? 0) % rule.pool.length
|
|
772
876
|
this.counters.set(ruleIndex, index + 1)
|
|
@@ -774,7 +878,9 @@ export class WhisperEngine {
|
|
|
774
878
|
}
|
|
775
879
|
this.charsSinceWhisper += text.length
|
|
776
880
|
if (this.charsSinceWhisper < this.charBudget) return undefined
|
|
777
|
-
const
|
|
881
|
+
const generic = this.generic()
|
|
882
|
+
if (generic.length === 0) return undefined
|
|
883
|
+
const line = generic[this.genericCursor % generic.length]!
|
|
778
884
|
this.genericCursor += 1
|
|
779
885
|
return this.speak(line, nowMs)
|
|
780
886
|
}
|
|
@@ -30,6 +30,8 @@ export interface PetSettings {
|
|
|
30
30
|
bottom?: number
|
|
31
31
|
/** Selected pet id (a registry entry). */
|
|
32
32
|
petId?: string
|
|
33
|
+
/** Status-decoration master switch (pet-center M5, #567). */
|
|
34
|
+
decorationEnabled?: boolean
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/** What the pet settings card renders. */
|
|
@@ -46,6 +48,8 @@ export interface PetSettingsCardState extends CardShell {
|
|
|
46
48
|
bottom: CardFieldState
|
|
47
49
|
/** Selected pet. */
|
|
48
50
|
petId: CardFieldState
|
|
51
|
+
/** Status-decoration master switch. */
|
|
52
|
+
decorationEnabled: CardFieldState
|
|
49
53
|
/** Pet choices (registry ids + display names), loaded from the host. */
|
|
50
54
|
petChoices: readonly { value: string; label: string }[]
|
|
51
55
|
/** Registry diagnostics (v1 migration hints, invalid entries), host-served. */
|
|
@@ -104,6 +108,7 @@ export class PetSettingsCardController {
|
|
|
104
108
|
constructor(scope: SettingsScope<PetSettings>) {
|
|
105
109
|
this.form = new CardForm(scope, [
|
|
106
110
|
booleanField('enabled'),
|
|
111
|
+
booleanField('decorationEnabled'),
|
|
107
112
|
booleanField('visible'),
|
|
108
113
|
numberField('size'),
|
|
109
114
|
numberField('right'),
|
|
@@ -146,6 +151,7 @@ export class PetSettingsCardController {
|
|
|
146
151
|
return {
|
|
147
152
|
...this.form.shell(),
|
|
148
153
|
enabled: this.form.field('enabled'),
|
|
154
|
+
decorationEnabled: this.form.field('decorationEnabled'),
|
|
149
155
|
visible: this.form.field('visible'),
|
|
150
156
|
size: this.form.field('size'),
|
|
151
157
|
right: this.form.field('right'),
|
|
@@ -215,6 +221,18 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
|
|
|
215
221
|
onEdit={(text) => { props.edit('enabled', text) }}
|
|
216
222
|
onReset={() => { props.resetField('enabled') }}
|
|
217
223
|
/>
|
|
224
|
+
<BooleanField
|
|
225
|
+
id="settings-pet-decoration"
|
|
226
|
+
label={t('settings.decoration')}
|
|
227
|
+
hint={t('settings.decorationHint')}
|
|
228
|
+
inheritLabel={t('settings.inherit')}
|
|
229
|
+
onLabel={t('settings.on')}
|
|
230
|
+
offLabel={t('settings.off')}
|
|
231
|
+
{...fieldProps}
|
|
232
|
+
{...state.decorationEnabled}
|
|
233
|
+
onEdit={(text) => { props.edit('decorationEnabled', text) }}
|
|
234
|
+
onReset={() => { props.resetField('decorationEnabled') }}
|
|
235
|
+
/>
|
|
218
236
|
<ChoiceField
|
|
219
237
|
id="settings-pet-pet"
|
|
220
238
|
label={t('settings.pet')}
|
|
@@ -11,6 +11,7 @@ import { t } from './locales.ts'
|
|
|
11
11
|
import type { PetStateView } from '../service.ts'
|
|
12
12
|
import type { PetDefinition, PetTrackDef } from '../registry.ts'
|
|
13
13
|
import type { PetAnimation } from '../state.ts'
|
|
14
|
+
import type { DecorationView } from '../contracts/status-decoration.ts'
|
|
14
15
|
|
|
15
16
|
/** A minimal pet definition (geometry + tracks) as served by the host. */
|
|
16
17
|
function petDefinition(): PetDefinition {
|
|
@@ -91,14 +92,9 @@ afterEach(() => {
|
|
|
91
92
|
vi.restoreAllMocks()
|
|
92
93
|
})
|
|
93
94
|
|
|
94
|
-
/**
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
onOpenSession: ReturnType<typeof vi.fn>
|
|
98
|
-
} {
|
|
99
|
-
const onRename = vi.fn()
|
|
100
|
-
const onOpenSession = vi.fn()
|
|
101
|
-
const props: PetSpriteProps = {
|
|
95
|
+
/** Build the mocked props for one render. */
|
|
96
|
+
function petProps(overrides: Partial<PetSpriteProps> = {}): PetSpriteProps {
|
|
97
|
+
return {
|
|
102
98
|
snapshot,
|
|
103
99
|
definition: petDefinition(),
|
|
104
100
|
display: snapshot.display,
|
|
@@ -107,14 +103,24 @@ function renderPet(overrides: Partial<PetSpriteProps> = {}): {
|
|
|
107
103
|
onFeed: vi.fn(),
|
|
108
104
|
onHide: vi.fn(),
|
|
109
105
|
onDragEnd: vi.fn(),
|
|
110
|
-
onRename,
|
|
111
|
-
onOpenSession,
|
|
106
|
+
onRename: vi.fn(),
|
|
107
|
+
onOpenSession: vi.fn(),
|
|
112
108
|
onFeedbackDone: vi.fn(),
|
|
113
109
|
t,
|
|
114
110
|
...overrides,
|
|
115
111
|
}
|
|
116
|
-
|
|
117
|
-
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Render the pet with mocked callbacks; returns the rename/open spys + the RTL result. */
|
|
115
|
+
function renderPet(overrides: Partial<PetSpriteProps> = {}): {
|
|
116
|
+
onRename: ReturnType<typeof vi.fn>
|
|
117
|
+
onOpenSession: ReturnType<typeof vi.fn>
|
|
118
|
+
result: ReturnType<typeof render>
|
|
119
|
+
} {
|
|
120
|
+
const onRename = vi.fn()
|
|
121
|
+
const onOpenSession = vi.fn()
|
|
122
|
+
const result = render(<PetSprite {...petProps({ onRename, onOpenSession, ...overrides })} />)
|
|
123
|
+
return { onRename, onOpenSession, result }
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
/** Hover the sprite to open the panel, then click the rename button. */
|
|
@@ -555,3 +561,239 @@ describe('PetSprite definition-driven render', () => {
|
|
|
555
561
|
expect(sprite.style.backgroundPosition).toBe('0px -960px')
|
|
556
562
|
})
|
|
557
563
|
})
|
|
564
|
+
|
|
565
|
+
describe('PetSprite panel chrome from the voice pack (pet-center M4)', () => {
|
|
566
|
+
const voicedDefinition = (): PetDefinition => ({
|
|
567
|
+
...petDefinition(),
|
|
568
|
+
panel: {
|
|
569
|
+
labels: { feed: '投喂', rename: '起名字', hide: '藏起来', confirm: '好的' },
|
|
570
|
+
stats: { rank: '好感 {rank}', treats: '鱼干 {n}', points: '{points} 分' },
|
|
571
|
+
},
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('renders pack labels and stat formats, falling back per slot', () => {
|
|
575
|
+
renderPet({ definition: voicedDefinition() })
|
|
576
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
577
|
+
expect(screen.getByText('投喂')).toBeDefined()
|
|
578
|
+
expect(screen.getByText('起名字')).toBeDefined()
|
|
579
|
+
expect(screen.getByText('藏起来')).toBeDefined()
|
|
580
|
+
expect(screen.getByText('好感 幼鲸')).toBeDefined()
|
|
581
|
+
expect(screen.getByText('鱼干 3')).toBeDefined()
|
|
582
|
+
expect(screen.getByText('0 分')).toBeDefined()
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('uses the pack confirm label inside the rename row', () => {
|
|
586
|
+
renderPet({ definition: voicedDefinition() })
|
|
587
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
588
|
+
fireEvent.click(screen.getByText('起名字'))
|
|
589
|
+
expect(screen.getByText('好的')).toBeDefined()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('hides actions the pack omits', () => {
|
|
593
|
+
renderPet({
|
|
594
|
+
definition: {
|
|
595
|
+
...petDefinition(),
|
|
596
|
+
panel: { labels: { feed: '投喂' }, actions: ['feed'] },
|
|
597
|
+
},
|
|
598
|
+
})
|
|
599
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
600
|
+
expect(screen.getByText('投喂')).toBeDefined()
|
|
601
|
+
expect(screen.queryByText('改名')).toBeNull()
|
|
602
|
+
expect(screen.queryByText('隐藏')).toBeNull()
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('renders no action buttons when the pack hides them all', () => {
|
|
606
|
+
renderPet({ definition: { ...petDefinition(), panel: { actions: [] } } })
|
|
607
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
608
|
+
expect(screen.queryByText('喂食')).toBeNull()
|
|
609
|
+
expect(screen.queryByText('改名')).toBeNull()
|
|
610
|
+
expect(screen.queryByText('隐藏')).toBeNull()
|
|
611
|
+
// The stat rows keep rendering.
|
|
612
|
+
expect(screen.getByText('亲密度 幼鲸')).toBeDefined()
|
|
613
|
+
expect(screen.getByText('小鱼干 ×3')).toBeDefined()
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
it('keeps the i18n copy when the pet carries no panel', () => {
|
|
617
|
+
renderPet()
|
|
618
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
619
|
+
expect(screen.getByText('喂食')).toBeDefined()
|
|
620
|
+
expect(screen.getByText('改名')).toBeDefined()
|
|
621
|
+
expect(screen.getByText('隐藏')).toBeDefined()
|
|
622
|
+
expect(screen.getByText('亲密度 幼鲸')).toBeDefined()
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('substitutes cross-slot placeholders in pack stat formats', () => {
|
|
626
|
+
renderPet({ definition: { ...petDefinition(), panel: { stats: { treats: '鱼干 {n}({points} 分,{rank})' } } } })
|
|
627
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
628
|
+
// The host whitelists {rank}/{n}/{points} in every stat slot, so a pack
|
|
629
|
+
// format may reference any of them; all three live values substitute.
|
|
630
|
+
expect(screen.getByText('鱼干 3(0 分,幼鲸)')).toBeDefined()
|
|
631
|
+
})
|
|
632
|
+
})
|
|
633
|
+
describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
634
|
+
const decoration: DecorationView = {
|
|
635
|
+
apiVersion: 'x-org.linxin666.pet-center/status-decoration-v1',
|
|
636
|
+
id: 'whale',
|
|
637
|
+
assetBase: '/api/pet/decoration/whale',
|
|
638
|
+
entryUrl: '/api/pet/decoration/whale/whale-frames.png',
|
|
639
|
+
cell: { width: 64, height: 48 },
|
|
640
|
+
columns: 4,
|
|
641
|
+
durations: [160, 160, 160, 160],
|
|
642
|
+
loop: true,
|
|
643
|
+
phases: {
|
|
644
|
+
idle: 'hide',
|
|
645
|
+
waiting: { from: 0, to: 1 },
|
|
646
|
+
thinking: { from: 0, to: 3 },
|
|
647
|
+
done: { from: 2, to: 3 },
|
|
648
|
+
},
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const ornament = (): HTMLElement | null => document.body.querySelector('[data-dsh-pet-decoration="whale"]')
|
|
652
|
+
|
|
653
|
+
it('renders an aria-hidden ornament inside the status bubble for a bound phase', () => {
|
|
654
|
+
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
655
|
+
const el = ornament()
|
|
656
|
+
expect(el).not.toBeNull()
|
|
657
|
+
expect(el!.getAttribute('aria-hidden')).toBe('true')
|
|
658
|
+
expect(el!.style.backgroundImage).toContain('whale-frames.png')
|
|
659
|
+
// The bubble keeps its semantics beside the ornament.
|
|
660
|
+
const bubble = document.body.querySelector('[role="status"][aria-live="polite"]')
|
|
661
|
+
expect(bubble).not.toBeNull()
|
|
662
|
+
expect(bubble!.textContent).toContain('正在思考')
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('hides the ornament for phases bound to hide and for the idle default', () => {
|
|
666
|
+
renderPet({ snapshot: { ...snapshot, bubble: '等待', phase: 'idle', decoration } })
|
|
667
|
+
expect(ornament()).toBeNull()
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('holds the segment first frame under prefers-reduced-motion', () => {
|
|
671
|
+
renderPet({ snapshot: { ...snapshot, bubble: '完成', phase: 'done', decoration } })
|
|
672
|
+
const el = ornament()
|
|
673
|
+
expect(el).not.toBeNull()
|
|
674
|
+
// The harness matchMedia mock reports reduced motion, so the ornament
|
|
675
|
+
// rests on the segment's first frame (column 2 of a 24px-wide frame).
|
|
676
|
+
expect(el!.style.backgroundPosition).toBe('-48px 0px')
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('yields the bubble to the whisper (voice moment hides the ornament)', () => {
|
|
680
|
+
renderPet({ snapshot: { ...snapshot, phase: 'thinking', whisper: '冲了冲了', decoration } })
|
|
681
|
+
expect(ornament()).toBeNull()
|
|
682
|
+
expect(document.body.textContent).toContain('冲了冲了')
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('renders no ornament when the host serves no decoration', () => {
|
|
686
|
+
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking' } })
|
|
687
|
+
expect(ornament()).toBeNull()
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('advances the ornament frames on the rAF loop and wraps when looping', () => {
|
|
691
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
692
|
+
matches: false,
|
|
693
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
694
|
+
onchange: null,
|
|
695
|
+
addEventListener: () => {},
|
|
696
|
+
removeEventListener: () => {},
|
|
697
|
+
addListener: () => {},
|
|
698
|
+
removeListener: () => {},
|
|
699
|
+
dispatchEvent: () => false,
|
|
700
|
+
})
|
|
701
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
702
|
+
const frames: FrameRequestCallback[] = []
|
|
703
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
704
|
+
frames.push(callback)
|
|
705
|
+
return frames.length
|
|
706
|
+
})
|
|
707
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
708
|
+
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
709
|
+
const el = ornament()!
|
|
710
|
+
// Both the sprite loop and the ornament loop schedule frames; step every
|
|
711
|
+
// pending callback together (the sprite's idle track never moves).
|
|
712
|
+
const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
|
|
713
|
+
// frameWidth = round(64 * 18 / 48) = 24 px; thinking binds frames 0..3.
|
|
714
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
715
|
+
act(() => { step(161) })
|
|
716
|
+
expect(el.style.backgroundPosition).toBe('-24px 0px')
|
|
717
|
+
act(() => { step(322) })
|
|
718
|
+
expect(el.style.backgroundPosition).toBe('-48px 0px')
|
|
719
|
+
act(() => { step(483) })
|
|
720
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
721
|
+
act(() => { step(644) })
|
|
722
|
+
// The looping segment wraps back to its first frame.
|
|
723
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('holds the segment last frame when the loop is off and stops scheduling', () => {
|
|
727
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
728
|
+
matches: false,
|
|
729
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
730
|
+
onchange: null,
|
|
731
|
+
addEventListener: () => {},
|
|
732
|
+
removeEventListener: () => {},
|
|
733
|
+
addListener: () => {},
|
|
734
|
+
removeListener: () => {},
|
|
735
|
+
dispatchEvent: () => false,
|
|
736
|
+
})
|
|
737
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
738
|
+
const frames: FrameRequestCallback[] = []
|
|
739
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
740
|
+
frames.push(callback)
|
|
741
|
+
return frames.length
|
|
742
|
+
})
|
|
743
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
744
|
+
renderPet({ snapshot: { ...snapshot, bubble: '完成', phase: 'done', decoration: { ...decoration, loop: false } } })
|
|
745
|
+
const el = ornament()!
|
|
746
|
+
const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
|
|
747
|
+
// done binds frames 2..3; the segment starts on frame 2.
|
|
748
|
+
expect(el.style.backgroundPosition).toBe('-48px 0px')
|
|
749
|
+
act(() => { step(161) })
|
|
750
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
751
|
+
// The ornament stopped scheduling (only the sprite loop remains pending).
|
|
752
|
+
expect(frames).toHaveLength(1)
|
|
753
|
+
act(() => { step(161) })
|
|
754
|
+
// The last frame holds.
|
|
755
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('does not restart the frame loop when an equal-content decoration re-renders', () => {
|
|
759
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
760
|
+
matches: false,
|
|
761
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
762
|
+
onchange: null,
|
|
763
|
+
addEventListener: () => {},
|
|
764
|
+
removeEventListener: () => {},
|
|
765
|
+
addListener: () => {},
|
|
766
|
+
removeListener: () => {},
|
|
767
|
+
dispatchEvent: () => false,
|
|
768
|
+
})
|
|
769
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
770
|
+
const frames: FrameRequestCallback[] = []
|
|
771
|
+
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
772
|
+
frames.push(callback)
|
|
773
|
+
return frames.length
|
|
774
|
+
})
|
|
775
|
+
const cancelSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
776
|
+
// The definition comes from '/api/pet/pets', fetched once — a state
|
|
777
|
+
// poll never replaces it, so both renders share one definition object.
|
|
778
|
+
const definition = petDefinition()
|
|
779
|
+
const { result } = renderPet({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
780
|
+
const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
|
|
781
|
+
act(() => { step(161) })
|
|
782
|
+
expect(ornament()!.style.backgroundPosition).toBe('-24px 0px')
|
|
783
|
+
const schedulesBefore = rafSpy.mock.calls.length
|
|
784
|
+
// The 2 s poll delivers a fresh JSON round-trip: identical content, new
|
|
785
|
+
// object identities everywhere. The loop must not cancel/restart.
|
|
786
|
+
const repolled: DecorationView = {
|
|
787
|
+
...decoration,
|
|
788
|
+
cell: { ...decoration.cell },
|
|
789
|
+
durations: [...decoration.durations],
|
|
790
|
+
phases: { ...decoration.phases },
|
|
791
|
+
}
|
|
792
|
+
result.rerender(<PetSprite {...petProps({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration: repolled } })} />)
|
|
793
|
+
act(() => { step(322) })
|
|
794
|
+
expect(ornament()!.style.backgroundPosition).toBe('-48px 0px')
|
|
795
|
+
expect(cancelSpy).not.toHaveBeenCalled()
|
|
796
|
+
// One reschedule per loop tick; no effect restart added new schedules.
|
|
797
|
+
expect(rafSpy.mock.calls.length).toBe(schedulesBefore + 2)
|
|
798
|
+
})
|
|
799
|
+
})
|