@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.
- package/README.i18n.yaml +2 -2
- package/README.md +30 -13
- package/README.zh.md +30 -13
- package/assets/ouo-neko/pet.json +31 -0
- package/assets/ouo-neko/previews/failed.gif +0 -0
- package/assets/ouo-neko/previews/idle.gif +0 -0
- package/assets/ouo-neko/previews/jumping.gif +0 -0
- package/assets/ouo-neko/previews/review.gif +0 -0
- package/assets/ouo-neko/previews/running-left.gif +0 -0
- package/assets/ouo-neko/previews/running-right.gif +0 -0
- package/assets/ouo-neko/previews/running.gif +0 -0
- package/assets/ouo-neko/previews/waiting.gif +0 -0
- package/assets/ouo-neko/previews/waving.gif +0 -0
- package/assets/ouo-neko/spritesheet.webp +0 -0
- package/contracts/pet-manifest-v2.schema.json +102 -1
- package/contracts/voice-pack-v1.schema.json +23 -28
- package/lib/client.js +781 -40
- package/lib/client.js.map +1 -1
- package/lib/index.js +1567 -482
- package/lib/types/chatter.d.ts +68 -46
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +243 -301
- package/lib/types/client/PetDockEntry.d.ts +3 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +18 -2
- package/lib/types/client/PetSprite.d.ts +23 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +49 -45
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/drag-stream.d.ts +19 -0
- package/lib/types/client/drag-stream.d.ts.map +1 -0
- package/lib/types/client/drag-stream.js +26 -0
- package/lib/types/client/gameplay-hud.d.ts +50 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -0
- package/lib/types/client/gameplay-hud.js +274 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +38 -9
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/pet-store.d.ts +7 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
- package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
- package/lib/types/client/renderers/frames2d.d.ts +36 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
- package/lib/types/client/renderers/frames2d.js +196 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/defaults.js +5 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/event-projection.d.ts +9 -4
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +46 -14
- package/lib/types/gameplay.d.ts +182 -0
- package/lib/types/gameplay.d.ts.map +1 -0
- package/lib/types/gameplay.js +618 -0
- package/lib/types/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- package/lib/types/manifest-v2.d.ts +38 -1
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +182 -2
- package/lib/types/persist.d.ts +4 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +41 -4
- package/lib/types/registry.d.ts +29 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +170 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +30 -3
- package/lib/types/service.d.ts +63 -11
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +208 -19
- package/lib/types/voice-pack.d.ts +6 -7
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +44 -47
- package/package.json +5 -2
- package/src/chatter.test.ts +68 -38
- package/src/chatter.ts +269 -316
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +68 -21
- package/src/client/PetSprite.tsx +63 -45
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/drag-stream.ts +35 -0
- package/src/client/gameplay-hud.test.tsx +338 -0
- package/src/client/gameplay-hud.tsx +403 -0
- package/src/client/index.test.tsx +10 -1
- package/src/client/index.ts +46 -11
- package/src/client/locales.ts +36 -0
- package/src/client/pet-store.ts +10 -1
- package/src/client/pet.module.css +192 -29
- package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
- package/src/client/renderers/PetRendererSwitch.tsx +28 -1
- package/src/client/renderers/frames2d.test.ts +137 -0
- package/src/client/renderers/frames2d.ts +216 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/event-projection.ts +57 -16
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/persist.test.ts +3 -0
- package/src/persist.ts +43 -6
- package/src/registry.test.ts +168 -1
- package/src/registry.ts +199 -1
- package/src/routes.ts +28 -4
- package/src/service.ts +240 -29
- package/src/voice-pack.test.ts +48 -25
- 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;
|
package/src/defaults.ts
ADDED
|
@@ -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
|
|
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
|
|
34
|
+
return isAbs(expanded) ? expanded : j(process.cwd(), expanded)
|
|
29
35
|
}
|
|
30
|
-
return
|
|
36
|
+
return j(home, '.dsh')
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/** Resolve the DSH home directory from the live environment. */
|
package/src/event-projection.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
+
})
|