@linxin666/dsh-pet 0.3.24 → 0.3.25
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 +4 -3
- package/README.zh.md +4 -3
- package/icon.svg +3 -0
- package/lib/client.js +522 -194
- package/lib/client.js.map +1 -1
- package/lib/index.js +706 -422
- package/lib/live2d-vendor.js +96 -96
- package/lib/live2d-vendor.js.map +1 -1
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSettingsCard.d.ts +20 -9
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +116 -5
- package/lib/types/client/PetSprite.d.ts +6 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +95 -0
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/gameplay-hud.d.ts +10 -1
- package/lib/types/client/gameplay-hud.d.ts.map +1 -1
- package/lib/types/client/gameplay-hud.js +137 -23
- package/lib/types/client/index.d.ts +8 -5
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +61 -27
- package/lib/types/client/main-session.d.ts +33 -0
- package/lib/types/client/main-session.d.ts.map +1 -0
- package/lib/types/client/main-session.js +34 -0
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +5 -3
- package/lib/types/gameplay.d.ts +79 -5
- package/lib/types/gameplay.d.ts.map +1 -1
- package/lib/types/gameplay.js +212 -17
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +100 -29
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +113 -64
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +3 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +3 -1
- package/lib/types/service.d.ts +8 -3
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +26 -4
- package/package.json +46 -26
- package/src/client/PetDockEntry.tsx +1 -0
- package/src/client/PetSettingsCard.test.tsx +103 -0
- package/src/client/PetSettingsCard.tsx +129 -26
- package/src/client/PetSprite.tsx +96 -0
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/gameplay-hud.test.tsx +106 -8
- package/src/client/gameplay-hud.tsx +143 -28
- package/src/client/index.test.tsx +37 -4
- package/src/client/index.ts +74 -34
- package/src/client/main-session.ts +43 -0
- package/src/client/settings-form.ts +34 -31
- package/src/event-projection.ts +5 -3
- package/src/gameplay.test.ts +138 -1
- package/src/gameplay.ts +253 -16
- package/src/http.ts +23 -1
- package/src/index.test.ts +107 -11
- package/src/index.ts +162 -73
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +3 -2
- package/src/routes.ts +3 -1
- package/src/service.ts +27 -5
package/src/gameplay.ts
CHANGED
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
* @module @linxin666/dsh-pet/gameplay
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/** One roam direction the pet may walk in. */
|
|
16
|
+
export type PetRoamDirection = 'up' | 'down' | 'left' | 'right'
|
|
17
|
+
|
|
18
|
+
/** Every roam direction, in roll order (equal chance each unless restricted). */
|
|
19
|
+
export const PET_ROAM_DIRECTIONS: readonly PetRoamDirection[] = ['up', 'down', 'left', 'right']
|
|
20
|
+
|
|
15
21
|
/** One gameplay effect: add amount to a declared stat or a currency. */
|
|
16
22
|
export interface PetGameplayEffect {
|
|
17
23
|
stat?: string
|
|
@@ -64,6 +70,23 @@ export interface PetGameplayShopItem {
|
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
/**
|
|
74
|
+
* One extra named gameplay mode beyond work/sleep (a bath, a play session…).
|
|
75
|
+
* The host holds `state` while the mode is active and may restore a stat on
|
|
76
|
+
* a fixed cadence — the same lazy-settle rule the sleep mode uses. A mode is
|
|
77
|
+
* pure data: adding one to a manifest needs no host change.
|
|
78
|
+
*/
|
|
79
|
+
export interface PetGameplayModeDef {
|
|
80
|
+
/** frames2d track held for as long as the mode is active. */
|
|
81
|
+
state: string
|
|
82
|
+
/** Action-button label (plain text); falls back to the i18n key pet.gameplay.<name>. */
|
|
83
|
+
label?: string
|
|
84
|
+
/** Label while the mode is active (plain text); falls back to `label`. */
|
|
85
|
+
activeLabel?: string
|
|
86
|
+
/** Periodic stat restore while the mode is active (omitted = no restore). */
|
|
87
|
+
restore?: { stat: string; amount: number; intervalMs: number }
|
|
88
|
+
}
|
|
89
|
+
|
|
67
90
|
/** The validated manifest 'gameplay' block. */
|
|
68
91
|
export interface PetGameplayManifest {
|
|
69
92
|
idleDirector?: {
|
|
@@ -96,6 +119,38 @@ export interface PetGameplayManifest {
|
|
|
96
119
|
wakeState?: string
|
|
97
120
|
restore: { stat: string; amount: number; intervalMs: number }
|
|
98
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Extra named modes beyond work/sleep, keyed by a kebab mode id. Each one
|
|
124
|
+
* gets its own menu button, holds its track while active and may restore a
|
|
125
|
+
* stat on a cadence. 'work' and 'sleep' are reserved keys.
|
|
126
|
+
*/
|
|
127
|
+
modes?: Record<string, PetGameplayModeDef>
|
|
128
|
+
/**
|
|
129
|
+
* Random roaming: on a slow interval the pet rolls whether to wander and
|
|
130
|
+
* walks to a new spot on screen with `state` held for the travel time. The
|
|
131
|
+
* chrome owns the motion (clamped to the viewport) and persists the resting
|
|
132
|
+
* spot the way a drag does; the roll only runs while the pet is idle and no
|
|
133
|
+
* mode, drag or touch animation owns it.
|
|
134
|
+
*/
|
|
135
|
+
roam?: {
|
|
136
|
+
/** frames2d track held while walking. */
|
|
137
|
+
state: string
|
|
138
|
+
/** Ms between roam decisions. */
|
|
139
|
+
intervalMs: number
|
|
140
|
+
/** Chance one decision actually walks (0, 1]. */
|
|
141
|
+
probability: number
|
|
142
|
+
/** Travel per roam in px (distanceMin <= distanceMax). */
|
|
143
|
+
distanceMin: number
|
|
144
|
+
distanceMax: number
|
|
145
|
+
/** Walking speed in px per second. */
|
|
146
|
+
speed: number
|
|
147
|
+
/**
|
|
148
|
+
* Directions the pet may walk in, rolled uniformly (equal chance each).
|
|
149
|
+
* Omitted = all four. A side-view crawl reads best walking left/right, so
|
|
150
|
+
* a pet may restrict the list to those two.
|
|
151
|
+
*/
|
|
152
|
+
directions?: PetRoamDirection[]
|
|
153
|
+
}
|
|
99
154
|
passiveIncome?: { currency: string; amount: number; intervalMs: number }
|
|
100
155
|
shop?: { state?: string; items: PetGameplayShopItem[] }
|
|
101
156
|
/** Track played while the chrome reports dragging (default 'drag'). */
|
|
@@ -117,10 +172,12 @@ const MAX_SHOP_ITEMS = 32
|
|
|
117
172
|
const MAX_LOTTERY_TIERS = 16
|
|
118
173
|
const MAX_PHRASES = 64
|
|
119
174
|
const PHRASE_MAX_LENGTH = 120
|
|
175
|
+
const MAX_MODES = 8
|
|
176
|
+
const MODE_LABEL_MAX_LENGTH = 40
|
|
120
177
|
const STAT_VALUE_MAX = 1_000_000
|
|
121
178
|
const CURRENCY_MAX = 9_999_999
|
|
122
179
|
|
|
123
|
-
const KNOWN_GAMEPLAY = new Set(['idleDirector', 'stats', 'hitBox', 'touch', 'work', 'sleep', 'passiveIncome', 'shop', 'dragState', 'dragEndState'])
|
|
180
|
+
const KNOWN_GAMEPLAY = new Set(['idleDirector', 'stats', 'hitBox', 'touch', 'work', 'sleep', 'modes', 'roam', 'passiveIncome', 'shop', 'dragState', 'dragEndState'])
|
|
124
181
|
const KNOWN_STAT = new Set(['max', 'initial', 'decayPerMinute', 'workingDecayPerMinute', 'idleDecayPerMinute'])
|
|
125
182
|
const KNOWN_ZONE = new Set(['name', 'y0', 'y1', 'branches'])
|
|
126
183
|
const KNOWN_TOUCH = new Set(['zones', 'clickBoost'])
|
|
@@ -128,6 +185,9 @@ const KNOWN_BRANCH = new Set(['probability', 'effects', 'state', 'stateMs', 'phr
|
|
|
128
185
|
const KNOWN_EFFECT = new Set(['stat', 'currency', 'amount'])
|
|
129
186
|
const KNOWN_WORK = new Set(['state', 'successState', 'failState', 'tickMs', 'resultMs', 'successProbability', 'success', 'fail'])
|
|
130
187
|
const KNOWN_SLEEP = new Set(['state', 'wakeState', 'restore'])
|
|
188
|
+
const KNOWN_MODE = new Set(['state', 'label', 'activeLabel', 'restore'])
|
|
189
|
+
const KNOWN_ROAM = new Set(['state', 'intervalMs', 'probability', 'distanceMin', 'distanceMax', 'speed', 'directions'])
|
|
190
|
+
const KNOWN_RESTORE = new Set(['stat', 'amount', 'intervalMs'])
|
|
131
191
|
const KNOWN_SHOP_ITEM = new Set(['id', 'label', 'image', 'price', 'currency', 'effects', 'lottery'])
|
|
132
192
|
const KNOWN_LOTTERY = new Set(['effects', 'currency', 'tiers'])
|
|
133
193
|
const KNOWN_IDLE_DIRECTOR = new Set(['intervalMs', 'maxMiss', 'idleWeight', 'acts'])
|
|
@@ -219,6 +279,27 @@ function parseStateRef(raw: unknown, field: string, hooks: GameplayParseHooks):
|
|
|
219
279
|
return raw
|
|
220
280
|
}
|
|
221
281
|
|
|
282
|
+
/** Parse one mode restore rule "{ stat (declared), amount, intervalMs }". */
|
|
283
|
+
function parseModeRestore(
|
|
284
|
+
raw: unknown,
|
|
285
|
+
field: string,
|
|
286
|
+
stats: Record<string, PetGameplayStatDef>,
|
|
287
|
+
fail: (message: string) => void,
|
|
288
|
+
): { stat: string; amount: number; intervalMs: number } | undefined {
|
|
289
|
+
if (!isRecord(raw)) {
|
|
290
|
+
fail(field + ' must be an object { stat, amount, intervalMs }')
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
const extra = unknownKeys(raw, KNOWN_RESTORE)
|
|
294
|
+
if (extra.length > 0) fail(field + ': unknown field(s) ' + extra.map(k => JSON.stringify(k)).join(', '))
|
|
295
|
+
const stat = typeof raw.stat === 'string' && stats[raw.stat] !== undefined ? raw.stat : undefined
|
|
296
|
+
if (stat === undefined) fail(field + '.stat must reference a declared stat')
|
|
297
|
+
if (!intIn(raw.amount, 1, 1000)) fail(field + '.amount must be an integer in [1, 1000]')
|
|
298
|
+
if (!intIn(raw.intervalMs, 1000, 600_000)) fail(field + '.intervalMs must be an integer in [1000, 600000]')
|
|
299
|
+
if (stat === undefined || !intIn(raw.amount, 1, 1000) || !intIn(raw.intervalMs, 1000, 600_000)) return undefined
|
|
300
|
+
return { stat, amount: raw.amount, intervalMs: raw.intervalMs }
|
|
301
|
+
}
|
|
302
|
+
|
|
222
303
|
/**
|
|
223
304
|
* Validate the manifest 'gameplay' block (fail-closed). Only frames2d pets
|
|
224
305
|
* may declare gameplay today: every state reference checks against the
|
|
@@ -430,15 +511,121 @@ export function parseGameplayManifest(raw: unknown, hooks: GameplayParseHooks):
|
|
|
430
511
|
if (sExtra.length > 0) fail('gameplay.sleep: unknown field(s) ' + sExtra.map(k => JSON.stringify(k)).join(', '))
|
|
431
512
|
const state = parseStateRef(s.state, 'gameplay.sleep.state', hooks)
|
|
432
513
|
const wakeState = parseStateRef(s.wakeState, 'gameplay.sleep.wakeState', hooks)
|
|
433
|
-
const
|
|
434
|
-
if (
|
|
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)) {
|
|
514
|
+
const restore = parseModeRestore(s.restore, 'gameplay.sleep.restore', stats, fail)
|
|
515
|
+
if (state !== undefined && restore !== undefined) {
|
|
438
516
|
block.sleep = {
|
|
439
517
|
state,
|
|
440
518
|
...(wakeState === undefined ? {} : { wakeState }),
|
|
441
|
-
restore
|
|
519
|
+
restore,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// --- extra modes (bath and friends) ---
|
|
526
|
+
if (raw.modes !== undefined) {
|
|
527
|
+
if (!isRecord(raw.modes)) fail('gameplay.modes must be an object keyed by mode id')
|
|
528
|
+
else {
|
|
529
|
+
const entries = Object.entries(raw.modes)
|
|
530
|
+
if (entries.length > MAX_MODES) fail('gameplay.modes declares too many modes (max ' + MAX_MODES + ')')
|
|
531
|
+
const modes: Record<string, PetGameplayModeDef> = {}
|
|
532
|
+
for (const [name, value] of entries) {
|
|
533
|
+
if (!validName(name, 24) || name === 'work' || name === 'sleep') {
|
|
534
|
+
fail('gameplay.modes: invalid mode id ' + JSON.stringify(name) + ' (kebab, and not the reserved work/sleep)')
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
if (!isRecord(value)) {
|
|
538
|
+
fail('gameplay.modes.' + name + ' must be an object')
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
const mExtra = unknownKeys(value, KNOWN_MODE)
|
|
542
|
+
if (mExtra.length > 0) fail('gameplay.modes.' + name + ': unknown field(s) ' + mExtra.map(k => JSON.stringify(k)).join(', '))
|
|
543
|
+
const state = parseStateRef(value.state, 'gameplay.modes.' + name + '.state', hooks)
|
|
544
|
+
let usable = state !== undefined
|
|
545
|
+
let label: string | undefined
|
|
546
|
+
if (value.label !== undefined) {
|
|
547
|
+
if (typeof value.label !== 'string' || value.label.trim() === '' || value.label.length > MODE_LABEL_MAX_LENGTH) {
|
|
548
|
+
fail('gameplay.modes.' + name + '.label must be a non-empty string of at most ' + MODE_LABEL_MAX_LENGTH + ' chars')
|
|
549
|
+
usable = false
|
|
550
|
+
} else label = value.label.trim()
|
|
551
|
+
}
|
|
552
|
+
let activeLabel: string | undefined
|
|
553
|
+
if (value.activeLabel !== undefined) {
|
|
554
|
+
if (typeof value.activeLabel !== 'string' || value.activeLabel.trim() === '' || value.activeLabel.length > MODE_LABEL_MAX_LENGTH) {
|
|
555
|
+
fail('gameplay.modes.' + name + '.activeLabel must be a non-empty string of at most ' + MODE_LABEL_MAX_LENGTH + ' chars')
|
|
556
|
+
usable = false
|
|
557
|
+
} else activeLabel = value.activeLabel.trim()
|
|
558
|
+
}
|
|
559
|
+
const restore = value.restore === undefined ? undefined : parseModeRestore(value.restore, 'gameplay.modes.' + name + '.restore', stats, fail)
|
|
560
|
+
if (value.restore !== undefined && restore === undefined) usable = false
|
|
561
|
+
if (usable && state !== undefined) {
|
|
562
|
+
modes[name] = {
|
|
563
|
+
state,
|
|
564
|
+
...(label === undefined ? {} : { label }),
|
|
565
|
+
...(activeLabel === undefined ? {} : { activeLabel }),
|
|
566
|
+
...(restore === undefined ? {} : { restore }),
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (Object.keys(modes).length > 0) block.modes = modes
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- random roaming ---
|
|
575
|
+
if (raw.roam !== undefined) {
|
|
576
|
+
const rm = raw.roam
|
|
577
|
+
if (!isRecord(rm)) fail('gameplay.roam must be an object')
|
|
578
|
+
else {
|
|
579
|
+
const rExtra = unknownKeys(rm, KNOWN_ROAM)
|
|
580
|
+
if (rExtra.length > 0) fail('gameplay.roam: unknown field(s) ' + rExtra.map(k => JSON.stringify(k)).join(', '))
|
|
581
|
+
const state = parseStateRef(rm.state, 'gameplay.roam.state', hooks)
|
|
582
|
+
if (!intIn(rm.intervalMs, 1000, 3_600_000)) fail('gameplay.roam.intervalMs must be an integer in [1000, 3600000]')
|
|
583
|
+
if (!numIn(rm.probability, 0, 1) || rm.probability === 0) fail('gameplay.roam.probability must be a number in (0, 1]')
|
|
584
|
+
if (!intIn(rm.distanceMin, 1, 2000)) fail('gameplay.roam.distanceMin must be an integer in [1, 2000]')
|
|
585
|
+
if (!intIn(rm.distanceMax, 1, 2000)) fail('gameplay.roam.distanceMax must be an integer in [1, 2000]')
|
|
586
|
+
if (intIn(rm.distanceMin, 1, 2000) && intIn(rm.distanceMax, 1, 2000) && rm.distanceMin > rm.distanceMax) {
|
|
587
|
+
fail('gameplay.roam.distanceMin must not exceed distanceMax')
|
|
588
|
+
}
|
|
589
|
+
if (!intIn(rm.speed, 1, 2000)) fail('gameplay.roam.speed must be an integer in [1, 2000] (px per second)')
|
|
590
|
+
let directions: PetRoamDirection[] | undefined
|
|
591
|
+
if (rm.directions !== undefined) {
|
|
592
|
+
if (!Array.isArray(rm.directions) || rm.directions.length === 0) {
|
|
593
|
+
fail('gameplay.roam.directions must be a non-empty array of up, down, left, right')
|
|
594
|
+
} else {
|
|
595
|
+
const picked: PetRoamDirection[] = []
|
|
596
|
+
for (const entry of rm.directions as unknown[]) {
|
|
597
|
+
if (typeof entry !== 'string' || !PET_ROAM_DIRECTIONS.includes(entry as PetRoamDirection)) {
|
|
598
|
+
fail('gameplay.roam.directions entries must be up, down, left or right')
|
|
599
|
+
picked.length = 0
|
|
600
|
+
break
|
|
601
|
+
}
|
|
602
|
+
if (picked.includes(entry as PetRoamDirection)) {
|
|
603
|
+
fail('gameplay.roam.directions must not repeat ' + entry)
|
|
604
|
+
picked.length = 0
|
|
605
|
+
break
|
|
606
|
+
}
|
|
607
|
+
picked.push(entry as PetRoamDirection)
|
|
608
|
+
}
|
|
609
|
+
if (picked.length > 0) directions = picked
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// The guards are repeated inline: tsc only narrows the unknown fields
|
|
613
|
+
// through a call in the condition itself.
|
|
614
|
+
if (state !== undefined
|
|
615
|
+
&& intIn(rm.intervalMs, 1000, 3_600_000)
|
|
616
|
+
&& numIn(rm.probability, 0, 1) && rm.probability !== 0
|
|
617
|
+
&& intIn(rm.distanceMin, 1, 2000)
|
|
618
|
+
&& intIn(rm.distanceMax, 1, 2000)
|
|
619
|
+
&& rm.distanceMin <= rm.distanceMax
|
|
620
|
+
&& intIn(rm.speed, 1, 2000)) {
|
|
621
|
+
block.roam = {
|
|
622
|
+
state,
|
|
623
|
+
intervalMs: rm.intervalMs,
|
|
624
|
+
probability: rm.probability,
|
|
625
|
+
distanceMin: rm.distanceMin,
|
|
626
|
+
distanceMax: rm.distanceMax,
|
|
627
|
+
speed: rm.speed,
|
|
628
|
+
...(directions === undefined ? {} : { directions }),
|
|
442
629
|
}
|
|
443
630
|
}
|
|
444
631
|
}
|
|
@@ -569,15 +756,63 @@ export function parseGameplayManifest(raw: unknown, hooks: GameplayParseHooks):
|
|
|
569
756
|
export interface PetGameplayState {
|
|
570
757
|
stats: Record<string, number>
|
|
571
758
|
currencies: Record<string, number>
|
|
572
|
-
mode
|
|
759
|
+
/** Active gameplay mode id ('work' | 'sleep' | a declared extra mode) or null. */
|
|
760
|
+
mode: string | null
|
|
573
761
|
/** Epoch ms of the last lazy settle. */
|
|
574
762
|
settledAt: number
|
|
575
763
|
/** Accumulated remainder ms towards the next passive income tick. */
|
|
576
764
|
incomeCarryMs?: number
|
|
577
|
-
/** Accumulated remainder ms towards the next
|
|
765
|
+
/** Accumulated remainder ms towards the next mode restore tick. */
|
|
578
766
|
restoreCarryMs?: number
|
|
579
767
|
}
|
|
580
768
|
|
|
769
|
+
/**
|
|
770
|
+
* One declared extra mode, by OWN key only. A plain `modes[id]` lookup also
|
|
771
|
+
* answers for Object.prototype members ('constructor', 'toString', …), which
|
|
772
|
+
* would let a crafted mode id pass the "is it declared?" test.
|
|
773
|
+
*/
|
|
774
|
+
function declaredModeOf(manifest: PetGameplayManifest, mode: string): PetGameplayModeDef | undefined {
|
|
775
|
+
const modes = manifest.modes
|
|
776
|
+
if (modes === undefined || !Object.prototype.hasOwnProperty.call(modes, mode)) return undefined
|
|
777
|
+
return modes[mode]
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Every mode the menu offers, in manifest order (sleep first, then extras). */
|
|
781
|
+
export function declaredModes(manifest: PetGameplayManifest): string[] {
|
|
782
|
+
return [
|
|
783
|
+
...(manifest.sleep === undefined ? [] : ['sleep']),
|
|
784
|
+
...Object.keys(manifest.modes ?? {}),
|
|
785
|
+
]
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* The frames2d track one active mode holds. 'work' is owned by the work loop
|
|
790
|
+
* (its state, result and fallback are its own), so it resolves to undefined.
|
|
791
|
+
*/
|
|
792
|
+
export function modeStateOf(manifest: PetGameplayManifest, mode: string): string | undefined {
|
|
793
|
+
if (mode === 'sleep') return manifest.sleep?.state
|
|
794
|
+
if (mode === 'work') return manifest.work?.state
|
|
795
|
+
return declaredModeOf(manifest, mode)?.state
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** The restore rule of one active mode, when it declares one. */
|
|
799
|
+
export function modeRestoreOf(
|
|
800
|
+
manifest: PetGameplayManifest,
|
|
801
|
+
mode: string | null,
|
|
802
|
+
): { stat: string; amount: number; intervalMs: number } | undefined {
|
|
803
|
+
if (mode === null) return undefined
|
|
804
|
+
if (mode === 'sleep') return manifest.sleep?.restore
|
|
805
|
+
if (mode === 'work') return undefined
|
|
806
|
+
return declaredModeOf(manifest, mode)?.restore
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Whether the manifest still declares this gameplay mode id. */
|
|
810
|
+
export function isDeclaredMode(manifest: PetGameplayManifest, mode: string): boolean {
|
|
811
|
+
if (mode === 'work') return manifest.work !== undefined
|
|
812
|
+
if (mode === 'sleep') return manifest.sleep !== undefined
|
|
813
|
+
return declaredModeOf(manifest, mode) !== undefined
|
|
814
|
+
}
|
|
815
|
+
|
|
581
816
|
/** Fresh state for one pet: stats at their initial (default max), no currency. */
|
|
582
817
|
export function initialGameplayState(manifest: PetGameplayManifest, now: number): PetGameplayState {
|
|
583
818
|
const stats: Record<string, number> = {}
|
|
@@ -600,9 +835,11 @@ export function clampGameplay(state: PetGameplayState, manifest: PetGameplayMani
|
|
|
600
835
|
}
|
|
601
836
|
|
|
602
837
|
/**
|
|
603
|
-
* Lazy settle: apply stat decay, passive income and
|
|
604
|
-
*
|
|
605
|
-
*
|
|
838
|
+
* Lazy settle: apply stat decay, passive income and the active mode's restore
|
|
839
|
+
* rule (sleep restores energy, a declared extra mode restores whatever it
|
|
840
|
+
* declares) for the elapsed wall time since the last settle. Mirrors the
|
|
841
|
+
* treats.ts discipline (no host timers; read paths settle). Returns whether
|
|
842
|
+
* anything changed.
|
|
606
843
|
*/
|
|
607
844
|
export function settleGameplay(
|
|
608
845
|
state: PetGameplayState,
|
|
@@ -638,14 +875,14 @@ export function settleGameplay(
|
|
|
638
875
|
changed = true
|
|
639
876
|
}
|
|
640
877
|
}
|
|
641
|
-
|
|
878
|
+
const restore = modeRestoreOf(manifest, state.mode)
|
|
879
|
+
if (restore !== undefined) {
|
|
642
880
|
const restoreElapsed = elapsedMs + (state.restoreCarryMs ?? 0)
|
|
643
|
-
const interval =
|
|
881
|
+
const interval = restore.intervalMs
|
|
644
882
|
const ticks = Math.floor(restoreElapsed / interval)
|
|
645
883
|
state.restoreCarryMs = restoreElapsed % interval
|
|
646
884
|
if (ticks > 0) {
|
|
647
|
-
|
|
648
|
-
state.stats[stat] = (state.stats[stat] ?? 0) + ticks * manifest.sleep.restore.amount
|
|
885
|
+
state.stats[restore.stat] = (state.stats[restore.stat] ?? 0) + ticks * restore.amount
|
|
649
886
|
changed = true
|
|
650
887
|
}
|
|
651
888
|
} else {
|
package/src/http.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Shared JSON body/response helpers for the host route families: one strict
|
|
4
4
|
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
5
|
-
* narrow,
|
|
5
|
+
* narrow, one JSON writer, and the outbound request-init helper that keeps a
|
|
6
|
+
* host fetch body decodable (withIdentityEncoding). Previously the readers
|
|
7
|
+
* were copy-pasted across the
|
|
6
8
|
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
7
9
|
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
8
10
|
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
|
|
|
88
90
|
return isJsonObject(value) ? value : undefined
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Request init that asks a remote origin for an uncompressed body.
|
|
95
|
+
*
|
|
96
|
+
* The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
|
|
97
|
+
* the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
|
|
98
|
+
* Node's built-in `fetch()` reads. That cross-major wrapper drops the
|
|
99
|
+
* `content-encoding` header and automatic decompression, so a plain fetch
|
|
100
|
+
* resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
|
|
101
|
+
* consumer can read. Every host fetch that parses a remote body must request
|
|
102
|
+
* identity encoding. Caller headers survive; `accept-encoding` is forced to
|
|
103
|
+
* `identity` because a caller value has no decoder behind it in this host.
|
|
104
|
+
* @param init - the caller's request init (signal, headers, method, ...).
|
|
105
|
+
* @returns a copy with `accept-encoding: identity` merged in.
|
|
106
|
+
*/
|
|
107
|
+
export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
|
|
108
|
+
const headers = new Headers(init.headers)
|
|
109
|
+
headers.set('accept-encoding', 'identity')
|
|
110
|
+
return { ...init, headers }
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
/**
|
|
92
114
|
* Write one JSON response. Default headers are the family defaults
|
|
93
115
|
* (content-type and referrer-policy); caller headers are appended or
|
package/src/index.test.ts
CHANGED
|
@@ -1,23 +1,119 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import { Config, PET_FORM_DEFAULTS, petSettingsSection, type PetFormConfig } from './index.ts'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
/** Resolve one config as plain values: volatile fields resolve to live references. */
|
|
5
|
+
function plain(config: ReturnType<typeof Config>): Record<string, unknown> {
|
|
6
|
+
return Object.fromEntries(Object.entries(config).map(([field, value]) => {
|
|
7
|
+
const ref = value as { get?: () => unknown }
|
|
8
|
+
return [field, typeof ref.get === 'function' ? ref.get() : value]
|
|
9
|
+
}))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The write hook the Loader uses to commit a value into a live config reference. */
|
|
13
|
+
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
|
|
14
|
+
|
|
15
|
+
/** Commit a value the way the Loader's volatile update does. */
|
|
16
|
+
function commitLive(reference: unknown, value: unknown): void {
|
|
17
|
+
const write = (reference as Record<symbol, ((next: unknown) => void) | undefined>)[VOLATILE_WRITE]
|
|
18
|
+
if (write === undefined) throw new Error('the config field is not a live reference')
|
|
19
|
+
write(value)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('pet configuration schema', () => {
|
|
23
|
+
it('operator gets the pet selection and display defaults from an entry config that sets nothing', () => {
|
|
24
|
+
// Given a profile entry that declares no pet settings at all
|
|
25
|
+
// When the Host resolves the plugin's own Config schema
|
|
26
|
+
const resolved = plain(Config({}))
|
|
27
|
+
// Then every field the settings page edits carries its documented default
|
|
28
|
+
expect(resolved).toMatchObject({
|
|
9
29
|
visible: true,
|
|
10
30
|
size: 160,
|
|
11
31
|
right: 24,
|
|
12
32
|
bottom: 20,
|
|
33
|
+
bubbleScale: 1,
|
|
34
|
+
enabled: true,
|
|
35
|
+
decorationEnabled: true,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('operator keeps a stale pet selection instead of failing config validation', () => {
|
|
40
|
+
// Given a stored selection naming a pet the registry no longer has
|
|
41
|
+
// When the entry config is resolved against the schema
|
|
42
|
+
const resolved = Config({ petId: 'dragon' })
|
|
43
|
+
// Then the selection survives (the service clamps it against the registry)
|
|
44
|
+
expect(resolved.petId.get()).toBe('dragon')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('user keeps the saved pet after restart when the profile never selected one', () => {
|
|
48
|
+
// Given a persisted maid whale and no profile-level pet choice
|
|
49
|
+
const resolved = Config({})
|
|
50
|
+
// When the plugin starts and resolves its active settings
|
|
51
|
+
const section = petSettingsSection(resolved, 'jyn')
|
|
52
|
+
// Then the persisted pet survives instead of being reset to the schema default
|
|
53
|
+
expect(section.petId).toBe('jyn')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('operator edits every page field through a Host-served config path', () => {
|
|
57
|
+
// Given the Host serves only the volatile fields of a plugin's Config
|
|
58
|
+
// When the pet's schema is inspected field by field
|
|
59
|
+
const volatile = Object.fromEntries(
|
|
60
|
+
Object.entries(Config.dict ?? {}).map(([field, fieldSchema]) => [field, fieldSchema.meta.volatile === true]),
|
|
61
|
+
)
|
|
62
|
+
// Then every field the settings card renders is a volatile one
|
|
63
|
+
expect(volatile).toEqual({
|
|
64
|
+
visible: true,
|
|
65
|
+
size: true,
|
|
66
|
+
right: true,
|
|
67
|
+
bottom: true,
|
|
68
|
+
bubbleScale: true,
|
|
69
|
+
petId: true,
|
|
13
70
|
enabled: true,
|
|
71
|
+
decorationEnabled: true,
|
|
14
72
|
})
|
|
15
73
|
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('petSettingsSection', () => {
|
|
77
|
+
it('user runs the pet on the settings the Host committed into the live config', () => {
|
|
78
|
+
// Given a running row whose config references carry the edited values
|
|
79
|
+
const config = {
|
|
80
|
+
size: { get: () => 240 },
|
|
81
|
+
visible: { get: () => false },
|
|
82
|
+
petId: { get: () => 'doro' },
|
|
83
|
+
} as unknown as PetFormConfig
|
|
84
|
+
// When the plugin resolves the settings section it runs with
|
|
85
|
+
const section = petSettingsSection(config, 'whale-girl')
|
|
86
|
+
// Then the edited fields win and the untouched ones resolve to their defaults
|
|
87
|
+
expect(section).toEqual({
|
|
88
|
+
visible: false,
|
|
89
|
+
size: 240,
|
|
90
|
+
right: 24,
|
|
91
|
+
bottom: 20,
|
|
92
|
+
bubbleScale: 1,
|
|
93
|
+
petId: 'doro',
|
|
94
|
+
enabled: true,
|
|
95
|
+
decorationEnabled: true,
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('user edits a served field and the running pet reads the committed value', () => {
|
|
100
|
+
// Given a resolved config whose volatile fields are live references
|
|
101
|
+
const resolved = Config({})
|
|
102
|
+
const before = petSettingsSection(resolved, 'whale-girl')
|
|
103
|
+
|
|
104
|
+
// When the Host commits an edited size the way the Loader does
|
|
105
|
+
commitLive(resolved.size, 260)
|
|
106
|
+
|
|
107
|
+
// Then the same activation reads the edit, while the earlier read stays a snapshot
|
|
108
|
+
expect(petSettingsSection(resolved, 'whale-girl').size).toBe(260)
|
|
109
|
+
expect(before.size).toBe(160)
|
|
110
|
+
})
|
|
16
111
|
|
|
17
|
-
it('
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
const
|
|
21
|
-
|
|
112
|
+
it('user keeps the pet selection it already has when the config names none', () => {
|
|
113
|
+
// Given a mount whose config carries no pet selection
|
|
114
|
+
// When the plugin resolves the settings section it runs with
|
|
115
|
+
const section = petSettingsSection({}, 'doro')
|
|
116
|
+
// Then the selection the pet already has stands
|
|
117
|
+
expect(section.petId).toBe('doro')
|
|
22
118
|
})
|
|
23
119
|
})
|