@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.
- package/README.i18n.yaml +2 -2
- package/README.md +25 -7
- package/README.zh.md +25 -7
- 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 +830 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +1226 -24
- 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 +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +16 -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/client/settings-form.d.ts +1 -1
- package/lib/types/client/settings-form.js +1 -1
- package/lib/types/client/telemetry.d.ts +27 -0
- package/lib/types/client/telemetry.d.ts.map +1 -0
- package/lib/types/client/telemetry.js +97 -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/http.d.ts +1 -1
- package/lib/types/http.js +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -1
- 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/loopback.d.ts +1 -1
- package/lib/types/loopback.js +1 -1
- 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/mount-once.d.ts +1 -1
- package/lib/types/mount-once.js +2 -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 +6 -3
- 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 +23 -2
- 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/client/settings-form.ts +1 -1
- package/src/client/telemetry.ts +105 -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/http.ts +1 -1
- package/src/index.ts +1 -1
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/loopback.ts +1 -1
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/mount-once.ts +2 -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
package/src/gameplay.ts
ADDED
|
@@ -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
|
+
}
|