@linxin666/dsh-pet 0.3.3 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +17 -3
- package/README.zh.md +17 -3
- 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/lib/client.js +759 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +1224 -22
- 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 +34 -29
- 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 +12 -0
- 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/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 +22 -0
- package/lib/types/service.d.ts +56 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +181 -0
- package/package.json +5 -2
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +36 -6
- package/src/client/PetSprite.tsx +42 -21
- 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.ts +17 -1
- 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/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 +19 -0
- package/src/service.ts +208 -0
|
@@ -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. */
|
|
@@ -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
|
+
})
|