@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
@@ -16,15 +16,18 @@
16
16
  import type { Context as ClientContext } from '@deepseek-ai/cordis'
17
17
  import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
18
18
  import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
19
- import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
19
+ import type { ConfigForm, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
20
20
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
21
21
  import type {} from '@deepseek-ai/dsh-client-locale/client'
22
- // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
22
+ // Type-only: pulls the shared configuration forms Context merge (ctx.configForms).
23
23
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
24
24
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
25
25
  // Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
26
26
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
27
27
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
28
+ // Type-only: pulls the workspace plugin's Context merge (ctx.uiWorkspace), the
29
+ // multi-instance navigation face that replaced ISessions.open().
30
+ import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
28
31
  import type { PetDisplayConfig } from '../persist.ts'
29
32
  import type { PetGameplayVerbResult, PetInteractResult, PetStateView } from '../service.ts'
30
33
  import type { PetInteraction } from '../affinity.ts'
@@ -40,6 +43,7 @@ import { frames2dRenderer } from './renderers/frames2d.ts'
40
43
  import { registerPetUiTeardown, takeoverPetUiTeardown } from './ui-teardown.ts'
41
44
  import { PetSettingsSection, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
42
45
  import { NS, en, zh, t } from './locales.ts'
46
+ import { mainViewSessionId } from './main-session.ts'
43
47
  import { reportDailyHeartbeat } from './telemetry.ts'
44
48
 
45
49
  /** The host pet API as the browser sees it (same-origin JSON endpoints). */
@@ -54,7 +58,7 @@ interface PetHttpApi {
54
58
  setPet(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }>
55
59
  setSkin(skin?: string): Promise<{ ok: boolean; error?: string; skin?: string }>
56
60
  gameplayTouch(zone?: string): Promise<PetGameplayVerbResult>
57
- gameplaySetMode(mode: 'work' | 'sleep' | null): Promise<PetGameplayVerbResult>
61
+ gameplaySetMode(mode: string | null): Promise<PetGameplayVerbResult>
58
62
  gameplayWorkTick(): Promise<PetGameplayVerbResult>
59
63
  gameplayBuy(item: string): Promise<PetGameplayVerbResult>
60
64
  }
@@ -94,11 +98,11 @@ const petApi: PetHttpApi = {
94
98
  /** Poll interval for the host snapshot. */
95
99
  const POLL_MS = 2000
96
100
 
97
- /** Settings namespace the pet settings card edits (the Host plugin registers it). */
101
+ /** Settings namespace of the pet's settings page. It is the profile entry id the Host serves a form for. */
98
102
  const PET_SETTINGS_NS = 'pet'
99
103
 
100
104
  /** Required services (sessions powers bubble-to-session navigation). */
101
- export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote', 'sessions']
105
+ export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote', 'sessions', 'uiWorkspace']
102
106
 
103
107
  /** Re-exported for consumers that type against the injected face. */
104
108
  export type { PetInjected, PetDockEntryProps } from './PetDockEntry.tsx'
@@ -111,12 +115,49 @@ export type { PetDefinition } from '../registry.ts'
111
115
  declare module '@deepseek-ai/cordis' {
112
116
  interface Context {
113
117
  /**
114
- * Optional rc.6 compatibility binder provided by dsh-web-settings;
115
- * absent when that group plugin is not installed, so callers fall back to
116
- * the official settings scope.
118
+ * Optional family binder provided by dsh-web-settings; absent when that
119
+ * group plugin is not installed, so callers fall back to the shared
120
+ * per-entry form.
117
121
  */
118
- webUiSettings?: { bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S> }
122
+ webUiSettings?: {
123
+ bind<S>(spec: { namespace: string; decode?: (section: unknown) => S | undefined }): ConfigForm<S>
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The configuration form the settings card stages and saves through: the
130
+ * family binder when dsh-web-settings is mounted, otherwise the shared form of
131
+ * this plugin's own profile entry.
132
+ *
133
+ * The two are not interchangeable by name. A plugin's settings ARE its own
134
+ * config on 0.1.7, so the form belongs to the profile entry that carries the
135
+ * plugin — and only the family binder knows which entry that is, because the
136
+ * family bundle renames child rows ('pet' becomes 'web-ui-pet' there) while a
137
+ * standalone install keeps the package's own id.
138
+ * @param ctx - client root context.
139
+ * @returns the form for the pet's settings page.
140
+ */
141
+ const AGGREGATE_ENTRY_ID = 'web-ui-pet'
142
+ const PET_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'ui-pet', PET_SETTINGS_NS]
143
+
144
+ function servedEntryId(forms: ConfigForms): string {
145
+ let served: readonly string[] | undefined
146
+ try {
147
+ served = forms.describe().getSnapshot().view?.namespaces.map(view => view.ns)
148
+ } catch {
149
+ served = undefined
150
+ }
151
+ if (!served || served.length === 0) return PET_SETTINGS_NS
152
+ return PET_ENTRY_IDS.find(id => served.includes(id)) ?? PET_SETTINGS_NS
153
+ }
154
+
155
+ function petSettingsForm(ctx: ClientContext): ConfigForm<PetSettings> {
156
+ const binder = ctx.get('webUiSettings')
157
+ if (binder !== undefined && typeof binder.bind === 'function') {
158
+ return binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
119
159
  }
160
+ return ctx.configForms.get<PetSettings>(servedEntryId(ctx.configForms))
120
161
  }
121
162
 
122
163
  /**
@@ -158,24 +199,23 @@ export function apply(ctx: ClientContext): void {
158
199
  defaultPetRendererRegistry.register(live2dRenderer)
159
200
  defaultPetRendererRegistry.register(frames2dRenderer)
160
201
 
161
- const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
162
- const settingsScope = binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
202
+ const settingsForm = petSettingsForm(ctx)
163
203
  const enabled = (): boolean => {
164
- const snapshot = settingsScope.getSnapshot()
204
+ const snapshot = settingsForm.getSnapshot()
165
205
  return snapshot.status === 'ready'
166
206
  ? snapshot.value?.enabled ?? true
167
207
  : snapshot.status === 'unavailable'
168
208
  }
169
209
 
170
- // First-level settings section: one staged form over the 'pet' settings
171
- // namespace, registered as a top-level settings page. The controller loads
172
- // the petId choices from the registry endpoint itself — the registry lists
173
- // the available pets (built-in assets plus user dirs), so the section only
174
- // ever shows installed pets. Installing new pets happens in the Workshop
175
- // store.
176
- const petSettings = new PetSettingsCardController(settingsScope)
210
+ // First-level settings section: one staged form over the 'pet' profile
211
+ // entry's configuration, registered as a top-level settings page. The
212
+ // controller loads the petId choices from the registry endpoint itself — the
213
+ // registry lists the available pets (built-in assets plus user dirs), so the
214
+ // section only ever shows installed pets. Installing new pets happens in the
215
+ // Workshop store.
216
+ const petSettings = new PetSettingsCardController(settingsForm)
177
217
  // The section entry owns the controller: unregistering it (fiber disposal,
178
- // hot reload) releases the scope subscription through petSettings.dispose.
218
+ // hot reload) releases the form subscription through petSettings.dispose.
179
219
  ctx.slots.inject('settings.section', () => {
180
220
  try {
181
221
  const unregister = ctx.slots.register({
@@ -224,19 +264,17 @@ export function apply(ctx: ClientContext): void {
224
264
  const setState = petStore.actions.setState
225
265
  const setFeedback = petStore.actions.setFeedback
226
266
 
227
- // Clicking a session bubble jumps the GUI to that session; the same
228
- // sessions face reports which session the user is currently on, so the
229
- // host can lead the bubble stack with it. A bubble can outlive its
230
- // disposed session by one poll tick, and the sessions service fails
231
- // loud on unknown ids, so consult the live list first. The pet's type
232
- // program also loads the host-side dsh-session package through the
233
- // service types, whose Context merge declares a different 'sessions'
234
- // face; pin the browser runtime's outward face here.
267
+ // Clicking a session bubble jumps the GUI to that session; the
268
+ // catalog's main-view ownership marker tells which session the main view
269
+ // holds (the multi-instance replacement for the removed list.current), so
270
+ // the host can lead the bubble stack with it. A bubble can outlive its
271
+ // disposed session by one poll tick, and navigation rejects unknown ids,
272
+ // so consult the live list first. The pet's type program also loads the
273
+ // host-side dsh-session package through the service types, whose Context
274
+ // merge declares a different 'sessions' face; pin the browser runtime's
275
+ // outward face here.
235
276
  const sessions = ctx.sessions as unknown as ISessions
236
- const currentSessionId = (): string | undefined => {
237
- const current = sessions.list.getSnapshot().current
238
- return current === undefined ? undefined : String(current)
239
- }
277
+ const currentSessionId = (): string | undefined => mainViewSessionId(sessions.list.getSnapshot().byId)
240
278
 
241
279
  // The registry list is fetched lazily with retries baked into the poll
242
280
  // cycle: until it lands, the dock entry renders nothing and every 2s
@@ -315,7 +353,9 @@ export function apply(ctx: ClientContext): void {
315
353
  const openSession = (sessionId: string): void => {
316
354
  const list = sessions.list.getSnapshot()
317
355
  if ((list.byId as any)[sessionId] === undefined) return
318
- sessions.open(sessionId as never)
356
+ // Navigation belongs to the workspace UI since the multi-instance Client
357
+ // Session model; the Session Controller no longer opens one.
358
+ ctx.uiWorkspace.openSession(sessionId as never)
319
359
  }
320
360
 
321
361
  const injected = (): PetInjected => ({
@@ -456,7 +496,7 @@ export function apply(ctx: ClientContext): void {
456
496
  // (issue #785): disposal drops the subscription and tears the UI down
457
497
  // (terminal), so a hot-reloaded or re-injected bundle never leaves the
458
498
  // previous React root, container, or poll loop behind on document.body.
459
- const unsubscribeSettings = settingsScope.subscribe(syncUi)
499
+ const unsubscribeSettings = settingsForm.subscribe(syncUi)
460
500
  ctx.effect(
461
501
  () => () => {
462
502
  unsubscribeSettings()
@@ -0,0 +1,43 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/main-session.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Main-view Session derivation for the multi-instance Client Session model.
4
+ *
5
+ * The Client Session Controller carries no global "current" selection: view
6
+ * selection belongs to the workspace UI, which owns the main-area reference and
7
+ * publishes it through the catalog's per-source ownership counts
8
+ * (`SessionSummary.retainedBy.mainView`). A plugin that needs the Session the
9
+ * main view shows derives it from that marker instead of the removed
10
+ * `SessionListState.current` field.
11
+ *
12
+ * The marker describes view ownership, not authority. It is the right input for
13
+ * presentation and for deciding which view a navigation targets; it is not a
14
+ * business fallback, because source counts never imply visibility or success.
15
+ * @module dsh-web/shared/main-session
16
+ */
17
+
18
+ /** The catalog row subset this helper reads (a `SessionSummary` projection). */
19
+ export interface MainViewOwnedRow<Id extends string = string> {
20
+ /** Catalog row identity. */
21
+ readonly id: Id
22
+ /** Per-source local ownership counts; absent rows are never owned. */
23
+ readonly retainedBy?: Readonly<Partial<Record<string, number>>> | undefined
24
+ }
25
+
26
+ /**
27
+ * Resolve the Session the main view currently shows.
28
+ *
29
+ * Reads the catalog's rows rather than a per-id retain-info source: ownership
30
+ * counts ride the list snapshot, so this neither allocates observers nor opens
31
+ * history, and a subscription to the list still fires when the selection moves.
32
+ * @param byId - the session catalog's rows (`SessionListState.byId`), when available.
33
+ * @returns the main-view session id, or undefined when the main view shows none.
34
+ */
35
+ export function mainViewSessionId<Id extends string>(
36
+ byId: Readonly<Record<Id, MainViewOwnedRow<Id> | undefined>> | undefined | null,
37
+ ): Id | undefined {
38
+ if (byId === undefined || byId === null) return undefined
39
+ for (const row of Object.values<MainViewOwnedRow<Id> | undefined>(byId)) {
40
+ if (row !== undefined && (row.retainedBy?.mainView ?? 0) > 0) return row.id
41
+ }
42
+ return undefined
43
+ }
@@ -8,7 +8,7 @@
8
8
  * card-store pattern.
9
9
  */
10
10
 
11
- import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
11
+ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
12
12
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
13
13
  import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
14
14
 
@@ -114,7 +114,7 @@ interface PlannedWrite {
114
114
  judge: (() => boolean) | undefined
115
115
  }
116
116
 
117
- /** One durable write inside the save's atomic scope mutation. */
117
+ /** One durable write inside the save's atomic form mutation. */
118
118
  export interface BatchedWrite {
119
119
  /** Field this entry writes. */
120
120
  field: string
@@ -211,34 +211,34 @@ export class CardForm<T> {
211
211
  private readonly specs: Map<string, FieldSpec>
212
212
  private readonly staged = new Map<string, StagedEdit>()
213
213
  private readonly listeners = new Set<() => void>()
214
- /** The scope subscription installed in the constructor; released by dispose(). */
215
- private readonly disposeScope: () => void
214
+ /** The form subscription installed in the constructor; released by dispose(). */
215
+ private readonly disposeForm: () => void
216
216
  private disposed = false
217
217
  private saving = false
218
218
  private failed = false
219
219
  private failedReason: string | undefined
220
220
 
221
- /** @param scope - the bound settings scope for this card's namespace. */
221
+ /** @param scope - the bound configuration form for this card's namespace. */
222
222
  constructor(
223
- private readonly scope: SettingsScope<T>,
223
+ private readonly scope: ConfigForm<T>,
224
224
  specs: FieldSpec[],
225
225
  ) {
226
226
  this.specs = new Map(specs.map(spec => [spec.field, spec]))
227
- this.disposeScope = scope.subscribe(() => { this.publish() })
227
+ this.disposeForm = scope.subscribe(() => { this.publish() })
228
228
  }
229
229
 
230
230
  /**
231
- * Release the scope subscription and every bound store listener. The card
231
+ * Release the form subscription and every bound store listener. The card
232
232
  * must call this on teardown; later calls are no-ops.
233
233
  */
234
234
  dispose(): void {
235
235
  if (this.disposed) return
236
236
  this.disposed = true
237
- this.disposeScope()
237
+ this.disposeForm()
238
238
  this.listeners.clear()
239
239
  }
240
240
 
241
- /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
241
+ /** Publish a projection of this form, rebuilt whenever the form or a draft changes. */
242
242
  bind<S>(project: () => S): SnapshotStore<S> {
243
243
  const store = createSnapshotStore(project())
244
244
  this.listeners.add(() => { store.set(project()) })
@@ -295,19 +295,19 @@ export class CardForm<T> {
295
295
  }
296
296
 
297
297
  /**
298
- * Write every staged edit in one atomic scope mutation, then re-seed from
298
+ * Write every staged edit in one atomic form mutation, then re-seed from
299
299
  * what the Host accepted.
300
300
  *
301
301
  * The whole batch rides one mutate, so cross-field validate hooks
302
302
  * (baseURL+model) judge it as a unit: the Host either applies every write
303
- * or refuses the batch. The 0.1.2 scope contract never rejects a refused
304
- * mutation — the scope recovers with a fresh Host view and resolves — so
305
- * resolution alone proves nothing: the outcome is judged by reading the
306
- * settled snapshot back, one planned write at a time, and one missed write
307
- * fails the whole save. A scope that still rejects on refusal (the dsh-web
308
- * bridge scope) reports through the same failure path with its rejection
309
- * message. A save that did not land keeps its drafts, so the user can
310
- * correct them instead of retyping.
303
+ * or refuses the batch. The form contract answers a refusal or a skipped
304
+ * write with `false` (it recovers with a fresh Host view instead of
305
+ * throwing), so the outcome is judged twice: the answer itself, and then the
306
+ * settled snapshot read back one planned write at a time. One missed write
307
+ * fails the whole save. A transport that rejects instead (the dsh-web bridge
308
+ * controller on a dead connection) reports through the same failure path
309
+ * with its rejection message. A save that did not land keeps its drafts, so
310
+ * the user can correct them instead of retyping.
311
311
  * @returns settlement after the mutation and the read-back.
312
312
  */
313
313
  async save(): Promise<void> {
@@ -323,30 +323,33 @@ export class CardForm<T> {
323
323
  this.failed = false
324
324
  this.failedReason = undefined
325
325
  this.publish()
326
- // One atomic namespace mutation: the 0.1.2 scope contract takes ordered
327
- // path operations, so the whole staged batch is validated, persisted, and
326
+ // One atomic namespace mutation: the form contract takes ordered path
327
+ // operations, so the whole staged batch is validated, persisted, and
328
328
  // recovered together — either every write lands or none does.
329
329
  const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
330
330
  ? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
331
331
  : { op: 'unset', path: [item.field] })
332
332
  let failedReason: string | undefined
333
+ let accepted = false
333
334
  try {
334
- await this.scope.mutate(ops)
335
+ accepted = await this.scope.mutate(ops)
335
336
  } catch (error) {
336
337
  failedReason = error instanceof Error ? error.message : String(error)
337
338
  }
338
- // The 0.1.2 scope resolves even a refused mutation (it recovers with a
339
- // fresh view instead of throwing), so resolution alone proves nothing:
340
- // judge every planned write against the settled snapshot. The mutation is
341
- // atomic, so one missed write fails the whole save and keeps the drafts.
342
- const landed = failedReason === undefined && valid.every(item => item.judge())
339
+ // `false` is the contract's refusal/skip answer, and the form still
340
+ // resolves after a refusal (it recovers with a fresh view), so the answer
341
+ // alone is not enough: judge every planned write against the settled
342
+ // snapshot as well. The mutation is atomic, so one missed write fails the
343
+ // whole save and keeps the drafts.
344
+ const landed = accepted && failedReason === undefined && valid.every(item => item.judge())
343
345
  for (const [field, before] of pending) {
344
346
  if (landed && this.staged.get(field) === before) this.staged.delete(field)
345
347
  }
346
348
  this.saving = false
347
349
  this.failed = !landed
348
- // A read-back failure carries no server reason: the card surfaces its
349
- // generic failure copy; a rejecting scope (the bridge) adds its message.
350
+ // A refused or unlanded batch carries no server reason: the card surfaces
351
+ // its generic failure copy; a rejecting transport (the bridge) adds its
352
+ // message.
350
353
  this.failedReason = failedReason
351
354
  this.publish()
352
355
  }
@@ -381,7 +384,7 @@ export class CardForm<T> {
381
384
  */
382
385
  private landedSet(field: string, value: unknown): boolean {
383
386
  // A redacted secret never appears in any wire view layer: the Host strips
384
- // role('secret') fields and reports them through a sidecar the scope
387
+ // role('secret') fields and reports them through a sidecar the form
385
388
  // snapshot does not expose, so there is nothing to compare the draft
386
389
  // against. Settling is the only signal the form has; the rest of the
387
390
  // batch, when one exists, still carries the atomic verdict by read-back.
@@ -412,7 +415,7 @@ export class CardForm<T> {
412
415
  return spec
413
416
  }
414
417
 
415
- private snapshotOf(): SettingsScopeSnapshot<T> {
418
+ private snapshotOf(): ConfigFormSnapshot<T> {
416
419
  return this.scope.getSnapshot()
417
420
  }
418
421
 
@@ -132,9 +132,11 @@ export function projectOfficialEvent(
132
132
  }
133
133
  }
134
134
  case 'tool/result': {
135
- const block = event.data.message.content[0]
136
- const callId = String(event.data.message.source.callId)
137
- const failed = event.data.error !== undefined || block.isError === true
135
+ // Session format V4 moved the call identity and the outcome onto the
136
+ // tool-role message root; the content blocks no longer carry either.
137
+ const { message } = event.data
138
+ const callId = String(message.toolCallId)
139
+ const failed = event.data.error !== undefined || message.isError === true
138
140
  const wasTest = runtime.testCalls.delete(callId)
139
141
  runtime.activeTools.delete(callId)
140
142
  runtime.stepHadFailure ||= failed
@@ -8,8 +8,12 @@ import { parsePetManifest } from './manifest-v2.ts'
8
8
  import {
9
9
  applyGameplayEffects,
10
10
  clampGameplay,
11
+ declaredModes,
12
+ isDeclaredMode,
11
13
  drawLotteryTier,
12
14
  initialGameplayState,
15
+ modeRestoreOf,
16
+ modeStateOf,
13
17
  rollTouchBranch,
14
18
  rollWorkOutcome,
15
19
  settleGameplay,
@@ -28,7 +32,7 @@ function mikuManifest(gameplay: Record<string, unknown>): Record<string, unknown
28
32
  frames2d: {
29
33
  dir: 'thumb',
30
34
  tracks: {
31
- idle: {}, drag: {}, standup: {}, sleep: {}, work: {}, success: {}, fail: {},
35
+ idle: {}, drag: {}, standup: {}, sleep: {}, work: {}, success: {}, fail: {}, bath: {},
32
36
  happy: {}, shy: {}, flirty: {}, angry: {}, scratch: {}, blink1: {}, blink2: {}, eat: {}, shop: {},
33
37
  },
34
38
  phases: { idle: 'idle', done: 'success', failed: 'fail' },
@@ -148,6 +152,94 @@ describe('gameplay manifest', () => {
148
152
  expect(parsePetManifest(bare, 'mem').ok).toBe(false)
149
153
  })
150
154
 
155
+ it('user declares extra named modes and sees them listed after sleep', () => {
156
+ // Given a manifest that adds two named modes; when it is parsed; then both
157
+ // modes survive and the declared list appends them after sleep.
158
+ const withModes = mikuManifest({
159
+ ...FULL_GAMEPLAY,
160
+ modes: {
161
+ bath: { state: 'bath', label: 'Bath', activeLabel: 'Bathing', restore: { stat: 'mood', amount: 3, intervalMs: 1000 } },
162
+ play: { state: 'happy' },
163
+ },
164
+ })
165
+ const result = parsePetManifest(withModes, 'mem')
166
+ expect(result.ok).toBe(true)
167
+ if (!result.ok) return
168
+ const gameplay = result.manifest.gameplay!
169
+ expect(gameplay.modes?.bath).toEqual({
170
+ state: 'bath', label: 'Bath', activeLabel: 'Bathing',
171
+ restore: { stat: 'mood', amount: 3, intervalMs: 1000 },
172
+ })
173
+ expect(gameplay.modes?.play).toEqual({ state: 'happy' })
174
+ expect(declaredModes(gameplay)).toEqual(['sleep', 'bath', 'play'])
175
+ })
176
+
177
+ it('user declares a roam block and malformed ones are rejected', () => {
178
+ // Given a valid roam block; when it is parsed; then it round-trips while
179
+ // malformed variants fail closed.
180
+ const roam = { state: 'happy', intervalMs: 12_000, probability: 0.5, distanceMin: 80, distanceMax: 220, speed: 90 }
181
+ const parsed = parsePetManifest(mikuManifest({ ...FULL_GAMEPLAY, roam }), 'mem')
182
+ expect(parsed.ok).toBe(true)
183
+ if (!parsed.ok) return
184
+ expect(parsed.manifest.gameplay?.roam).toEqual(roam)
185
+ expect(parsePetManifest(mikuManifest({ roam: { ...roam, state: 'ghost' } }), 'mem').ok).toBe(false)
186
+ expect(parsePetManifest(mikuManifest({ roam: { ...roam, distanceMin: 300, distanceMax: 100 } }), 'mem').ok).toBe(false)
187
+ expect(parsePetManifest(mikuManifest({ roam: { ...roam, probability: 0 } }), 'mem').ok).toBe(false)
188
+ expect(parsePetManifest(mikuManifest({ roam: { ...roam, speed: 0 } }), 'mem').ok).toBe(false)
189
+ expect(parsePetManifest(mikuManifest({ roam: { ...roam, sparkle: true } }), 'mem').ok).toBe(false)
190
+ })
191
+
192
+ it('user restricts the roam directions to a declared subset', () => {
193
+ // Given a roam block; when directions are omitted or restricted; then the
194
+ // default four apply and a bad list fails closed.
195
+ const roam = { state: 'happy', intervalMs: 15_000, probability: 0.2, distanceMin: 80, distanceMax: 220, speed: 90 }
196
+ const plain = parsePetManifest(mikuManifest({ ...FULL_GAMEPLAY, roam }), 'mem')
197
+ expect(plain.ok).toBe(true)
198
+ if (!plain.ok) return
199
+ expect(plain.manifest.gameplay?.roam?.directions).toBeUndefined()
200
+ const restricted = parsePetManifest(mikuManifest({ ...FULL_GAMEPLAY, roam: { ...roam, directions: ['left', 'right'] } }), 'mem')
201
+ expect(restricted.ok).toBe(true)
202
+ if (!restricted.ok) return
203
+ expect(restricted.manifest.gameplay?.roam?.directions).toEqual(['left', 'right'])
204
+ expect(parsePetManifest(mikuManifest({ roam: { ...roam, directions: [] } }), 'mem').ok).toBe(false)
205
+ expect(parsePetManifest(mikuManifest({ roam: { ...roam, directions: ['left', 'left'] } }), 'mem').ok).toBe(false)
206
+ expect(parsePetManifest(mikuManifest({ roam: { ...roam, directions: ['sideways'] } }), 'mem').ok).toBe(false)
207
+ })
208
+
209
+ it('user drops a persisted mode the manifest no longer declares', () => {
210
+ // Given a manifest with one extra mode; when the persisted ids are checked;
211
+ // then only declared ids (never prototype keys) count.
212
+ const parsed = parsePetManifest(mikuManifest({
213
+ ...FULL_GAMEPLAY,
214
+ modes: { bath: { state: 'happy' } },
215
+ }), 'mem')
216
+ expect(parsed.ok).toBe(true)
217
+ if (!parsed.ok) return
218
+ const def = parsed.manifest.gameplay!
219
+ expect(isDeclaredMode(def, 'work')).toBe(true)
220
+ expect(isDeclaredMode(def, 'sleep')).toBe(true)
221
+ expect(isDeclaredMode(def, 'bath')).toBe(true)
222
+ // A mode that was persisted before the manifest dropped it is not declared.
223
+ expect(isDeclaredMode(def, 'wash')).toBe(false)
224
+ expect(isDeclaredMode(def, 'ghost')).toBe(false)
225
+ // Object.prototype members must never count as declared modes.
226
+ expect(isDeclaredMode(def, 'constructor')).toBe(false)
227
+ expect(isDeclaredMode(def, 'toString')).toBe(false)
228
+ expect(modeStateOf(def, 'constructor')).toBeUndefined()
229
+ expect(modeRestoreOf(def, 'constructor')).toBeUndefined()
230
+ })
231
+
232
+ it('user cannot declare malformed or reserved extra modes', () => {
233
+ // Given reserved and malformed mode blocks; when each is parsed; then every
234
+ // one of them fails closed.
235
+ expect(parsePetManifest(mikuManifest({ modes: { sleep: { state: 'bath' } } }), 'mem').ok).toBe(false)
236
+ expect(parsePetManifest(mikuManifest({ modes: { work: { state: 'bath' } } }), 'mem').ok).toBe(false)
237
+ expect(parsePetManifest(mikuManifest({ modes: { bath: { state: 'ghost' } } }), 'mem').ok).toBe(false)
238
+ expect(parsePetManifest(mikuManifest({ modes: { bath: { state: 'bath', sparkle: true } } }), 'mem').ok).toBe(false)
239
+ expect(parsePetManifest(mikuManifest({ modes: { bath: { state: 'bath', restore: { stat: 'ghost', amount: 3, intervalMs: 1000 } } } }), 'mem').ok).toBe(false)
240
+ expect(parsePetManifest(mikuManifest({ modes: { bath: { state: 'bath', restore: { stat: 'mood', amount: 3, intervalMs: 10 } } } }), 'mem').ok).toBe(false)
241
+ })
242
+
151
243
  it('rejects bad ranges (tickMs, decay, zone slices, stateMs)', () => {
152
244
  const badTick = mikuManifest({ work: { state: 'work', successState: 'success', failState: 'fail', tickMs: 50, successProbability: 0.5 } })
153
245
  expect(parsePetManifest(badTick, 'mem').ok).toBe(false)
@@ -185,6 +277,51 @@ describe('gameplay engine', () => {
185
277
  expect(state.settledAt).toBe(600_000)
186
278
  })
187
279
 
280
+ it('user activates a declared extra mode and its restore ticks', () => {
281
+ // Given a manifest with a bath mode; when the pet sits in it; then the mode
282
+ // restore pays mood on its own interval.
283
+ const parsed = parsePetManifest(mikuManifest({
284
+ ...FULL_GAMEPLAY,
285
+ modes: { bath: { state: 'bath', label: 'Bath', restore: { stat: 'mood', amount: 3, intervalMs: 1000 } } },
286
+ }), 'mem')
287
+ if (!parsed.ok) throw new Error('fixture must parse')
288
+ const bathDef = parsed.manifest.gameplay!
289
+ expect(modeStateOf(bathDef, 'bath')).toBe('bath')
290
+ expect(modeRestoreOf(bathDef, 'bath')).toEqual({ stat: 'mood', amount: 3, intervalMs: 1000 })
291
+ expect(modeRestoreOf(bathDef, 'work')).toBeUndefined()
292
+ expect(modeRestoreOf(bathDef, null)).toBeUndefined()
293
+
294
+ const state = initialGameplayState(bathDef, 0)
295
+ state.stats.mood = 10
296
+ state.mode = 'bath'
297
+ // 5 s of bath: 5 x 3 mood, minus 5 s of the 0.5/min baseline decay
298
+ settleGameplay(state, bathDef, 5000, { sessionActive: true })
299
+ expect(state.stats.mood).toBeCloseTo(10 - (5 / 60) * 0.5 + 15, 4)
300
+ })
301
+
302
+ it('user keeps a 1 s mode cadence across a fast polling interval', () => {
303
+ // Given a fast 200 ms poll loop; when ten seconds elapse; then the mode
304
+ // still pays exactly ten ticks with no drift.
305
+ const parsed = parsePetManifest(mikuManifest({
306
+ ...FULL_GAMEPLAY,
307
+ modes: { bath: { state: 'bath', restore: { stat: 'mood', amount: 3, intervalMs: 1000 } } },
308
+ }), 'mem')
309
+ if (!parsed.ok) throw new Error('fixture must parse')
310
+ const bathDef = parsed.manifest.gameplay!
311
+ // Start above zero: a stat parked at 0 is exempt from decay, which would
312
+ // otherwise hide part of the decay side of the balance.
313
+ const state = initialGameplayState(bathDef, 0)
314
+ state.stats.mood = 50
315
+ state.mode = 'bath'
316
+ let now = 0
317
+ for (let i = 0; i < 50; i++) {
318
+ now += 200
319
+ settleGameplay(state, bathDef, now, { sessionActive: true })
320
+ }
321
+ // 10 s of 200 ms polls still pays exactly 10 x 3 mood (carry, no drift)
322
+ expect(state.stats.mood).toBeCloseTo(50 + 30 - (10 / 60) * 0.5, 4)
323
+ })
324
+
188
325
  it('preserves remainder carry across frequent polling intervals for passive income and sleep restore (#1478)', () => {
189
326
  const state = initialGameplayState(def, 0)
190
327
  state.stats.energy = 20