@linxin666/dsh-pet 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +25 -7
  3. package/README.zh.md +25 -7
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/lib/client.js +830 -26
  17. package/lib/client.js.map +1 -1
  18. package/lib/index.js +1226 -24
  19. package/lib/types/client/PetDockEntry.d.ts +3 -0
  20. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  21. package/lib/types/client/PetDockEntry.js +18 -2
  22. package/lib/types/client/PetSprite.d.ts +23 -0
  23. package/lib/types/client/PetSprite.d.ts.map +1 -1
  24. package/lib/types/client/PetSprite.js +34 -29
  25. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  26. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PluginSettingsCard.js +2 -2
  28. package/lib/types/client/drag-stream.d.ts +19 -0
  29. package/lib/types/client/drag-stream.d.ts.map +1 -0
  30. package/lib/types/client/drag-stream.js +26 -0
  31. package/lib/types/client/gameplay-hud.d.ts +50 -0
  32. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  33. package/lib/types/client/gameplay-hud.js +274 -0
  34. package/lib/types/client/index.d.ts +1 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/lib/types/client/index.js +16 -0
  37. package/lib/types/client/locales.d.ts +34 -0
  38. package/lib/types/client/locales.d.ts.map +1 -1
  39. package/lib/types/client/locales.js +36 -0
  40. package/lib/types/client/pet-store.d.ts +7 -1
  41. package/lib/types/client/pet-store.d.ts.map +1 -1
  42. package/lib/types/client/pet-store.js +4 -0
  43. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  44. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  45. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  46. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  47. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  48. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  49. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  50. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  51. package/lib/types/client/renderers/frames2d.js +196 -0
  52. package/lib/types/client/settings-form.d.ts +1 -1
  53. package/lib/types/client/settings-form.js +1 -1
  54. package/lib/types/client/telemetry.d.ts +27 -0
  55. package/lib/types/client/telemetry.d.ts.map +1 -0
  56. package/lib/types/client/telemetry.js +97 -0
  57. package/lib/types/defaults.d.ts +6 -0
  58. package/lib/types/defaults.d.ts.map +1 -0
  59. package/lib/types/defaults.js +5 -0
  60. package/lib/types/dsh-home.d.ts.map +1 -1
  61. package/lib/types/dsh-home.js +9 -3
  62. package/lib/types/gameplay.d.ts +182 -0
  63. package/lib/types/gameplay.d.ts.map +1 -0
  64. package/lib/types/gameplay.js +618 -0
  65. package/lib/types/http.d.ts +1 -1
  66. package/lib/types/http.js +1 -1
  67. package/lib/types/index.d.ts +1 -1
  68. package/lib/types/index.js +1 -1
  69. package/lib/types/ledger.d.ts +14 -0
  70. package/lib/types/ledger.d.ts.map +1 -1
  71. package/lib/types/ledger.js +30 -0
  72. package/lib/types/loopback.d.ts +1 -1
  73. package/lib/types/loopback.js +1 -1
  74. package/lib/types/manifest-v2.d.ts +38 -1
  75. package/lib/types/manifest-v2.d.ts.map +1 -1
  76. package/lib/types/manifest-v2.js +182 -2
  77. package/lib/types/mount-once.d.ts +1 -1
  78. package/lib/types/mount-once.js +2 -2
  79. package/lib/types/persist.d.ts +4 -4
  80. package/lib/types/persist.d.ts.map +1 -1
  81. package/lib/types/persist.js +41 -4
  82. package/lib/types/registry.d.ts +29 -0
  83. package/lib/types/registry.d.ts.map +1 -1
  84. package/lib/types/registry.js +170 -1
  85. package/lib/types/routes.d.ts.map +1 -1
  86. package/lib/types/routes.js +22 -0
  87. package/lib/types/service.d.ts +56 -0
  88. package/lib/types/service.d.ts.map +1 -1
  89. package/lib/types/service.js +181 -0
  90. package/package.json +6 -3
  91. package/src/client/PetDockEntry.test.tsx +6 -0
  92. package/src/client/PetDockEntry.tsx +32 -1
  93. package/src/client/PetSprite.test.tsx +36 -6
  94. package/src/client/PetSprite.tsx +42 -21
  95. package/src/client/PluginSettingsCard.tsx +4 -2
  96. package/src/client/drag-stream.ts +35 -0
  97. package/src/client/gameplay-hud.test.tsx +338 -0
  98. package/src/client/gameplay-hud.tsx +403 -0
  99. package/src/client/index.ts +23 -2
  100. package/src/client/locales.ts +36 -0
  101. package/src/client/pet-store.ts +10 -1
  102. package/src/client/pet.module.css +192 -29
  103. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  104. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  105. package/src/client/renderers/frames2d.test.ts +137 -0
  106. package/src/client/renderers/frames2d.ts +216 -0
  107. package/src/client/settings-card.module.css +5 -0
  108. package/src/client/settings-form.ts +1 -1
  109. package/src/client/telemetry.ts +105 -0
  110. package/src/defaults.ts +7 -0
  111. package/src/dsh-home.ts +9 -3
  112. package/src/gameplay.test.ts +239 -0
  113. package/src/gameplay.ts +692 -0
  114. package/src/http.ts +1 -1
  115. package/src/index.ts +1 -1
  116. package/src/ledger.test.ts +16 -0
  117. package/src/ledger.ts +31 -0
  118. package/src/loopback.ts +1 -1
  119. package/src/manifest-v2.test.ts +130 -1
  120. package/src/manifest-v2.ts +181 -2
  121. package/src/mount-once.ts +2 -2
  122. package/src/persist.test.ts +3 -0
  123. package/src/persist.ts +43 -6
  124. package/src/registry.test.ts +168 -1
  125. package/src/registry.ts +199 -1
  126. package/src/routes.ts +19 -0
  127. package/src/service.ts +208 -0
@@ -0,0 +1,692 @@
1
+ /**
2
+ * Pet gameplay — the optional manifest 'gameplay' block and its host engine.
3
+ * The block layers an opt-in mini-game over any frames2d pet: decaying stat
4
+ * bars, currencies, a weighted idle director, touch zones, a work loop, a
5
+ * sleep loop, passive income and a shop (issue: miku-pet generalization).
6
+ *
7
+ * Discipline split matches manifest-v2: STRUCTURE is fail-closed (types,
8
+ * ranges, references into stats/tracks); the host engine is pure — every
9
+ * verb takes an explicit clock and rng so tests stay deterministic. Decay,
10
+ * passive income and sleep restore are lazy-settled on read (the treats.ts
11
+ * discipline): the host runs no timers for gameplay.
12
+ * @module @linxin666/dsh-pet/gameplay
13
+ */
14
+
15
+ /** One gameplay effect: add amount to a declared stat or a currency. */
16
+ export interface PetGameplayEffect {
17
+ stat?: string
18
+ currency?: string
19
+ amount: number
20
+ }
21
+
22
+ /** One roll branch inside a touch zone; uncovered roll mass is a no-op. */
23
+ export interface PetGameplayTouchBranch {
24
+ probability: number
25
+ effects?: PetGameplayEffect[]
26
+ /** Track played on hit (held for stateMs, then the renderer settles). */
27
+ state?: string
28
+ stateMs?: number
29
+ /** Bubble phrase pool; one is picked on hit. */
30
+ phrases?: string[]
31
+ }
32
+
33
+ export interface PetGameplayTouchZone {
34
+ name: string
35
+ /** Vertical slice of the hit box (fractions, y0 < y1). */
36
+ y0: number
37
+ y1: number
38
+ branches: PetGameplayTouchBranch[]
39
+ }
40
+
41
+ export interface PetGameplayStatDef {
42
+ max: number
43
+ initial?: number
44
+ decayPerMinute?: number
45
+ /** Decay rate while the work mode is active (defaults to decayPerMinute). */
46
+ workingDecayPerMinute?: number
47
+ /** Extra decay rate while no session is active. */
48
+ idleDecayPerMinute?: number
49
+ }
50
+
51
+ export interface PetGameplayShopItem {
52
+ id: string
53
+ label: string
54
+ /** Optional frame path (manifest-relative) shown as the item icon. */
55
+ image?: string
56
+ price: number
57
+ currency: string
58
+ effects?: PetGameplayEffect[]
59
+ lottery?: {
60
+ effects?: PetGameplayEffect[]
61
+ /** Default currency the prize is paid in (tiers may override). */
62
+ currency?: string
63
+ tiers: { probability: number; prize: number; currency?: string }[]
64
+ }
65
+ }
66
+
67
+ /** The validated manifest 'gameplay' block. */
68
+ export interface PetGameplayManifest {
69
+ idleDirector?: {
70
+ intervalMs: number
71
+ maxMiss: number
72
+ idleWeight: number
73
+ acts: { track: string; weight: number; phrases?: string[] }[]
74
+ }
75
+ stats?: Record<string, PetGameplayStatDef>
76
+ /** Click hit box inside the sprite box (fractions). */
77
+ hitBox?: { x0: number; y0: number; x1: number; y1: number }
78
+ touch?: {
79
+ zones: PetGameplayTouchZone[]
80
+ /** Plain-click effect while a touch animation holds (miku: mood +0..3). */
81
+ clickBoost?: { stat: string; min: number; max: number }
82
+ }
83
+ work?: {
84
+ state: string
85
+ successState: string
86
+ failState: string
87
+ tickMs: number
88
+ /** Hold time of the result track before the next round. */
89
+ resultMs?: { success: number; fail: number }
90
+ successProbability: number
91
+ success?: { effects: PetGameplayEffect[] }
92
+ fail?: { effects: PetGameplayEffect[] }
93
+ }
94
+ sleep?: {
95
+ state: string
96
+ wakeState?: string
97
+ restore: { stat: string; amount: number; intervalMs: number }
98
+ }
99
+ passiveIncome?: { currency: string; amount: number; intervalMs: number }
100
+ shop?: { state?: string; items: PetGameplayShopItem[] }
101
+ /** Track played while the chrome reports dragging (default 'drag'). */
102
+ dragState?: string
103
+ /** Track played once when a drag ends (miku: standup), before settling. */
104
+ dragEndState?: string
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ *
108
+ * Manifest parsing (fail-closed structure)
109
+ * ------------------------------------------------------------------ */
110
+
111
+ const KEBAB = /^[a-z0-9][a-z0-9-]*$/
112
+ const MAX_STATS = 16
113
+ const MAX_ZONES = 8
114
+ const MAX_BRANCHES = 8
115
+ const MAX_ACTS = 16
116
+ const MAX_SHOP_ITEMS = 32
117
+ const MAX_LOTTERY_TIERS = 16
118
+ const MAX_PHRASES = 64
119
+ const PHRASE_MAX_LENGTH = 120
120
+ const STAT_VALUE_MAX = 1_000_000
121
+ const CURRENCY_MAX = 9_999_999
122
+
123
+ const KNOWN_GAMEPLAY = new Set(['idleDirector', 'stats', 'hitBox', 'touch', 'work', 'sleep', 'passiveIncome', 'shop', 'dragState', 'dragEndState'])
124
+ const KNOWN_STAT = new Set(['max', 'initial', 'decayPerMinute', 'workingDecayPerMinute', 'idleDecayPerMinute'])
125
+ const KNOWN_ZONE = new Set(['name', 'y0', 'y1', 'branches'])
126
+ const KNOWN_TOUCH = new Set(['zones', 'clickBoost'])
127
+ const KNOWN_BRANCH = new Set(['probability', 'effects', 'state', 'stateMs', 'phrases'])
128
+ const KNOWN_EFFECT = new Set(['stat', 'currency', 'amount'])
129
+ const KNOWN_WORK = new Set(['state', 'successState', 'failState', 'tickMs', 'resultMs', 'successProbability', 'success', 'fail'])
130
+ const KNOWN_SLEEP = new Set(['state', 'wakeState', 'restore'])
131
+ const KNOWN_SHOP_ITEM = new Set(['id', 'label', 'image', 'price', 'currency', 'effects', 'lottery'])
132
+ const KNOWN_LOTTERY = new Set(['effects', 'currency', 'tiers'])
133
+ const KNOWN_IDLE_DIRECTOR = new Set(['intervalMs', 'maxMiss', 'idleWeight', 'acts'])
134
+ const KNOWN_ACT = new Set(['track', 'weight', 'phrases'])
135
+
136
+ function isRecord(value: unknown): value is Record<string, unknown> {
137
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
138
+ }
139
+
140
+ function unknownKeys(source: Record<string, unknown>, known: Set<string>): string[] {
141
+ return Object.keys(source).filter(key => !known.has(key))
142
+ }
143
+
144
+ export interface GameplayParseHooks {
145
+ /** State names the renderer can play (frames2d track ids). */
146
+ stateNames: ReadonlySet<string>
147
+ error: (message: string) => void
148
+ }
149
+
150
+ function validName(name: unknown, max = 32): name is string {
151
+ return typeof name === 'string' && name.length <= max && KEBAB.test(name)
152
+ }
153
+
154
+ function intIn(value: unknown, min: number, max: number): value is number {
155
+ return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max
156
+ }
157
+
158
+ function numIn(value: unknown, min: number, max: number): value is number {
159
+ return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max
160
+ }
161
+
162
+ function parseEffects(raw: unknown, field: string, stats: Record<string, PetGameplayStatDef>, hooks: GameplayParseHooks): PetGameplayEffect[] | undefined {
163
+ if (raw === undefined) return undefined
164
+ if (!Array.isArray(raw) || raw.length === 0) {
165
+ hooks.error(field + ' must be a non-empty array of effects')
166
+ return undefined
167
+ }
168
+ const effects: PetGameplayEffect[] = []
169
+ for (const entry of raw) {
170
+ if (!isRecord(entry)) {
171
+ hooks.error(field + ': every effect must be an object')
172
+ continue
173
+ }
174
+ const extra = unknownKeys(entry, KNOWN_EFFECT)
175
+ if (extra.length > 0) hooks.error(field + ': unknown effect field(s) ' + extra.map(k => JSON.stringify(k)).join(', '))
176
+ const hasStat = typeof entry.stat === 'string'
177
+ const hasCurrency = typeof entry.currency === 'string'
178
+ if (hasStat === hasCurrency) {
179
+ hooks.error(field + ': an effect needs exactly one of stat or currency')
180
+ continue
181
+ }
182
+ if (!intIn(entry.amount, -STAT_VALUE_MAX, STAT_VALUE_MAX) || entry.amount === 0) {
183
+ hooks.error(field + ': effect amount must be a non-zero integer within ±' + STAT_VALUE_MAX)
184
+ continue
185
+ }
186
+ if (hasStat && stats[entry.stat as string] === undefined) {
187
+ hooks.error(field + ': effect references undeclared stat ' + JSON.stringify(entry.stat))
188
+ continue
189
+ }
190
+ if (hasCurrency && !validName(entry.currency, 24)) {
191
+ hooks.error(field + ': effect currency must be a kebab id')
192
+ continue
193
+ }
194
+ effects.push({
195
+ ...(hasStat ? { stat: entry.stat as string } : {}),
196
+ ...(hasCurrency ? { currency: entry.currency as string } : {}),
197
+ amount: entry.amount as number,
198
+ })
199
+ }
200
+ return effects.length === 0 ? undefined : effects
201
+ }
202
+
203
+ function parsePhrases(raw: unknown, field: string, hooks: GameplayParseHooks): string[] | undefined {
204
+ if (raw === undefined) return undefined
205
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_PHRASES
206
+ || raw.some(line => typeof line !== 'string' || line.trim() === '' || line.length > PHRASE_MAX_LENGTH)) {
207
+ hooks.error(field + ' must be 1..' + MAX_PHRASES + ' non-empty lines of at most ' + PHRASE_MAX_LENGTH + ' chars')
208
+ return undefined
209
+ }
210
+ return raw as string[]
211
+ }
212
+
213
+ function parseStateRef(raw: unknown, field: string, hooks: GameplayParseHooks): string | undefined {
214
+ if (raw === undefined) return undefined
215
+ if (typeof raw !== 'string' || !hooks.stateNames.has(raw)) {
216
+ hooks.error(field + ' must name a declared frames2d track')
217
+ return undefined
218
+ }
219
+ return raw
220
+ }
221
+
222
+ /**
223
+ * Validate the manifest 'gameplay' block (fail-closed). Only frames2d pets
224
+ * may declare gameplay today: every state reference checks against the
225
+ * declared track names.
226
+ */
227
+ export function parseGameplayManifest(raw: unknown, hooks: GameplayParseHooks): PetGameplayManifest | undefined {
228
+ const error = (message: string): void => hooks.error(message)
229
+ if (!isRecord(raw)) {
230
+ error('gameplay must be an object')
231
+ return undefined
232
+ }
233
+ const extra = unknownKeys(raw, KNOWN_GAMEPLAY)
234
+ if (extra.length > 0) error('gameplay: unknown field(s) ' + extra.map(k => JSON.stringify(k)).join(', '))
235
+ let failed = false
236
+ const fail = (message: string): void => { failed = true; error(message) }
237
+
238
+ // --- stats (parsed first: effects reference them) ---
239
+ const stats: Record<string, PetGameplayStatDef> = {}
240
+ if (raw.stats !== undefined) {
241
+ if (!isRecord(raw.stats)) fail('gameplay.stats must be an object keyed by stat id')
242
+ else {
243
+ const entries = Object.entries(raw.stats)
244
+ if (entries.length > MAX_STATS) fail('gameplay.stats declares too many stats (max ' + MAX_STATS + ')')
245
+ for (const [name, value] of entries) {
246
+ if (!validName(name, 24)) {
247
+ fail('gameplay.stats: invalid stat id ' + JSON.stringify(name))
248
+ continue
249
+ }
250
+ if (!isRecord(value)) {
251
+ fail('gameplay.stats.' + name + ' must be an object')
252
+ continue
253
+ }
254
+ const statExtra = unknownKeys(value, KNOWN_STAT)
255
+ if (statExtra.length > 0) fail('gameplay.stats.' + name + ': unknown field(s) ' + statExtra.map(k => JSON.stringify(k)).join(', '))
256
+ if (!intIn(value.max, 1, STAT_VALUE_MAX)) {
257
+ fail('gameplay.stats.' + name + '.max must be an integer in [1, ' + STAT_VALUE_MAX + ']')
258
+ continue
259
+ }
260
+ const def: PetGameplayStatDef = { max: value.max }
261
+ if (value.initial !== undefined) {
262
+ if (!numIn(value.initial, 0, value.max)) fail('gameplay.stats.' + name + '.initial must be within [0, max]')
263
+ else def.initial = value.initial
264
+ }
265
+ for (const key of ['decayPerMinute', 'workingDecayPerMinute', 'idleDecayPerMinute'] as const) {
266
+ if (value[key] !== undefined) {
267
+ if (!numIn(value[key], 0, 1000)) fail('gameplay.stats.' + name + '.' + key + ' must be a number in [0, 1000]')
268
+ else def[key] = value[key] as number
269
+ }
270
+ }
271
+ stats[name] = def
272
+ }
273
+ }
274
+ }
275
+
276
+ const block: PetGameplayManifest = {}
277
+ if (Object.keys(stats).length > 0) block.stats = stats
278
+
279
+ // --- idle director ---
280
+ if (raw.idleDirector !== undefined) {
281
+ if (!isRecord(raw.idleDirector) || !Array.isArray(raw.idleDirector.acts)) {
282
+ fail('gameplay.idleDirector must be an object with an acts array')
283
+ } else {
284
+ const d = raw.idleDirector as Record<string, unknown> & { acts: unknown[] }
285
+ const dExtra = unknownKeys(d, KNOWN_IDLE_DIRECTOR)
286
+ if (dExtra.length > 0) fail('gameplay.idleDirector: unknown field(s) ' + dExtra.map(k => JSON.stringify(k)).join(', '))
287
+ if (d.intervalMs !== undefined && !intIn(d.intervalMs, 1000, 60_000)) fail('gameplay.idleDirector.intervalMs must be an integer in [1000, 60000]')
288
+ if (d.maxMiss !== undefined && !intIn(d.maxMiss, 0, 10)) fail('gameplay.idleDirector.maxMiss must be an integer in [0, 10]')
289
+ if (d.idleWeight !== undefined && !intIn(d.idleWeight, 0, 10_000)) fail('gameplay.idleDirector.idleWeight must be an integer in [0, 10000]')
290
+ if (d.acts.length === 0 || d.acts.length > MAX_ACTS) fail('gameplay.idleDirector.acts must declare 1..' + MAX_ACTS + ' acts')
291
+ const acts: { track: string; weight: number; phrases?: string[] }[] = []
292
+ for (const act of d.acts as unknown[]) {
293
+ if (!isRecord(act) || !intIn(act.weight, 1, 10_000)) {
294
+ fail('gameplay.idleDirector.acts entries need a weight integer in [1, 10000]')
295
+ continue
296
+ }
297
+ const aExtra = unknownKeys(act, KNOWN_ACT)
298
+ if (aExtra.length > 0) fail('gameplay.idleDirector.acts: unknown field(s) ' + aExtra.map(k => JSON.stringify(k)).join(', '))
299
+ const track = parseStateRef(act.track, 'gameplay.idleDirector.acts.track', hooks)
300
+ if (track === undefined) continue
301
+ const entry: { track: string; weight: number; phrases?: string[] } = { track, weight: act.weight }
302
+ const phrases = parsePhrases(act.phrases, 'gameplay.idleDirector.acts.phrases', hooks)
303
+ if (phrases !== undefined) entry.phrases = phrases
304
+ acts.push(entry)
305
+ }
306
+ if (acts.length > 0) {
307
+ block.idleDirector = {
308
+ intervalMs: intIn(d.intervalMs, 1000, 60_000) ? d.intervalMs as number : 5000,
309
+ maxMiss: intIn(d.maxMiss, 0, 10) ? d.maxMiss as number : 2,
310
+ idleWeight: intIn(d.idleWeight, 0, 10_000) ? d.idleWeight as number : 0,
311
+ acts,
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ // --- hit box ---
318
+ if (raw.hitBox !== undefined) {
319
+ const b = raw.hitBox
320
+ if (!isRecord(b) || !numIn(b.x0, 0, 1) || !numIn(b.x1, 0, 1) || !numIn(b.y0, 0, 1) || !numIn(b.y1, 0, 1)
321
+ || !(b.x0 < b.x1) || !(b.y0 < b.y1)) {
322
+ fail('gameplay.hitBox must be { x0, y0, x1, y1 } fractions with x0 < x1 and y0 < y1')
323
+ } else {
324
+ block.hitBox = { x0: b.x0, y0: b.y0, x1: b.x1, y1: b.y1 }
325
+ }
326
+ }
327
+
328
+ // --- touch zones ---
329
+ if (raw.touch !== undefined) {
330
+ if (!isRecord(raw.touch) || !Array.isArray(raw.touch.zones)) {
331
+ fail('gameplay.touch must be an object with a zones array')
332
+ } else {
333
+ const tExtra = unknownKeys(raw.touch, KNOWN_TOUCH)
334
+ if (tExtra.length > 0) fail('gameplay.touch: unknown field(s) ' + tExtra.map(k => JSON.stringify(k)).join(', '))
335
+ let clickBoost: { stat: string; min: number; max: number } | undefined
336
+ if (raw.touch.clickBoost !== undefined) {
337
+ const cb = raw.touch.clickBoost
338
+ if (!isRecord(cb) || typeof cb.stat !== 'string' || stats[cb.stat] === undefined
339
+ || !intIn(cb.min, 0, 1000) || !intIn(cb.max, 0, 1000) || (cb.min as number) > (cb.max as number)) {
340
+ fail('gameplay.touch.clickBoost must be { stat (declared), min, max } integers with 0 <= min <= max <= 1000')
341
+ } else clickBoost = { stat: cb.stat, min: cb.min, max: cb.max }
342
+ }
343
+ const zones: PetGameplayTouchZone[] = []
344
+ if (raw.touch.zones.length === 0 || raw.touch.zones.length > MAX_ZONES) fail('gameplay.touch.zones must declare 1..' + MAX_ZONES + ' zones')
345
+ for (const zoneRaw of raw.touch.zones as unknown[]) {
346
+ if (!isRecord(zoneRaw) || !validName(zoneRaw.name) || !numIn(zoneRaw.y0, 0, 1) || !numIn(zoneRaw.y1, 0, 1) || !(zoneRaw.y0 < zoneRaw.y1)) {
347
+ fail('gameplay.touch.zones entries need a kebab name and 0 <= y0 < y1 <= 1')
348
+ continue
349
+ }
350
+ const zExtra = unknownKeys(zoneRaw, KNOWN_ZONE)
351
+ if (zExtra.length > 0) fail('gameplay.touch.' + zoneRaw.name + ': unknown field(s) ' + zExtra.map(k => JSON.stringify(k)).join(', '))
352
+ if (!Array.isArray(zoneRaw.branches) || zoneRaw.branches.length === 0 || zoneRaw.branches.length > MAX_BRANCHES) {
353
+ fail('gameplay.touch.' + zoneRaw.name + '.branches must declare 1..' + MAX_BRANCHES + ' branches')
354
+ continue
355
+ }
356
+ let probabilitySum = 0
357
+ const branches: PetGameplayTouchBranch[] = []
358
+ for (const branchRaw of zoneRaw.branches as unknown[]) {
359
+ if (!isRecord(branchRaw) || !numIn(branchRaw.probability, 0, 1) || branchRaw.probability === 0) {
360
+ fail('gameplay.touch.' + zoneRaw.name + '.branches entries need a probability in (0, 1]')
361
+ continue
362
+ }
363
+ const bExtra = unknownKeys(branchRaw, KNOWN_BRANCH)
364
+ if (bExtra.length > 0) fail('gameplay.touch.' + zoneRaw.name + ': unknown branch field(s) ' + bExtra.map(k => JSON.stringify(k)).join(', '))
365
+ probabilitySum += branchRaw.probability
366
+ const branch: PetGameplayTouchBranch = { probability: branchRaw.probability }
367
+ const effects = parseEffects(branchRaw.effects, 'gameplay.touch.' + zoneRaw.name + '.effects', stats, hooks)
368
+ if (effects !== undefined) branch.effects = effects
369
+ const state = parseStateRef(branchRaw.state, 'gameplay.touch.' + zoneRaw.name + '.state', hooks)
370
+ if (state !== undefined) branch.state = state
371
+ if (branchRaw.stateMs !== undefined) {
372
+ if (!intIn(branchRaw.stateMs, 200, 10_000)) fail('gameplay.touch.' + zoneRaw.name + '.stateMs must be an integer in [200, 10000]')
373
+ else branch.stateMs = branchRaw.stateMs
374
+ }
375
+ const phrases = parsePhrases(branchRaw.phrases, 'gameplay.touch.' + zoneRaw.name + '.phrases', hooks)
376
+ if (phrases !== undefined) branch.phrases = phrases
377
+ branches.push(branch)
378
+ }
379
+ if (probabilitySum > 1 + 1e-9) fail('gameplay.touch.' + zoneRaw.name + ': branch probabilities must sum to at most 1')
380
+ if (branches.length > 0) zones.push({ name: zoneRaw.name, y0: zoneRaw.y0, y1: zoneRaw.y1, branches })
381
+ }
382
+ if (zones.length > 0 || clickBoost !== undefined) block.touch = { zones, ...(clickBoost === undefined ? {} : { clickBoost }) }
383
+ }
384
+ }
385
+
386
+ // --- work ---
387
+ if (raw.work !== undefined) {
388
+ const w = raw.work
389
+ if (!isRecord(w)) fail('gameplay.work must be an object')
390
+ else {
391
+ const wExtra = unknownKeys(w, KNOWN_WORK)
392
+ if (wExtra.length > 0) fail('gameplay.work: unknown field(s) ' + wExtra.map(k => JSON.stringify(k)).join(', '))
393
+ const state = parseStateRef(w.state, 'gameplay.work.state', hooks)
394
+ const successState = parseStateRef(w.successState, 'gameplay.work.successState', hooks)
395
+ const failState = parseStateRef(w.failState, 'gameplay.work.failState', hooks)
396
+ if (!intIn(w.tickMs, 1000, 60_000)) fail('gameplay.work.tickMs must be an integer in [1000, 60000]')
397
+ if (!numIn(w.successProbability, 0, 1)) fail('gameplay.work.successProbability must be a number in [0, 1]')
398
+ if (state !== undefined && successState !== undefined && failState !== undefined
399
+ && intIn(w.tickMs, 1000, 60_000) && numIn(w.successProbability, 0, 1)) {
400
+ const work: NonNullable<PetGameplayManifest['work']> = {
401
+ state, successState, failState,
402
+ tickMs: w.tickMs,
403
+ successProbability: w.successProbability,
404
+ }
405
+ if (w.resultMs !== undefined) {
406
+ if (!isRecord(w.resultMs) || !intIn(w.resultMs.success, 200, 10_000) || !intIn(w.resultMs.fail, 200, 10_000)) {
407
+ fail('gameplay.work.resultMs must be { success, fail } integers in [200, 10000]')
408
+ } else work.resultMs = { success: w.resultMs.success, fail: w.resultMs.fail }
409
+ }
410
+ for (const key of ['success', 'fail'] as const) {
411
+ if (w[key] !== undefined) {
412
+ if (!isRecord(w[key])) fail('gameplay.work.' + key + ' must be an object { effects }')
413
+ else {
414
+ const effects = parseEffects((w[key] as Record<string, unknown>).effects, 'gameplay.work.' + key + '.effects', stats, hooks)
415
+ if (effects !== undefined) work[key] = { effects }
416
+ }
417
+ }
418
+ }
419
+ block.work = work
420
+ }
421
+ }
422
+ }
423
+
424
+ // --- sleep ---
425
+ if (raw.sleep !== undefined) {
426
+ const s = raw.sleep
427
+ if (!isRecord(s) || !isRecord(s.restore)) fail('gameplay.sleep must be an object with a restore block')
428
+ else {
429
+ const sExtra = unknownKeys(s, KNOWN_SLEEP)
430
+ if (sExtra.length > 0) fail('gameplay.sleep: unknown field(s) ' + sExtra.map(k => JSON.stringify(k)).join(', '))
431
+ const state = parseStateRef(s.state, 'gameplay.sleep.state', hooks)
432
+ const wakeState = parseStateRef(s.wakeState, 'gameplay.sleep.wakeState', hooks)
433
+ const restoreStat = typeof s.restore.stat === 'string' && stats[s.restore.stat] !== undefined ? s.restore.stat : undefined
434
+ if (restoreStat === undefined) fail('gameplay.sleep.restore.stat must reference a declared stat')
435
+ if (!intIn(s.restore.amount, 1, 1000)) fail('gameplay.sleep.restore.amount must be an integer in [1, 1000]')
436
+ if (!intIn(s.restore.intervalMs, 1000, 600_000)) fail('gameplay.sleep.restore.intervalMs must be an integer in [1000, 600000]')
437
+ if (state !== undefined && restoreStat !== undefined && intIn(s.restore.amount, 1, 1000) && intIn(s.restore.intervalMs, 1000, 600_000)) {
438
+ block.sleep = {
439
+ state,
440
+ ...(wakeState === undefined ? {} : { wakeState }),
441
+ restore: { stat: restoreStat, amount: s.restore.amount, intervalMs: s.restore.intervalMs },
442
+ }
443
+ }
444
+ }
445
+ }
446
+
447
+ // --- passive income ---
448
+ if (raw.passiveIncome !== undefined) {
449
+ const p = raw.passiveIncome
450
+ if (!isRecord(p) || !validName(p.currency, 24) || !intIn(p.amount, 1, 10_000) || !intIn(p.intervalMs, 1000, 86_400_000)) {
451
+ fail('gameplay.passiveIncome must be { currency (kebab), amount 1..10000, intervalMs 1000..86400000 }')
452
+ } else {
453
+ block.passiveIncome = { currency: p.currency, amount: p.amount, intervalMs: p.intervalMs }
454
+ }
455
+ }
456
+
457
+ // --- shop ---
458
+ if (raw.shop !== undefined) {
459
+ const s = raw.shop
460
+ if (!isRecord(s) || !Array.isArray(s.items) || s.items.length === 0 || s.items.length > MAX_SHOP_ITEMS) {
461
+ fail('gameplay.shop must be an object with 1..' + MAX_SHOP_ITEMS + ' items')
462
+ } else {
463
+ const shopState = parseStateRef(s.state, 'gameplay.shop.state', hooks)
464
+ const items: PetGameplayShopItem[] = []
465
+ const seen = new Set<string>()
466
+ for (const itemRaw of s.items as unknown[]) {
467
+ if (!isRecord(itemRaw) || !validName(itemRaw.id, 24)) {
468
+ fail('gameplay.shop.items entries need a kebab id')
469
+ continue
470
+ }
471
+ if (seen.has(itemRaw.id)) {
472
+ fail('gameplay.shop: duplicate item id ' + JSON.stringify(itemRaw.id))
473
+ continue
474
+ }
475
+ seen.add(itemRaw.id)
476
+ const iExtra = unknownKeys(itemRaw, KNOWN_SHOP_ITEM)
477
+ if (iExtra.length > 0) fail('gameplay.shop.' + itemRaw.id + ': unknown field(s) ' + iExtra.map(k => JSON.stringify(k)).join(', '))
478
+ if (typeof itemRaw.label !== 'string' || itemRaw.label.trim() === '' || itemRaw.label.length > 80) {
479
+ fail('gameplay.shop.' + itemRaw.id + '.label must be a non-empty string of at most 80 chars')
480
+ continue
481
+ }
482
+ if (!intIn(itemRaw.price, 1, 1_000_000)) {
483
+ fail('gameplay.shop.' + itemRaw.id + '.price must be an integer in [1, 1000000]')
484
+ continue
485
+ }
486
+ if (!validName(itemRaw.currency, 24)) {
487
+ fail('gameplay.shop.' + itemRaw.id + '.currency must be a kebab id')
488
+ continue
489
+ }
490
+ const item: PetGameplayShopItem = {
491
+ id: itemRaw.id,
492
+ label: itemRaw.label.trim(),
493
+ price: itemRaw.price,
494
+ currency: itemRaw.currency,
495
+ }
496
+ if (itemRaw.image !== undefined) {
497
+ if (typeof itemRaw.image !== 'string' || itemRaw.image.includes('..') || itemRaw.image.includes('\\') || itemRaw.image.startsWith('/')) {
498
+ fail('gameplay.shop.' + itemRaw.id + '.image must be a safe manifest-relative frame path')
499
+ } else item.image = itemRaw.image
500
+ }
501
+ const effects = parseEffects(itemRaw.effects, 'gameplay.shop.' + itemRaw.id + '.effects', stats, hooks)
502
+ if (effects !== undefined) item.effects = effects
503
+ if (itemRaw.lottery !== undefined) {
504
+ const l = itemRaw.lottery
505
+ if (!isRecord(l) || !Array.isArray(l.tiers) || l.tiers.length === 0 || l.tiers.length > MAX_LOTTERY_TIERS) {
506
+ fail('gameplay.shop.' + itemRaw.id + '.lottery needs 1..' + MAX_LOTTERY_TIERS + ' tiers')
507
+ } else {
508
+ const lExtra = unknownKeys(l, KNOWN_LOTTERY)
509
+ if (lExtra.length > 0) fail('gameplay.shop.' + itemRaw.id + '.lottery: unknown field(s) ' + lExtra.map(k => JSON.stringify(k)).join(', '))
510
+ if (l.currency !== undefined && !validName(l.currency, 24)) fail('gameplay.shop.' + itemRaw.id + '.lottery.currency must be a kebab id')
511
+ let tierSum = 0
512
+ const tiers: { probability: number; prize: number; currency?: string }[] = []
513
+ for (const tierRaw of l.tiers as unknown[]) {
514
+ if (!isRecord(tierRaw) || !numIn(tierRaw.probability, 0, 1) || tierRaw.probability === 0
515
+ || !intIn(tierRaw.prize, 0, 1_000_000_000)) {
516
+ fail('gameplay.shop.' + itemRaw.id + '.lottery.tiers entries need probability (0,1] and prize 0..1e9')
517
+ continue
518
+ }
519
+ tierSum += tierRaw.probability
520
+ const tier: { probability: number; prize: number; currency?: string } = {
521
+ probability: tierRaw.probability,
522
+ prize: tierRaw.prize,
523
+ }
524
+ if (tierRaw.currency !== undefined) {
525
+ if (!validName(tierRaw.currency, 24)) fail('gameplay.shop.' + itemRaw.id + '.lottery tier currency must be a kebab id')
526
+ else tier.currency = tierRaw.currency
527
+ }
528
+ tiers.push(tier)
529
+ }
530
+ if (tierSum > 1 + 1e-9) fail('gameplay.shop.' + itemRaw.id + '.lottery tier probabilities must sum to at most 1')
531
+ if (tiers.length > 0) {
532
+ const lotteryEffects = parseEffects(l.effects, 'gameplay.shop.' + itemRaw.id + '.lottery.effects', stats, hooks)
533
+ item.lottery = {
534
+ tiers,
535
+ ...(lotteryEffects === undefined ? {} : { effects: lotteryEffects }),
536
+ ...(validName(l.currency, 24) ? { currency: l.currency } : {}),
537
+ }
538
+ }
539
+ }
540
+ }
541
+ if (item.effects === undefined && item.lottery === undefined) {
542
+ fail('gameplay.shop.' + itemRaw.id + ' needs effects or a lottery')
543
+ continue
544
+ }
545
+ items.push(item)
546
+ }
547
+ if (items.length > 0) block.shop = { ...(shopState === undefined ? {} : { state: shopState }), items }
548
+ }
549
+ }
550
+
551
+ // --- drag state ---
552
+ if (raw.dragState !== undefined) {
553
+ const state = parseStateRef(raw.dragState, 'gameplay.dragState', hooks)
554
+ if (state !== undefined) block.dragState = state
555
+ }
556
+ if (raw.dragEndState !== undefined) {
557
+ const state = parseStateRef(raw.dragEndState, 'gameplay.dragEndState', hooks)
558
+ if (state !== undefined) block.dragEndState = state
559
+ }
560
+
561
+ return failed ? undefined : block
562
+ }
563
+
564
+ /* ------------------------------------------------------------------ *
565
+ * Host engine (pure; clock and rng injected)
566
+ * ------------------------------------------------------------------ */
567
+
568
+ /** Persisted per-pet gameplay state (pet.json 'gameplay' map values). */
569
+ export interface PetGameplayState {
570
+ stats: Record<string, number>
571
+ currencies: Record<string, number>
572
+ mode: 'work' | 'sleep' | null
573
+ /** Epoch ms of the last lazy settle. */
574
+ settledAt: number
575
+ }
576
+
577
+ /** Fresh state for one pet: stats at their initial (default max), no currency. */
578
+ export function initialGameplayState(manifest: PetGameplayManifest, now: number): PetGameplayState {
579
+ const stats: Record<string, number> = {}
580
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
581
+ stats[name] = def.initial ?? def.max
582
+ }
583
+ return { stats, currencies: {}, mode: null, settledAt: now }
584
+ }
585
+
586
+ /** Clamp one stat value into [0, max]; currencies into [0, CURRENCY_MAX]. */
587
+ export function clampGameplay(state: PetGameplayState, manifest: PetGameplayManifest): void {
588
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
589
+ const value = state.stats[name]
590
+ if (value === undefined) state.stats[name] = def.initial ?? def.max
591
+ else state.stats[name] = Math.min(def.max, Math.max(0, value))
592
+ }
593
+ for (const [name, value] of Object.entries(state.currencies)) {
594
+ state.currencies[name] = Math.min(CURRENCY_MAX, Math.max(0, Math.floor(value)))
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Lazy settle: apply stat decay, passive income and sleep restore for the
600
+ * elapsed wall time since the last settle. Mirrors the treats.ts discipline
601
+ * (no host timers; read paths settle). Returns whether anything changed.
602
+ */
603
+ export function settleGameplay(
604
+ state: PetGameplayState,
605
+ manifest: PetGameplayManifest,
606
+ now: number,
607
+ options: { sessionActive: boolean },
608
+ ): boolean {
609
+ const elapsedMs = now - state.settledAt
610
+ if (elapsedMs <= 0) return false
611
+ const minutes = elapsedMs / 60_000
612
+ let changed = false
613
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
614
+ const current = state.stats[name]
615
+ if (current === undefined || current <= 0) continue
616
+ let rate = def.decayPerMinute ?? 0
617
+ if (state.mode === 'work' && def.workingDecayPerMinute !== undefined) rate = def.workingDecayPerMinute
618
+ if (!options.sessionActive) rate += def.idleDecayPerMinute ?? 0
619
+ if (rate <= 0) continue
620
+ const next = Math.max(0, current - rate * minutes)
621
+ if (next !== current) {
622
+ state.stats[name] = next
623
+ changed = true
624
+ }
625
+ }
626
+ if (manifest.passiveIncome !== undefined) {
627
+ const ticks = Math.floor(elapsedMs / manifest.passiveIncome.intervalMs)
628
+ if (ticks > 0) {
629
+ const currency = manifest.passiveIncome.currency
630
+ state.currencies[currency] = (state.currencies[currency] ?? 0) + ticks * manifest.passiveIncome.amount
631
+ changed = true
632
+ }
633
+ }
634
+ if (state.mode === 'sleep' && manifest.sleep !== undefined) {
635
+ const ticks = Math.floor(elapsedMs / manifest.sleep.restore.intervalMs)
636
+ if (ticks > 0) {
637
+ const stat = manifest.sleep.restore.stat
638
+ state.stats[stat] = (state.stats[stat] ?? 0) + ticks * manifest.sleep.restore.amount
639
+ changed = true
640
+ }
641
+ }
642
+ state.settledAt = now
643
+ clampGameplay(state, manifest)
644
+ return changed
645
+ }
646
+
647
+ /** Apply one effect vector (touch/work/shop), clamped. */
648
+ export function applyGameplayEffects(state: PetGameplayState, manifest: PetGameplayManifest, effects: readonly PetGameplayEffect[]): void {
649
+ for (const effect of effects) {
650
+ if (effect.stat !== undefined) {
651
+ state.stats[effect.stat] = (state.stats[effect.stat] ?? 0) + effect.amount
652
+ } else if (effect.currency !== undefined) {
653
+ state.currencies[effect.currency] = (state.currencies[effect.currency] ?? 0) + effect.amount
654
+ }
655
+ }
656
+ clampGameplay(state, manifest)
657
+ }
658
+
659
+ /** Roll one touch zone branch; undefined when the roll lands in no-op mass. */
660
+ export function rollTouchBranch(zone: PetGameplayTouchZone, rng: () => number): PetGameplayTouchBranch | undefined {
661
+ const roll = rng()
662
+ let acc = 0
663
+ for (const branch of zone.branches) {
664
+ acc += branch.probability
665
+ if (roll < acc) return branch
666
+ }
667
+ return undefined
668
+ }
669
+
670
+ /** Roll one work tick outcome. */
671
+ export function rollWorkOutcome(work: NonNullable<PetGameplayManifest['work']>, rng: () => number): 'success' | 'fail' {
672
+ return rng() < work.successProbability ? 'success' : 'fail'
673
+ }
674
+
675
+ /** Draw one lottery prize tier; uncovered mass falls through to the last tier. */
676
+ export function drawLotteryTier(
677
+ lottery: NonNullable<PetGameplayShopItem['lottery']>,
678
+ rng: () => number,
679
+ ): { probability: number; prize: number; currency?: string } {
680
+ const roll = rng()
681
+ let acc = 0
682
+ for (const tier of lottery.tiers) {
683
+ acc += tier.probability
684
+ if (roll < acc) return tier
685
+ }
686
+ return lottery.tiers[lottery.tiers.length - 1]!
687
+ }
688
+
689
+ /** The zone one normalized hit-box point lands in, if any. */
690
+ export function touchZoneAt(touch: { zones: PetGameplayTouchZone[] }, yFraction: number): PetGameplayTouchZone | undefined {
691
+ return touch.zones.find(zone => yFraction >= zone.y0 && yFraction < zone.y1)
692
+ }