@linxin666/dsh-pet 0.1.14 → 0.1.16

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 (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +73 -38
  3. package/README.zh.md +91 -55
  4. package/assets/whale/pet.json +12 -1
  5. package/cordis.patch.yml +4 -4
  6. package/lib/client.js +296 -347
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +975 -317
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  11. package/lib/types/affinity.d.ts +15 -0
  12. package/lib/types/affinity.d.ts.map +1 -1
  13. package/lib/types/affinity.js +14 -0
  14. package/lib/types/client/PetDockEntry.d.ts +14 -12
  15. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  16. package/lib/types/client/PetDockEntry.js +13 -8
  17. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  18. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PetSettingsCard.js +42 -7
  20. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  21. package/lib/types/client/PetSprite.d.ts.map +1 -0
  22. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  23. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  24. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  25. package/lib/types/client/PluginSettingsCard.js +30 -8
  26. package/lib/types/client/index.d.ts +11 -8
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +32 -14
  29. package/lib/types/client/locales.d.ts +10 -10
  30. package/lib/types/client/locales.js +10 -10
  31. package/lib/types/client/pet-store.d.ts +5 -0
  32. package/lib/types/client/pet-store.d.ts.map +1 -1
  33. package/lib/types/client/pet-store.js +4 -0
  34. package/lib/types/client/settings-form.d.ts +76 -5
  35. package/lib/types/client/settings-form.d.ts.map +1 -1
  36. package/lib/types/client/settings-form.js +97 -20
  37. package/lib/types/client/spritesheet.d.ts +14 -48
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +13 -81
  40. package/lib/types/dsh-home.d.ts +17 -0
  41. package/lib/types/dsh-home.d.ts.map +1 -0
  42. package/lib/types/dsh-home.js +34 -0
  43. package/lib/types/event-projection.d.ts +36 -0
  44. package/lib/types/event-projection.d.ts.map +1 -0
  45. package/lib/types/event-projection.js +98 -0
  46. package/lib/types/index.d.ts +24 -11
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +53 -33
  49. package/lib/types/ledger.d.ts +79 -0
  50. package/lib/types/ledger.d.ts.map +1 -0
  51. package/lib/types/ledger.js +146 -0
  52. package/lib/types/persist.d.ts +15 -4
  53. package/lib/types/persist.d.ts.map +1 -1
  54. package/lib/types/persist.js +39 -8
  55. package/lib/types/registry.d.ts +158 -0
  56. package/lib/types/registry.d.ts.map +1 -0
  57. package/lib/types/registry.js +276 -0
  58. package/lib/types/routes.d.ts +10 -10
  59. package/lib/types/routes.d.ts.map +1 -1
  60. package/lib/types/routes.js +169 -51
  61. package/lib/types/service.d.ts +59 -57
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +111 -211
  64. package/package.json +2 -2
  65. package/src/affinity.ts +33 -0
  66. package/src/client/PetDockEntry.tsx +34 -26
  67. package/src/client/PetSettingsCard.tsx +67 -22
  68. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  69. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  70. package/src/client/PluginSettingsCard.tsx +90 -18
  71. package/src/client/index.ts +38 -16
  72. package/src/client/locales.ts +10 -10
  73. package/src/client/pet-store.ts +9 -0
  74. package/src/client/settings-card.module.css +9 -0
  75. package/src/client/settings-form.ts +153 -19
  76. package/src/client/spritesheet.ts +16 -92
  77. package/src/dsh-home.test.ts +35 -0
  78. package/src/dsh-home.ts +36 -0
  79. package/src/event-projection.ts +128 -0
  80. package/src/index.test.ts +23 -0
  81. package/src/index.ts +89 -45
  82. package/src/ledger.test.ts +69 -0
  83. package/src/ledger.ts +190 -0
  84. package/src/persist.test.ts +52 -3
  85. package/src/persist.ts +49 -11
  86. package/src/registry.test.ts +132 -0
  87. package/src/registry.ts +400 -0
  88. package/src/routes.ts +166 -53
  89. package/src/service.ts +153 -285
  90. package/lib/types/client/WhalePet.d.ts.map +0 -1
@@ -10,6 +10,7 @@ import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
10
10
  import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-runtime/client'
11
11
  import type { PetStateView } from '../service.ts'
12
12
  import type { PetInteraction } from '../affinity.ts'
13
+ import type { PetDefinition } from '../registry.ts'
13
14
 
14
15
  /** One transient reaction bubble on the pet. */
15
16
  export interface PetFeedback {
@@ -25,6 +26,8 @@ export interface PetFeedback {
25
26
  export interface PetUiState {
26
27
  /** Latest host snapshot; null before the first successful fetch. */
27
28
  snapshot: PetStateView | null
29
+ /** The registry list the host serves (atlas URLs + geometry + tracks). */
30
+ pets: PetDefinition[]
28
31
  /** Fetch lifecycle. */
29
32
  state: 'loading' | 'ready' | 'error'
30
33
  /** Transport error message (for the debug surface), when any. */
@@ -37,6 +40,8 @@ export interface PetUiState {
37
40
  export type PetUiActions = {
38
41
  /** Replace the host snapshot (poll result). */
39
42
  setSnapshot: (draft: PetUiState, snapshot: PetStateView) => void
43
+ /** Replace the registry list. */
44
+ setPets: (draft: PetUiState, pets: PetDefinition[]) => void
40
45
  /** Mark the fetch lifecycle. */
41
46
  setState: (draft: PetUiState, state: PetUiState['state'], error: string | null) => void
42
47
  /** Show a reaction bubble. */
@@ -48,6 +53,7 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
48
53
  return defineStore({
49
54
  init: (): PetUiState => ({
50
55
  snapshot: null,
56
+ pets: [],
51
57
  state: 'loading',
52
58
  error: null,
53
59
  feedback: null,
@@ -58,6 +64,9 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
58
64
  draft.state = 'ready'
59
65
  draft.error = null
60
66
  },
67
+ setPets: (draft, pets) => {
68
+ draft.pets = pets
69
+ },
61
70
  setState: (draft, state, error) => {
62
71
  draft.state = state
63
72
  draft.error = error
@@ -1,3 +1,4 @@
1
+ /* Generated by scripts/sync-shared.mjs from shared/client/settings/settings-card.module.css. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs". */
1
2
  /* Plugin settings card chrome + staged form fields.
2
3
  * Aligned with the official ui-settings-plugins PluginCard / fields CSS:
3
4
  * same semantic tokens, radius, typography and states so family cards read
@@ -120,6 +121,9 @@
120
121
  margin: 0;
121
122
  font-size: 12px;
122
123
  line-height: 1.5;
124
+ text-overflow: ellipsis;
125
+ overflow: hidden;
126
+ white-space: nowrap;
123
127
  }
124
128
 
125
129
  .discard,
@@ -229,6 +233,11 @@
229
233
  outline-offset: 2px;
230
234
  }
231
235
 
236
+ .reset:focus-visible {
237
+ outline: 2px solid var(--dsw-alias-brand-primary);
238
+ outline-offset: 2px;
239
+ }
240
+
232
241
  .input,
233
242
  .select {
234
243
  border: 1px solid var(--dsw-alias-border-l2);
@@ -1,10 +1,11 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
3
  * Staged form model behind the plugin settings card. A card stages what the
3
4
  * user types and writes it only when they save — the settings write is a
4
5
  * durable, revision-fenced document mutation, so staging keeps what is on
5
- * screen exactly what a save would store. Mirrors the official
6
- * ui-plugin-config card-store pattern in a self-contained slice: this
7
- * package must not depend on a sibling UI package.
6
+ * screen exactly what a save would store. Family-shared slice inlined into
7
+ * each plugin's client bundle; mirrors the official ui-plugin-config
8
+ * card-store pattern.
8
9
  */
9
10
 
10
11
  import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -19,6 +20,14 @@ export type FieldWrite =
19
20
  export interface FieldSpec {
20
21
  /** Field name inside the namespace section. */
21
22
  field: string
23
+ /**
24
+ * Whether the Host treats this field as a secret and redacts its value from
25
+ * the read-back (role('secret') in the section schema). Redacted secrets are
26
+ * never compared against the draft on save; the field lands when the scope
27
+ * reports the write succeeded (its secret-set marker under the bridge), so
28
+ * a successful secret save is not misreported as failed.
29
+ */
30
+ secret?: boolean
22
31
  /** Render a stored value as draft text; the empty string when the section carries none. */
23
32
  format: (value: unknown) => string
24
33
  /**
@@ -60,6 +69,12 @@ export interface CardShell {
60
69
  saving: boolean
61
70
  /** Whether the last save did not land as staged; cleared by the next edit or save. */
62
71
  failed: boolean
72
+ /**
73
+ * The rejection code/message the Host returned for the last failed save,
74
+ * surfaced next to the generic failure text. Undefined while no save has
75
+ * failed (or the failure carried no server reason).
76
+ */
77
+ failedReason?: string
63
78
  }
64
79
 
65
80
  /** The write actions the card's slot entry injects. */
@@ -86,12 +101,63 @@ interface StagedEdit {
86
101
  interface PlannedWrite {
87
102
  /** Field this entry writes. */
88
103
  field: string
104
+ /** The durable write this entry performs, described for a batched scope. */
105
+ op: BatchedWrite
89
106
  /** Perform the write and report whether the Host holds the staged value afterwards. */
90
107
  run: (() => Promise<boolean>) | undefined
91
108
  }
92
109
 
93
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
94
- export function numberField(field: string): FieldSpec {
110
+ /** One durable write a batched settings scope performs. */
111
+ export interface BatchedWrite {
112
+ /** Field this entry writes. */
113
+ field: string
114
+ /** set stores a value; unset drops the leaf. */
115
+ op: 'set' | 'unset'
116
+ /** Value for op set (absent for unset). */
117
+ value?: unknown
118
+ }
119
+
120
+ /** Per-field outcome of one batched scope write. */
121
+ export interface BatchedFieldResult {
122
+ /** Field this entry writes. */
123
+ field: string
124
+ /** Whether the Host accepted this field's write (per the read-back view). */
125
+ landed: boolean
126
+ }
127
+
128
+ /**
129
+ * Result of a batched scope write. The bridge scope posts every planned write
130
+ * in one /mutate so the Host validate hook judges baseURL+model together; a
131
+ * batched refusal fails the whole save rather than per-field.
132
+ */
133
+ export interface BatchResult {
134
+ /** Whether the whole mutate was accepted. */
135
+ ok: boolean
136
+ /** Per-field success, in the request order (always present when ok). */
137
+ fields: BatchedFieldResult[]
138
+ /** Host rejection code (mutate refused). */
139
+ code?: string
140
+ /** Host rejection message (mutate refused). */
141
+ message?: string
142
+ }
143
+
144
+ /** The optional batch surface the bridge scope adds over the SettingsScope contract. */
145
+ interface BatchedSettingsScope {
146
+ /** Write every operation in one scope mutation, reporting per-field success. */
147
+ mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
148
+ }
149
+
150
+ /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
151
+ export interface NumberConstraints {
152
+ /** The accepted value must be a whole number. */
153
+ integer?: boolean
154
+ /** The accepted value must be at least this. */
155
+ min?: number
156
+ }
157
+
158
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
159
+ export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
160
+ const { integer = false, min } = constraints
95
161
  return {
96
162
  field,
97
163
  format: value => typeof value === 'number' ? String(value) : '',
@@ -99,7 +165,10 @@ export function numberField(field: string): FieldSpec {
99
165
  const trimmed = text.trim()
100
166
  if (trimmed === '') return { kind: 'clear' }
101
167
  const parsed = Number(trimmed)
102
- return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
168
+ if (!Number.isFinite(parsed)) return undefined
169
+ if (integer && !Number.isInteger(parsed)) return undefined
170
+ if (min !== undefined && parsed < min) return undefined
171
+ return { kind: 'set', value: parsed }
103
172
  },
104
173
  }
105
174
  }
@@ -116,19 +185,43 @@ export function textField(field: string): FieldSpec {
116
185
  }
117
186
  }
118
187
 
188
+ /**
189
+ * A free-text field the Host treats as a secret and redacts from the read-back
190
+ * (role('secret') in the section schema). The card still edits it like text,
191
+ * but a save never compares the redacted value back and relies on the scope
192
+ * reporting the write landed.
193
+ */
194
+ export function secretField(field: string): FieldSpec {
195
+ return { ...textField(field), secret: true }
196
+ }
197
+
119
198
  /** A boolean field, edited through true/false draft text. */
120
199
  export function booleanField(field: string): FieldSpec {
121
200
  return {
122
201
  field,
123
202
  format: value => typeof value === 'boolean' ? String(value) : '',
124
203
  parse: (text) => {
125
- if (text === 'true') return { kind: 'set', value: true }
126
- if (text === 'false') return { kind: 'set', value: false }
204
+ const trimmed = text.trim()
205
+ if (trimmed === '') return { kind: 'clear' }
206
+ if (trimmed === 'true') return { kind: 'set', value: true }
207
+ if (trimmed === 'false') return { kind: 'set', value: false }
127
208
  return undefined
128
209
  },
129
210
  }
130
211
  }
131
212
 
213
+ /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
214
+ export function choiceField(field: string, choices: readonly string[]): FieldSpec {
215
+ return {
216
+ field,
217
+ format: value => typeof value === 'string' && choices.includes(value) ? value : '',
218
+ parse: (text) => {
219
+ if (text === '') return { kind: 'clear' }
220
+ return choices.includes(text) ? { kind: 'set', value: text } : undefined
221
+ },
222
+ }
223
+ }
224
+
132
225
  /**
133
226
  * Stages one card's edits over one settings namespace and writes them on save.
134
227
  *
@@ -143,6 +236,7 @@ export class CardForm<T> {
143
236
  private readonly listeners = new Set<() => void>()
144
237
  private saving = false
145
238
  private failed = false
239
+ private failedReason: string | undefined
146
240
 
147
241
  /** @param scope - the bound settings scope for this card's namespace. */
148
242
  constructor(
@@ -172,6 +266,7 @@ export class CardForm<T> {
172
266
  invalid: plan.some(item => item.run === undefined),
173
267
  saving: this.saving,
174
268
  failed: this.failed,
269
+ ...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
175
270
  }
176
271
  }
177
272
 
@@ -202,6 +297,7 @@ export class CardForm<T> {
202
297
  if (this.staged.size === 0 && !this.failed) return
203
298
  this.staged.clear()
204
299
  this.failed = false
300
+ this.failedReason = undefined
205
301
  this.publish()
206
302
  },
207
303
  }
@@ -209,25 +305,57 @@ export class CardForm<T> {
209
305
 
210
306
  /**
211
307
  * Write every staged edit, then re-seed from what the Host accepted.
308
+ *
309
+ * When the scope carries the optional batch surface (the dsh-web-ui
310
+ * bridge scope), every planned write rides one mutation so cross-field
311
+ * validate hooks (baseURL+model) judge the batch as a unit instead of
312
+ * deadlocking on per-field writes. Otherwise the per-field loop runs.
313
+ * A field lands only when the Host reports it held the staged value; a
314
+ * landed field's draft is dropped, a failed one stays staged for the user.
212
315
  * @returns settlement after every write and the read-back.
213
316
  */
214
317
  async save(): Promise<void> {
215
318
  const plan = this.plan()
216
- const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
217
- if (plan.length === 0 || this.saving || writes.length !== plan.length) return
319
+ const valid = plan.filter(item => item.run !== undefined)
320
+ if (plan.length === 0 || this.saving || valid.length !== plan.length) return
321
+ const plannedWrites = valid.map(item => item.op)
322
+ // Snapshot the fields this save writes, so edits staged while it is in
323
+ // flight survive: only the staged keys this save actually wrote are cleared.
324
+ const fields = new Set(plan.map(item => item.field))
218
325
  this.saving = true
219
326
  this.failed = false
327
+ this.failedReason = undefined
220
328
  this.publish()
221
- let landed = true
222
- for (const write of writes) {
223
- landed = await write() && landed
329
+ const landed = new Set<string>()
330
+ const batch = this.batchedScope()
331
+ if (batch !== undefined) {
332
+ const result = await batch.mutate(plannedWrites)
333
+ if (result.ok) {
334
+ for (const field of result.fields) {
335
+ if (field.landed) landed.add(field.field)
336
+ }
337
+ } else {
338
+ this.failedReason = result.message
339
+ }
340
+ } else {
341
+ for (const item of valid) {
342
+ if (await item.run!()) landed.add(item.field)
343
+ }
344
+ }
345
+ for (const field of fields) {
346
+ if (landed.has(field)) this.staged.delete(field)
224
347
  }
225
- if (landed) this.staged.clear()
226
348
  this.saving = false
227
- this.failed = !landed
349
+ this.failed = landed.size !== fields.size
228
350
  this.publish()
229
351
  }
230
352
 
353
+ /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
354
+ private batchedScope(): BatchedSettingsScope | undefined {
355
+ const candidate = this.scope as unknown as BatchedSettingsScope | undefined
356
+ return typeof candidate?.mutate === 'function' ? candidate : undefined
357
+ }
358
+
231
359
  /**
232
360
  * Every staged edit a save would write. An entry whose draft is not a value
233
361
  * its field accepts carries no write: the form is still dirty, and the save
@@ -240,14 +368,14 @@ export class CardForm<T> {
240
368
  for (const [field, staged] of this.staged) {
241
369
  const spec = this.specOf(field)
242
370
  if (staged.clear) {
243
- if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
371
+ if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
244
372
  continue
245
373
  }
246
374
  if (staged.text === spec.format(this.sectionValue(field))) continue
247
375
  const write = spec.parse(staged.text)
248
- if (write === undefined) plan.push({ field, run: undefined })
249
- else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
250
- else plan.push({ field, run: () => this.store(field, write.value) })
376
+ if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
377
+ else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
378
+ else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
251
379
  }
252
380
  return plan
253
381
  }
@@ -259,12 +387,18 @@ export class CardForm<T> {
259
387
 
260
388
  private async store(field: string, value: unknown): Promise<boolean> {
261
389
  await this.scope.set(field, value)
390
+ // A redacted secret never appears in the user layer read-back; judging it
391
+ // by value would misreport a successful secret save as failed. The bridge
392
+ // reports secret writes through its secret-set markers (batch path); on
393
+ // the per-field path the scope resolved, so the write is landed.
394
+ if (this.specOf(field).secret) return true
262
395
  return this.userLayer()?.[field] === value
263
396
  }
264
397
 
265
398
  private stage(field: string, edit: StagedEdit): void {
266
399
  this.staged.set(field, edit)
267
400
  this.failed = false
401
+ this.failedReason = undefined
268
402
  this.publish()
269
403
  }
270
404
 
@@ -1,57 +1,19 @@
1
1
  /**
2
- * Whale-girl spritesheet geometry and animation tracks.
3
- *
4
- * The atlas follows the Codex/hatch-pet contract: 8 columns × 9 rows of
5
- * 192×208 cells (1536×1872 total), rows in this order:
6
- * 0 idle, 1 running-right, 2 running-left, 3 waving, 4 jumping,
7
- * 5 failed, 6 waiting, 7 running, 8 review
8
- *
9
- * Frame counts and per-frame durations are per-track definitions below; the
10
- * whale-girl atlas is produced by the hatch-pet pipeline, so calibrate
11
- * `TRACKS` against the actual run (`pet_request.json` frame counts) when the
12
- * asset lands. Tracks that do not loop hand off to `fallback`.
2
+ * Spritesheet geometry helpers — parameterized by the pet definition the
3
+ * host serves over '/api/pet/pets', so the browser half renders any registry
4
+ * entry without per-pet code. The per-track tables (frames, durations, loop,
5
+ * fallback) also come from the registry; these helpers only place frames,
6
+ * guard track lengths, and map the fixed 9-row animation contract.
13
7
  * @module @linxin666/dsh-pet/client/spritesheet
14
8
  */
15
9
 
16
10
  import type { PetAnimation } from '../state.ts'
11
+ import type { PetCell, PetTrackDef } from '../registry.ts'
17
12
 
18
- /** Atlas cell size in px (Codex contract). */
19
- export const FRAME_WIDTH = 192
20
- export const FRAME_HEIGHT = 208
21
- /** Columns per row (max frames per track). */
22
- export const FRAME_COLUMNS = 8
13
+ /** Animation track shape the frame loop consumes. */
14
+ export type TrackDef = PetTrackDef
23
15
 
24
- /** One animation track: frame indices into the row + per-frame durations. */
25
- export interface TrackDef {
26
- /** Frame indices (columns) played in order; must be < FRAME_COLUMNS. */
27
- frames: readonly number[]
28
- /** Per-frame duration in ms; same length as frames. */
29
- durations: readonly number[]
30
- /** Whether the track loops; a non-looping track hands off to fallback. */
31
- loop: boolean
32
- /** Track to play after a non-looping track finishes. */
33
- fallback?: PetAnimation
34
- }
35
-
36
- /**
37
- * Track definitions for the whale-girl. Durations are tuned for a soft,
38
- * slow-healing feel (roughly 2.5× the earlier fast draft — the pet should
39
- * breathe, not race); calibrate frame counts against the hatch-pet run when
40
- * the asset lands (rows may carry 4–8 frames).
41
- */
42
- export const TRACKS: Record<PetAnimation, TrackDef> = {
43
- idle: { frames: [0, 1, 2, 3, 4, 5], durations: [400, 400, 500, 400, 400, 500], loop: true },
44
- 'running-right': { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [225, 225, 225, 225, 225, 225, 225, 225], loop: true },
45
- 'running-left': { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [225, 225, 225, 225, 225, 225, 225, 225], loop: true },
46
- waving: { frames: [0, 1, 2, 3], durations: [350, 350, 350, 350], loop: true },
47
- jumping: { frames: [0, 1, 2, 3, 4], durations: [300, 300, 300, 350, 350], loop: false, fallback: 'idle' },
48
- failed: { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [450, 450, 450, 500, 550, 600, 450, 450], loop: false, fallback: 'idle' },
49
- waiting: { frames: [0, 1, 2, 3, 4, 5], durations: [450, 450, 500, 450, 450, 500], loop: true },
50
- running: { frames: [0, 1, 2, 3, 4, 5], durations: [250, 250, 250, 250, 250, 250], loop: true },
51
- review: { frames: [0, 1, 2, 3, 4, 5], durations: [550, 550, 550, 550, 550, 550], loop: true },
52
- }
53
-
54
- /** Row index of one animation track (mirrors state.ts rowOf). */
16
+ /** Row index of one animation track (the fixed 9-row contract). */
55
17
  export function rowOfTrack(animation: PetAnimation): number {
56
18
  const rows: Record<PetAnimation, number> = {
57
19
  idle: 0,
@@ -74,8 +36,8 @@ export function rowOfTrack(animation: PetAnimation): number {
74
36
  * atlas coordinates here would drift each frame by the scale factor and
75
37
  * render torn/overlapping frames.
76
38
  */
77
- export function framePosition(row: number, col: number, scale = 1): { x: number; y: number } {
78
- return { x: -col * FRAME_WIDTH * scale, y: -row * FRAME_HEIGHT * scale }
39
+ export function framePosition(cell: PetCell, columns: number, row: number, col: number, scale = 1): { x: number; y: number } {
40
+ return { x: -col * cell.width * scale, y: -row * cell.height * scale }
79
41
  }
80
42
 
81
43
  /** Total duration of one track, ms. */
@@ -84,51 +46,13 @@ export function trackDuration(track: TrackDef): number {
84
46
  }
85
47
 
86
48
  /**
87
- * Detect how many frames each row actually carries by scanning the decoded
88
- * atlas for non-transparent cells (hatch-pet rows may hold 4–8 frames; the
89
- * unused trailing cells are fully transparent). Rows whose every sample is
90
- * transparent report 0.
91
- * @param image - the fully decoded spritesheet (natural size 1536×1872).
92
- * @returns per-row frame counts, length 9.
93
- */
94
- export function detectFrameCounts(image: HTMLImageElement): number[] {
95
- const canvas = document.createElement('canvas')
96
- canvas.width = image.naturalWidth
97
- canvas.height = image.naturalHeight
98
- const ctx = canvas.getContext('2d')
99
- if (ctx === null) return Array.from({ length: 9 }, () => FRAME_COLUMNS)
100
- ctx.drawImage(image, 0, 0)
101
- const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data
102
- const counts: number[] = []
103
- const stride = FRAME_COLUMNS * FRAME_WIDTH
104
- const probeStep = 8
105
- const margin = 12
106
- for (let row = 0; row < 9; row++) {
107
- let count = 0
108
- for (let col = 0; col < FRAME_COLUMNS; col++) {
109
- let hasContent = false
110
- const x0 = col * FRAME_WIDTH
111
- const y0 = row * FRAME_HEIGHT
112
- for (let y = y0 + margin; y < y0 + FRAME_HEIGHT - margin && !hasContent; y += probeStep) {
113
- for (let x = x0 + margin; x < x0 + FRAME_WIDTH - margin && !hasContent; x += probeStep) {
114
- const idx = (y * stride + x) * 4
115
- if ((data[idx + 3] ?? 0) > 8) hasContent = true
116
- }
117
- }
118
- if (hasContent) count += 1
119
- }
120
- counts.push(count)
121
- }
122
- return counts
123
- }
124
-
125
- /**
126
- * Trim a track to the actual frame count of its row. A row with 0 detected
127
- * frames degrades to the first frame (the atlas is still loading or corrupt)
128
- * so the pet never renders blank.
49
+ * Trim a track to the actual frame count of its row (the manifest's per-row
50
+ * counts are authoritative; this is a last-line guard against a definition
51
+ * whose row count disagrees with its track table). A row with 0 detected
52
+ * frames degrades to the first frame so the pet never renders blank.
129
53
  */
130
54
  export function trimTrack(track: TrackDef, frameCount: number): TrackDef {
131
- const n = Math.max(1, Math.min(frameCount, track.frames.length))
55
+ const n = Math.max(1, Math.min(frameCount, track.frames.length, track.durations.length))
132
56
  return {
133
57
  frames: track.frames.slice(0, n),
134
58
  durations: track.durations.slice(0, n),
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { join } from 'node:path'
3
+ import { expandHome, resolveDshHome } from './dsh-home.ts'
4
+ import { petHomeDir } from './persist.ts'
5
+
6
+ describe('resolveDshHome', () => {
7
+ it('falls back to ~/.dsh when DSH_HOME is unset', () => {
8
+ const home = join('/home', 'tester')
9
+ expect(resolveDshHome({}, home)).toBe(join(home, '.dsh'))
10
+ })
11
+
12
+ it('prefers the DSH_HOME env override', () => {
13
+ const home = join('/home', 'tester')
14
+ expect(resolveDshHome({ DSH_HOME: '/custom/dsh' }, home)).toBe('/custom/dsh')
15
+ })
16
+
17
+ it('expands a leading ~ (and ~/) against the platform home', () => {
18
+ const home = join('/home', 'tester')
19
+ expect(resolveDshHome({ DSH_HOME: '~/data' }, home)).toBe(join(home, 'data'))
20
+ expect(expandHome('~', home)).toBe(home)
21
+ })
22
+
23
+ it('joins a relative DSH_HOME onto the working directory', () => {
24
+ const home = join('/home', 'tester')
25
+ const rel = resolveDshHome({ DSH_HOME: 'rel/dsh' }, home)
26
+ expect(rel).not.toBe('rel/dsh')
27
+ expect(rel).toBe(join(process.cwd(), 'rel/dsh'))
28
+ })
29
+ })
30
+
31
+ describe('petHomeDir delegates to the shared DSH_HOME resolution', () => {
32
+ it('returns the same value as dshHome under the same env', () => {
33
+ expect(petHomeDir).toBeTypeOf('function')
34
+ })
35
+ })
@@ -0,0 +1,36 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/dsh-home.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
4
+ * environment override wins, the platform home fallback follows. Mirrors
5
+ * what dsh-pet and dsh-liangshen each used to implement locally.
6
+ */
7
+
8
+ import { homedir } from 'node:os'
9
+ import { isAbsolute, join } from 'node:path'
10
+
11
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
12
+ export function expandHome(path: string, home: string = homedir()): string {
13
+ if (path === '~') return home
14
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
15
+ return path
16
+ }
17
+
18
+ /**
19
+ * Resolve the DSH home directory.
20
+ * @param env - process environment to read DSH_HOME from.
21
+ * @param home - platform home directory fallback (test seam).
22
+ * @returns the absolute DSH home path.
23
+ */
24
+ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
25
+ const raw = env.DSH_HOME
26
+ if (raw !== undefined && raw.trim() !== '') {
27
+ const expanded = expandHome(raw.trim(), home)
28
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
29
+ }
30
+ return join(home, '.dsh')
31
+ }
32
+
33
+ /** Resolve the DSH home directory from the live environment. */
34
+ export function dshHome(): string {
35
+ return resolveDshHome()
36
+ }