@linxin666/dsh-pet 0.3.24 → 0.4.2

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 (72) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -3
  3. package/README.zh.md +4 -3
  4. package/icon.svg +3 -0
  5. package/lib/client.js +522 -194
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +706 -422
  8. package/lib/live2d-vendor.js +96 -96
  9. package/lib/live2d-vendor.js.map +1 -1
  10. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  11. package/lib/types/client/PetDockEntry.js +1 -1
  12. package/lib/types/client/PetSettingsCard.d.ts +20 -9
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +116 -5
  15. package/lib/types/client/PetSprite.d.ts +6 -0
  16. package/lib/types/client/PetSprite.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.js +95 -0
  18. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PluginSettingsCard.js +3 -2
  21. package/lib/types/client/gameplay-hud.d.ts +10 -1
  22. package/lib/types/client/gameplay-hud.d.ts.map +1 -1
  23. package/lib/types/client/gameplay-hud.js +137 -23
  24. package/lib/types/client/index.d.ts +8 -5
  25. package/lib/types/client/index.d.ts.map +1 -1
  26. package/lib/types/client/index.js +61 -27
  27. package/lib/types/client/main-session.d.ts +33 -0
  28. package/lib/types/client/main-session.d.ts.map +1 -0
  29. package/lib/types/client/main-session.js +34 -0
  30. package/lib/types/client/settings-form.d.ts +17 -17
  31. package/lib/types/client/settings-form.d.ts.map +1 -1
  32. package/lib/types/client/settings-form.js +30 -27
  33. package/lib/types/event-projection.d.ts.map +1 -1
  34. package/lib/types/event-projection.js +5 -3
  35. package/lib/types/gameplay.d.ts +79 -5
  36. package/lib/types/gameplay.d.ts.map +1 -1
  37. package/lib/types/gameplay.js +212 -17
  38. package/lib/types/http.d.ts +18 -1
  39. package/lib/types/http.d.ts.map +1 -1
  40. package/lib/types/http.js +22 -1
  41. package/lib/types/index.d.ts +100 -29
  42. package/lib/types/index.d.ts.map +1 -1
  43. package/lib/types/index.js +113 -64
  44. package/lib/types/persist.d.ts.map +1 -1
  45. package/lib/types/persist.js +3 -1
  46. package/lib/types/routes.d.ts.map +1 -1
  47. package/lib/types/routes.js +3 -1
  48. package/lib/types/service.d.ts +8 -3
  49. package/lib/types/service.d.ts.map +1 -1
  50. package/lib/types/service.js +26 -4
  51. package/package.json +46 -26
  52. package/src/client/PetDockEntry.tsx +1 -0
  53. package/src/client/PetSettingsCard.test.tsx +103 -0
  54. package/src/client/PetSettingsCard.tsx +129 -26
  55. package/src/client/PetSprite.tsx +96 -0
  56. package/src/client/PluginSettingsCard.tsx +7 -2
  57. package/src/client/gameplay-hud.test.tsx +106 -8
  58. package/src/client/gameplay-hud.tsx +143 -28
  59. package/src/client/index.test.tsx +37 -4
  60. package/src/client/index.ts +74 -34
  61. package/src/client/main-session.ts +43 -0
  62. package/src/client/settings-form.ts +34 -31
  63. package/src/event-projection.ts +5 -3
  64. package/src/gameplay.test.ts +138 -1
  65. package/src/gameplay.ts +253 -16
  66. package/src/http.ts +23 -1
  67. package/src/index.test.ts +107 -11
  68. package/src/index.ts +162 -73
  69. package/src/persist.ts +3 -1
  70. package/src/registry.test.ts +3 -2
  71. package/src/routes.ts +3 -1
  72. 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 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)) {
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: { stat: restoreStat, amount: s.restore.amount, intervalMs: s.restore.intervalMs },
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: 'work' | 'sleep' | null
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 sleep restore tick. */
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 sleep restore for the
604
- * elapsed wall time since the last settle. Mirrors the treats.ts discipline
605
- * (no host timers; read paths settle). Returns whether anything changed.
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
- if (state.mode === 'sleep' && manifest.sleep !== undefined) {
878
+ const restore = modeRestoreOf(manifest, state.mode)
879
+ if (restore !== undefined) {
642
880
  const restoreElapsed = elapsedMs + (state.restoreCarryMs ?? 0)
643
- const interval = manifest.sleep.restore.intervalMs
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
- const stat = manifest.sleep.restore.stat
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, and one JSON writer. Previously these were copy-pasted across the
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 { makePetSettingsSchema } from './index.ts'
2
+ import { Config, PET_FORM_DEFAULTS, petSettingsSection, type PetFormConfig } from './index.ts'
3
3
 
4
- describe('makePetSettingsSchema', () => {
5
- it('resolves the selected pet and display defaults from an empty section', () => {
6
- const schema = makePetSettingsSchema('whale-girl')
7
- expect(schema({})).toMatchObject({
8
- petId: 'whale-girl',
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('accepts any petId string so a removed pet cannot brick the namespace', () => {
18
- // The service clamps the value against the registry; the schema must
19
- // never reject a stale stored selection outright.
20
- const schema = makePetSettingsSchema('whale-girl')
21
- expect(schema({ petId: 'dragon' }).petId).toBe('dragon')
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
  })