@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/client/PetSprite.tsx
CHANGED
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
13
|
-
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode, ReactPortal } from 'react'
|
|
13
|
+
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactElement, ReactNode, ReactPortal } from 'react'
|
|
14
14
|
import { createPortal } from 'react-dom'
|
|
15
15
|
import clsx from 'clsx'
|
|
16
16
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
17
17
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
18
18
|
import type { PetStateView } from '../service.ts'
|
|
19
19
|
import type { PetDefinition } from '../registry.ts'
|
|
20
|
+
import type { DecorationView } from '../contracts/status-decoration.ts'
|
|
20
21
|
import type { PetFeedback } from './pet-store.ts'
|
|
21
22
|
import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
|
|
22
23
|
import { sequenceFrameAt } from './sequences.ts'
|
|
23
|
-
import { animationForPhase, type PetAnimation } from '../state.ts'
|
|
24
|
+
import { animationForPhase, type ActivityPhase, type PetAnimation } from '../state.ts'
|
|
24
25
|
import { NS } from './locales.ts'
|
|
25
26
|
import styles from './pet.module.css'
|
|
26
27
|
|
|
@@ -63,6 +64,81 @@ function clampOffset(value: number, max: number): number {
|
|
|
63
64
|
return Math.max(0, Math.min(max, value))
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The status decoration ornament (pet-center M5, #567). Renders the active
|
|
69
|
+
* phase's frame segment as a CSS-background strip at a compact bubble
|
|
70
|
+
* height; prefers-reduced-motion holds the segment's first frame, and a
|
|
71
|
+
* missing or undecodable asset simply paints nothing (CSS background
|
|
72
|
+
* failure) — the bubble text is never disturbed. The span is aria-hidden;
|
|
73
|
+
* the bubble keeps its own semantics untouched.
|
|
74
|
+
*/
|
|
75
|
+
function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhase }): ReactElement | null {
|
|
76
|
+
const { decoration, phase } = props
|
|
77
|
+
const segment = decoration.phases[phase]
|
|
78
|
+
const shown = segment !== undefined && segment !== 'hide'
|
|
79
|
+
const segmentKey = segment !== undefined && segment !== 'hide' ? segment.from + ':' + segment.to : 'none'
|
|
80
|
+
const spanRef = useRef<HTMLSpanElement | null>(null)
|
|
81
|
+
const scale = 18 / decoration.cell.height
|
|
82
|
+
const frameWidth = Math.round(decoration.cell.width * scale)
|
|
83
|
+
const stripWidth = decoration.columns * frameWidth
|
|
84
|
+
// Value-stable dependency key: the host serves a fresh DecorationView
|
|
85
|
+
// object on every state poll (2 s), so the effect must not depend on the
|
|
86
|
+
// object identity — otherwise each poll would cancel and restart the
|
|
87
|
+
// frame loop and the animation would jump back to its first frame.
|
|
88
|
+
const durationsKey = decoration.durations.join(',')
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (segment === undefined || segment === 'hide') return
|
|
91
|
+
const el = spanRef.current
|
|
92
|
+
if (el === null) return
|
|
93
|
+
const position = (index: number): string => (-index * frameWidth) + 'px 0px'
|
|
94
|
+
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
95
|
+
el.style.backgroundPosition = position(segment.from)
|
|
96
|
+
if (reduceMotion) return
|
|
97
|
+
let raf = 0
|
|
98
|
+
let index = segment.from
|
|
99
|
+
let elapsed = 0
|
|
100
|
+
let last = performance.now()
|
|
101
|
+
const tick = (ts: number): void => {
|
|
102
|
+
const delta = ts - last
|
|
103
|
+
last = ts
|
|
104
|
+
elapsed += delta
|
|
105
|
+
const duration = decoration.durations[index] ?? 160
|
|
106
|
+
if (elapsed >= duration) {
|
|
107
|
+
elapsed = 0
|
|
108
|
+
if (index < segment.to) index += 1
|
|
109
|
+
else if (decoration.loop) index = segment.from
|
|
110
|
+
}
|
|
111
|
+
el.style.backgroundPosition = position(index)
|
|
112
|
+
// A non-looping segment settles on its last frame; stop scheduling
|
|
113
|
+
// instead of repainting the same position every frame.
|
|
114
|
+
if (!decoration.loop && index === segment.to) return
|
|
115
|
+
raf = requestAnimationFrame(tick)
|
|
116
|
+
}
|
|
117
|
+
raf = requestAnimationFrame(tick)
|
|
118
|
+
return () => cancelAnimationFrame(raf)
|
|
119
|
+
}, [shown, segmentKey, frameWidth, decoration.loop, durationsKey])
|
|
120
|
+
if (!shown) return null
|
|
121
|
+
return (
|
|
122
|
+
<span
|
|
123
|
+
ref={spanRef}
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
data-dsh-pet-decoration={decoration.id}
|
|
126
|
+
style={{
|
|
127
|
+
display: 'inline-block',
|
|
128
|
+
width: frameWidth,
|
|
129
|
+
height: 18,
|
|
130
|
+
marginRight: 6,
|
|
131
|
+
verticalAlign: 'middle',
|
|
132
|
+
flexShrink: 0,
|
|
133
|
+
backgroundImage: 'url(' + decoration.entryUrl + ')',
|
|
134
|
+
backgroundSize: stripWidth + 'px 18px',
|
|
135
|
+
backgroundRepeat: 'no-repeat',
|
|
136
|
+
backgroundPosition: '0px 0px',
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
66
142
|
/**
|
|
67
143
|
* The floating pet. The spritesheet frame advances on requestAnimationFrame
|
|
68
144
|
* with per-frame durations from the definition's tracks; the atlas image is
|
|
@@ -110,6 +186,33 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
110
186
|
const rows = definition.rows
|
|
111
187
|
const tracks = definition.tracks
|
|
112
188
|
const sequences = definition.sequences
|
|
189
|
+
// Hover-panel chrome from the pet's voice pack (pet-center M4, issue
|
|
190
|
+
// #677): every slot falls back to the i18n dictionary when unset. Stat
|
|
191
|
+
// formats carry {rank}/{n}/{points} placeholders the host validated.
|
|
192
|
+
const panel = definition.panel
|
|
193
|
+
const panelLabel = (slot: 'feed' | 'rename' | 'hide' | 'confirm', i18n: string): string =>
|
|
194
|
+
panel?.labels?.[slot] ?? i18n
|
|
195
|
+
const panelStat = (
|
|
196
|
+
slot: 'rank' | 'treats' | 'points',
|
|
197
|
+
i18nKey: 'pet.rank' | 'pet.treats' | 'pet.points',
|
|
198
|
+
values: Record<string, string | number>,
|
|
199
|
+
): string => {
|
|
200
|
+
const format = panel?.stats?.[slot] ?? props.t(i18nKey, values)
|
|
201
|
+
if (panel?.stats?.[slot] === undefined) return format
|
|
202
|
+
// The host whitelists {rank}/{n}/{points} in every stat slot, so a pack
|
|
203
|
+
// format may reference any of them; substitute all three live values
|
|
204
|
+
// (the slot's own value plus the siblings) instead of only the slot's.
|
|
205
|
+
const all: Record<string, string | number> = {
|
|
206
|
+
rank: snapshot?.affinity.rank ?? '?',
|
|
207
|
+
n: snapshot?.treats.stocked ?? 0,
|
|
208
|
+
points: snapshot?.affinity.points ?? 0,
|
|
209
|
+
}
|
|
210
|
+
let text = format
|
|
211
|
+
for (const [name, value] of Object.entries(all)) text = text.replaceAll('{' + name + '}', String(value))
|
|
212
|
+
return text
|
|
213
|
+
}
|
|
214
|
+
const panelShows = (action: 'feed' | 'rename' | 'hide'): boolean =>
|
|
215
|
+
panel?.actions === undefined || panel.actions.includes(action)
|
|
113
216
|
|
|
114
217
|
// Load the atlas once; the definition carries the authoritative per-row
|
|
115
218
|
// frame counts and per-track durations, so nothing else is fetched. A
|
|
@@ -287,6 +390,8 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
287
390
|
const whisper = feedback === null ? snapshot?.whisper : undefined
|
|
288
391
|
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
|
|
289
392
|
const displayName = snapshot?.name ?? definition.displayName
|
|
393
|
+
// The host-served status decoration (M5, #567); absent = text-only bubbles.
|
|
394
|
+
const decoration = snapshot?.decoration
|
|
290
395
|
|
|
291
396
|
// A settled session list can no longer stay pinned open.
|
|
292
397
|
useEffect(() => {
|
|
@@ -408,6 +513,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
408
513
|
title={props.t('pet.openSessionHint')}
|
|
409
514
|
onClick={() => { props.onOpenSession(session.sessionId) }}
|
|
410
515
|
>
|
|
516
|
+
{index === 0 && !speaksWhisper && decoration !== undefined && (
|
|
517
|
+
<StatusOrnament decoration={decoration} phase={phase} />
|
|
518
|
+
)}
|
|
411
519
|
{speaksWhisper ? whisper : session.bubble}
|
|
412
520
|
</button>
|
|
413
521
|
)
|
|
@@ -446,6 +554,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
446
554
|
role="status"
|
|
447
555
|
aria-live="polite"
|
|
448
556
|
>
|
|
557
|
+
{whisper === undefined && decoration !== undefined && (
|
|
558
|
+
<StatusOrnament decoration={decoration} phase={phase} />
|
|
559
|
+
)}
|
|
449
560
|
{whisper ?? statusBubble}
|
|
450
561
|
</div>
|
|
451
562
|
)}
|
|
@@ -507,39 +618,45 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
507
618
|
}
|
|
508
619
|
}}
|
|
509
620
|
>
|
|
510
|
-
{props.t('pet.confirm')}
|
|
621
|
+
{panelLabel('confirm', props.t('pet.confirm'))}
|
|
511
622
|
</button>
|
|
512
623
|
</div>
|
|
513
624
|
) : (
|
|
514
625
|
<>
|
|
515
626
|
<div className={styles.rankRow}>
|
|
516
627
|
<span className={styles.nameCell}>{displayName}</span>
|
|
517
|
-
<span className={styles.statRank}>{
|
|
628
|
+
<span className={styles.statRank}>{panelStat('rank', 'pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
518
629
|
</div>
|
|
519
630
|
<div className={styles.rankRow}>
|
|
520
|
-
<span className={styles.statTreats}>{
|
|
521
|
-
<span className={styles.statPoints}>{
|
|
631
|
+
<span className={styles.statTreats}>{panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
|
|
632
|
+
<span className={styles.statPoints}>{panelStat('points', 'pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
|
|
522
633
|
</div>
|
|
523
634
|
<div className={styles.actions}>
|
|
524
|
-
|
|
525
|
-
{props.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
635
|
+
{panelShows('feed') && (
|
|
636
|
+
<button type="button" className={styles.action} onClick={props.onFeed}>
|
|
637
|
+
{panelLabel('feed', props.t('pet.feed'))}
|
|
638
|
+
</button>
|
|
639
|
+
)}
|
|
640
|
+
{panelShows('rename') && (
|
|
641
|
+
<button
|
|
642
|
+
type="button"
|
|
643
|
+
className={styles.action}
|
|
644
|
+
onClick={() => {
|
|
645
|
+
// Cancel any pending hide so the rename box cannot
|
|
646
|
+
// unmount right as the user starts typing (#303).
|
|
647
|
+
clearHideTimer()
|
|
648
|
+
setNameDraft(displayName)
|
|
649
|
+
setRenaming(true)
|
|
650
|
+
}}
|
|
651
|
+
>
|
|
652
|
+
{panelLabel('rename', props.t('pet.rename'))}
|
|
653
|
+
</button>
|
|
654
|
+
)}
|
|
655
|
+
{panelShows('hide') && (
|
|
656
|
+
<button type="button" className={styles.action} onClick={props.onHide}>
|
|
657
|
+
{panelLabel('hide', props.t('pet.hide'))}
|
|
658
|
+
</button>
|
|
659
|
+
)}
|
|
543
660
|
</div>
|
|
544
661
|
</>
|
|
545
662
|
)}
|
package/src/client/locales.ts
CHANGED
|
@@ -34,6 +34,8 @@ export const zh = {
|
|
|
34
34
|
'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
|
|
35
35
|
'settings.enabled': '启用宠物',
|
|
36
36
|
'settings.enabledHint': '关闭后隐藏宠物并停止轮询,可在设置里重新启用。',
|
|
37
|
+
'settings.decoration': '状态装饰',
|
|
38
|
+
'settings.decorationHint': '在宠物状态气泡里显示喷水鲸鱼等状态装饰;关闭后气泡只剩文字。',
|
|
37
39
|
'settings.visible': '显示宠物',
|
|
38
40
|
'settings.visibleHint': '关闭后宠物隐藏,可从聊天输入区重新召唤。',
|
|
39
41
|
'settings.size': '大小(px)',
|
|
@@ -87,6 +89,8 @@ export const en = {
|
|
|
87
89
|
'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
|
|
88
90
|
'settings.enabled': 'Enable the pet',
|
|
89
91
|
'settings.enabledHint': 'When off, the pet hides and polling stops; re-enable it here.',
|
|
92
|
+
'settings.decoration': 'Status decoration',
|
|
93
|
+
'settings.decorationHint': 'Show ornaments like the spouting whale inside the pet status bubbles; when off, bubbles stay text-only.',
|
|
90
94
|
'settings.visible': 'Show the pet',
|
|
91
95
|
'settings.visibleHint': 'When off, the pet hides; summon it again from the input row.',
|
|
92
96
|
'settings.size': 'Size (px)',
|
|
@@ -75,13 +75,17 @@ function fakeApp(): FakeApp {
|
|
|
75
75
|
return app
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function fakeVendor(
|
|
78
|
+
function fakeVendor(
|
|
79
|
+
model: FakeModel,
|
|
80
|
+
app: FakeApp,
|
|
81
|
+
from: (source: string, options?: Record<string, unknown>) => Promise<FakeModel> = () => Promise.resolve(model),
|
|
82
|
+
): unknown {
|
|
79
83
|
return {
|
|
80
84
|
Application: class { constructor() { return app } },
|
|
81
85
|
extensions: { add: () => {} },
|
|
82
86
|
Live2DPlugin: {},
|
|
83
87
|
configureCubismSDK: () => {},
|
|
84
|
-
Live2DModel: { from
|
|
88
|
+
Live2DModel: { from },
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -142,6 +146,23 @@ describe('live2dRenderer', () => {
|
|
|
142
146
|
expect(app.destroyed).toBe(1)
|
|
143
147
|
})
|
|
144
148
|
|
|
149
|
+
it('uses one automatic texture LOD instead of the default full mip chain', async () => {
|
|
150
|
+
const model = fakeModel()
|
|
151
|
+
const app = fakeApp()
|
|
152
|
+
const from = vi.fn(async () => model)
|
|
153
|
+
runtime.vendor = fakeVendor(model, app, from)
|
|
154
|
+
const { ctx } = makeCtx()
|
|
155
|
+
|
|
156
|
+
live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
157
|
+
await flush()
|
|
158
|
+
|
|
159
|
+
expect(from).toHaveBeenCalledWith(CONFIG.modelUrl, {
|
|
160
|
+
autoHitTest: false,
|
|
161
|
+
autoFocus: false,
|
|
162
|
+
textureOptions: { lod: 'single-auto' },
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
145
166
|
it('falls back to the idle group when a mapped group is absent from the model', async () => {
|
|
146
167
|
const model = fakeModel({ Idle: [{}] })
|
|
147
168
|
runtime.vendor = fakeVendor(model, fakeApp())
|
|
@@ -60,6 +60,15 @@ export interface Live2dRendererHandle extends PetRendererHandle {
|
|
|
60
60
|
/** The de-facto tap-motion group of Cubism sample models. */
|
|
61
61
|
const TAP_GROUP = 'TapBody'
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Keep one screen-appropriate atlas LOD instead of asking Pixi for the
|
|
65
|
+
* engine's default full mip chain. A user model can legitimately carry an
|
|
66
|
+
* 8192px texture while the pet itself is only a few hundred pixels tall;
|
|
67
|
+
* `single-auto` preserves the source for larger renders and generates one
|
|
68
|
+
* downsampled atlas only when the effective on-screen scale warrants it.
|
|
69
|
+
*/
|
|
70
|
+
const TEXTURE_OPTIONS = { lod: 'single-auto' } as const
|
|
71
|
+
|
|
63
72
|
let vendorConfigured = false
|
|
64
73
|
|
|
65
74
|
/** Configure pixi extensions + the Cubism SDK once per page. */
|
|
@@ -150,7 +159,11 @@ export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
|
|
|
150
159
|
pixiApp.canvas.style.width = '100%'
|
|
151
160
|
pixiApp.canvas.style.height = '100%'
|
|
152
161
|
ctx.container.appendChild(pixiApp.canvas)
|
|
153
|
-
const loaded = await vendor.Live2DModel.from(config.modelUrl, {
|
|
162
|
+
const loaded = await vendor.Live2DModel.from(config.modelUrl, {
|
|
163
|
+
autoHitTest: false,
|
|
164
|
+
autoFocus: false,
|
|
165
|
+
textureOptions: TEXTURE_OPTIONS,
|
|
166
|
+
})
|
|
154
167
|
if (disposed) {
|
|
155
168
|
pixiApp.destroy(true, { children: true })
|
|
156
169
|
return
|
|
@@ -333,9 +333,11 @@ export class CardForm<T> {
|
|
|
333
333
|
const valid = plan.filter(item => item.run !== undefined)
|
|
334
334
|
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
335
335
|
const plannedWrites = valid.map(item => item.op)
|
|
336
|
-
// Snapshot the
|
|
337
|
-
// flight
|
|
338
|
-
|
|
336
|
+
// Snapshot the staged entries this save writes, so an edit staged while it
|
|
337
|
+
// is in flight (which replaces the same key) survives: only delete the key
|
|
338
|
+
// when the entry is still the one this save started from.
|
|
339
|
+
const pending = new Map<string, StagedEdit | undefined>()
|
|
340
|
+
for (const item of plan) pending.set(item.field, this.staged.get(item.field))
|
|
339
341
|
this.saving = true
|
|
340
342
|
this.failed = false
|
|
341
343
|
this.failedReason = undefined
|
|
@@ -356,11 +358,11 @@ export class CardForm<T> {
|
|
|
356
358
|
if (await item.run!()) landed.add(item.field)
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
|
-
for (const field of
|
|
360
|
-
if (landed.has(field)) this.staged.delete(field)
|
|
361
|
+
for (const [field, before] of pending) {
|
|
362
|
+
if (landed.has(field) && this.staged.get(field) === before) this.staged.delete(field)
|
|
361
363
|
}
|
|
362
364
|
this.saving = false
|
|
363
|
-
this.failed = landed.size !==
|
|
365
|
+
this.failed = landed.size !== pending.size
|
|
364
366
|
this.publish()
|
|
365
367
|
}
|
|
366
368
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-decoration contract — the L3 extension slot for small status
|
|
3
|
+
* ornaments (issue #623 milestone M5, protocol #567, first reference
|
|
4
|
+
* implementation #463). A decoration is an INDEPENDENT content entry (own
|
|
5
|
+
* id, own directory, own descriptor) whose PNG/WebP sprite strip ornaments
|
|
6
|
+
* the pet center's status bubble chrome; it never touches the pet
|
|
7
|
+
* manifests and never changes the bubble's semantics.
|
|
8
|
+
*
|
|
9
|
+
* Adopted disciplines (#623): entry assets are PNG/WebP sprite strips only
|
|
10
|
+
* (no SVG, no CSS animation); the bubble's own role=status/aria-live (or
|
|
11
|
+
* session-bubble button semantics) always stays intact and the ornament is
|
|
12
|
+
* aria-hidden; load failure or prefers-reduced-motion degrades to no
|
|
13
|
+
* ornament or the static first frame.
|
|
14
|
+
*
|
|
15
|
+
* The ActivityPhase stream the pet center owns drives the ornament: each
|
|
16
|
+
* phase binds to a frame segment (inclusive from/to indices into the
|
|
17
|
+
* strip) or to 'hide' (no ornament for that phase; the default).
|
|
18
|
+
* @module @linxin666/dsh-pet/contracts/status-decoration
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ActivityPhase } from '../state.ts'
|
|
22
|
+
|
|
23
|
+
/** Contract version decorations declare against (independent of manifests). */
|
|
24
|
+
export const PET_DECORATION_API_VERSION = 'x-org.linxin666.pet-center/status-decoration-v1' as const
|
|
25
|
+
|
|
26
|
+
/** One phase binding: a frame segment, or hidden. */
|
|
27
|
+
export type PhaseSegment = { from: number; to: number } | 'hide'
|
|
28
|
+
|
|
29
|
+
/** The ActivityPhase -> frame-segment binding table (unmapped phases hide). */
|
|
30
|
+
export type PhaseBindings = Partial<Record<ActivityPhase, PhaseSegment>>
|
|
31
|
+
|
|
32
|
+
/** Normalized decoration descriptor as the registry consumes it. */
|
|
33
|
+
export interface DecorationManifest {
|
|
34
|
+
decorationManifestVersion: 1
|
|
35
|
+
id: string
|
|
36
|
+
displayName: string
|
|
37
|
+
license: string
|
|
38
|
+
/** Strip path relative to the descriptor directory (PNG/WebP). */
|
|
39
|
+
entry: string
|
|
40
|
+
cell: { width: number; height: number }
|
|
41
|
+
columns: number
|
|
42
|
+
/** Per-frame duration ms (same length as columns, or a single constant). */
|
|
43
|
+
durations: number[]
|
|
44
|
+
loop: boolean
|
|
45
|
+
phases: PhaseBindings
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One structured diagnostic emitted while parsing a descriptor. */
|
|
49
|
+
export interface DecorationDiagnostic {
|
|
50
|
+
level: 'error' | 'warning'
|
|
51
|
+
message: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse outcome: a usable descriptor plus diagnostics, or rejection. */
|
|
55
|
+
export type DecorationManifestParse =
|
|
56
|
+
| { ok: true; manifest: DecorationManifest; diagnostics: DecorationDiagnostic[] }
|
|
57
|
+
| { ok: false; diagnostics: DecorationDiagnostic[] }
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The decoration block the host serves inside the pet state view — exactly
|
|
61
|
+
* what the browser half needs to render the ornament, nothing more. The
|
|
62
|
+
* apiVersion rides the wire so a future protocol revision can be detected
|
|
63
|
+
* and negotiated by clients (review-spd follow-up, pet-center M5).
|
|
64
|
+
*/
|
|
65
|
+
export interface DecorationView {
|
|
66
|
+
/** The protocol version this view speaks (PET_DECORATION_API_VERSION). */
|
|
67
|
+
apiVersion: typeof PET_DECORATION_API_VERSION
|
|
68
|
+
id: string
|
|
69
|
+
/** Same-origin URL prefix of the decoration's assets. */
|
|
70
|
+
assetBase: string
|
|
71
|
+
/** Browser URL of the strip. */
|
|
72
|
+
entryUrl: string
|
|
73
|
+
cell: { width: number; height: number }
|
|
74
|
+
columns: number
|
|
75
|
+
durations: number[]
|
|
76
|
+
loop: boolean
|
|
77
|
+
phases: PhaseBindings
|
|
78
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-decoration manifest tests (pet-center M5, #567): fail-closed
|
|
3
|
+
* structure, warn-and-drop content, duration normalization and the
|
|
4
|
+
* PNG/WebP entry discipline.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { KNOWN_DECORATION_TOP_LEVEL, parseDecorationManifest, safeDecorationEntry } from './decoration.ts'
|
|
10
|
+
import { PET_ACTIVITY_PHASES } from './manifest-v2.ts'
|
|
11
|
+
import { petPackageRoot } from './registry.ts'
|
|
12
|
+
|
|
13
|
+
function parse(raw: unknown) {
|
|
14
|
+
return parseDecorationManifest(raw, 'test/decoration.json')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function valid(): Record<string, unknown> {
|
|
18
|
+
return {
|
|
19
|
+
decorationManifestVersion: 1,
|
|
20
|
+
id: 'whale',
|
|
21
|
+
displayName: '喷水鲸鱼',
|
|
22
|
+
license: 'MIT',
|
|
23
|
+
entry: 'whale-frames.png',
|
|
24
|
+
cell: { width: 64, height: 48 },
|
|
25
|
+
columns: 4,
|
|
26
|
+
frameMs: 140,
|
|
27
|
+
loop: true,
|
|
28
|
+
phases: {
|
|
29
|
+
idle: 'hide',
|
|
30
|
+
waiting: { from: 0, to: 1 },
|
|
31
|
+
thinking: { from: 0, to: 3 },
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('parseDecorationManifest structure', () => {
|
|
37
|
+
it('accepts a valid descriptor with duration and display-name defaults', () => {
|
|
38
|
+
const verdict = parse(valid())
|
|
39
|
+
expect(verdict.ok).toBe(true)
|
|
40
|
+
if (!verdict.ok) return
|
|
41
|
+
expect(verdict.manifest.durations).toEqual([140, 140, 140, 140])
|
|
42
|
+
expect(verdict.manifest.loop).toBe(true)
|
|
43
|
+
expect(verdict.manifest.phases.thinking).toEqual({ from: 0, to: 3 })
|
|
44
|
+
expect(verdict.manifest.phases.idle).toBe('hide')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('defaults displayName to the id and frameMs to 120', () => {
|
|
48
|
+
const manifest = { ...valid() }
|
|
49
|
+
delete manifest.displayName
|
|
50
|
+
delete manifest.frameMs
|
|
51
|
+
const verdict = parse(manifest)
|
|
52
|
+
expect(verdict.ok).toBe(true)
|
|
53
|
+
if (!verdict.ok) return
|
|
54
|
+
expect(verdict.manifest.displayName).toBe('whale')
|
|
55
|
+
expect(verdict.manifest.durations).toEqual([120, 120, 120, 120])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('rejects non-object roots, wrong versions and unknown top-level fields', () => {
|
|
59
|
+
expect(parse(['x']).ok).toBe(false)
|
|
60
|
+
const wrongVersion = { ...valid(), decorationManifestVersion: 2 }
|
|
61
|
+
const verdict = parse(wrongVersion)
|
|
62
|
+
expect(verdict.ok).toBe(false)
|
|
63
|
+
if (verdict.ok) return
|
|
64
|
+
expect(verdict.diagnostics.some(d => d.message.includes('decorationManifestVersion'))).toBe(true)
|
|
65
|
+
const unknown = { ...valid(), mystery: true }
|
|
66
|
+
expect(parse(unknown).ok).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('rejects unsafe or non-image entries', () => {
|
|
70
|
+
for (const entry of ['../etc/passwd', '/abs.png', 'a.svg', 'a.css', 'x\\y.png']) {
|
|
71
|
+
const verdict = parse({ ...valid(), entry })
|
|
72
|
+
expect(verdict.ok).toBe(false)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('rejects out-of-range geometry and missing license/id', () => {
|
|
77
|
+
expect(parse({ ...valid(), cell: { width: 999, height: 48 } }).ok).toBe(false)
|
|
78
|
+
expect(parse({ ...valid(), columns: 99 }).ok).toBe(false)
|
|
79
|
+
expect(parse({ ...valid(), license: '' }).ok).toBe(false)
|
|
80
|
+
expect(parse({ ...valid(), id: 'Bad Id' }).ok).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('parseDecorationManifest content (warn-and-drop)', () => {
|
|
85
|
+
it('drops unknown phase keys and out-of-range segments with warnings', () => {
|
|
86
|
+
const verdict = parse({
|
|
87
|
+
...valid(),
|
|
88
|
+
phases: {
|
|
89
|
+
...(valid().phases as Record<string, unknown>),
|
|
90
|
+
bogus: { from: 0, to: 1 },
|
|
91
|
+
waiting: { from: 2, to: 9 },
|
|
92
|
+
review: { from: 3, to: 1 },
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
expect(verdict.ok).toBe(true)
|
|
96
|
+
if (!verdict.ok) return
|
|
97
|
+
expect(verdict.manifest.phases.done).toBeUndefined()
|
|
98
|
+
expect(verdict.manifest.phases.waiting).toBeUndefined()
|
|
99
|
+
expect(verdict.manifest.phases.review).toBeUndefined()
|
|
100
|
+
expect(verdict.diagnostics.some(d => d.level === 'warning' && d.message.includes('ignored'))).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('warns when no phase shows the ornament', () => {
|
|
104
|
+
const verdict = parse({ ...valid(), phases: { idle: 'hide' } })
|
|
105
|
+
expect(verdict.ok).toBe(true)
|
|
106
|
+
expect(verdict.diagnostics.some(d => d.level === 'warning' && d.message.includes('stays hidden'))).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('falls back to the constant frameMs when durations has the wrong length', () => {
|
|
110
|
+
const verdict = parse({ ...valid(), durations: [100] })
|
|
111
|
+
expect(verdict.ok).toBe(true)
|
|
112
|
+
if (!verdict.ok) return
|
|
113
|
+
expect(verdict.manifest.durations).toEqual([140, 140, 140, 140])
|
|
114
|
+
expect(verdict.diagnostics.some(d => d.level === 'warning' && d.message.includes('durations'))).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('keeps a well-sized durations array', () => {
|
|
118
|
+
const verdict = parse({ ...valid(), durations: [120, 130, 140, 150] })
|
|
119
|
+
expect(verdict.ok).toBe(true)
|
|
120
|
+
if (!verdict.ok) return
|
|
121
|
+
expect(verdict.manifest.durations).toEqual([120, 130, 140, 150])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('warns and defaults to looping when loop is not a boolean', () => {
|
|
125
|
+
const verdict = parse({ ...valid(), loop: 'yes' })
|
|
126
|
+
expect(verdict.ok).toBe(true)
|
|
127
|
+
if (!verdict.ok) return
|
|
128
|
+
expect(verdict.manifest.loop).toBe(true)
|
|
129
|
+
expect(verdict.diagnostics.some(d => d.level === 'warning' && d.message.includes('loop'))).toBe(true)
|
|
130
|
+
const off = parse({ ...valid(), loop: false })
|
|
131
|
+
expect(off.ok && off.manifest.loop).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('safeDecorationEntry', () => {
|
|
136
|
+
it('accepts safe relative PNG/WebP paths and rejects everything else', () => {
|
|
137
|
+
expect(safeDecorationEntry('frames.webp')).toBe('frames.webp')
|
|
138
|
+
expect(safeDecorationEntry('a/b.png')).toBe('a/b.png')
|
|
139
|
+
expect(safeDecorationEntry('')).toBeUndefined()
|
|
140
|
+
expect(safeDecorationEntry('a/../b.png')).toBeUndefined()
|
|
141
|
+
expect(safeDecorationEntry('/tmp/x.png')).toBeUndefined()
|
|
142
|
+
expect(safeDecorationEntry('x.svg')).toBeUndefined()
|
|
143
|
+
expect(safeDecorationEntry('x')).toBeUndefined()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('rejects case-mismatched extensions (the route serves paths verbatim)', () => {
|
|
147
|
+
// A lenient case check would pass validation here, but the asset route
|
|
148
|
+
// matches the declared path exactly and 403s on case-sensitive hosts.
|
|
149
|
+
expect(safeDecorationEntry('img/FRAMES.PNG')).toBeUndefined()
|
|
150
|
+
expect(safeDecorationEntry('img/frames.WebP')).toBeUndefined()
|
|
151
|
+
expect(safeDecorationEntry('img/frames.png')).toBe('img/frames.png')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('status-decoration schema file drift lock', () => {
|
|
156
|
+
const schema = JSON.parse(readFileSync(
|
|
157
|
+
join(petPackageRoot(import.meta.url), 'contracts', 'status-decoration-v1.schema.json'),
|
|
158
|
+
'utf8',
|
|
159
|
+
)) as {
|
|
160
|
+
required: string[]
|
|
161
|
+
properties: Record<string, { const?: number; properties?: Record<string, unknown> }>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
it('locks the schema top-level fields to the validator allow-list', () => {
|
|
165
|
+
expect(new Set(Object.keys(schema.properties))).toEqual(KNOWN_DECORATION_TOP_LEVEL)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('locks the required set and the version const', () => {
|
|
169
|
+
expect([...schema.required].sort()).toEqual([
|
|
170
|
+
'cell', 'columns', 'decorationManifestVersion', 'entry', 'id', 'license',
|
|
171
|
+
])
|
|
172
|
+
expect(schema.properties.decorationManifestVersion?.const).toBe(1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('locks the phases key set to the ActivityPhase stream', () => {
|
|
176
|
+
expect(new Set(Object.keys(schema.properties.phases?.properties ?? {}))).toEqual(new Set(PET_ACTIVITY_PHASES))
|
|
177
|
+
})
|
|
178
|
+
})
|