@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.
Files changed (107) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -3
  3. package/README.zh.md +17 -3
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/lib/client.js +759 -26
  17. package/lib/client.js.map +1 -1
  18. package/lib/index.js +1224 -22
  19. package/lib/types/client/PetDockEntry.d.ts +3 -0
  20. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  21. package/lib/types/client/PetDockEntry.js +18 -2
  22. package/lib/types/client/PetSprite.d.ts +23 -0
  23. package/lib/types/client/PetSprite.d.ts.map +1 -1
  24. package/lib/types/client/PetSprite.js +34 -29
  25. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  26. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PluginSettingsCard.js +2 -2
  28. package/lib/types/client/drag-stream.d.ts +19 -0
  29. package/lib/types/client/drag-stream.d.ts.map +1 -0
  30. package/lib/types/client/drag-stream.js +26 -0
  31. package/lib/types/client/gameplay-hud.d.ts +50 -0
  32. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  33. package/lib/types/client/gameplay-hud.js +274 -0
  34. package/lib/types/client/index.d.ts.map +1 -1
  35. package/lib/types/client/index.js +12 -0
  36. package/lib/types/client/locales.d.ts +34 -0
  37. package/lib/types/client/locales.d.ts.map +1 -1
  38. package/lib/types/client/locales.js +36 -0
  39. package/lib/types/client/pet-store.d.ts +7 -1
  40. package/lib/types/client/pet-store.d.ts.map +1 -1
  41. package/lib/types/client/pet-store.js +4 -0
  42. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  43. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  44. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  45. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  46. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  47. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  48. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  49. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  50. package/lib/types/client/renderers/frames2d.js +196 -0
  51. package/lib/types/defaults.d.ts +6 -0
  52. package/lib/types/defaults.d.ts.map +1 -0
  53. package/lib/types/defaults.js +5 -0
  54. package/lib/types/dsh-home.d.ts.map +1 -1
  55. package/lib/types/dsh-home.js +9 -3
  56. package/lib/types/gameplay.d.ts +182 -0
  57. package/lib/types/gameplay.d.ts.map +1 -0
  58. package/lib/types/gameplay.js +618 -0
  59. package/lib/types/ledger.d.ts +14 -0
  60. package/lib/types/ledger.d.ts.map +1 -1
  61. package/lib/types/ledger.js +30 -0
  62. package/lib/types/manifest-v2.d.ts +38 -1
  63. package/lib/types/manifest-v2.d.ts.map +1 -1
  64. package/lib/types/manifest-v2.js +182 -2
  65. package/lib/types/persist.d.ts +4 -4
  66. package/lib/types/persist.d.ts.map +1 -1
  67. package/lib/types/persist.js +41 -4
  68. package/lib/types/registry.d.ts +29 -0
  69. package/lib/types/registry.d.ts.map +1 -1
  70. package/lib/types/registry.js +170 -1
  71. package/lib/types/routes.d.ts.map +1 -1
  72. package/lib/types/routes.js +22 -0
  73. package/lib/types/service.d.ts +56 -0
  74. package/lib/types/service.d.ts.map +1 -1
  75. package/lib/types/service.js +181 -0
  76. package/package.json +5 -2
  77. package/src/client/PetDockEntry.test.tsx +6 -0
  78. package/src/client/PetDockEntry.tsx +32 -1
  79. package/src/client/PetSprite.test.tsx +36 -6
  80. package/src/client/PetSprite.tsx +42 -21
  81. package/src/client/PluginSettingsCard.tsx +4 -2
  82. package/src/client/drag-stream.ts +35 -0
  83. package/src/client/gameplay-hud.test.tsx +338 -0
  84. package/src/client/gameplay-hud.tsx +403 -0
  85. package/src/client/index.ts +17 -1
  86. package/src/client/locales.ts +36 -0
  87. package/src/client/pet-store.ts +10 -1
  88. package/src/client/pet.module.css +192 -29
  89. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  90. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  91. package/src/client/renderers/frames2d.test.ts +137 -0
  92. package/src/client/renderers/frames2d.ts +216 -0
  93. package/src/client/settings-card.module.css +5 -0
  94. package/src/defaults.ts +7 -0
  95. package/src/dsh-home.ts +9 -3
  96. package/src/gameplay.test.ts +239 -0
  97. package/src/gameplay.ts +692 -0
  98. package/src/ledger.test.ts +16 -0
  99. package/src/ledger.ts +31 -0
  100. package/src/manifest-v2.test.ts +130 -1
  101. package/src/manifest-v2.ts +181 -2
  102. package/src/persist.test.ts +3 -0
  103. package/src/persist.ts +43 -6
  104. package/src/registry.test.ts +168 -1
  105. package/src/registry.ts +199 -1
  106. package/src/routes.ts +19 -0
  107. 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;
@@ -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. */
@@ -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
+ })