@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/index.ts CHANGED
@@ -11,14 +11,15 @@
11
11
  * @module @linxin666/dsh-pet
12
12
  */
13
13
 
14
- import { Context } from '@deepseek-ai/cordis'
15
- import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
14
+ import { Context, type Volatile } from '@deepseek-ai/cordis'
15
+ // Type-only: pulls the settings service's Context merge (ctx.settings).
16
+ import type {} from '@deepseek-ai/dsh-settings'
16
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
17
- import z from 'schemastery'
18
- import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSection } from './service.ts'
18
+ import z from '@deepseek-ai/schemastery'
19
+ import { PetService, type PetConfig, type PetSettingsSection } from './service.ts'
19
20
  import { makePetRoutes } from './routes.ts'
20
21
  import { loadPetRegistry, petPackageRoot } from './registry.ts'
21
- import { BUBBLE_SCALE_MAX, BUBBLE_SCALE_MIN, DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
22
+ import { BUBBLE_SCALE_MAX, BUBBLE_SCALE_MIN, DEFAULT_PET_ID, DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
22
23
  import { mountOnce } from './mount-once.ts'
23
24
 
24
25
  export { PetService, MAX_SESSION_BUBBLES } from './service.ts'
@@ -119,54 +120,137 @@ export const name = 'pet'
119
120
  export const inject = ['webServer']
120
121
 
121
122
  /**
122
- * Settings section schema: pet selection and display fields the web settings
123
- * surface edits. petId is a plain string on purpose: the service clamps the
124
- * resolved value against the registry, so a stored selection that points at
125
- * a removed pet cannot invalidate the section (a strict union would refuse
126
- * the whole registration). The settings card renders the actual registry
127
- * choices itself from '/api/pet/pets'.
123
+ * Defaults of the fields the pet's settings page edits. They are the schema
124
+ * defaults of the pet row's own config, i.e. what a field the profile entry
125
+ * never set resolves to.
128
126
  */
129
- export function makePetSettingsSchema(fallbackPetId: string) {
130
- return z.object({
131
- visible: z.boolean().default(true),
132
- size: z.number().step(1).min(DISPLAY_SIZE_MIN).max(DISPLAY_SIZE_MAX).default(160),
133
- right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
134
- bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
135
- bubbleScale: z.number().step(0.05).min(BUBBLE_SCALE_MIN).max(BUBBLE_SCALE_MAX).default(1),
136
- petId: z.string().default(fallbackPetId),
137
- enabled: z.boolean().default(true),
138
- decorationEnabled: z.boolean().default(true),
139
- })
127
+ export const PET_FORM_DEFAULTS = {
128
+ visible: true,
129
+ size: 160,
130
+ right: 24,
131
+ bottom: 20,
132
+ bubbleScale: 1,
133
+ petId: DEFAULT_PET_ID,
134
+ enabled: true,
135
+ decorationEnabled: true,
136
+ } as const
137
+
138
+ /**
139
+ * One settings field as the config carries it. The Host commits an edit into
140
+ * the running config through a live reference rather than remounting the row,
141
+ * so a field usually arrives as that reference; a plain value appears when the
142
+ * plugin runs outside a Loader (tests, direct mounts).
143
+ */
144
+ export type LiveField<T> = Volatile<T> | T
145
+
146
+ /** The pet's settings fields, as a profile entry's config declares them. */
147
+ export interface PetFormConfig {
148
+ /** Master switch for the plugin (browser half + host routes). */
149
+ enabled?: LiveField<boolean>
150
+ /** Status-decoration master switch (pet-center M5, #567); defaults to on. */
151
+ decorationEnabled?: LiveField<boolean>
152
+ /** Master switch for the pet surface. */
153
+ visible?: LiveField<boolean>
154
+ /** Scale of the rendered pet in px (sprite cell height). */
155
+ size?: LiveField<number>
156
+ /** Horizontal inset from the viewport right edge, px. */
157
+ right?: LiveField<number>
158
+ /** Vertical inset from the viewport bottom edge, px. */
159
+ bottom?: LiveField<number>
160
+ /** Bubble typography multiplier on the automatic size following (#1549). */
161
+ bubbleScale?: LiveField<number>
162
+ /** Selected pet id (a registry entry; the service clamps stale values). */
163
+ petId?: LiveField<string | undefined>
164
+ }
165
+
166
+ /**
167
+ * Plugin configuration. Under the 0.1.7 settings model a plugin's own Cordis
168
+ * Config IS its settings page: the Host derives one form per profile entry
169
+ * from this schema, so the fields the pet card edits live here with the
170
+ * defaults the card inherits, next to the tuning block a profile may still
171
+ * declare.
172
+ *
173
+ * Every page field is `volatile()` on purpose. The Host serves exactly the
174
+ * volatile fields of a Config and refuses writes to any other path, and a
175
+ * volatile field is the one it can commit into the RUNNING config: the pet
176
+ * reads the edited value from the live reference instead of being remounted
177
+ * for each edit (see `syncSettings` in `apply`). petId stays a plain string
178
+ * because the service clamps the value against the registry, so a stored
179
+ * selection naming a removed pet cannot invalidate the entry.
180
+ */
181
+ export const Config = z.object({
182
+ visible: z.boolean().default(PET_FORM_DEFAULTS.visible).volatile(),
183
+ size: z.number().step(1).min(DISPLAY_SIZE_MIN).max(DISPLAY_SIZE_MAX).default(PET_FORM_DEFAULTS.size).volatile(),
184
+ right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(PET_FORM_DEFAULTS.right).volatile(),
185
+ bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(PET_FORM_DEFAULTS.bottom).volatile(),
186
+ bubbleScale: z.number().step(0.05).min(BUBBLE_SCALE_MIN).max(BUBBLE_SCALE_MAX).default(PET_FORM_DEFAULTS.bubbleScale).volatile(),
187
+ // An absent profile choice must leave the selection persisted in pet.json
188
+ // intact across restarts (aggregate rows have no served Host pet form).
189
+ petId: z.string().volatile(),
190
+ enabled: z.boolean().default(PET_FORM_DEFAULTS.enabled).volatile(),
191
+ decorationEnabled: z.boolean().default(PET_FORM_DEFAULTS.decorationEnabled).volatile(),
192
+ })
193
+
194
+ /**
195
+ * Read one live config field.
196
+ * @param field - the field's live reference, plain value, or nothing at all.
197
+ * @param fallback - value used when the field is absent.
198
+ * @returns the current field value.
199
+ */
200
+ function readLive<T>(field: LiveField<T> | undefined, fallback: T): T {
201
+ if (field === undefined) return fallback
202
+ const ref = field as { get?: () => T | undefined }
203
+ return typeof ref.get === 'function' ? ref.get() ?? fallback : field as T
204
+ }
205
+
206
+ /**
207
+ * The settings section the pet runs with: the effective values of the row's
208
+ * own config. `fallbackPetId` covers a mount whose config names no pet at all
209
+ * (a direct mount outside a Loader) — under a Loader the schema default is
210
+ * always present, so the persisted selection stands whenever the config
211
+ * carries it.
212
+ * @param config - the effective config of the pet row.
213
+ * @param fallbackPetId - pet id to use when the config names none.
214
+ * @returns the resolved settings section.
215
+ */
216
+ export function petSettingsSection(config: PetFormConfig, fallbackPetId: string): PetSettingsSection {
217
+ return {
218
+ visible: readLive(config.visible, PET_FORM_DEFAULTS.visible),
219
+ size: readLive(config.size, PET_FORM_DEFAULTS.size),
220
+ right: readLive(config.right, PET_FORM_DEFAULTS.right),
221
+ bottom: readLive(config.bottom, PET_FORM_DEFAULTS.bottom),
222
+ bubbleScale: readLive(config.bubbleScale, PET_FORM_DEFAULTS.bubbleScale),
223
+ petId: readLive(config.petId, fallbackPetId),
224
+ enabled: readLive(config.enabled, PET_FORM_DEFAULTS.enabled),
225
+ decorationEnabled: readLive(config.decorationEnabled, PET_FORM_DEFAULTS.decorationEnabled),
226
+ }
140
227
  }
141
228
 
142
229
  /** Register the pet service and its API + asset routes on the context. */
143
230
  export const apply = mountOnce('@linxin666/dsh-pet', applyImpl)
144
231
 
145
- function applyImpl(ctx: Context, config: PetConfig = {}): void {
232
+ /** Plugin config: the tuning block a profile may declare plus the pet's settings fields. */
233
+ export type PetPluginConfig = Omit<PetConfig, 'enabled' | 'decorationEnabled'> & PetFormConfig
234
+
235
+ function applyImpl(ctx: Context, config: PetPluginConfig = {}): void {
146
236
  const registry = config.registry
147
237
  ?? loadPetRegistry({
148
238
  packageRoot: petPackageRoot(import.meta.url),
149
239
  ...(config.pets === undefined ? {} : { extra: config.pets }),
150
240
  })
151
- const service = new PetService(ctx, { ...config, registry })
152
-
153
- // The settings surface edits the pet selection + display config through
154
- // the 'pet' namespace. The composition 'base' starts as the persisted
155
- // pet.json values (clamped to schema bounds), so an empty user layer
156
- // resolves to exactly what the pet already shows — a fresh deployment
157
- // never overwrites a customized layout, and reset re-inherits it. Runtime
158
- // drag interactions mirror back into the settings document through the
159
- // service (see syncSettingsFromPet), keeping both views consistent.
160
- let current: () => PetSettingsSection = () => base
161
- const base: PetSettingsSection = {
162
- visible: service.display().visible,
163
- size: service.display().size,
164
- right: service.display().right,
165
- bottom: service.display().bottom,
166
- petId: service.selectedPetId(),
167
- enabled: config.enabled ?? true,
168
- decorationEnabled: config.decorationEnabled ?? true,
169
- }
241
+ const service = new PetService(ctx, {
242
+ ...config,
243
+ enabled: readLive(config.enabled, PET_FORM_DEFAULTS.enabled),
244
+ decorationEnabled: readLive(config.decorationEnabled, PET_FORM_DEFAULTS.decorationEnabled),
245
+ registry,
246
+ })
247
+
248
+ // The effective settings ARE this row's own config: the Host serves one form
249
+ // per profile entry from `Config` above, and every edit is committed into the
250
+ // running config, so the plugin re-reads the section here instead of holding
251
+ // a separate settings document (0.1.6 registered one through
252
+ // settings.installSection/register and re-resolved it on every change).
253
+ const current = (): PetSettingsSection => petSettingsSection(config, service.selectedPetId())
170
254
  // The browser half talks to the pet through same-origin JSON endpoints and
171
255
  // loads each pet's atlas from the registry's own media route (RPC domains
172
256
  // are platform-registered, so the pet serves its own API — the same
@@ -190,38 +274,43 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
190
274
  disposeRoutes = undefined
191
275
  }
192
276
  }
277
+ // Apply the row's config to the running service: pet.json mirrors the live
278
+ // display the drag / hide / summon interactions write (see
279
+ // syncSettingsFromPet), so a section the settings surface never touched
280
+ // resolves back to exactly what the pet already shows (the composition
281
+ // 'base' of the pre-0.1.7 section).
282
+ const syncSettings = (): void => {
283
+ const section = current()
284
+ service.applySettingsSection(section)
285
+ service.setEnabled(section.enabled ?? true)
286
+ syncRoutes()
287
+ }
288
+ // A settings edit lands in this row's live config reference and is announced
289
+ // here; the entry is NOT remounted for it, so the pet applies the new section
290
+ // itself (the pre-0.1.7 host ran the section's onChange hook for this).
291
+ ctx.on('loader/volatile-update', () => { syncSettings() })
193
292
  ctx.inject(['settings'], (settingsCtx) => {
194
- try {
195
- const schema = makePetSettingsSchema(service.selectedPetId())
196
- if (typeof settingsCtx.settings?.installSection === 'function') {
197
- settingsCtx.settings.installSection(
198
- ctx,
199
- PET_SETTINGS_NAMESPACE as SettingsNamespace,
200
- schema,
201
- base,
202
- {
203
- setSource: (source) => { current = source },
204
- onChange: () => {
205
- const section = current()
206
- service.applySettingsSection(section)
207
- service.setEnabled(section.enabled ?? true)
208
- syncRoutes()
209
- },
210
- },
211
- )
212
- } else if (typeof settingsCtx.settings?.register === 'function') {
213
- const scope = settingsCtx.settings.register(PET_SETTINGS_NAMESPACE as SettingsNamespace, schema, { base })
214
- current = () => scope?.get?.() ?? base
215
- scope?.watch?.(() => {
216
- const section = current()
217
- service.applySettingsSection(section)
218
- service.setEnabled(section.enabled ?? true)
219
- syncRoutes()
220
- })
293
+ // The pet ships its own settings card (the browser half's
294
+ // 'settings.section' page), so the Host must not also generate a page for
295
+ // this entry from Config.
296
+ settingsCtx.effect(() => {
297
+ try {
298
+ return settingsCtx.settings.configure({ auto: false }, ctx.fiber)
299
+ } catch {
300
+ return () => {}
221
301
  }
222
- } catch {
223
- // Defensive fallback against settings registration differences
224
- }
302
+ }, 'pet: settings page policy')
225
303
  })
226
- syncRoutes()
304
+ syncSettings()
305
+ }
306
+
307
+ declare module '@deepseek-ai/cordis' {
308
+ interface Events {
309
+ /**
310
+ * Volatile config values were committed into the running fiber without a
311
+ * remount; dispatched to the owning fiber only. Declared by the Loader and
312
+ * restated here because this package carries no dependency on its types.
313
+ */
314
+ 'loader/volatile-update'(paths: readonly (readonly string[])[]): void
315
+ }
227
316
  }
package/src/persist.ts CHANGED
@@ -197,7 +197,9 @@ function loadGameplay(parsed: PetPersistDocument): Record<string, PetGameplaySta
197
197
  const item: PetGameplayState = {
198
198
  stats,
199
199
  currencies,
200
- mode: record.mode === 'work' || record.mode === 'sleep' ? record.mode : null,
200
+ // Any non-empty id survives the round trip; whether it is still declared
201
+ // is settled against the manifest when the mode is read back.
202
+ mode: typeof record.mode === 'string' && record.mode.length > 0 && record.mode.length <= 24 ? record.mode : null,
201
203
  settledAt: clamp(finiteNum(record.settledAt, 0), Number.MAX_SAFE_INTEGER),
202
204
  }
203
205
  if (typeof record.incomeCarryMs === 'number' && Number.isFinite(record.incomeCarryMs)) {
@@ -314,14 +314,15 @@ describe('loadPetRegistry', () => {
314
314
  dshPetsDir: '',
315
315
  })
316
316
 
317
- // The repo checkout also resolves miku (frames2d gameplay pet), jyn
318
- // (frames2d gameplay pet) and starry-doll (community sprite2d pet) from
317
+ // The repo checkout also resolves doro and miku (frames2d gameplay pets),
318
+ // jyn (frames2d gameplay pet) and starry-doll (community sprite2d pet) from
319
319
  // assets/; the npm files whitelist excludes them (Workshop delivery), so
320
320
  // npm installs see the atlas pets until a Workshop install lands them
321
321
  // under $DSH_HOME/pets. blue-throated-bee-eater ships bundled alongside
322
322
  // the other atlas pets.
323
323
  expect(registry.entries.map(entry => entry.id)).toEqual([
324
324
  'blue-throated-bee-eater',
325
+ 'doro',
325
326
  'jyn',
326
327
  'miku',
327
328
  'ouo-neko',
package/src/routes.ts CHANGED
@@ -623,7 +623,9 @@ export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCa
623
623
  }),
624
624
  postRoute(ctx, PET_API_PREFIX + '/gameplay/mode', (body) => {
625
625
  const mode = body.mode
626
- if (mode !== null && mode !== 'work' && mode !== 'sleep') return Promise.reject(new Error('invalid-mode'))
626
+ // The id is checked against the pet's own mode set in the service (work,
627
+ // sleep, or any declared 'modes' entry).
628
+ if (mode !== null && typeof mode !== 'string') return Promise.reject(new Error('invalid-mode'))
627
629
  return service.gameplaySetMode(mode)
628
630
  }),
629
631
  postRoute(ctx, PET_API_PREFIX + '/gameplay/work-tick', () => service.gameplayWorkTick()),
package/src/service.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  applyGameplayEffects,
66
66
  drawLotteryTier,
67
67
  initialGameplayState,
68
+ isDeclaredMode,
68
69
  rollTouchBranch,
69
70
  rollWorkOutcome,
70
71
  settleGameplay,
@@ -219,7 +220,8 @@ export type PetInteractResult = LedgerInteractionResult
219
220
  export interface PetGameplayStateView {
220
221
  /** Stat values rounded for display. */
221
222
  stats: Record<string, number>
222
- mode: 'work' | 'sleep' | null
223
+ /** Active mode id ('work' | 'sleep' | a declared extra mode) or null. */
224
+ mode: string | null
223
225
  }
224
226
 
225
227
  /** Result of the gameplay verbs (touch / setMode / workTick / buy). */
@@ -585,7 +587,12 @@ export class PetService extends Service {
585
587
  petId,
586
588
  state: stored === undefined
587
589
  ? initialGameplayState(def, now)
588
- : { stats: { ...stored.stats }, currencies: { ...stored.currencies }, mode: stored.mode, settledAt: stored.settledAt },
590
+ // Spread first so the settle remainders (restoreCarryMs / incomeCarryMs)
591
+ // survive a verb. Enumerating the fields dropped them, so every verb
592
+ // floored the elapsed time and threw the remainder away -- which starves
593
+ // any interval longer than the gap between verbs (30 s sleep restore,
594
+ // 30 min passive income).
595
+ : { ...stored, stats: { ...stored.stats }, currencies: { ...stored.currencies } },
589
596
  }
590
597
  }
591
598
 
@@ -593,7 +600,14 @@ export class PetService extends Service {
593
600
  private gameplayViewOf(state: PetGameplayState): PetGameplayStateView {
594
601
  const stats: Record<string, number> = {}
595
602
  for (const [name, value] of Object.entries(state.stats)) stats[name] = Math.round(value)
596
- return { stats, mode: state.mode }
603
+ // A persisted mode the manifest no longer declares (a pet.json edit after
604
+ // the pet was parked in it) reads as "no mode". Otherwise the client would
605
+ // latch a mode it cannot resolve: the mode chip prints a raw i18n key, and
606
+ // the client's roam roll — suppressed while any mode is active — would
607
+ // never fire again.
608
+ const def = this.gameplayDef()
609
+ const mode = state.mode !== null && def !== undefined && !isDeclaredMode(def, state.mode) ? null : state.mode
610
+ return { stats, mode }
597
611
  }
598
612
 
599
613
  /**
@@ -657,15 +671,23 @@ export class PetService extends Service {
657
671
  }
658
672
  }
659
673
 
660
- /** RPC: enter or leave a gameplay mode ('work' | 'sleep' | null). */
661
- async gameplaySetMode(mode: 'work' | 'sleep' | null): Promise<PetGameplayVerbResult> {
674
+ /**
675
+ * RPC: enter or leave a gameplay mode (null clears it). Every mode the
676
+ * manifest declares is accepted: 'work', 'sleep', or one of the extra
677
+ * 'modes' entries (a bath, a play session…).
678
+ */
679
+ async gameplaySetMode(mode: string | null): Promise<PetGameplayVerbResult> {
662
680
  const def = this.gameplayDef()
663
681
  if (def === undefined) return { ok: false, error: 'no-gameplay' }
664
682
  if (mode === 'work' && def.work === undefined) return { ok: false, error: 'no-work' }
665
683
  if (mode === 'sleep' && def.sleep === undefined) return { ok: false, error: 'no-sleep' }
684
+ if (mode !== null && !isDeclaredMode(def, mode)) return { ok: false, error: 'unknown-mode' }
666
685
  const now = Date.now()
667
686
  const { petId, state } = this.gameplayState(def, now)
668
687
  settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive })
688
+ // Switching modes restarts the restore cadence: a leftover carry from the
689
+ // previous mode must not advance the new mode's first tick.
690
+ if (state.mode !== mode) state.restoreCarryMs = 0
669
691
  state.mode = mode
670
692
  this.drainGameplayTreats(state)
671
693
  this.commitGameplay(petId, state)