@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.
Files changed (74) 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 +270 -334
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +641 -87
  9. package/lib/types/client/PetDockEntry.d.ts +14 -12
  10. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  11. package/lib/types/client/PetDockEntry.js +13 -8
  12. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +42 -7
  15. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  16. package/lib/types/client/PetSprite.d.ts.map +1 -0
  17. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  18. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PluginSettingsCard.js +3 -1
  20. package/lib/types/client/index.d.ts +11 -8
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +30 -12
  23. package/lib/types/client/locales.d.ts +10 -10
  24. package/lib/types/client/locales.js +10 -10
  25. package/lib/types/client/pet-store.d.ts +5 -0
  26. package/lib/types/client/pet-store.d.ts.map +1 -1
  27. package/lib/types/client/pet-store.js +4 -0
  28. package/lib/types/client/settings-form.d.ts +62 -0
  29. package/lib/types/client/settings-form.d.ts.map +1 -1
  30. package/lib/types/client/settings-form.js +61 -12
  31. package/lib/types/client/spritesheet.d.ts +14 -48
  32. package/lib/types/client/spritesheet.d.ts.map +1 -1
  33. package/lib/types/client/spritesheet.js +13 -81
  34. package/lib/types/index.d.ts +24 -11
  35. package/lib/types/index.d.ts.map +1 -1
  36. package/lib/types/index.js +53 -33
  37. package/lib/types/ledger.d.ts +4 -2
  38. package/lib/types/ledger.d.ts.map +1 -1
  39. package/lib/types/ledger.js +11 -4
  40. package/lib/types/persist.d.ts +10 -3
  41. package/lib/types/persist.d.ts.map +1 -1
  42. package/lib/types/persist.js +32 -5
  43. package/lib/types/registry.d.ts +158 -0
  44. package/lib/types/registry.d.ts.map +1 -0
  45. package/lib/types/registry.js +276 -0
  46. package/lib/types/routes.d.ts +10 -10
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +169 -51
  49. package/lib/types/service.d.ts +46 -13
  50. package/lib/types/service.d.ts.map +1 -1
  51. package/lib/types/service.js +73 -16
  52. package/package.json +2 -2
  53. package/src/client/PetDockEntry.tsx +34 -26
  54. package/src/client/PetSettingsCard.tsx +67 -22
  55. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  56. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  57. package/src/client/PluginSettingsCard.tsx +7 -1
  58. package/src/client/index.ts +36 -14
  59. package/src/client/locales.ts +10 -10
  60. package/src/client/pet-store.ts +9 -0
  61. package/src/client/settings-card.module.css +3 -0
  62. package/src/client/settings-form.ts +114 -12
  63. package/src/client/spritesheet.ts +16 -92
  64. package/src/index.test.ts +23 -0
  65. package/src/index.ts +89 -45
  66. package/src/ledger.test.ts +5 -3
  67. package/src/ledger.ts +11 -4
  68. package/src/persist.test.ts +52 -3
  69. package/src/persist.ts +42 -8
  70. package/src/registry.test.ts +132 -0
  71. package/src/registry.ts +400 -0
  72. package/src/routes.ts +166 -53
  73. package/src/service.ts +102 -20
  74. package/lib/types/client/WhalePet.d.ts.map +0 -1
package/src/index.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * dsh-pet host half — mounts the pet service and its HTTP routes. The
3
- * browser half (the `./client` entry) renders the whale-girl companion and
4
- * drives it through the same-origin `/api/pet/*` JSON endpoints plus the
5
- * `/pet/whale/*` media route. Install via `dsh plugin --profile web add
6
- * link:<dsh-web-ui>/packages/dsh-pet`; the cordis.patch.yml inserts this plugin row.
3
+ * browser half (the './client' entry) renders the selected pet and drives it
4
+ * through the same-origin '/api/pet/*' JSON endpoints plus the '/pet/<id>/*'
5
+ * media route. The host builds the multi-pet registry once at startup from
6
+ * the package assets, the hatch-pet custom pets directory, and composed
7
+ * config entries; adding a pet means dropping a manifest + atlas into one of
8
+ * those sources, never touching host or client code. Install via
9
+ * 'dsh plugin --profile web add link:<dsh-web-ui>/packages/dsh-pet'; the
10
+ * cordis.patch.yml inserts this plugin row.
7
11
  * @module @linxin666/dsh-pet
8
12
  */
9
13
 
@@ -12,19 +16,15 @@ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-sett
12
16
  import type {} from '@deepseek-ai/dsh-host-webserver'
13
17
  import z from 'schemastery'
14
18
  import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSection } from './service.ts'
15
- import { makePetRoutes, petPackageRoot } from './routes.ts'
16
- import {
17
- DEFAULT_PET_NAME,
18
- DISPLAY_INSET_MAX,
19
- DISPLAY_SIZE_MAX,
20
- DISPLAY_SIZE_MIN,
21
- PET_NAME_MAX_LENGTH,
22
- } from './persist.ts'
19
+ import { makePetRoutes } from './routes.ts'
20
+ import { loadPetRegistry, petPackageRoot } from './registry.ts'
21
+ import { DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
23
22
 
24
23
  export { PetService } from './service.ts'
25
24
  export type {
26
25
  PetConfig,
27
26
  PetInteractResult,
27
+ PetSettingsSection,
28
28
  PetStateView,
29
29
  } from './service.ts'
30
30
  export {
@@ -61,6 +61,9 @@ export {
61
61
  } from './treats.ts'
62
62
  export type { TreatConfig, TreatLedger, TreatSettlement } from './treats.ts'
63
63
  export {
64
+ DEFAULT_PET_ID,
65
+ DEFAULT_PET_NAME,
66
+ PET_NAME_MAX_LENGTH,
64
67
  defaultDisplayConfig,
65
68
  emptyPersist,
66
69
  loadPetPersist,
@@ -68,10 +71,31 @@ export {
68
71
  savePetPersist,
69
72
  } from './persist.ts'
70
73
  export type { PetDisplayConfig, PetPersist } from './persist.ts'
74
+ export {
75
+ DEFAULT_FRAME_COUNTS,
76
+ DEFAULT_PET_CELL,
77
+ DEFAULT_PET_COLUMNS,
78
+ DEFAULT_PET_ROW_COUNT,
79
+ DEFAULT_TRACK_PATTERNS,
80
+ PET_ROW_ORDER,
81
+ codexPetsDir,
82
+ loadPetRegistry,
83
+ petEntryView,
84
+ petPackageRoot,
85
+ resolvePetManifest,
86
+ } from './registry.ts'
87
+ export type {
88
+ PetDefinition,
89
+ PetEntry,
90
+ PetManifest,
91
+ PetRegistry,
92
+ PetRegistryOptions,
93
+ PetTrackDef,
94
+ PetTrackOverride,
95
+ } from './registry.ts'
71
96
 
72
97
  export {
73
98
  makePetRoutes,
74
- petPackageRoot,
75
99
  PET_API_PREFIX,
76
100
  PET_ASSET_PREFIX,
77
101
  } from './routes.ts'
@@ -82,43 +106,57 @@ export const name = 'pet'
82
106
  /** Services required before the pet can mount its surfaces. */
83
107
  export const inject = ['webServer']
84
108
 
85
- /** Settings section schema: the display fields and name the web settings surface edits. */
86
- export const PET_SETTINGS_SCHEMA = z.object({
87
- visible: z.boolean().default(true),
88
- size: z.number().step(1).min(DISPLAY_SIZE_MIN).max(DISPLAY_SIZE_MAX).default(160),
89
- right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
90
- bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
91
- name: z.string().min(1).max(PET_NAME_MAX_LENGTH).pattern(/\S/).default(DEFAULT_PET_NAME),
92
- enabled: z.boolean().default(true),
93
- })
109
+ /**
110
+ * Settings section schema: pet selection and display fields the web settings
111
+ * surface edits. petId is a plain string on purpose: the service clamps the
112
+ * resolved value against the registry, so a stored selection that points at
113
+ * a removed pet cannot invalidate the section (a strict union would refuse
114
+ * the whole registration). The settings card renders the actual registry
115
+ * choices itself from '/api/pet/pets'.
116
+ */
117
+ export function makePetSettingsSchema(fallbackPetId: string) {
118
+ return z.object({
119
+ visible: z.boolean().default(true),
120
+ size: z.number().step(1).min(DISPLAY_SIZE_MIN).max(DISPLAY_SIZE_MAX).default(160),
121
+ right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
122
+ bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
123
+ petId: z.string().default(fallbackPetId),
124
+ enabled: z.boolean().default(true),
125
+ })
126
+ }
94
127
 
95
128
  /** Register the pet service and its API + asset routes on the context. */
96
129
  export function apply(ctx: Context, config: PetConfig = {}): void {
97
- const service = new PetService(ctx, config)
130
+ const registry = config.registry
131
+ ?? loadPetRegistry({
132
+ packageRoot: petPackageRoot(import.meta.url),
133
+ ...(config.pets === undefined ? {} : { extra: config.pets }),
134
+ })
135
+ const service = new PetService(ctx, { ...config, registry })
98
136
 
99
- // The settings surface edits the display config through the `pet`
100
- // namespace. The composition `base` starts as the persisted pet.json
101
- // values (clamped to schema bounds), so an empty user layer resolves to
102
- // exactly what the pet already shows — a fresh deployment never
103
- // overwrites a customized layout, and reset re-inherits it. Runtime drag
104
- // interactions mirror back into the settings document through the service
105
- // (see syncSettingsFromPet), keeping both views consistent.
137
+ // The settings surface edits the pet selection + display config through
138
+ // the 'pet' namespace. The composition 'base' starts as the persisted
139
+ // pet.json values (clamped to schema bounds), so an empty user layer
140
+ // resolves to exactly what the pet already shows — a fresh deployment
141
+ // never overwrites a customized layout, and reset re-inherits it. Runtime
142
+ // drag interactions mirror back into the settings document through the
143
+ // service (see syncSettingsFromPet), keeping both views consistent.
106
144
  let current: () => PetSettingsSection = () => base
107
145
  const base: PetSettingsSection = {
108
146
  visible: service.display().visible,
109
147
  size: service.display().size,
110
148
  right: service.display().right,
111
149
  bottom: service.display().bottom,
112
- name: service.petName(),
150
+ petId: service.selectedPetId(),
113
151
  enabled: config.enabled ?? true,
114
152
  }
115
153
  // The browser half talks to the pet through same-origin JSON endpoints and
116
- // loads the atlas from the pet's own media route (RPC domains are
117
- // platform-registered, so the pet serves its own API — the same pattern as
118
- // dsh-remote-web-ui's /api/pair family). The routes are registered while
119
- // the plugin is enabled; toggling the setting off makes the pet API
120
- // disappear until it is re-enabled.
121
- const routes = makePetRoutes({ service, packageRoot: petPackageRoot(import.meta.url) })
154
+ // loads each pet's atlas from the registry's own media route (RPC domains
155
+ // are platform-registered, so the pet serves its own API — the same
156
+ // pattern as dsh-remote-web-ui's /api/pair family). The routes are
157
+ // registered while the plugin is enabled; toggling the setting off makes
158
+ // the pet API disappear until it is re-enabled.
159
+ const routes = makePetRoutes({ service })
122
160
  let disposeRoutes: (() => void) | undefined
123
161
  const syncRoutes = (): void => {
124
162
  const enabled = current().enabled ?? true
@@ -135,14 +173,20 @@ export function apply(ctx: Context, config: PetConfig = {}): void {
135
173
  disposeRoutes = undefined
136
174
  }
137
175
  }
138
- installSettingsSection(ctx, settingsNamespace(PET_SETTINGS_NAMESPACE), PET_SETTINGS_SCHEMA, base, {
139
- setSource: (source) => { current = source },
140
- onChange: () => {
141
- const section = current()
142
- service.applySettingsSection(section)
143
- service.setEnabled(section.enabled ?? true)
144
- syncRoutes()
176
+ installSettingsSection(
177
+ ctx,
178
+ settingsNamespace(PET_SETTINGS_NAMESPACE),
179
+ makePetSettingsSchema(service.selectedPetId()),
180
+ base,
181
+ {
182
+ setSource: (source) => { current = source },
183
+ onChange: () => {
184
+ const section = current()
185
+ service.applySettingsSection(section)
186
+ service.setEnabled(section.enabled ?? true)
187
+ syncRoutes()
188
+ },
145
189
  },
146
- })
190
+ )
147
191
  syncRoutes()
148
192
  }
@@ -55,13 +55,15 @@ describe('PetLedger', () => {
55
55
  expect(ledger.takeDirty()).toBe(true)
56
56
  })
57
57
 
58
- it('exposes the treat stock cap and display/name setters', () => {
58
+ it('exposes the treat stock cap and display/pet/name setters', () => {
59
59
  const ledger = new PetLedger(emptyPersist())
60
60
  expect(ledger.treatMax).toBe(defaultTreatConfig.maxTreats)
61
61
  ledger.setDisplay({ ...ledger.snapshot.display, visible: false })
62
- ledger.setName('泡泡')
62
+ ledger.setPetId('otter')
63
+ ledger.setPetName('otter', '泡泡')
63
64
  expect(ledger.snapshot.display.visible).toBe(false)
64
- expect(ledger.snapshot.name).toBe('泡泡')
65
+ expect(ledger.snapshot.petId).toBe('otter')
66
+ expect(ledger.snapshot.names).toEqual({ otter: '泡泡' })
65
67
  expect(ledger.takeDirty()).toBe(true)
66
68
  })
67
69
  })
package/src/ledger.ts CHANGED
@@ -88,9 +88,16 @@ export class PetLedger {
88
88
  this.dirty = true
89
89
  }
90
90
 
91
- /** Replace the pet display name (validation stays a caller concern). */
92
- setName(name: string): void {
93
- this.current = { ...this.current, name }
91
+ /** Replace the selected pet id (validation stays a caller concern). */
92
+ setPetId(petId: string): void {
93
+ if (this.current.petId === petId) return
94
+ this.current = { ...this.current, petId }
95
+ this.dirty = true
96
+ }
97
+
98
+ /** Replace one pet's display name (validation stays a caller concern). */
99
+ setPetName(petId: string, name: string): void {
100
+ this.current = { ...this.current, names: { ...this.current.names, [petId]: name } }
94
101
  this.dirty = true
95
102
  }
96
103
 
@@ -161,7 +168,7 @@ export class PetLedger {
161
168
  const consume = consumeTreat(this.current.treats)
162
169
  if (!consume.ok) {
163
170
  return {
164
- reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
171
+ reaction: '没有小鱼干了,多陪我工作一会儿吧~',
165
172
  delta: 0,
166
173
  affinity: this.affinityView(nowMs),
167
174
  }
@@ -5,7 +5,7 @@ import { join } from 'node:path'
5
5
  import { AFFINITY_MAX, emptyAffinity } from './affinity.ts'
6
6
  import { defaultTreatConfig, emptyTreatLedger } from './treats.ts'
7
7
  import {
8
- DEFAULT_PET_NAME,
8
+ DEFAULT_PET_ID,
9
9
  DISPLAY_INSET_MAX,
10
10
  DISPLAY_SIZE_MAX,
11
11
  DISPLAY_SIZE_MIN,
@@ -43,7 +43,8 @@ describe('loadPetPersist', () => {
43
43
  const dir = tempDir()
44
44
  try {
45
45
  const data = {
46
- name: '泡泡',
46
+ petId: 'otter',
47
+ names: { otter: '泡泡', 'whale-girl': '鲸鱼娘' },
47
48
  affinity: { ...emptyAffinity(), points: 42, pets: 3, feeds: 1, turns: 10 },
48
49
  treats: { ...emptyTreatLedger(), treats: 7, lastTreatGrantAt: 1234, turnsAtLastTreatGrant: 9 },
49
50
  display: { visible: false, size: 200, right: 10, bottom: 40 },
@@ -55,17 +56,65 @@ describe('loadPetPersist', () => {
55
56
  }
56
57
  })
57
58
 
59
+ it('migrates the legacy flat name onto the legacy pet id', () => {
60
+ const dir = tempDir()
61
+ try {
62
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({
63
+ name: '泡泡',
64
+ affinity: { points: 5 },
65
+ }), 'utf8')
66
+ const loaded = loadPetPersist(dir)
67
+ expect(loaded.petId).toBe(DEFAULT_PET_ID)
68
+ expect(loaded.names).toEqual({ [DEFAULT_PET_ID]: '泡泡' })
69
+ expect(loaded.affinity.points).toBe(5)
70
+ expect(loaded.display).toEqual(defaultDisplayConfig)
71
+ } finally {
72
+ rmSync(dir, { recursive: true, force: true })
73
+ }
74
+ })
75
+
76
+ it('migrates the legacy flat name onto the persisted selection when both exist', () => {
77
+ const dir = tempDir()
78
+ try {
79
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({
80
+ petId: 'otter',
81
+ name: '水獭',
82
+ }), 'utf8')
83
+ const loaded = loadPetPersist(dir)
84
+ expect(loaded.petId).toBe('otter')
85
+ expect(loaded.names).toEqual({ otter: '水獭' })
86
+ } finally {
87
+ rmSync(dir, { recursive: true, force: true })
88
+ }
89
+ })
90
+
91
+ it('keeps stored per-pet names when the legacy name is also present', () => {
92
+ const dir = tempDir()
93
+ try {
94
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({
95
+ name: '旧名字',
96
+ names: { [DEFAULT_PET_ID]: '新名字' },
97
+ }), 'utf8')
98
+ const loaded = loadPetPersist(dir)
99
+ expect(loaded.names[DEFAULT_PET_ID]).toBe('新名字')
100
+ } finally {
101
+ rmSync(dir, { recursive: true, force: true })
102
+ }
103
+ })
104
+
58
105
  it('clamps out-of-range and non-numeric fields', () => {
59
106
  const dir = tempDir()
60
107
  try {
61
108
  writeFileSync(join(dir, 'pet.json'), JSON.stringify({
62
109
  name: ' ',
110
+ names: { bad: ' ' },
63
111
  affinity: { points: 9999, lastPetAt: -5, lastFeedAt: 'x', pets: -1, feeds: 1.5, turns: 0 },
64
112
  treats: { treats: 150, lastTreatGrantAt: -1, turnsAtLastTreatGrant: 0 },
65
113
  display: { visible: 'yes', size: -10, right: 1e12, bottom: 20 },
66
114
  }), 'utf8')
67
115
  const loaded = loadPetPersist(dir)
68
- expect(loaded.name).toBe(DEFAULT_PET_NAME)
116
+ expect(loaded.petId).toBe(DEFAULT_PET_ID)
117
+ expect(loaded.names).toEqual({})
69
118
  expect(loaded.affinity.points).toBe(AFFINITY_MAX)
70
119
  expect(loaded.affinity.lastPetAt).toBe(0)
71
120
  expect(loaded.affinity.lastFeedAt).toBe(0)
package/src/persist.ts CHANGED
@@ -37,15 +37,23 @@ export const DISPLAY_INSET_MAX = 10_000
37
37
 
38
38
  /** Everything persisted for the pet. */
39
39
  export interface PetPersist {
40
- /** User-customizable pet display name. */
41
- name: string
40
+ /** Selected pet id (a registry entry; clamped at service startup). */
41
+ petId: string
42
+ /**
43
+ * Per-pet display names keyed by pet id. A pet without an entry falls back
44
+ * to its manifest displayName, so only user renames are stored here.
45
+ */
46
+ names: Record<string, string>
42
47
  affinity: AffinityState
43
48
  /** Treat (小鱼干) stock ledger. */
44
49
  treats: TreatLedger
45
50
  display: PetDisplayConfig
46
51
  }
47
52
 
48
- /** Default pet name (used until the user renames the pet). */
53
+ /** Pet id the legacy single-pet installs resolve to on migration. */
54
+ export const DEFAULT_PET_ID = 'whale-girl'
55
+
56
+ /** Default pet name (used only when a manifest carries no displayName). */
49
57
  export const DEFAULT_PET_NAME = '鲸鱼娘'
50
58
 
51
59
  /** Name constraints. */
@@ -53,7 +61,8 @@ export const PET_NAME_MAX_LENGTH = 20
53
61
 
54
62
  export function emptyPersist(): PetPersist {
55
63
  return {
56
- name: DEFAULT_PET_NAME,
64
+ petId: DEFAULT_PET_ID,
65
+ names: {},
57
66
  affinity: emptyAffinity(),
58
67
  treats: emptyTreatLedger(),
59
68
  display: { ...defaultDisplayConfig },
@@ -74,6 +83,22 @@ function finiteNum(value: unknown, fallback: number): number {
74
83
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback
75
84
  }
76
85
 
86
+ /** A persisted document from any schema era (legacy carried a flat `name`). */
87
+ type PetPersistDocument = Partial<PetPersist> & { name?: unknown }
88
+
89
+ /** Sanitize the per-pet names map (string keys, non-empty trimmed values). */
90
+ function loadPetNames(parsed: PetPersistDocument): Record<string, string> {
91
+ const names: Record<string, string> = {}
92
+ if (typeof parsed.names !== 'object' || parsed.names === null) return names
93
+ for (const [id, value] of Object.entries(parsed.names as Record<string, unknown>)) {
94
+ if (id === '' || typeof value !== 'string') continue
95
+ const name = value.trim()
96
+ if (name === '') continue
97
+ names[id] = name.slice(0, PET_NAME_MAX_LENGTH)
98
+ }
99
+ return names
100
+ }
101
+
77
102
  /** Clamp one count/score into [0, max]. */
78
103
  function clamp(value: number, max: number): number {
79
104
  return Math.min(max, Math.max(0, value))
@@ -83,7 +108,7 @@ function clamp(value: number, max: number): number {
83
108
  export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
84
109
  try {
85
110
  const raw = readFileSync(join(dir, 'pet.json'), 'utf8')
86
- const parsed = JSON.parse(raw) as Partial<PetPersist>
111
+ const parsed = JSON.parse(raw) as PetPersistDocument
87
112
  const base = emptyPersist()
88
113
  const rawAffinity = (parsed.affinity ?? {}) as Partial<AffinityState>
89
114
  const affinity: AffinityState = {
@@ -109,10 +134,19 @@ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
109
134
  right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
110
135
  bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX)),
111
136
  }
137
+ const petId = typeof parsed.petId === 'string' && parsed.petId.trim() !== ''
138
+ ? parsed.petId.trim()
139
+ : base.petId
140
+ const names = loadPetNames(parsed)
141
+ // Legacy migration: pre-registry installs persisted one flat `name`
142
+ // field. Move it onto the selected pet (the legacy whale-girl unless the
143
+ // file already names another pet) so renames survive the upgrade.
144
+ if (typeof parsed.name === 'string' && parsed.name.trim() !== '' && names[petId] === undefined) {
145
+ names[petId] = parsed.name.trim().slice(0, PET_NAME_MAX_LENGTH)
146
+ }
112
147
  return {
113
- name: typeof parsed.name === 'string' && parsed.name.trim() !== ''
114
- ? parsed.name
115
- : base.name,
148
+ petId,
149
+ names,
116
150
  affinity,
117
151
  treats,
118
152
  display,
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ DEFAULT_FRAME_COUNTS,
7
+ DEFAULT_PET_CELL,
8
+ codexPetsDir,
9
+ loadPetRegistry,
10
+ resolvePetManifest,
11
+ } from './registry.ts'
12
+
13
+ function tempDir(): string {
14
+ return mkdtempSync(join(tmpdir(), 'dsh-pet-registry-'))
15
+ }
16
+
17
+ describe('resolvePetManifest', () => {
18
+ it('resolves a bare Codex manifest onto the hatch-pet contract defaults', () => {
19
+ const entry = resolvePetManifest({
20
+ id: 'otter',
21
+ displayName: '水獭',
22
+ spritesheetPath: 'spritesheet.webp',
23
+ }, join(tmpdir(), 'otter'))
24
+ expect(entry).toBeDefined()
25
+ expect(entry!.id).toBe('otter')
26
+ expect(entry!.cell).toEqual(DEFAULT_PET_CELL)
27
+ expect(entry!.columns).toBe(8)
28
+ expect(entry!.rows).toEqual([...DEFAULT_FRAME_COUNTS])
29
+ expect(entry!.atlasUrl).toBe('/pet/otter/spritesheet.webp')
30
+ expect(entry!.manifestUrl).toBe('/pet/otter/pet.json')
31
+ // Every track's frames/durations line up with its row count.
32
+ expect(entry!.tracks.idle.frames.length).toBe(entry!.rows[0])
33
+ expect(entry!.tracks.idle.durations.length).toBe(entry!.rows[0])
34
+ expect(entry!.tracks.jumping.loop).toBe(false)
35
+ expect(entry!.tracks.jumping.fallback).toBe('idle')
36
+ expect(entry!.tracks.failed.loop).toBe(false)
37
+ expect(entry!.tracks.running.loop).toBe(true)
38
+ })
39
+
40
+ it('keeps the legacy whale-girl frame counts and its own durations', () => {
41
+ const entry = resolvePetManifest({
42
+ id: 'whale-girl',
43
+ displayName: '鲸鱼娘',
44
+ spritesheetPath: 'spritesheet.webp',
45
+ frames: [6, 8, 8, 4, 5, 8, 6, 6, 6],
46
+ tracks: { idle: { durations: [400, 400, 500, 400, 400, 500] } },
47
+ }, join(tmpdir(), 'whale'))
48
+ expect(entry!.rows).toEqual([6, 8, 8, 4, 5, 8, 6, 6, 6])
49
+ expect(entry!.tracks.idle.durations).toEqual([400, 400, 500, 400, 400, 500])
50
+ // Non-overridden tracks keep the contract rhythm.
51
+ expect(entry!.tracks['running-right'].durations.length).toBe(8)
52
+ })
53
+
54
+ it('cycles short override durations up to the row frame count', () => {
55
+ const entry = resolvePetManifest({
56
+ id: 'fox',
57
+ displayName: '狐狸',
58
+ spritesheetPath: 'atlas.png',
59
+ frames: [4, 4, 4, 4, 4, 4, 4, 4, 4],
60
+ tracks: { idle: { durations: [200, 300] } },
61
+ }, join(tmpdir(), 'fox'))
62
+ expect(entry!.tracks.idle.durations).toEqual([200, 300, 200, 300])
63
+ expect(entry!.tracks.idle.frames).toEqual([0, 1, 2, 3])
64
+ })
65
+
66
+ it('rejects unsafe ids and spritesheet paths with warnings', () => {
67
+ const warnings: string[] = []
68
+ expect(resolvePetManifest({ id: 'Bad Id', displayName: 'x', spritesheetPath: 'a.webp' }, '/tmp', { warnings })).toBeUndefined()
69
+ expect(resolvePetManifest({ id: 'ok', displayName: 'x', spritesheetPath: '../etc/passwd' }, '/tmp', { warnings })).toBeUndefined()
70
+ expect(resolvePetManifest({ id: 'ok', displayName: 'x', spritesheetPath: '/absolute.webp' }, '/tmp', { warnings })).toBeUndefined()
71
+ expect(warnings.length).toBe(3)
72
+ })
73
+ })
74
+
75
+ describe('loadPetRegistry', () => {
76
+ it('scans built-in assets, the custom pets dir, and composed extras with precedence', () => {
77
+ const root = tempDir()
78
+ try {
79
+ const assets = join(root, 'assets')
80
+ mkdirSync(join(assets, 'whale'), { recursive: true })
81
+ writeFileSync(join(assets, 'whale', 'pet.json'), JSON.stringify({
82
+ id: 'whale-girl', displayName: '鲸鱼娘', spritesheetPath: 'spritesheet.webp',
83
+ }), 'utf8')
84
+ const petsDir = join(root, 'pets')
85
+ mkdirSync(join(petsDir, 'otter'), { recursive: true })
86
+ writeFileSync(join(petsDir, 'otter', 'pet.json'), JSON.stringify({
87
+ id: 'otter', displayName: '水獭', spritesheetPath: 'spritesheet.webp',
88
+ }), 'utf8')
89
+ // A broken manifest is skipped with a warning, never thrown.
90
+ mkdirSync(join(petsDir, 'broken'), { recursive: true })
91
+ writeFileSync(join(petsDir, 'broken', 'pet.json'), '{ not json', 'utf8')
92
+
93
+ const registry = loadPetRegistry({ packageRoot: root, petsDir })
94
+ expect(registry.entries.map(entry => entry.id)).toEqual(['whale-girl', 'otter'])
95
+ expect(registry.defaultEntry().id).toBe('whale-girl')
96
+ expect(registry.warnings.some(warning => warning.includes('broken'))).toBe(true)
97
+
98
+ // A composed extra with the same id overrides the earlier sources.
99
+ const overridden = loadPetRegistry({
100
+ packageRoot: root,
101
+ petsDir,
102
+ extra: [{ id: 'whale-girl', displayName: '替换鲸', spritesheetPath: 'spritesheet.webp' }],
103
+ })
104
+ expect(overridden.byId('whale-girl')!.displayName).toBe('替换鲸')
105
+ } finally {
106
+ rmSync(root, { recursive: true, force: true })
107
+ }
108
+ })
109
+
110
+ it('defaults to the built-in pet even when custom pets sort first', () => {
111
+ const root = tempDir()
112
+ try {
113
+ const petsDir = join(root, 'pets')
114
+ mkdirSync(join(petsDir, 'aardvark'), { recursive: true })
115
+ writeFileSync(join(petsDir, 'aardvark', 'pet.json'), JSON.stringify({
116
+ id: 'aardvark', displayName: '土豚', spritesheetPath: 'spritesheet.webp',
117
+ }), 'utf8')
118
+ const registry = loadPetRegistry({ packageRoot: join(root, 'no-assets'), petsDir })
119
+ expect(registry.defaultEntry().id).toBe('aardvark')
120
+ } finally {
121
+ rmSync(root, { recursive: true, force: true })
122
+ }
123
+ })
124
+ })
125
+
126
+ describe('codexPetsDir', () => {
127
+ it('honors CODEX_HOME and expands a leading tilde', () => {
128
+ expect(codexPetsDir({ CODEX_HOME: '/opt/codex' }, '/home/user')).toBe('/opt/codex/pets')
129
+ expect(codexPetsDir({ CODEX_HOME: '~/codex' }, '/home/user')).toBe('/home/user/codex/pets')
130
+ expect(codexPetsDir({}, '/home/user')).toBe('/home/user/.codex/pets')
131
+ })
132
+ })