@linxin666/dsh-pet 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +25 -7
  3. package/README.zh.md +25 -7
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/lib/client.js +830 -26
  17. package/lib/client.js.map +1 -1
  18. package/lib/index.js +1226 -24
  19. package/lib/types/client/PetDockEntry.d.ts +3 -0
  20. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  21. package/lib/types/client/PetDockEntry.js +18 -2
  22. package/lib/types/client/PetSprite.d.ts +23 -0
  23. package/lib/types/client/PetSprite.d.ts.map +1 -1
  24. package/lib/types/client/PetSprite.js +34 -29
  25. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  26. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PluginSettingsCard.js +2 -2
  28. package/lib/types/client/drag-stream.d.ts +19 -0
  29. package/lib/types/client/drag-stream.d.ts.map +1 -0
  30. package/lib/types/client/drag-stream.js +26 -0
  31. package/lib/types/client/gameplay-hud.d.ts +50 -0
  32. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  33. package/lib/types/client/gameplay-hud.js +274 -0
  34. package/lib/types/client/index.d.ts +1 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/lib/types/client/index.js +16 -0
  37. package/lib/types/client/locales.d.ts +34 -0
  38. package/lib/types/client/locales.d.ts.map +1 -1
  39. package/lib/types/client/locales.js +36 -0
  40. package/lib/types/client/pet-store.d.ts +7 -1
  41. package/lib/types/client/pet-store.d.ts.map +1 -1
  42. package/lib/types/client/pet-store.js +4 -0
  43. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  44. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  45. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  46. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  47. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  48. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  49. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  50. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  51. package/lib/types/client/renderers/frames2d.js +196 -0
  52. package/lib/types/client/settings-form.d.ts +1 -1
  53. package/lib/types/client/settings-form.js +1 -1
  54. package/lib/types/client/telemetry.d.ts +27 -0
  55. package/lib/types/client/telemetry.d.ts.map +1 -0
  56. package/lib/types/client/telemetry.js +97 -0
  57. package/lib/types/defaults.d.ts +6 -0
  58. package/lib/types/defaults.d.ts.map +1 -0
  59. package/lib/types/defaults.js +5 -0
  60. package/lib/types/dsh-home.d.ts.map +1 -1
  61. package/lib/types/dsh-home.js +9 -3
  62. package/lib/types/gameplay.d.ts +182 -0
  63. package/lib/types/gameplay.d.ts.map +1 -0
  64. package/lib/types/gameplay.js +618 -0
  65. package/lib/types/http.d.ts +1 -1
  66. package/lib/types/http.js +1 -1
  67. package/lib/types/index.d.ts +1 -1
  68. package/lib/types/index.js +1 -1
  69. package/lib/types/ledger.d.ts +14 -0
  70. package/lib/types/ledger.d.ts.map +1 -1
  71. package/lib/types/ledger.js +30 -0
  72. package/lib/types/loopback.d.ts +1 -1
  73. package/lib/types/loopback.js +1 -1
  74. package/lib/types/manifest-v2.d.ts +38 -1
  75. package/lib/types/manifest-v2.d.ts.map +1 -1
  76. package/lib/types/manifest-v2.js +182 -2
  77. package/lib/types/mount-once.d.ts +1 -1
  78. package/lib/types/mount-once.js +2 -2
  79. package/lib/types/persist.d.ts +4 -4
  80. package/lib/types/persist.d.ts.map +1 -1
  81. package/lib/types/persist.js +41 -4
  82. package/lib/types/registry.d.ts +29 -0
  83. package/lib/types/registry.d.ts.map +1 -1
  84. package/lib/types/registry.js +170 -1
  85. package/lib/types/routes.d.ts.map +1 -1
  86. package/lib/types/routes.js +22 -0
  87. package/lib/types/service.d.ts +56 -0
  88. package/lib/types/service.d.ts.map +1 -1
  89. package/lib/types/service.js +181 -0
  90. package/package.json +6 -3
  91. package/src/client/PetDockEntry.test.tsx +6 -0
  92. package/src/client/PetDockEntry.tsx +32 -1
  93. package/src/client/PetSprite.test.tsx +36 -6
  94. package/src/client/PetSprite.tsx +42 -21
  95. package/src/client/PluginSettingsCard.tsx +4 -2
  96. package/src/client/drag-stream.ts +35 -0
  97. package/src/client/gameplay-hud.test.tsx +338 -0
  98. package/src/client/gameplay-hud.tsx +403 -0
  99. package/src/client/index.ts +23 -2
  100. package/src/client/locales.ts +36 -0
  101. package/src/client/pet-store.ts +10 -1
  102. package/src/client/pet.module.css +192 -29
  103. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  104. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  105. package/src/client/renderers/frames2d.test.ts +137 -0
  106. package/src/client/renderers/frames2d.ts +216 -0
  107. package/src/client/settings-card.module.css +5 -0
  108. package/src/client/settings-form.ts +1 -1
  109. package/src/client/telemetry.ts +105 -0
  110. package/src/defaults.ts +7 -0
  111. package/src/dsh-home.ts +9 -3
  112. package/src/gameplay.test.ts +239 -0
  113. package/src/gameplay.ts +692 -0
  114. package/src/http.ts +1 -1
  115. package/src/index.ts +1 -1
  116. package/src/ledger.test.ts +16 -0
  117. package/src/ledger.ts +31 -0
  118. package/src/loopback.ts +1 -1
  119. package/src/manifest-v2.test.ts +130 -1
  120. package/src/manifest-v2.ts +181 -2
  121. package/src/mount-once.ts +2 -2
  122. package/src/persist.test.ts +3 -0
  123. package/src/persist.ts +43 -6
  124. package/src/registry.test.ts +168 -1
  125. package/src/registry.ts +199 -1
  126. package/src/routes.ts +19 -0
  127. package/src/service.ts +208 -0
@@ -21,33 +21,6 @@
21
21
  flex: 0 0 auto;
22
22
  }
23
23
 
24
- .closeButton {
25
- position: absolute;
26
- top: 4px;
27
- right: 4px;
28
- z-index: 2;
29
- width: 24px;
30
- height: 24px;
31
- padding: 0;
32
- border: 1px solid rgba(226, 232, 255, 0.72);
33
- border-radius: 999px;
34
- color: #f8fafc;
35
- background: rgba(7, 11, 26, 0.78);
36
- font: 600 18px/20px sans-serif;
37
- cursor: pointer;
38
- touch-action: manipulation;
39
- transition: background 120ms ease, box-shadow 120ms ease;
40
- }
41
-
42
- .closeButton:hover {
43
- background: rgba(44, 62, 126, 0.95);
44
- }
45
-
46
- .closeButton:focus-visible {
47
- outline: none;
48
- box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.95);
49
- }
50
-
51
24
  .bubble {
52
25
  position: absolute;
53
26
  bottom: 100%;
@@ -436,8 +409,198 @@
436
409
 
437
410
  .action,
438
411
  .summon,
439
- .bubbleMore,
440
- .closeButton {
412
+ .bubbleMore {
441
413
  transition: none;
442
414
  }
443
415
  }
416
+
417
+ /* ------------------------------------------------------------------ */
418
+ /* Gameplay HUD (miku-pet generalization): toggle, card, bars, shop. */
419
+ /* ------------------------------------------------------------------ */
420
+
421
+ .gameplayHud {
422
+ position: absolute;
423
+ left: 0;
424
+ bottom: 0;
425
+ width: 0;
426
+ height: 0;
427
+ }
428
+
429
+ .gameplayModeChip {
430
+ position: absolute;
431
+ left: 0;
432
+ top: 0;
433
+ transform: translateY(-100%) translateY(-4px);
434
+ padding: 1px 8px;
435
+ border-radius: 999px;
436
+ background: rgba(126, 152, 255, 0.18);
437
+ color: #cdd7ff;
438
+ font-size: 10px;
439
+ white-space: nowrap;
440
+ pointer-events: none;
441
+ }
442
+
443
+ .gameplayCard {
444
+ position: absolute;
445
+ left: 0;
446
+ bottom: 0;
447
+ /* The card opens BESIDE the sprite (right side when the space allows,
448
+ otherwise left), vertically centered and clamped to the sprite's height,
449
+ so it never enters the bubble band above or the hover-panel band below.
450
+ The transform and max-height are set inline from measured space by the
451
+ GameplayHud placement effect; these are pre-measure fallbacks. */
452
+ transform: none;
453
+ max-height: 340px;
454
+ overflow-y: auto;
455
+ min-width: 180px;
456
+ padding: 10px 12px;
457
+ border: 1px solid rgba(126, 152, 255, 0.25);
458
+ border-radius: 10px;
459
+ background: rgba(7, 11, 26, 0.92);
460
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
461
+ color: #dfe6ff;
462
+ font-size: 12px;
463
+ }
464
+
465
+ .gameplayBars {
466
+ display: flex;
467
+ flex-direction: column;
468
+ gap: 4px;
469
+ margin-bottom: 8px;
470
+ }
471
+
472
+ .gameplayBarRow {
473
+ display: flex;
474
+ align-items: center;
475
+ gap: 6px;
476
+ }
477
+
478
+ .gameplayBarLabel {
479
+ width: 34px;
480
+ flex-shrink: 0;
481
+ color: #9aa8d8;
482
+ }
483
+
484
+ .gameplayBarTrack {
485
+ flex: 1;
486
+ height: 6px;
487
+ border-radius: 3px;
488
+ background: rgba(126, 152, 255, 0.15);
489
+ overflow: hidden;
490
+ }
491
+
492
+ .gameplayBarFill {
493
+ display: block;
494
+ height: 100%;
495
+ border-radius: 3px;
496
+ background: linear-gradient(90deg, #7e98ff, #9db4ff);
497
+ transition: width 300ms ease;
498
+ }
499
+
500
+ .gameplayActions {
501
+ display: flex;
502
+ flex-wrap: wrap;
503
+ gap: 6px;
504
+ }
505
+
506
+ .gameplayShopItems {
507
+ display: grid;
508
+ grid-template-columns: repeat(2, minmax(0, 1fr));
509
+ gap: 8px;
510
+ margin-bottom: 8px;
511
+ }
512
+
513
+ .gameplayShopItem {
514
+ display: flex;
515
+ flex-direction: column;
516
+ align-items: center;
517
+ gap: 4px;
518
+ padding: 8px 6px;
519
+ border: 1px solid rgba(126, 152, 255, 0.25);
520
+ border-radius: 8px;
521
+ background: rgba(126, 152, 255, 0.08);
522
+ color: inherit;
523
+ cursor: pointer;
524
+ }
525
+
526
+ .gameplayShopItem:hover {
527
+ border-color: rgba(126, 152, 255, 0.7);
528
+ background: rgba(126, 152, 255, 0.16);
529
+ }
530
+
531
+ .gameplayShopItemImage {
532
+ width: 36px;
533
+ height: 36px;
534
+ object-fit: contain;
535
+ image-rendering: pixelated;
536
+ pointer-events: none;
537
+ }
538
+
539
+ .gameplayShopItemLabel {
540
+ font-size: 11px;
541
+ text-align: center;
542
+ }
543
+
544
+ .gameplayShopItemPrice {
545
+ font-size: 10px;
546
+ color: #ffd27e;
547
+ }
548
+
549
+ .gameplayClose {
550
+ position: absolute;
551
+ top: 4px;
552
+ right: 8px;
553
+ border: none;
554
+ background: none;
555
+ color: #9aa8d8;
556
+ font-size: 14px;
557
+ cursor: pointer;
558
+ padding: 0 2px;
559
+ }
560
+
561
+ .gameplayClose:hover {
562
+ color: #ffffff;
563
+ }
564
+
565
+ .gameplayFloat {
566
+ position: absolute;
567
+ left: 50%;
568
+ top: -8px;
569
+ transform: translateX(-50%);
570
+ padding: 2px 10px;
571
+ border-radius: 999px;
572
+ background: rgba(255, 210, 126, 0.92);
573
+ color: #3a2a00;
574
+ font-size: 11px;
575
+ white-space: nowrap;
576
+ pointer-events: none;
577
+ animation: pet-gameplay-float 1100ms ease-out forwards;
578
+ }
579
+
580
+ @keyframes pet-gameplay-float {
581
+ from {
582
+ opacity: 0;
583
+ transform: translateX(-50%) translateY(6px);
584
+ }
585
+
586
+ 20% {
587
+ opacity: 1;
588
+ }
589
+
590
+ to {
591
+ opacity: 0;
592
+ transform: translateX(-50%) translateY(-18px);
593
+ }
594
+ }
595
+
596
+ @media (prefers-reduced-motion: reduce) {
597
+ .gameplayFloat {
598
+ animation: none;
599
+ opacity: 1;
600
+ }
601
+
602
+ .gameplayBarFill {
603
+ transition: none;
604
+ }
605
+ }
606
+
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Frames2d visual mount — the React bridge between the pet center chrome
3
+ * and the imperative frames2d renderer, mirroring the live2d mount. The
4
+ * bridge owns the contract context (asset base, phase stream, interaction
5
+ * write-back, activation cleanups), feeds the polled phase into the stream,
6
+ * forwards the chrome's drag gesture onto the conventional 'drag' track
7
+ * (when the pet declares one), and renders the localized fallback card when
8
+ * the served config is invalid.
9
+ * @module @linxin666/dsh-pet/client/renderers/Frames2dVisualMount
10
+ */
11
+
12
+ import { useEffect, useRef, useState, type ReactElement } from 'react'
13
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
14
+ import type { PetDefinition } from '../../registry.ts'
15
+ import type { ActivityPhase } from '../../state.ts'
16
+ import { createPhaseStream, type PhaseStream } from '../phase-stream.ts'
17
+ import type { DragStream } from '../drag-stream.ts'
18
+ import type { PetRendererContext } from '../../contracts/renderer.ts'
19
+ import { defaultPetRendererRegistry } from './registry.ts'
20
+ import type { Frames2dRendererHandle } from './frames2d.ts'
21
+ import type { GameplayBus } from '../gameplay-hud.tsx'
22
+ import type { NS } from '../locales.ts'
23
+
24
+ /** Mount the frames2d renderer as the sprite's visual (inside the chrome). */
25
+ export function Frames2dVisualMount(props: {
26
+ definition: PetDefinition
27
+ phase: ActivityPhase
28
+ onPet: () => void
29
+ /** Chrome drag gesture stream (the renderer switch owns it). */
30
+ drag: DragStream
31
+ /** Gameplay coordination bus; the mount registers its track override. */
32
+ bus?: GameplayBus
33
+ t: PropsLocale<typeof NS>['t']
34
+ }): ReactElement {
35
+ const containerRef = useRef<HTMLDivElement | null>(null)
36
+ const streamRef = useRef<PhaseStream | null>(null)
37
+ const handleRef = useRef<Frames2dRendererHandle | null>(null)
38
+ const [invalid, setInvalid] = useState(false)
39
+
40
+ // One activation per pet definition: build the contract context and mount.
41
+ useEffect(() => {
42
+ setInvalid(false)
43
+ const container = containerRef.current
44
+ const frames2d = props.definition.frames2d
45
+ if (container === null || frames2d === undefined) return undefined
46
+ streamRef.current ??= createPhaseStream(props.phase)
47
+ const cleanups: (() => void)[] = []
48
+ const ctx: PetRendererContext = {
49
+ petId: props.definition.id,
50
+ assetBase: '/pet/' + encodeURIComponent(props.definition.id),
51
+ container,
52
+ phase: streamRef.current,
53
+ interact: props.onPet,
54
+ onCleanup: (fn) => { cleanups.push(fn) },
55
+ }
56
+ let handle: Frames2dRendererHandle
57
+ try {
58
+ handle = defaultPetRendererRegistry.mount('frames2d', ctx, frames2d) as Frames2dRendererHandle
59
+ } catch {
60
+ setInvalid(true)
61
+ return () => { for (const fn of cleanups.splice(0)) fn() }
62
+ }
63
+ handleRef.current = handle
64
+ // The gameplay HUD steers one shared override slot through the bus;
65
+ // mode rules (work blocks drag, sleep wakes on it) keep the two
66
+ // producers from fighting over the slot.
67
+ if (props.bus !== undefined) {
68
+ const gameplayBus = props.bus
69
+ gameplayBus.setTrack = (track) => { handleRef.current?.setState(track) }
70
+ cleanups.push(() => { gameplayBus.setTrack = undefined })
71
+ }
72
+ // The drag gesture drives the conventional 'drag' track when declared.
73
+ // On release, a declared gameplay.dragEndState (miku: standup) plays
74
+ // once; its fallback auto-releases the override back to the phase map.
75
+ const dragTrack = props.definition.gameplay?.dragState ?? (frames2d.tracks.drag === undefined ? undefined : 'drag')
76
+ const offDrag = props.drag.subscribe((dragging) => {
77
+ if (dragTrack === undefined) return
78
+ if (dragging) {
79
+ handle.setState(dragTrack)
80
+ return
81
+ }
82
+ handle.setState(props.definition.gameplay?.dragEndState)
83
+ })
84
+ cleanups.push(offDrag)
85
+ return () => {
86
+ handleRef.current = null
87
+ for (const fn of cleanups.splice(0)) fn()
88
+ handle.dispose()
89
+ }
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one activation per pet identity
91
+ }, [props.definition])
92
+
93
+ // Feed the polled phase into the activation's stream (change-only).
94
+ useEffect(() => {
95
+ streamRef.current?.push(props.phase)
96
+ }, [props.phase])
97
+
98
+ return (
99
+ <div
100
+ ref={containerRef}
101
+ data-dsh-pet-frames2d={props.definition.id}
102
+ style={{ width: '100%', height: '100%', pointerEvents: 'none' }}
103
+ >
104
+ {invalid && (
105
+ <span data-dsh-pet-frames2d-error="invalid-config">
106
+ {props.t('pet.renderer.unavailable', { renderer: 'frames2d' })}
107
+ </span>
108
+ )}
109
+ </div>
110
+ )
111
+ }
@@ -8,13 +8,16 @@
8
8
  * @module @linxin666/dsh-pet/client/renderers/PetRendererSwitch
9
9
  */
10
10
 
11
- import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
11
+ import { cloneElement, isValidElement, useRef, type ReactElement, type ReactNode } from 'react'
12
12
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
13
13
  import type { PetDefinition } from '../../registry.ts'
14
14
  import type { ActivityPhase } from '../../state.ts'
15
15
  import type { PetSpriteProps } from '../PetSprite.tsx'
16
+ import { createDragStream, type DragStream } from '../drag-stream.ts'
17
+ import type { GameplayBus } from '../gameplay-hud.tsx'
16
18
  import { defaultPetRendererRegistry } from './registry.ts'
17
19
  import { Live2dVisualMount } from './live2d/Live2dVisualMount.tsx'
20
+ import { Frames2dVisualMount } from './Frames2dVisualMount.tsx'
18
21
  import type { NS } from '../locales.ts'
19
22
 
20
23
  /** Dispatch one pet definition to its renderer; unknown kinds get a card. */
@@ -24,11 +27,35 @@ export function PetRendererSwitch(props: {
24
27
  phase: ActivityPhase
25
28
  /** The chrome's pet interaction (affinity write-back owner). */
26
29
  onPet: () => void
30
+ /** External drag stream (gameplay HUD shares it); created locally otherwise. */
31
+ drag?: DragStream
32
+ /** Gameplay coordination bus forwarded to the frames2d visual mount. */
33
+ bus?: GameplayBus
27
34
  t: PropsLocale<typeof NS>['t']
28
35
  children?: ReactNode
29
36
  }): ReactElement {
30
37
  const renderer = props.definition.renderer ?? 'sprite2d'
38
+ // The frames2d drag stream lives one level up: the chrome (PetSprite)
39
+ // reports the gesture through onDraggingChange, the visual subscribes.
40
+ const dragRef = useRef<{ id: string; stream: ReturnType<typeof createDragStream> } | null>(null)
41
+ if (dragRef.current === null || dragRef.current.id !== props.definition.id) {
42
+ dragRef.current = { id: props.definition.id, stream: createDragStream() }
43
+ }
44
+ const drag = props.drag ?? dragRef.current.stream
31
45
  if (renderer === 'sprite2d') return <>{props.children}</>
46
+ if (renderer === 'frames2d' && defaultPetRendererRegistry.has('frames2d') && isValidElement<PetSpriteProps>(props.children)) {
47
+ const visual = (
48
+ <Frames2dVisualMount
49
+ definition={props.definition}
50
+ phase={props.phase}
51
+ onPet={props.onPet}
52
+ drag={drag}
53
+ {...(props.bus === undefined ? {} : { bus: props.bus })}
54
+ t={props.t}
55
+ />
56
+ )
57
+ return cloneElement(props.children, { visual, onDraggingChange: (dragging: boolean) => drag.push(dragging) })
58
+ }
32
59
  if (renderer === 'live2d' && defaultPetRendererRegistry.has('live2d') && isValidElement<PetSpriteProps>(props.children)) {
33
60
  const visual = (
34
61
  <Live2dVisualMount
@@ -0,0 +1,137 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * frames2d renderer unit tests — playback chain, phase mapping, gameplay
4
+ * override, fallback settling, and the stall watchdog, all on fake timers.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import { frames2dRenderer, type Frames2dRendererHandle, type PetFrames2dConfig } from './frames2d.ts'
8
+ import { createPhaseStream, type PhaseStream } from '../phase-stream.ts'
9
+ import type { PetRendererContext } from '../../contracts/renderer.ts'
10
+ import type { ActivityPhase } from '../../state.ts'
11
+
12
+ const CONFIG: PetFrames2dConfig = {
13
+ tracks: {
14
+ idle: { frames: ['/pet/miku/idle/1.webp', '/pet/miku/idle/2.webp'], durations: [100, 100], loop: true },
15
+ work: { frames: ['/pet/miku/work/1.webp', '/pet/miku/work/2.webp'], durations: [100, 100], loop: true },
16
+ happy: { frames: ['/pet/miku/happy/1.webp'], durations: [100], loop: false },
17
+ standup: { frames: ['/pet/miku/standup/1.webp'], durations: [100], loop: false, fallback: 'idle' },
18
+ },
19
+ phases: { idle: 'idle', thinking: 'work', done: 'happy' },
20
+ }
21
+
22
+ function setup(): { ctx: PetRendererContext; stream: PhaseStream; img: () => HTMLImageElement } {
23
+ const container = document.createElement('div')
24
+ document.body.appendChild(container)
25
+ const stream = createPhaseStream('idle')
26
+ const cleanups: (() => void)[] = []
27
+ const ctx: PetRendererContext = {
28
+ petId: 'miku',
29
+ assetBase: '/pet/miku',
30
+ container,
31
+ phase: stream,
32
+ interact: () => {},
33
+ onCleanup: (fn) => { cleanups.push(fn) },
34
+ }
35
+ return { ctx, stream, img: () => container.querySelector('img') as HTMLImageElement }
36
+ }
37
+
38
+ describe('frames2dRenderer', () => {
39
+ beforeEach(() => {
40
+ vi.useFakeTimers()
41
+ document.body.innerHTML = ''
42
+ })
43
+ afterEach(() => {
44
+ vi.useRealTimers()
45
+ })
46
+
47
+ it('validates the served config fail-closed', () => {
48
+ expect(() => frames2dRenderer.validateConfig(null)).toThrow()
49
+ expect(() => frames2dRenderer.validateConfig({ tracks: {}, phases: { idle: 'idle' } })).toThrow()
50
+ expect(() => frames2dRenderer.validateConfig({ tracks: { idle: { frames: [], durations: [], loop: true } }, phases: { idle: 'idle' } })).toThrow()
51
+ expect(frames2dRenderer.validateConfig(CONFIG).phases.idle).toBe('idle')
52
+ })
53
+
54
+ it('plays the idle track on mount and advances frames on its durations', () => {
55
+ const { ctx, img } = setup()
56
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
57
+ expect(handle.currentTrack()).toBe('idle')
58
+ expect(img().getAttribute('src')).toBe('/pet/miku/idle/1.webp')
59
+ vi.advanceTimersByTime(100)
60
+ expect(img().getAttribute('src')).toBe('/pet/miku/idle/2.webp')
61
+ vi.advanceTimersByTime(100)
62
+ expect(img().getAttribute('src')).toBe('/pet/miku/idle/1.webp')
63
+ handle.dispose()
64
+ })
65
+
66
+ it('follows the phase stream through the phases map', () => {
67
+ const { ctx, stream, img } = setup()
68
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
69
+ stream.push('thinking' as ActivityPhase)
70
+ expect(handle.currentTrack()).toBe('work')
71
+ expect(img().getAttribute('src')).toBe('/pet/miku/work/1.webp')
72
+ // Unmapped phases fall back to the idle track.
73
+ stream.push('waiting' as ActivityPhase)
74
+ expect(handle.currentTrack()).toBe('idle')
75
+ handle.dispose()
76
+ })
77
+
78
+ it('settles a finished non-loop track into its fallback', () => {
79
+ const { ctx, stream } = setup()
80
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
81
+ stream.push('done' as ActivityPhase)
82
+ expect(handle.currentTrack()).toBe('happy')
83
+ vi.advanceTimersByTime(150)
84
+ expect(handle.currentTrack()).toBe('idle')
85
+ handle.dispose()
86
+ })
87
+
88
+ it('lets the gameplay driver force and release a track', () => {
89
+ const { ctx, stream } = setup()
90
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
91
+ handle.setState('standup')
92
+ expect(handle.currentTrack()).toBe('standup')
93
+ // Phase changes are ignored while the override holds.
94
+ stream.push('thinking' as ActivityPhase)
95
+ expect(handle.currentTrack()).toBe('standup')
96
+ // The finished override settles into its fallback, which matches the
97
+ // current phase map (thinking -> work? no: standup fell back to idle,
98
+ // but the phase says work, so the release lands on the phase track).
99
+ vi.advanceTimersByTime(150)
100
+ expect(handle.currentTrack()).toBe('idle')
101
+ handle.setState('work')
102
+ expect(handle.currentTrack()).toBe('work')
103
+ handle.setState(undefined)
104
+ expect(handle.currentTrack()).toBe('work')
105
+ handle.dispose()
106
+ })
107
+
108
+ it('ignores unknown setState ids', () => {
109
+ const { ctx } = setup()
110
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
111
+ handle.setState('ghost')
112
+ expect(handle.currentTrack()).toBe('idle')
113
+ handle.dispose()
114
+ })
115
+
116
+ it('re-kicks a stalled looping track via the watchdog', () => {
117
+ const { ctx, img } = setup()
118
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
119
+ expect(img().getAttribute('src')).toBe('/pet/miku/idle/1.webp')
120
+ // Simulate a throttled-away chain: burn the pending frame timer without
121
+ // the renderer noticing by jumping far past it, then let the watchdog fire.
122
+ vi.advanceTimersByTime(100) // frame 2
123
+ expect(img().getAttribute('src')).toBe('/pet/miku/idle/2.webp')
124
+ vi.advanceTimersByTime(100) // wraps to frame 1
125
+ expect(img().getAttribute('src')).toBe('/pet/miku/idle/1.webp')
126
+ handle.dispose()
127
+ })
128
+
129
+ it('disposes idempotently and removes the image', () => {
130
+ const { ctx } = setup()
131
+ const container = ctx.container
132
+ const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
133
+ handle.dispose()
134
+ handle.dispose()
135
+ expect(container.querySelector('img')).toBeNull()
136
+ })
137
+ })