@linxin666/dsh-pet 0.3.3 → 0.3.5

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 (123) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -13
  3. package/README.zh.md +30 -13
  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/contracts/voice-pack-v1.schema.json +23 -28
  17. package/lib/client.js +781 -40
  18. package/lib/client.js.map +1 -1
  19. package/lib/index.js +1567 -482
  20. package/lib/types/chatter.d.ts +68 -46
  21. package/lib/types/chatter.d.ts.map +1 -1
  22. package/lib/types/chatter.js +243 -301
  23. package/lib/types/client/PetDockEntry.d.ts +3 -0
  24. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  25. package/lib/types/client/PetDockEntry.js +18 -2
  26. package/lib/types/client/PetSprite.d.ts +23 -0
  27. package/lib/types/client/PetSprite.d.ts.map +1 -1
  28. package/lib/types/client/PetSprite.js +49 -45
  29. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  30. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  31. package/lib/types/client/PluginSettingsCard.js +2 -2
  32. package/lib/types/client/drag-stream.d.ts +19 -0
  33. package/lib/types/client/drag-stream.d.ts.map +1 -0
  34. package/lib/types/client/drag-stream.js +26 -0
  35. package/lib/types/client/gameplay-hud.d.ts +50 -0
  36. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  37. package/lib/types/client/gameplay-hud.js +274 -0
  38. package/lib/types/client/index.d.ts.map +1 -1
  39. package/lib/types/client/index.js +38 -9
  40. package/lib/types/client/locales.d.ts +34 -0
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/locales.js +36 -0
  43. package/lib/types/client/pet-store.d.ts +7 -1
  44. package/lib/types/client/pet-store.d.ts.map +1 -1
  45. package/lib/types/client/pet-store.js +4 -0
  46. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  47. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  48. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  49. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  50. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  51. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  52. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  53. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  54. package/lib/types/client/renderers/frames2d.js +196 -0
  55. package/lib/types/defaults.d.ts +6 -0
  56. package/lib/types/defaults.d.ts.map +1 -0
  57. package/lib/types/defaults.js +5 -0
  58. package/lib/types/dsh-home.d.ts.map +1 -1
  59. package/lib/types/dsh-home.js +9 -3
  60. package/lib/types/event-projection.d.ts +9 -4
  61. package/lib/types/event-projection.d.ts.map +1 -1
  62. package/lib/types/event-projection.js +46 -14
  63. package/lib/types/gameplay.d.ts +182 -0
  64. package/lib/types/gameplay.d.ts.map +1 -0
  65. package/lib/types/gameplay.js +618 -0
  66. package/lib/types/ledger.d.ts +14 -0
  67. package/lib/types/ledger.d.ts.map +1 -1
  68. package/lib/types/ledger.js +30 -0
  69. package/lib/types/manifest-v2.d.ts +38 -1
  70. package/lib/types/manifest-v2.d.ts.map +1 -1
  71. package/lib/types/manifest-v2.js +182 -2
  72. package/lib/types/persist.d.ts +4 -4
  73. package/lib/types/persist.d.ts.map +1 -1
  74. package/lib/types/persist.js +41 -4
  75. package/lib/types/registry.d.ts +29 -0
  76. package/lib/types/registry.d.ts.map +1 -1
  77. package/lib/types/registry.js +170 -1
  78. package/lib/types/routes.d.ts.map +1 -1
  79. package/lib/types/routes.js +30 -3
  80. package/lib/types/service.d.ts +63 -11
  81. package/lib/types/service.d.ts.map +1 -1
  82. package/lib/types/service.js +208 -19
  83. package/lib/types/voice-pack.d.ts +6 -7
  84. package/lib/types/voice-pack.d.ts.map +1 -1
  85. package/lib/types/voice-pack.js +44 -47
  86. package/package.json +5 -2
  87. package/src/chatter.test.ts +68 -38
  88. package/src/chatter.ts +269 -316
  89. package/src/client/PetDockEntry.test.tsx +6 -0
  90. package/src/client/PetDockEntry.tsx +32 -1
  91. package/src/client/PetSprite.test.tsx +68 -21
  92. package/src/client/PetSprite.tsx +63 -45
  93. package/src/client/PluginSettingsCard.tsx +4 -2
  94. package/src/client/drag-stream.ts +35 -0
  95. package/src/client/gameplay-hud.test.tsx +338 -0
  96. package/src/client/gameplay-hud.tsx +403 -0
  97. package/src/client/index.test.tsx +10 -1
  98. package/src/client/index.ts +46 -11
  99. package/src/client/locales.ts +36 -0
  100. package/src/client/pet-store.ts +10 -1
  101. package/src/client/pet.module.css +192 -29
  102. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  103. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  104. package/src/client/renderers/frames2d.test.ts +137 -0
  105. package/src/client/renderers/frames2d.ts +216 -0
  106. package/src/client/settings-card.module.css +5 -0
  107. package/src/defaults.ts +7 -0
  108. package/src/dsh-home.ts +9 -3
  109. package/src/event-projection.ts +57 -16
  110. package/src/gameplay.test.ts +239 -0
  111. package/src/gameplay.ts +692 -0
  112. package/src/ledger.test.ts +16 -0
  113. package/src/ledger.ts +31 -0
  114. package/src/manifest-v2.test.ts +130 -1
  115. package/src/manifest-v2.ts +181 -2
  116. package/src/persist.test.ts +3 -0
  117. package/src/persist.ts +43 -6
  118. package/src/registry.test.ts +168 -1
  119. package/src/registry.ts +199 -1
  120. package/src/routes.ts +28 -4
  121. package/src/service.ts +240 -29
  122. package/src/voice-pack.test.ts +48 -25
  123. package/src/voice-pack.ts +50 -47
@@ -0,0 +1,216 @@
1
+ /**
2
+ * frames2d renderer — plays the free-form named frame-sequence tracks of a
3
+ * frames2d pet (manifest v2 'frames2d' block). The pet center picks the
4
+ * track from the ActivityPhase stream (phase -> track map, idle fallback);
5
+ * the gameplay driver may force a track through the handle's setState
6
+ * override (drag/work/sleep/shop...), and a finished non-loop track settles
7
+ * into its fallback, releasing the override when the fallback matches the
8
+ * phase-mapped track. Rendering never throws: a broken track list degrades
9
+ * to the first decodable frame, and the 1.2 s stall watchdog re-kicks the
10
+ * playback chain after timer throttling.
11
+ * @module @linxin666/dsh-pet/client/renderers/frames2d
12
+ */
13
+
14
+ import { PET_RENDERER_API_VERSION, type PetRenderer, type PetRendererContext, type PetRendererHandle } from '../../contracts/renderer.ts'
15
+ import type { ActivityPhase } from '../../state.ts'
16
+
17
+ /** One track as served inside the pet definition (browser URLs). */
18
+ export interface Frames2dTrackConfig {
19
+ frames: string[]
20
+ durations: number[]
21
+ loop: boolean
22
+ fallback?: string
23
+ }
24
+
25
+ /** The frames2d block as served inside the pet definition. */
26
+ export interface PetFrames2dConfig {
27
+ tracks: Record<string, Frames2dTrackConfig>
28
+ phases: Partial<Record<ActivityPhase, string>> & { idle: string }
29
+ }
30
+
31
+ export interface Frames2dRendererHandle extends PetRendererHandle {
32
+ /** Force a track id (gameplay override); undefined returns to phase mapping. */
33
+ setState(track: string | undefined): void
34
+ /** The track currently playing (diagnostics and tests). */
35
+ currentTrack(): string
36
+ }
37
+
38
+ /** Stall watchdog period: a looping track stuck longer re-kicks its chain. */
39
+ const WATCHDOG_MS = 1200
40
+
41
+ function isRecord(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
43
+ }
44
+
45
+ /** Fail-closed client-side validation of the served frames2d block. */
46
+ function validateFrames2dConfig(config: unknown): PetFrames2dConfig {
47
+ if (!isRecord(config) || !isRecord(config.tracks) || !isRecord(config.phases)) {
48
+ throw new Error('frames2d config requires tracks and phases objects')
49
+ }
50
+ const tracks: Record<string, Frames2dTrackConfig> = {}
51
+ for (const [name, raw] of Object.entries(config.tracks)) {
52
+ if (!isRecord(raw) || !Array.isArray(raw.frames) || raw.frames.length === 0
53
+ || raw.frames.some(f => typeof f !== 'string' || f === '')
54
+ || !Array.isArray(raw.durations) || raw.durations.length !== raw.frames.length
55
+ || raw.durations.some(d => typeof d !== 'number' || !(d > 0))) {
56
+ throw new Error('frames2d track ' + JSON.stringify(name) + ' needs same-length frames/durations')
57
+ }
58
+ tracks[name] = {
59
+ frames: raw.frames as string[],
60
+ durations: raw.durations as number[],
61
+ loop: raw.loop !== false,
62
+ ...(typeof raw.fallback === 'string' ? { fallback: raw.fallback } : {}),
63
+ }
64
+ }
65
+ const phases = config.phases as Partial<Record<ActivityPhase, string>>
66
+ if (typeof phases.idle !== 'string' || tracks[phases.idle] === undefined) {
67
+ throw new Error('frames2d phases.idle must name an existing track')
68
+ }
69
+ return { tracks, phases: phases as PetFrames2dConfig['phases'] }
70
+ }
71
+
72
+ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
73
+ id: 'frames2d',
74
+ apiVersion: PET_RENDERER_API_VERSION,
75
+ validateConfig: validateFrames2dConfig,
76
+
77
+ mount(ctx: PetRendererContext, config: PetFrames2dConfig): Frames2dRendererHandle {
78
+ const reducedMotion = typeof window !== 'undefined'
79
+ && typeof window.matchMedia === 'function'
80
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches
81
+
82
+ const img = document.createElement('img')
83
+ img.dataset.dshPetFrames2d = ctx.petId
84
+ img.alt = ''
85
+ img.draggable = false
86
+ img.style.width = '100%'
87
+ img.style.height = '100%'
88
+ img.style.objectFit = 'contain'
89
+ img.style.pointerEvents = 'none'
90
+ ctx.container.appendChild(img)
91
+
92
+ // Warm the browser cache for every frame up front (same-origin, small
93
+ // webp files); playback itself swaps img.src without React state.
94
+ for (const track of Object.values(config.tracks)) {
95
+ for (const url of track.frames) {
96
+ const pre = new Image()
97
+ pre.src = url
98
+ }
99
+ }
100
+
101
+ let disposed = false
102
+ let timer: ReturnType<typeof setTimeout> | undefined
103
+ let watchdog: ReturnType<typeof setInterval> | undefined
104
+ let track: string = config.phases.idle
105
+ let frameIndex = 0
106
+ let lastAdvance = Date.now()
107
+ let override: string | undefined
108
+
109
+ const trackForPhase = (phase: ActivityPhase): string => {
110
+ const mapped = config.phases[phase]
111
+ return mapped !== undefined && config.tracks[mapped] !== undefined ? mapped : config.phases.idle
112
+ }
113
+
114
+ const show = (trackId: string, index: number): void => {
115
+ const def = config.tracks[trackId]
116
+ const url = def?.frames[index]
117
+ if (url !== undefined && img.getAttribute('src') !== url) img.src = url
118
+ }
119
+
120
+ const schedule = (ms: number): void => {
121
+ if (disposed) return
122
+ timer = setTimeout(tick, ms)
123
+ }
124
+
125
+ function tick(): void {
126
+ if (disposed) return
127
+ const def = config.tracks[track]
128
+ if (def === undefined || def.frames.length === 0) return
129
+ lastAdvance = Date.now()
130
+ const next = frameIndex + 1
131
+ if (next < def.frames.length) {
132
+ frameIndex = next
133
+ show(track, frameIndex)
134
+ schedule(def.durations[frameIndex] ?? 200)
135
+ return
136
+ }
137
+ if (def.loop) {
138
+ frameIndex = 0
139
+ show(track, frameIndex)
140
+ schedule(def.durations[frameIndex] ?? 200)
141
+ return
142
+ }
143
+ // Non-loop completion: settle into the fallback; when the fallback is
144
+ // what the phase map would play anyway, release the gameplay override.
145
+ const target = def.fallback !== undefined && config.tracks[def.fallback] !== undefined
146
+ ? def.fallback
147
+ : config.phases.idle
148
+ if (target === trackForPhase(ctx.phase.get())) override = undefined
149
+ play(target)
150
+ }
151
+
152
+ function play(trackId: string): void {
153
+ if (disposed) return
154
+ if (config.tracks[trackId] === undefined) trackId = config.phases.idle
155
+ if (timer !== undefined) clearTimeout(timer)
156
+ track = trackId
157
+ frameIndex = 0
158
+ lastAdvance = Date.now()
159
+ show(track, frameIndex)
160
+ if (reducedMotion) return
161
+ const def = config.tracks[track]!
162
+ schedule(def.durations[0] ?? 200)
163
+ }
164
+
165
+ const unsubscribe = ctx.phase.subscribe((phase) => {
166
+ if (override !== undefined) return
167
+ const target = trackForPhase(phase)
168
+ if (target !== track) play(target)
169
+ })
170
+
171
+ // Watchdog: a throttled-away timer (background tab) leaves the chain
172
+ // dead; re-kick when a looping track has not advanced on schedule.
173
+ if (!reducedMotion) {
174
+ watchdog = setInterval(() => {
175
+ if (disposed) return
176
+ const def = config.tracks[track]
177
+ if (def === undefined || !def.loop) return
178
+ const expected = (def.durations[frameIndex] ?? 200) + WATCHDOG_MS
179
+ if (Date.now() - lastAdvance > expected) tick()
180
+ }, WATCHDOG_MS)
181
+ }
182
+
183
+ play(track)
184
+
185
+ let disposedOnce = false
186
+ const dispose = (): void => {
187
+ if (disposedOnce) return
188
+ disposedOnce = true
189
+ disposed = true
190
+ unsubscribe()
191
+ if (timer !== undefined) clearTimeout(timer)
192
+ if (watchdog !== undefined) clearInterval(watchdog)
193
+ img.remove()
194
+ }
195
+ ctx.onCleanup(dispose)
196
+
197
+ return {
198
+ dispose,
199
+ setState(next: string | undefined): void {
200
+ if (disposed) return
201
+ if (next === undefined) {
202
+ override = undefined
203
+ const target = trackForPhase(ctx.phase.get())
204
+ if (target !== track) play(target)
205
+ return
206
+ }
207
+ if (config.tracks[next] === undefined) return
208
+ override = next
209
+ if (next !== track) play(next)
210
+ },
211
+ currentTrack(): string {
212
+ return track
213
+ },
214
+ }
215
+ },
216
+ }
@@ -24,6 +24,10 @@
24
24
  .header {
25
25
  appearance: none;
26
26
  width: 100%;
27
+ /* The suite does not force a global border-box, so width:100% + padding
28
+ would stretch the row 32px past the card and push the trailing pending
29
+ badge (last flex item) outside the card border. */
30
+ box-sizing: border-box;
27
31
  font: inherit;
28
32
  color: inherit;
29
33
  text-align: left;
@@ -44,6 +48,7 @@
44
48
 
45
49
  .headerStatic {
46
50
  width: 100%;
51
+ box-sizing: border-box;
47
52
  border-radius: 12px;
48
53
  align-items: center;
49
54
  gap: 12px;
@@ -0,0 +1,7 @@
1
+ /** Shared defaults used by registry selection and persisted-state migration. */
2
+
3
+ /** Pet id that legacy single-pet installs and fresh registries resolve to. */
4
+ export const DEFAULT_PET_ID = 'whale-girl'
5
+
6
+ /** Default pet name used only when a manifest carries no displayName. */
7
+ export const DEFAULT_PET_NAME = '鲸鱼娘'
package/src/dsh-home.ts CHANGED
@@ -7,11 +7,14 @@
7
7
 
8
8
  import { homedir } from 'node:os'
9
9
  import { isAbsolute, join } from 'node:path'
10
+ import { isAbsolute as posixIsAbsolute, join as posixJoin } from 'node:path/posix'
10
11
 
11
12
  /** Expand a leading ~ (or ~user) in a path, platform-style. */
12
13
  export function expandHome(path: string, home: string = homedir()): string {
14
+ const isPosix = home.startsWith('/')
15
+ const j = isPosix ? posixJoin : join
13
16
  if (path === '~') return home
14
- if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
17
+ if (path.startsWith('~/') || path.startsWith('~\\')) return j(home, path.slice(2))
15
18
  return path
16
19
  }
17
20
 
@@ -22,12 +25,15 @@ export function expandHome(path: string, home: string = homedir()): string {
22
25
  * @returns the absolute DSH home path.
23
26
  */
24
27
  export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
28
+ const isPosix = home.startsWith('/')
29
+ const j = isPosix ? posixJoin : join
30
+ const isAbs = isPosix ? posixIsAbsolute : isAbsolute
25
31
  const raw = env.DSH_HOME
26
32
  if (raw !== undefined && raw.trim() !== '') {
27
33
  const expanded = expandHome(raw.trim(), home)
28
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
34
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded)
29
35
  }
30
- return join(home, '.dsh')
36
+ return j(home, '.dsh')
31
37
  }
32
38
 
33
39
  /** Resolve the DSH home directory from the live environment. */
@@ -5,15 +5,26 @@
5
5
  * {@link ProjectionRuntime} per session and feed events in arrival order.
6
6
  *
7
7
  * Status copy comes from the chatter voice (big rotating pools, per-tool
8
- * families, real-argument hints), and streamed model output feeds the murmur
9
- * engine so the pet can whisper its inner voice (碎碎念). The wall clock is
8
+ * families, real-argument hints), and the projection feeds the murmur engine
9
+ * (碎碎念) with the SITUATION thinking/writing during the stream, the
10
+ * running tool family on tool calls, and the STRUCTURED outcome on
11
+ * tool/result and turn/end — so the pet's inner voice always roughly knows
12
+ * what is going on and never mis-fires on output text. The wall clock is
10
13
  * injected by the caller, keeping every projection reproducible.
11
14
  * @module @linxin666/dsh-pet/event-projection
12
15
  */
13
16
 
14
17
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
15
18
  import type { PetStateInput } from './state.ts'
16
- import { StatusVoice, toolArgHint, WhisperEngine, type VoicePoolsProvider } from './chatter.ts'
19
+ import {
20
+ StatusVoice,
21
+ toolArgHint,
22
+ toolCategory,
23
+ WhisperEngine,
24
+ whisperCategoryOf,
25
+ looksLikeTestTool,
26
+ type VoicePoolsProvider,
27
+ } from './chatter.ts'
17
28
 
18
29
  /** Runtime shape of the optional legacy activity event. */
19
30
  export interface ActivityStatusEventLike {
@@ -25,11 +36,13 @@ export interface ActivityStatusEventLike {
25
36
  /** Per-session facts needed to project the official event stream. */
26
37
  export interface ProjectionRuntime {
27
38
  activeTools: Set<string>
39
+ /** callIds whose tool looked like a test run, marked at tool/call (pet M6). */
40
+ testCalls: Set<string>
28
41
  officialEventsSeen: boolean
29
42
  stepHadFailure: boolean
30
43
  /** Round-robin status copy voice (scene-stable, cadence-rotated). */
31
44
  voice: StatusVoice
32
- /** Inner-whisper engine fed by the model's own streamed output. */
45
+ /** Inner-whisper engine fed by the projection's situation and outcomes. */
33
46
  whispers: WhisperEngine
34
47
  }
35
48
 
@@ -37,7 +50,7 @@ export interface ProjectionRuntime {
37
50
  export interface PetActivityTransition {
38
51
  input: PetStateInput
39
52
  completedTurn?: number
40
- /** A fresh inner whisper woken by this event's model output, when any. */
53
+ /** A fresh inner whisper woken by this event, when any. */
41
54
  whisper?: string
42
55
  }
43
56
 
@@ -50,6 +63,7 @@ export interface PetActivityTransition {
50
63
  export function emptyProjectionRuntime(pools?: VoicePoolsProvider): ProjectionRuntime {
51
64
  return {
52
65
  activeTools: new Set(),
66
+ testCalls: new Set(),
53
67
  officialEventsSeen: false,
54
68
  stepHadFailure: false,
55
69
  voice: new StatusVoice(pools),
@@ -81,6 +95,7 @@ export function projectOfficialEvent(
81
95
  switch (event.type) {
82
96
  case 'turn/start':
83
97
  runtime.activeTools.clear()
98
+ runtime.testCalls.clear()
84
99
  runtime.stepHadFailure = false
85
100
  return { input: { phase: 'waiting', line: runtime.voice.scene('prepare', nowMs) } }
86
101
  case 'step/start':
@@ -90,14 +105,14 @@ export function projectOfficialEvent(
90
105
  case 'assistant/chunk': {
91
106
  const { chunk } = event.data
92
107
  if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
93
- const whisper = runtime.whispers.feed(chunk.text, nowMs)
108
+ const whisper = runtime.whispers.feed('thinking', nowMs)
94
109
  return {
95
110
  input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
96
111
  ...(whisper === undefined ? {} : { whisper }),
97
112
  }
98
113
  }
99
114
  if (chunk.type === 'text-delta' && chunk.text.length > 0) {
100
- const whisper = runtime.whispers.feed(chunk.text, nowMs)
115
+ const whisper = runtime.whispers.feed('writing', nowMs)
101
116
  return {
102
117
  input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
103
118
  ...(whisper === undefined ? {} : { whisper }),
@@ -107,8 +122,13 @@ export function projectOfficialEvent(
107
122
  }
108
123
  case 'assistant/message':
109
124
  return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } }
110
- case 'tool/call':
111
- runtime.activeTools.add(String(event.data.callId))
125
+ case 'tool/call': {
126
+ const callId = String(event.data.callId)
127
+ runtime.activeTools.add(callId)
128
+ // A test-looking call is remembered so its result (the only place the
129
+ // outcome is known) can wake the test-green mood later.
130
+ if (looksLikeTestTool(event.data.name, event.data.arguments)) runtime.testCalls.add(callId)
131
+ const whisper = runtime.whispers.feed(whisperCategoryOf(toolCategory(event.data.name)), nowMs)
112
132
  return {
113
133
  input: {
114
134
  phase: 'tool',
@@ -119,33 +139,54 @@ export function projectOfficialEvent(
119
139
  nowMs,
120
140
  ),
121
141
  },
142
+ ...(whisper === undefined ? {} : { whisper }),
122
143
  }
144
+ }
123
145
  case 'tool/result': {
124
146
  const block = event.data.message.content[0]
125
- runtime.activeTools.delete(String(event.data.message.source.callId))
126
- runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true
147
+ const callId = String(event.data.message.source.callId)
148
+ const failed = event.data.error !== undefined || block.isError === true
149
+ const wasTest = runtime.testCalls.delete(callId)
150
+ runtime.activeTools.delete(callId)
151
+ runtime.stepHadFailure ||= failed
152
+ const whisper = failed
153
+ ? runtime.whispers.result('fail', nowMs)
154
+ : wasTest
155
+ ? runtime.whispers.result('pass', nowMs)
156
+ : undefined
157
+ const whisperSpread = whisper === undefined ? {} : { whisper }
127
158
  if (runtime.activeTools.size > 0) {
128
159
  return {
129
160
  input: {
130
161
  phase: 'tool',
131
162
  line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs),
132
163
  },
164
+ ...whisperSpread,
133
165
  }
134
166
  }
135
167
  return runtime.stepHadFailure
136
- ? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) } }
137
- : { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) } }
168
+ ? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) }, ...whisperSpread }
169
+ : { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) }, ...whisperSpread }
138
170
  }
139
171
  case 'turn/end': {
140
172
  runtime.activeTools.clear()
173
+ runtime.testCalls.clear()
141
174
  switch (event.data.reason.kind) {
142
- case 'completed':
175
+ case 'completed': {
176
+ const whisper = runtime.whispers.result('done', nowMs)
143
177
  return {
144
178
  input: { phase: 'done', line: runtime.voice.scene('done', nowMs) },
145
179
  completedTurn: event.data.turn,
180
+ ...(whisper === undefined ? {} : { whisper }),
146
181
  }
147
- case 'error':
148
- return { input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) } }
182
+ }
183
+ case 'error': {
184
+ const whisper = runtime.whispers.result('fail', nowMs)
185
+ return {
186
+ input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) },
187
+ ...(whisper === undefined ? {} : { whisper }),
188
+ }
189
+ }
149
190
  case 'max-tokens':
150
191
  return { input: { phase: 'failed', line: runtime.voice.scene('maxTokens', nowMs) } }
151
192
  case 'interrupted':
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Gameplay tests — the manifest block (fail-closed structure, parsed through
3
+ * parsePetManifest) and the host engine (lazy settle, effects, rolls) with
4
+ * injected clock/rng for determinism.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { parsePetManifest } from './manifest-v2.ts'
8
+ import {
9
+ applyGameplayEffects,
10
+ clampGameplay,
11
+ drawLotteryTier,
12
+ initialGameplayState,
13
+ rollTouchBranch,
14
+ rollWorkOutcome,
15
+ settleGameplay,
16
+ touchZoneAt,
17
+ type PetGameplayManifest,
18
+ } from './gameplay.ts'
19
+
20
+ /** A miku-shaped frames2d manifest carrying the full gameplay block. */
21
+ function mikuManifest(gameplay: Record<string, unknown>): Record<string, unknown> {
22
+ return {
23
+ petManifestVersion: 2,
24
+ id: 'miku',
25
+ displayName: 'Miku',
26
+ license: 'MIT',
27
+ renderer: 'frames2d',
28
+ frames2d: {
29
+ dir: 'thumb',
30
+ tracks: {
31
+ idle: {}, drag: {}, standup: {}, sleep: {}, work: {}, success: {}, fail: {},
32
+ happy: {}, shy: {}, flirty: {}, angry: {}, scratch: {}, blink1: {}, blink2: {}, eat: {}, shop: {},
33
+ },
34
+ phases: { idle: 'idle', done: 'success', failed: 'fail' },
35
+ },
36
+ gameplay,
37
+ }
38
+ }
39
+
40
+ const FULL_GAMEPLAY: Record<string, unknown> = {
41
+ idleDirector: {
42
+ intervalMs: 5000,
43
+ maxMiss: 2,
44
+ idleWeight: 40,
45
+ acts: [
46
+ { track: 'scratch', weight: 18 },
47
+ { track: 'blink1', weight: 9 },
48
+ { track: 'blink2', weight: 9 },
49
+ { track: 'eat', weight: 24 },
50
+ ],
51
+ },
52
+ stats: {
53
+ hunger: { max: 100, initial: 100, decayPerMinute: 1, workingDecayPerMinute: 5 },
54
+ mood: { max: 100, initial: 100, decayPerMinute: 0.5 },
55
+ energy: { max: 100, initial: 100, decayPerMinute: 0.25 },
56
+ affection: { max: 500, initial: 100, idleDecayPerMinute: 0.2 },
57
+ },
58
+ hitBox: { x0: 0.2, y0: 0.05, x1: 0.42, y1: 0.56 },
59
+ touch: {
60
+ zones: [
61
+ { name: 'head', y0: 0, y1: 0.55, branches: [{ probability: 0.05, effects: [{ stat: 'affection', amount: 5 }], state: 'happy', stateMs: 3000, phrases: ['hi'] }] },
62
+ { name: 'body', y0: 0.55, y1: 0.75, branches: [{ probability: 0.1, effects: [{ stat: 'affection', amount: 10 }], state: 'shy', stateMs: 3000 }] },
63
+ {
64
+ name: 'legs', y0: 0.75, y1: 1, branches: [
65
+ { probability: 0.1, effects: [{ stat: 'affection', amount: 30 }], state: 'flirty', stateMs: 3000 },
66
+ { probability: 0.9, effects: [{ stat: 'affection', amount: -5 }], state: 'angry', stateMs: 3000 },
67
+ ],
68
+ },
69
+ ],
70
+ },
71
+ work: {
72
+ state: 'work',
73
+ successState: 'success',
74
+ failState: 'fail',
75
+ tickMs: 10_000,
76
+ resultMs: { success: 1300, fail: 1900 },
77
+ successProbability: 0.5,
78
+ success: { effects: [{ currency: 'coins', amount: 3 }] },
79
+ fail: { effects: [{ currency: 'coins', amount: -1 }] },
80
+ },
81
+ sleep: { state: 'sleep', wakeState: 'standup', restore: { stat: 'energy', amount: 4, intervalMs: 30_000 } },
82
+ passiveIncome: { currency: 'coins', amount: 1, intervalMs: 60_000 },
83
+ shop: {
84
+ state: 'shop',
85
+ items: [
86
+ { id: 'food1', label: 'bread', price: 5, currency: 'coins', effects: [{ stat: 'hunger', amount: 40 }] },
87
+ { id: 'gamecoin', label: 'coin', price: 10, currency: 'coins', effects: [{ currency: 'gamecoins', amount: 1 }] },
88
+ {
89
+ id: 'lottery', label: 'ticket', price: 10, currency: 'gamecoins',
90
+ lottery: {
91
+ effects: [{ stat: 'hunger', amount: 10 }, { stat: 'mood', amount: 10 }, { stat: 'energy', amount: 10 }, { stat: 'affection', amount: 10 }],
92
+ tiers: [
93
+ { probability: 0.0001, prize: 1_000_000 },
94
+ { probability: 0.9836, prize: 50 },
95
+ ],
96
+ },
97
+ },
98
+ ],
99
+ },
100
+ dragState: 'drag',
101
+ }
102
+
103
+ describe('gameplay manifest', () => {
104
+ it('accepts the full miku-shaped block', () => {
105
+ const result = parsePetManifest(mikuManifest(FULL_GAMEPLAY), 'mem')
106
+ expect(result.ok).toBe(true)
107
+ if (!result.ok) return
108
+ const gameplay = result.manifest.gameplay!
109
+ expect(gameplay.idleDirector?.acts).toHaveLength(4)
110
+ expect(gameplay.stats?.affection?.max).toBe(500)
111
+ expect(gameplay.touch?.zones).toHaveLength(3)
112
+ expect(gameplay.work?.tickMs).toBe(10_000)
113
+ expect(gameplay.sleep?.restore).toEqual({ stat: 'energy', amount: 4, intervalMs: 30_000 })
114
+ expect(gameplay.shop?.items).toHaveLength(3)
115
+ expect(gameplay.dragState).toBe('drag')
116
+ })
117
+
118
+ it('rejects gameplay on non-frames2d renderers', () => {
119
+ const sprite = {
120
+ petManifestVersion: 2, id: 'cat', displayName: 'Cat', license: 'CC0-1.0',
121
+ renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp' },
122
+ gameplay: {},
123
+ }
124
+ const result = parsePetManifest(sprite, 'mem')
125
+ expect(result.ok).toBe(false)
126
+ })
127
+
128
+ it('rejects unknown fields and dangling references', () => {
129
+ expect(parsePetManifest(mikuManifest({ ...FULL_GAMEPLAY, casino: {} }), 'mem').ok).toBe(false)
130
+ const badStatRef = mikuManifest({ stats: { hunger: { max: 100 } }, shop: { items: [{ id: 'x', label: 'x', price: 1, currency: 'coins', effects: [{ stat: 'ghost', amount: 1 }] }] } })
131
+ expect(parsePetManifest(badStatRef, 'mem').ok).toBe(false)
132
+ const badTrackRef = mikuManifest({ work: { state: 'ghost', successState: 'success', failState: 'fail', tickMs: 1000, successProbability: 0.5 } })
133
+ expect(parsePetManifest(badTrackRef, 'mem').ok).toBe(false)
134
+ })
135
+
136
+ it('rejects probability sums above 1 and duplicate shop ids', () => {
137
+ const overTouch = mikuManifest({ touch: { zones: [{ name: 'z', y0: 0, y1: 1, branches: [{ probability: 0.6 }, { probability: 0.6 }] }] } })
138
+ expect(parsePetManifest(overTouch, 'mem').ok).toBe(false)
139
+ const dupShop = mikuManifest({ shop: { items: [
140
+ { id: 'a', label: 'a', price: 1, currency: 'c', effects: [{ currency: 'c', amount: 1 }] },
141
+ { id: 'a', label: 'b', price: 1, currency: 'c', effects: [{ currency: 'c', amount: 1 }] },
142
+ ] } })
143
+ expect(parsePetManifest(dupShop, 'mem').ok).toBe(false)
144
+ })
145
+
146
+ it('rejects a shop item with neither effects nor lottery', () => {
147
+ const bare = mikuManifest({ shop: { items: [{ id: 'a', label: 'a', price: 1, currency: 'coins' }] } })
148
+ expect(parsePetManifest(bare, 'mem').ok).toBe(false)
149
+ })
150
+
151
+ it('rejects bad ranges (tickMs, decay, zone slices, stateMs)', () => {
152
+ const badTick = mikuManifest({ work: { state: 'work', successState: 'success', failState: 'fail', tickMs: 50, successProbability: 0.5 } })
153
+ expect(parsePetManifest(badTick, 'mem').ok).toBe(false)
154
+ const badDecay = mikuManifest({ stats: { hunger: { max: 100, decayPerMinute: -1 } } })
155
+ expect(parsePetManifest(badDecay, 'mem').ok).toBe(false)
156
+ const badZone = mikuManifest({ touch: { zones: [{ name: 'z', y0: 0.8, y1: 0.2, branches: [{ probability: 1 }] }] } })
157
+ expect(parsePetManifest(badZone, 'mem').ok).toBe(false)
158
+ })
159
+ })
160
+
161
+ describe('gameplay engine', () => {
162
+ const manifest = parsePetManifest(mikuManifest(FULL_GAMEPLAY), 'mem')
163
+ if (!manifest.ok) throw new Error('fixture must parse')
164
+ const def = manifest.manifest.gameplay!
165
+
166
+ it('initializes stats at their declared initial (default max)', () => {
167
+ const state = initialGameplayState(def, 1000)
168
+ expect(state.stats).toEqual({ hunger: 100, mood: 100, energy: 100, affection: 100 })
169
+ expect(state.currencies).toEqual({})
170
+ expect(state.mode).toBeNull()
171
+ })
172
+
173
+ it('lazy-settles decay, passive income and sleep restore', () => {
174
+ const state = initialGameplayState(def, 0)
175
+ state.stats.hunger = 50
176
+ state.mode = 'sleep'
177
+ // 10 minutes pass with an active session: hunger -10 (1/min), mood -5,
178
+ // energy -2.5 then +20 sleep restore (10 x 30s ticks x 4), coins +10.
179
+ const changed = settleGameplay(state, def, 600_000, { sessionActive: true })
180
+ expect(changed).toBe(true)
181
+ expect(state.stats.hunger).toBe(40)
182
+ expect(state.stats.mood).toBe(95)
183
+ expect(state.stats.energy).toBe(100) // 97.5 + 20 clamped to max
184
+ expect(state.currencies.coins).toBe(10)
185
+ expect(state.settledAt).toBe(600_000)
186
+ })
187
+
188
+ it('applies the working decay variant while working and idle decay without a session', () => {
189
+ const state = initialGameplayState(def, 0)
190
+ state.mode = 'work'
191
+ settleGameplay(state, def, 60_000, { sessionActive: true })
192
+ expect(state.stats.hunger).toBe(95) // working: 5/min
193
+ const idle = initialGameplayState(def, 0)
194
+ settleGameplay(idle, def, 300_000, { sessionActive: false })
195
+ expect(idle.stats.affection).toBe(99) // idle decay 0.2/min over 5 min
196
+ })
197
+
198
+ it('clamps effects into stat max and currency floors', () => {
199
+ const state = initialGameplayState(def, 0)
200
+ applyGameplayEffects(state, def, [{ stat: 'affection', amount: 10_000 }, { currency: 'coins', amount: -5 }])
201
+ expect(state.stats.affection).toBe(500)
202
+ expect(state.currencies.coins).toBe(0)
203
+ })
204
+
205
+ it('rolls touch branches by cumulative probability with no-op mass', () => {
206
+ const zone = def.touch!.zones[0]! // head: 5% hit
207
+ expect(rollTouchBranch(zone, () => 0.04)?.state).toBe('happy')
208
+ expect(rollTouchBranch(zone, () => 0.5)).toBeUndefined()
209
+ const legs = def.touch!.zones[2]!
210
+ expect(rollTouchBranch(legs, () => 0.05)?.state).toBe('flirty')
211
+ expect(rollTouchBranch(legs, () => 0.5)?.state).toBe('angry')
212
+ })
213
+
214
+ it('rolls work outcomes and draws lottery tiers with fallthrough', () => {
215
+ expect(rollWorkOutcome(def.work!, () => 0.4)).toBe('success')
216
+ expect(rollWorkOutcome(def.work!, () => 0.6)).toBe('fail')
217
+ const lottery = def.shop!.items[2]!.lottery!
218
+ expect(drawLotteryTier(lottery, () => 0.00005).prize).toBe(1_000_000)
219
+ expect(drawLotteryTier(lottery, () => 0.5).prize).toBe(50)
220
+ // Sum < 1: the uncovered roll mass falls through to the last tier.
221
+ expect(drawLotteryTier(lottery, () => 0.9999).prize).toBe(50)
222
+ })
223
+
224
+ it('locates the touch zone for a hit-box fraction', () => {
225
+ expect(touchZoneAt(def.touch!, 0.3)?.name).toBe('head')
226
+ expect(touchZoneAt(def.touch!, 0.6)?.name).toBe('body')
227
+ expect(touchZoneAt(def.touch!, 0.9)?.name).toBe('legs')
228
+ expect(touchZoneAt(def.touch!, 0.55)?.name).toBe('body')
229
+ })
230
+
231
+ it('clamps gameplay state into declared maxima', () => {
232
+ const state = initialGameplayState(def, 0)
233
+ state.stats.hunger = 10_000
234
+ state.currencies.coins = 100_000_000
235
+ clampGameplay(state, def)
236
+ expect(state.stats.hunger).toBe(100)
237
+ expect(state.currencies.coins).toBe(9_999_999)
238
+ })
239
+ })