@linxin666/dsh-pet 0.1.15 → 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 +270 -334
- package/lib/client.js.map +1 -1
- package/lib/index.js +641 -87
- 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.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -1
- 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 +30 -12
- 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 +62 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +61 -12
- 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/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 +4 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +11 -4
- package/lib/types/persist.d.ts +10 -3
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +32 -5
- 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 +46 -13
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +73 -16
- package/package.json +2 -2
- 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 +7 -1
- package/src/client/index.ts +36 -14
- package/src/client/locales.ts +10 -10
- package/src/client/pet-store.ts +9 -0
- package/src/client/settings-card.module.css +3 -0
- package/src/client/settings-form.ts +114 -12
- package/src/client/spritesheet.ts +16 -92
- package/src/index.test.ts +23 -0
- package/src/index.ts +89 -45
- package/src/ledger.test.ts +5 -3
- package/src/ledger.ts +11 -4
- package/src/persist.test.ts +52 -3
- package/src/persist.ts +42 -8
- package/src/registry.test.ts +132 -0
- package/src/registry.ts +400 -0
- package/src/routes.ts +166 -53
- package/src/service.ts +102 -20
- package/lib/types/client/WhalePet.d.ts.map +0 -1
package/src/registry.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet registry — the multi-pet contract. One pet is a directory holding a
|
|
3
|
+
* 'pet.json' manifest plus an atlas image; nothing else is required, and no
|
|
4
|
+
* host or client code changes when a pet is added. The registry scans three
|
|
5
|
+
* sources, later sources overriding earlier ones on an id collision:
|
|
6
|
+
*
|
|
7
|
+
* 1. the package's own 'assets' subdirectories (built-in pets);
|
|
8
|
+
* 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets);
|
|
9
|
+
* 3. 'PetConfig.pets' manifests composed by the embedding application
|
|
10
|
+
* (highest precedence).
|
|
11
|
+
*
|
|
12
|
+
* The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
|
|
13
|
+
* 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
|
|
14
|
+
* that only carry 'frames' keep working: geometry, per-row frame counts and
|
|
15
|
+
* per-track rhythm all fall back to the hatch-pet contract defaults, and the
|
|
16
|
+
* whale-girl manifest overrides its own durations.
|
|
17
|
+
* @module @linxin666/dsh-pet/registry
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
import type { PetAnimation } from './state.ts'
|
|
25
|
+
|
|
26
|
+
/** Fixed row order of the 9-state animation contract. */
|
|
27
|
+
export const PET_ROW_ORDER: readonly PetAnimation[] = [
|
|
28
|
+
'idle',
|
|
29
|
+
'running-right',
|
|
30
|
+
'running-left',
|
|
31
|
+
'waving',
|
|
32
|
+
'jumping',
|
|
33
|
+
'failed',
|
|
34
|
+
'waiting',
|
|
35
|
+
'running',
|
|
36
|
+
'review',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/** Atlas cell size in px. */
|
|
40
|
+
export interface PetCell {
|
|
41
|
+
width: number
|
|
42
|
+
height: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Atlas cell size in px (Codex/hatch-pet contract). */
|
|
46
|
+
export const DEFAULT_PET_CELL: PetCell = { width: 192, height: 208 }
|
|
47
|
+
/** Columns per row (max frames per track). */
|
|
48
|
+
export const DEFAULT_PET_COLUMNS = 8
|
|
49
|
+
/** Rows in the atlas (fixed by the animation contract). */
|
|
50
|
+
export const DEFAULT_PET_ROW_COUNT = 9
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Per-row used-column counts from the hatch-pet contract table. Manifests
|
|
54
|
+
* that carry no 'frames' field (the Codex custom-pet shape) resolve here.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_FRAME_COUNTS: readonly number[] = [6, 8, 8, 4, 5, 8, 6, 6, 6]
|
|
57
|
+
|
|
58
|
+
/** Absolute package root, resolved from a module URL (lib/ or src/). */
|
|
59
|
+
export function petPackageRoot(importMetaUrl: string): string {
|
|
60
|
+
return fileURLToPath(new URL('../', importMetaUrl))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolve the hatch-pet custom pets directory (CODEX_HOME or ~/.codex). */
|
|
64
|
+
export function codexPetsDir(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
|
|
65
|
+
const raw = env.CODEX_HOME !== undefined && env.CODEX_HOME.trim() !== ''
|
|
66
|
+
? env.CODEX_HOME.trim()
|
|
67
|
+
: join(home, '.codex')
|
|
68
|
+
const expanded = raw === '~'
|
|
69
|
+
? home
|
|
70
|
+
: (raw.startsWith('~/') || raw.startsWith('~\\')) ? join(home, raw.slice(2)) : raw
|
|
71
|
+
return join(expanded, 'pets')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Finite non-negative integer guard, else the fallback. */
|
|
75
|
+
function finiteInt(value: unknown, fallback: number, max: number): number {
|
|
76
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= max
|
|
77
|
+
? value
|
|
78
|
+
: fallback
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build the browser URL of one pet asset. */
|
|
82
|
+
function assetUrl(prefix: string, id: string, file: string): string {
|
|
83
|
+
const path = file.split('/').filter(segment => segment !== '').join('/')
|
|
84
|
+
return prefix + '/' + encodeURIComponent(id) + '/' + path
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One animation track as served to the browser half. */
|
|
88
|
+
export interface PetTrackDef {
|
|
89
|
+
/** Frame indices (columns) played in order. */
|
|
90
|
+
frames: number[]
|
|
91
|
+
/** Per-frame duration in ms; same length as frames. */
|
|
92
|
+
durations: number[]
|
|
93
|
+
/** Whether the track loops; a non-looping track holds its last frame. */
|
|
94
|
+
loop: boolean
|
|
95
|
+
/** Track to switch to after a non-looping track finishes. */
|
|
96
|
+
fallback?: PetAnimation
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Default per-track rhythm (hatch-pet contract table). */
|
|
100
|
+
export const DEFAULT_TRACK_PATTERNS: Record<PetAnimation, {
|
|
101
|
+
durations: number[]
|
|
102
|
+
loop: boolean
|
|
103
|
+
fallback?: PetAnimation
|
|
104
|
+
}> = {
|
|
105
|
+
idle: { durations: [280, 110, 110, 140, 140, 320], loop: true },
|
|
106
|
+
'running-right': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
|
|
107
|
+
'running-left': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
|
|
108
|
+
waving: { durations: [140, 140, 140, 280], loop: true },
|
|
109
|
+
jumping: { durations: [140, 140, 140, 140, 280], loop: false, fallback: 'idle' },
|
|
110
|
+
failed: { durations: [140, 140, 140, 140, 140, 140, 140, 240], loop: false, fallback: 'idle' },
|
|
111
|
+
waiting: { durations: [150, 150, 150, 150, 150, 260], loop: true },
|
|
112
|
+
running: { durations: [120, 120, 120, 120, 120, 220], loop: true },
|
|
113
|
+
review: { durations: [150, 150, 150, 150, 150, 280], loop: true },
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Manifest shape a pet directory (or 'PetConfig.pets' entry) declares. */
|
|
117
|
+
export interface PetManifest {
|
|
118
|
+
/** Unique pet id, lowercase kebab-case. */
|
|
119
|
+
id: string
|
|
120
|
+
/** Human-readable display name (settings selector, panel header). */
|
|
121
|
+
displayName: string
|
|
122
|
+
/** One-line description. */
|
|
123
|
+
description?: string
|
|
124
|
+
/** Atlas path relative to the manifest's directory. */
|
|
125
|
+
spritesheetPath: string
|
|
126
|
+
/** Atlas cell size; defaults to the Codex contract 192x208. */
|
|
127
|
+
cell?: { width?: number; height?: number }
|
|
128
|
+
/** Columns per row; defaults to 8. */
|
|
129
|
+
columns?: number
|
|
130
|
+
/**
|
|
131
|
+
* Per-row frame counts (9 entries, row order above). Manifests that omit
|
|
132
|
+
* it resolve the hatch-pet contract table.
|
|
133
|
+
*/
|
|
134
|
+
frames?: number[]
|
|
135
|
+
/** Optional per-track rhythm overrides; omitted tracks use the defaults. */
|
|
136
|
+
tracks?: Partial<Record<PetAnimation, PetTrackOverride>>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Per-track rhythm overrides a manifest may carry. */
|
|
140
|
+
export interface PetTrackOverride {
|
|
141
|
+
/** Per-frame durations in ms (cycled to the row's frame count). */
|
|
142
|
+
durations?: number[]
|
|
143
|
+
/** Whether the track loops. */
|
|
144
|
+
loop?: boolean
|
|
145
|
+
/** Track to switch to after a non-looping track finishes. */
|
|
146
|
+
fallback?: PetAnimation
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A normalized pet as served to the browser half. */
|
|
150
|
+
export interface PetDefinition {
|
|
151
|
+
id: string
|
|
152
|
+
displayName: string
|
|
153
|
+
description: string
|
|
154
|
+
/** Atlas cell size in px. */
|
|
155
|
+
cell: PetCell
|
|
156
|
+
/** Columns per row. */
|
|
157
|
+
columns: number
|
|
158
|
+
/** Per-row frame counts (length 9, row order above). */
|
|
159
|
+
rows: number[]
|
|
160
|
+
/** Fully resolved animation tracks (frames + durations + loop/fallback). */
|
|
161
|
+
tracks: Record<PetAnimation, PetTrackDef>
|
|
162
|
+
/** Browser URL of the atlas (served by the host asset route). */
|
|
163
|
+
atlasUrl: string
|
|
164
|
+
/** Browser URL of the manifest (served by the host asset route). */
|
|
165
|
+
manifestUrl: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A resolved pet plus its host-side file location. */
|
|
169
|
+
export interface PetEntry extends PetDefinition {
|
|
170
|
+
/** Absolute directory holding the manifest and atlas. */
|
|
171
|
+
dir: string
|
|
172
|
+
/** Atlas path relative to 'dir' (declared by the manifest). */
|
|
173
|
+
spritesheetPath: string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Registry load result: resolved entries plus load warnings. */
|
|
177
|
+
export interface PetRegistry {
|
|
178
|
+
entries: PetEntry[]
|
|
179
|
+
warnings: string[]
|
|
180
|
+
byId(id: string): PetEntry | undefined
|
|
181
|
+
/** The pet an installation falls back to when the selection is unknown. */
|
|
182
|
+
defaultEntry(): PetEntry
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Registry sources. */
|
|
186
|
+
export interface PetRegistryOptions {
|
|
187
|
+
/** Absolute package root whose 'assets/*' hold built-in pets. */
|
|
188
|
+
packageRoot: string
|
|
189
|
+
/** Asset route prefix the browser URLs are built under. */
|
|
190
|
+
assetPrefix?: string
|
|
191
|
+
/** Custom pet directory (defaults to '${CODEX_HOME:-~/.codex}/pets'). */
|
|
192
|
+
petsDir?: string
|
|
193
|
+
/** Extra manifest entries composed by the embedding application. */
|
|
194
|
+
extra?: readonly PetManifest[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Stable id charset: keeps asset URLs plain and filesystem-safe. */
|
|
198
|
+
const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
199
|
+
/** Safe path-segment charset for atlas files. */
|
|
200
|
+
const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/
|
|
201
|
+
const PET_NAME_MAX_LENGTH = 80
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Normalize one parsed manifest into a renderable pet entry, or undefined
|
|
205
|
+
* (with a warning recorded) when the manifest violates the contract.
|
|
206
|
+
*/
|
|
207
|
+
export function resolvePetManifest(
|
|
208
|
+
raw: unknown,
|
|
209
|
+
dir: string,
|
|
210
|
+
options: { assetPrefix?: string; warnings?: string[] } = {},
|
|
211
|
+
): PetEntry | undefined {
|
|
212
|
+
const { assetPrefix = '/pet', warnings = [] } = options
|
|
213
|
+
const warn = (message: string): void => { warnings.push(message) }
|
|
214
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
215
|
+
warn('manifest is not an object')
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
const source = raw as Record<string, unknown>
|
|
219
|
+
const id = typeof source.id === 'string' ? source.id.trim() : ''
|
|
220
|
+
if (!PET_ID_PATTERN.test(id)) {
|
|
221
|
+
warn('manifest id ' + JSON.stringify(String(source.id)) + ' is not a lowercase kebab id')
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
const displayName = typeof source.displayName === 'string' && source.displayName.trim() !== ''
|
|
225
|
+
? source.displayName.trim().slice(0, PET_NAME_MAX_LENGTH)
|
|
226
|
+
: id
|
|
227
|
+
const description = typeof source.description === 'string'
|
|
228
|
+
? source.description.trim()
|
|
229
|
+
: ''
|
|
230
|
+
const spritesheet = typeof source.spritesheetPath === 'string' && source.spritesheetPath.trim() !== ''
|
|
231
|
+
? source.spritesheetPath.trim()
|
|
232
|
+
: 'spritesheet.webp'
|
|
233
|
+
const spritesheetPath = spritesheet.split('/').filter(segment => segment !== '')
|
|
234
|
+
if (
|
|
235
|
+
spritesheetPath.length === 0
|
|
236
|
+
|| isAbsolute(spritesheet)
|
|
237
|
+
|| spritesheet.includes('\\')
|
|
238
|
+
|| spritesheetPath.some(segment => segment === '..' || !PATH_SEGMENT_PATTERN.test(segment))
|
|
239
|
+
) {
|
|
240
|
+
warn('manifest spritesheetPath ' + JSON.stringify(spritesheet) + ' is not a safe relative path')
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
const rawCell = (typeof source.cell === 'object' && source.cell !== null ? source.cell : {}) as Record<string, unknown>
|
|
244
|
+
const cell = {
|
|
245
|
+
width: finiteInt(rawCell.width, DEFAULT_PET_CELL.width, 2048),
|
|
246
|
+
height: finiteInt(rawCell.height, DEFAULT_PET_CELL.height, 2048),
|
|
247
|
+
}
|
|
248
|
+
const columns = finiteInt(source.columns, DEFAULT_PET_COLUMNS, 32)
|
|
249
|
+
const rows = DEFAULT_FRAME_COUNTS.map((fallback, index) => {
|
|
250
|
+
const value = Array.isArray(source.frames) ? source.frames[index] : undefined
|
|
251
|
+
return finiteInt(value, fallback, columns)
|
|
252
|
+
})
|
|
253
|
+
const trackOverrides = (typeof source.tracks === 'object' && source.tracks !== null ? source.tracks : {}) as Partial<Record<PetAnimation, PetTrackOverride>>
|
|
254
|
+
const tracks = {} as Record<PetAnimation, PetTrackDef>
|
|
255
|
+
for (const [row, animation] of PET_ROW_ORDER.entries()) {
|
|
256
|
+
const pattern = DEFAULT_TRACK_PATTERNS[animation]
|
|
257
|
+
const override = trackOverrides[animation]
|
|
258
|
+
const durations = Array.isArray(override?.durations) && override.durations.length > 0
|
|
259
|
+
? override.durations.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0)
|
|
260
|
+
: pattern.durations
|
|
261
|
+
if (durations.length === 0) {
|
|
262
|
+
warn('manifest ' + id + ': track ' + animation + ' carries no usable durations')
|
|
263
|
+
return undefined
|
|
264
|
+
}
|
|
265
|
+
const frameCount = Math.max(1, Math.min(rows[row]!, columns))
|
|
266
|
+
const sized = durations.length >= frameCount
|
|
267
|
+
? durations.slice(0, frameCount)
|
|
268
|
+
: Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]!)
|
|
269
|
+
tracks[animation] = {
|
|
270
|
+
frames: Array.from({ length: frameCount }, (_, index) => index),
|
|
271
|
+
durations: sized,
|
|
272
|
+
loop: typeof override?.loop === 'boolean' ? override.loop : pattern.loop,
|
|
273
|
+
...(override?.fallback === undefined
|
|
274
|
+
? pattern.fallback === undefined ? {} : { fallback: pattern.fallback }
|
|
275
|
+
: PET_ROW_ORDER.includes(override.fallback)
|
|
276
|
+
? { fallback: override.fallback }
|
|
277
|
+
: pattern.fallback === undefined ? {} : { fallback: pattern.fallback }),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
id,
|
|
282
|
+
displayName,
|
|
283
|
+
description,
|
|
284
|
+
cell,
|
|
285
|
+
columns,
|
|
286
|
+
rows,
|
|
287
|
+
tracks,
|
|
288
|
+
atlasUrl: assetUrl(assetPrefix, id, spritesheet),
|
|
289
|
+
manifestUrl: assetUrl(assetPrefix, id, 'pet.json'),
|
|
290
|
+
dir,
|
|
291
|
+
spritesheetPath: spritesheetPath.join('/'),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Scan one directory of pet folders; entries come back in name order. */
|
|
296
|
+
function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: string[] }): PetEntry[] {
|
|
297
|
+
if (!existsSync(dir)) return []
|
|
298
|
+
let names: string[] = []
|
|
299
|
+
try {
|
|
300
|
+
names = readdirSync(dir).filter(name => !name.startsWith('.'))
|
|
301
|
+
} catch {
|
|
302
|
+
return []
|
|
303
|
+
}
|
|
304
|
+
names.sort()
|
|
305
|
+
const entries: PetEntry[] = []
|
|
306
|
+
for (const name of names) {
|
|
307
|
+
const manifestFile = join(dir, name, 'pet.json')
|
|
308
|
+
if (!existsSync(manifestFile)) continue
|
|
309
|
+
const parsed = readPetJson(manifestFile, options.warnings)
|
|
310
|
+
if (parsed === undefined) continue
|
|
311
|
+
const entry = resolvePetManifest(parsed, join(dir, name), options)
|
|
312
|
+
if (entry !== undefined) entries.push(entry)
|
|
313
|
+
}
|
|
314
|
+
return entries
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Read and parse one manifest file; undefined (warning recorded) on failure. */
|
|
318
|
+
function readPetJson(file: string, warnings: string[] | undefined): unknown {
|
|
319
|
+
try {
|
|
320
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
321
|
+
} catch (error) {
|
|
322
|
+
warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)))
|
|
323
|
+
return undefined
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Load the pet registry: built-in 'assets/*' first, then the hatch-pet
|
|
329
|
+
* custom pets directory, then composed 'extra' manifests (each later source
|
|
330
|
+
* overrides an earlier one on id collision). The registry never throws on a
|
|
331
|
+
* bad manifest: it skips it and records a warning.
|
|
332
|
+
*/
|
|
333
|
+
export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
|
|
334
|
+
const { packageRoot, assetPrefix = '/pet' } = options
|
|
335
|
+
const warnings: string[] = []
|
|
336
|
+
const byId = new Map<string, PetEntry>()
|
|
337
|
+
const builtinIds = new Set<string>()
|
|
338
|
+
|
|
339
|
+
for (const entry of scanPetDir(join(packageRoot, 'assets'), { assetPrefix, warnings })) {
|
|
340
|
+
if (byId.has(entry.id)) {
|
|
341
|
+
warnings.push('duplicate built-in pet id ' + entry.id + '; the first one wins')
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
byId.set(entry.id, entry)
|
|
345
|
+
builtinIds.add(entry.id)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const petsDir = options.petsDir ?? codexPetsDir()
|
|
349
|
+
if (petsDir !== '') {
|
|
350
|
+
for (const entry of scanPetDir(petsDir, { assetPrefix, warnings })) {
|
|
351
|
+
if (byId.has(entry.id)) warnings.push('custom pet ' + entry.id + ' overrides the built-in one')
|
|
352
|
+
byId.set(entry.id, entry)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const manifest of options.extra ?? []) {
|
|
357
|
+
const dir = manifest.spritesheetPath === undefined || isAbsolute(manifest.spritesheetPath)
|
|
358
|
+
? join(packageRoot, 'assets', 'extra')
|
|
359
|
+
: dirname(resolve(packageRoot, manifest.spritesheetPath))
|
|
360
|
+
const entry = resolvePetManifest(manifest, dir, { assetPrefix, warnings })
|
|
361
|
+
if (entry === undefined) continue
|
|
362
|
+
if (byId.has(entry.id)) warnings.push('composed pet ' + entry.id + ' overrides an earlier registration')
|
|
363
|
+
byId.set(entry.id, entry)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const entries = [...byId.values()]
|
|
367
|
+
return {
|
|
368
|
+
entries,
|
|
369
|
+
warnings,
|
|
370
|
+
byId: (id: string) => byId.get(id),
|
|
371
|
+
defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Strip host-only fields, leaving the client-visible definition. */
|
|
376
|
+
export function petEntryView(entry: PetEntry): PetDefinition {
|
|
377
|
+
return {
|
|
378
|
+
id: entry.id,
|
|
379
|
+
displayName: entry.displayName,
|
|
380
|
+
description: entry.description,
|
|
381
|
+
cell: entry.cell,
|
|
382
|
+
columns: entry.columns,
|
|
383
|
+
rows: entry.rows,
|
|
384
|
+
tracks: entry.tracks,
|
|
385
|
+
atlasUrl: entry.atlasUrl,
|
|
386
|
+
manifestUrl: entry.manifestUrl,
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The absolute file a pet's atlas resolves to (host asset route). */
|
|
391
|
+
export function petAtlasFile(entry: PetEntry): string {
|
|
392
|
+
return join(entry.dir, entry.spritesheetPath)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The directory basename of one entry (legacy asset URL alias, e.g. whale). */
|
|
396
|
+
export function petDirAlias(entry: PetEntry): string {
|
|
397
|
+
return basename(entry.dir)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
package/src/routes.ts
CHANGED
|
@@ -1,35 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pet HTTP routes — the browser half talks to the host through plain
|
|
3
|
-
* same-origin JSON endpoints (
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* same-origin JSON endpoints ('/api/pet/*') and loads pet assets from
|
|
4
|
+
* '/pet/<id>/*'. The '/plugins/' endpoint only serves client bundles and RPC
|
|
5
|
+
* domains are platform-registered, so the pet serves its own API and media —
|
|
6
|
+
* the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
|
|
7
|
+
* is one prefix registration serving every registry entry (manifest, atlas,
|
|
8
|
+
* optional previews), so adding a pet never touches route wiring.
|
|
7
9
|
* @module @linxin666/dsh-pet/routes
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
|
-
import
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
11
13
|
import { readFile } from 'node:fs/promises'
|
|
12
14
|
import { join } from 'node:path'
|
|
13
|
-
import {
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
14
16
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
15
17
|
import type { PetService } from './service.ts'
|
|
16
18
|
import type { PetInteraction } from './affinity.ts'
|
|
19
|
+
import { petEntryView, type PetEntry, type PetRegistry } from './registry.ts'
|
|
17
20
|
|
|
18
21
|
/** Browser-facing base path of the pet API. */
|
|
19
22
|
export const PET_API_PREFIX = '/api/pet'
|
|
20
23
|
|
|
21
|
-
/** Browser-facing base path of the pet asset routes. */
|
|
22
|
-
export const PET_ASSET_PREFIX = '/pet
|
|
24
|
+
/** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
|
|
25
|
+
export const PET_ASSET_PREFIX = '/pet'
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
{ name: 'pet.json', mime: 'application/json' },
|
|
28
|
-
] as const
|
|
27
|
+
const MANIFEST_FILE = 'pet.json'
|
|
28
|
+
const PREVIEW_DIR = 'previews'
|
|
29
|
+
const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const MIME_BY_EXT: Readonly<Record<string, string>> = {
|
|
32
|
+
'.webp': 'image/webp',
|
|
33
|
+
'.png': 'image/png',
|
|
34
|
+
'.gif': 'image/gif',
|
|
35
|
+
'.jpg': 'image/jpeg',
|
|
36
|
+
'.jpeg': 'image/jpeg',
|
|
37
|
+
'.json': 'application/json',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Content type by file extension (safe fallback: octet-stream). */
|
|
41
|
+
function mimeFor(file: string): string {
|
|
42
|
+
const dot = file.lastIndexOf('.')
|
|
43
|
+
if (dot < 0) return 'application/octet-stream'
|
|
44
|
+
return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream'
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
/** Write one JSON response. */
|
|
@@ -53,8 +65,6 @@ function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
|
53
65
|
req.on('data', (chunk: Buffer) => {
|
|
54
66
|
size += chunk.length
|
|
55
67
|
if (size > 64 * 1024) {
|
|
56
|
-
// Reject first so the error handler can write the 400 response,
|
|
57
|
-
// then close the connection once the response is flushed.
|
|
58
68
|
reject(new Error('body-too-large'))
|
|
59
69
|
queueMicrotask(() => req.destroy())
|
|
60
70
|
return
|
|
@@ -112,60 +122,163 @@ function postRoute(path: string, run: (body: Record<string, unknown>) => Promise
|
|
|
112
122
|
}
|
|
113
123
|
}
|
|
114
124
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
const
|
|
125
|
+
/** Legacy URL aliases: each entry's directory basename (e.g. 'whale'). */
|
|
126
|
+
function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
|
|
127
|
+
const aliases = new Map<string, PetEntry>()
|
|
128
|
+
for (const entry of registry.entries) {
|
|
129
|
+
const alias = entry.dir.split(/[\\/]/).pop() ?? ''
|
|
130
|
+
if (alias !== '' && !aliases.has(alias)) aliases.set(alias, entry)
|
|
131
|
+
}
|
|
132
|
+
return aliases
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
|
|
137
|
+
* legacy directory alias), then serves exactly the files a manifest declares:
|
|
138
|
+
* pet.json, the declared spritesheet path, and optional 'previews/<name>'
|
|
139
|
+
* media. Composed pets without a manifest file get a synthesized pet.json.
|
|
140
|
+
*/
|
|
141
|
+
function assetHandler(registry: PetRegistry): WebRoute['handler'] {
|
|
142
|
+
const aliases = dirAliases(registry)
|
|
143
|
+
return (req: IncomingMessage, res: ServerResponse): void => {
|
|
144
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
145
|
+
res.writeHead(405)
|
|
146
|
+
res.end()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
let pathname: string
|
|
150
|
+
try {
|
|
151
|
+
pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
|
|
152
|
+
} catch {
|
|
153
|
+
res.writeHead(400)
|
|
154
|
+
res.end()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
const segments = pathname.split('/').filter(segment => segment !== '')
|
|
158
|
+
if (segments[0] !== 'pet' || segments[1] === undefined) {
|
|
159
|
+
res.writeHead(404)
|
|
160
|
+
res.end()
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
let id: string
|
|
164
|
+
try {
|
|
165
|
+
id = decodeURIComponent(segments[1])
|
|
166
|
+
} catch {
|
|
167
|
+
res.writeHead(400)
|
|
168
|
+
res.end()
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const entry = registry.byId(id) ?? aliases.get(id)
|
|
172
|
+
if (entry === undefined) {
|
|
173
|
+
res.writeHead(404)
|
|
174
|
+
res.end()
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
const rest: string[] = []
|
|
178
|
+
for (const segment of segments.slice(2)) {
|
|
179
|
+
let decoded: string
|
|
180
|
+
try {
|
|
181
|
+
decoded = decodeURIComponent(segment)
|
|
182
|
+
} catch {
|
|
183
|
+
res.writeHead(400)
|
|
184
|
+
res.end()
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
rest.push(decoded)
|
|
188
|
+
}
|
|
189
|
+
const rel = rest.join('/')
|
|
190
|
+
let file: string | undefined
|
|
191
|
+
let synthesized = false
|
|
192
|
+
if (rest.length === 1 && rest[0] === MANIFEST_FILE) {
|
|
193
|
+
const manifestFile = join(entry.dir, MANIFEST_FILE)
|
|
194
|
+
file = existsSync(manifestFile) ? manifestFile : undefined
|
|
195
|
+
if (file === undefined) synthesized = true
|
|
196
|
+
} else if (rest.length > 0 && rel === entry.spritesheetPath) {
|
|
197
|
+
file = join(entry.dir, entry.spritesheetPath)
|
|
198
|
+
} else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1]!)) {
|
|
199
|
+
const preview = join(entry.dir, PREVIEW_DIR, rest[1]!)
|
|
200
|
+
file = existsSync(preview) ? preview : undefined
|
|
201
|
+
}
|
|
202
|
+
if (synthesized) {
|
|
203
|
+
const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8')
|
|
204
|
+
res.writeHead(200, {
|
|
205
|
+
'content-type': 'application/json; charset=utf-8',
|
|
206
|
+
'content-length': String(body.byteLength),
|
|
207
|
+
'cache-control': 'no-cache',
|
|
208
|
+
})
|
|
209
|
+
if (req.method === 'HEAD') {
|
|
210
|
+
res.end()
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
res.end(body)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (file === undefined) {
|
|
217
|
+
res.writeHead(404)
|
|
218
|
+
res.end()
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
const resolved = file
|
|
222
|
+
readFile(resolved).then((body) => {
|
|
223
|
+
res.writeHead(200, {
|
|
224
|
+
'content-type': mimeFor(resolved),
|
|
225
|
+
'content-length': String(body.byteLength),
|
|
226
|
+
'cache-control': 'no-cache',
|
|
227
|
+
})
|
|
228
|
+
if (req.method === 'HEAD') {
|
|
229
|
+
res.end()
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
res.end(body)
|
|
233
|
+
}, () => {
|
|
234
|
+
res.writeHead(404)
|
|
235
|
+
res.end()
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Build the full route family (API + assets) for one service. */
|
|
241
|
+
export function makePetRoutes(deps: { service: PetService }): WebRoute[] {
|
|
242
|
+
const { service } = deps
|
|
118
243
|
const apiRoutes: WebRoute[] = [
|
|
119
|
-
getRoute(
|
|
120
|
-
|
|
244
|
+
getRoute(PET_API_PREFIX + '/state', () => service.state()),
|
|
245
|
+
getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
|
|
246
|
+
postRoute(PET_API_PREFIX + '/interact', (body) => {
|
|
121
247
|
const kind = body.kind as PetInteraction | undefined
|
|
122
248
|
if (kind !== 'pet' && kind !== 'feed') return Promise.reject(new Error('invalid-kind'))
|
|
123
249
|
return service.interact(kind)
|
|
124
250
|
}),
|
|
125
|
-
postRoute(
|
|
251
|
+
postRoute(PET_API_PREFIX + '/set-visible', (body) => {
|
|
126
252
|
const visible = body.visible
|
|
127
253
|
if (typeof visible !== 'boolean') return Promise.reject(new Error('invalid-visible'))
|
|
128
254
|
return service.setVisible(visible)
|
|
129
255
|
}),
|
|
130
|
-
postRoute(
|
|
256
|
+
postRoute(PET_API_PREFIX + '/set-config', (body) => service.setConfig({
|
|
131
257
|
...(typeof body.size === 'number' ? { size: body.size } : {}),
|
|
132
258
|
...(typeof body.right === 'number' ? { right: body.right } : {}),
|
|
133
259
|
...(typeof body.bottom === 'number' ? { bottom: body.bottom } : {}),
|
|
134
260
|
...(typeof body.visible === 'boolean' ? { visible: body.visible } : {}),
|
|
135
261
|
})),
|
|
136
|
-
postRoute(
|
|
262
|
+
postRoute(PET_API_PREFIX + '/set-name', (body) => {
|
|
137
263
|
const name = body.name
|
|
138
264
|
if (typeof name !== 'string') return Promise.reject(new Error('invalid-name'))
|
|
139
265
|
return service.setName(name)
|
|
140
266
|
}),
|
|
267
|
+
postRoute(PET_API_PREFIX + '/set-pet', (body) => {
|
|
268
|
+
const petId = body.petId
|
|
269
|
+
if (typeof petId !== 'string') return Promise.reject(new Error('invalid-pet'))
|
|
270
|
+
return service.setPetId(petId)
|
|
271
|
+
}),
|
|
141
272
|
]
|
|
142
273
|
|
|
143
|
-
const
|
|
144
|
-
kind: '
|
|
145
|
-
path:
|
|
146
|
-
handler: (
|
|
147
|
-
|
|
148
|
-
res.writeHead(405)
|
|
149
|
-
res.end()
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
return readFile(join(packageRoot, 'assets', 'whale', file.name)).then((body) => {
|
|
153
|
-
res.writeHead(200, {
|
|
154
|
-
'content-type': file.mime,
|
|
155
|
-
'content-length': String(body.byteLength),
|
|
156
|
-
'cache-control': 'no-cache',
|
|
157
|
-
})
|
|
158
|
-
if (req.method === 'HEAD') {
|
|
159
|
-
res.end()
|
|
160
|
-
return
|
|
161
|
-
}
|
|
162
|
-
res.end(body)
|
|
163
|
-
}, () => {
|
|
164
|
-
res.writeHead(404)
|
|
165
|
-
res.end()
|
|
166
|
-
})
|
|
167
|
-
},
|
|
168
|
-
}))
|
|
274
|
+
const assetRoute: WebRoute = {
|
|
275
|
+
kind: 'prefix',
|
|
276
|
+
path: PET_ASSET_PREFIX,
|
|
277
|
+
handler: assetHandler(service.registrySnapshot()),
|
|
278
|
+
}
|
|
169
279
|
|
|
170
|
-
return [...apiRoutes,
|
|
280
|
+
return [...apiRoutes, assetRoute]
|
|
171
281
|
}
|
|
282
|
+
|
|
283
|
+
// Re-exported for the package surface (the registry owns the definition now).
|
|
284
|
+
export { petPackageRoot } from './registry.ts'
|