@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/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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
16
|
-
import {
|
|
17
|
-
|
|
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
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
|
100
|
-
// namespace. The composition
|
|
101
|
-
// values (clamped to schema bounds), so an empty user layer
|
|
102
|
-
// exactly what the pet already shows — a fresh deployment
|
|
103
|
-
// overwrites a customized layout, and reset re-inherits it. Runtime
|
|
104
|
-
// interactions mirror back into the settings document through the
|
|
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
|
-
|
|
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
|
|
117
|
-
// platform-registered, so the pet serves its own API — the same
|
|
118
|
-
// dsh-remote-web-ui's /api/pair family). The routes are
|
|
119
|
-
// the plugin is enabled; toggling the setting off makes
|
|
120
|
-
// disappear until it is re-enabled.
|
|
121
|
-
const routes = makePetRoutes({ service
|
|
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(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
}
|
package/src/ledger.test.ts
CHANGED
|
@@ -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.
|
|
62
|
+
ledger.setPetId('otter')
|
|
63
|
+
ledger.setPetName('otter', '泡泡')
|
|
63
64
|
expect(ledger.snapshot.display.visible).toBe(false)
|
|
64
|
-
expect(ledger.snapshot.
|
|
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
|
|
92
|
-
|
|
93
|
-
this.current
|
|
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
|
}
|
package/src/persist.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
41
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
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
|
+
})
|