@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.
- package/README.i18n.yaml +2 -2
- package/README.md +73 -38
- package/README.zh.md +91 -55
- package/assets/whale/pet.json +12 -1
- package/cordis.patch.yml +4 -4
- package/lib/client.js +296 -347
- package/lib/client.js.map +1 -1
- package/lib/index.js +975 -317
- package/lib/invariant.js +1 -1
- package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
- package/lib/types/affinity.d.ts +15 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +14 -0
- package/lib/types/client/PetDockEntry.d.ts +14 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +13 -8
- package/lib/types/client/PetSettingsCard.d.ts +22 -9
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +42 -7
- package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
- package/lib/types/client/PetSprite.d.ts.map +1 -0
- package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +30 -8
- package/lib/types/client/index.d.ts +11 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +32 -14
- package/lib/types/client/locales.d.ts +10 -10
- package/lib/types/client/locales.js +10 -10
- package/lib/types/client/pet-store.d.ts +5 -0
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/settings-form.d.ts +76 -5
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +97 -20
- package/lib/types/client/spritesheet.d.ts +14 -48
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +13 -81
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/event-projection.d.ts +36 -0
- package/lib/types/event-projection.d.ts.map +1 -0
- package/lib/types/event-projection.js +98 -0
- package/lib/types/index.d.ts +24 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +53 -33
- package/lib/types/ledger.d.ts +79 -0
- package/lib/types/ledger.d.ts.map +1 -0
- package/lib/types/ledger.js +146 -0
- package/lib/types/persist.d.ts +15 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +39 -8
- package/lib/types/registry.d.ts +158 -0
- package/lib/types/registry.d.ts.map +1 -0
- package/lib/types/registry.js +276 -0
- package/lib/types/routes.d.ts +10 -10
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +169 -51
- package/lib/types/service.d.ts +59 -57
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +111 -211
- package/package.json +2 -2
- package/src/affinity.ts +33 -0
- package/src/client/PetDockEntry.tsx +34 -26
- package/src/client/PetSettingsCard.tsx +67 -22
- package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
- package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
- package/src/client/PluginSettingsCard.tsx +90 -18
- package/src/client/index.ts +38 -16
- package/src/client/locales.ts +10 -10
- package/src/client/pet-store.ts +9 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/client/settings-form.ts +153 -19
- package/src/client/spritesheet.ts +16 -92
- package/src/dsh-home.test.ts +35 -0
- package/src/dsh-home.ts +36 -0
- package/src/event-projection.ts +128 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +89 -45
- package/src/ledger.test.ts +69 -0
- package/src/ledger.ts +190 -0
- package/src/persist.test.ts +52 -3
- package/src/persist.ts +49 -11
- package/src/registry.test.ts +132 -0
- package/src/registry.ts +400 -0
- package/src/routes.ts +166 -53
- package/src/service.ts +153 -285
- package/lib/types/client/WhalePet.d.ts.map +0 -1
package/src/client/pet-store.ts
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
94
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
if (
|
|
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
|
|
217
|
-
if (plan.length === 0 || this.saving ||
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 =
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
19
|
-
export
|
|
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
|
-
/**
|
|
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 *
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
+
})
|
package/src/dsh-home.ts
ADDED
|
@@ -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
|
+
}
|