@linxin666/dsh-pet 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +73 -38
  3. package/README.zh.md +91 -55
  4. package/assets/whale/pet.json +12 -1
  5. package/cordis.patch.yml +4 -4
  6. package/lib/client.js +296 -347
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +975 -317
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  11. package/lib/types/affinity.d.ts +15 -0
  12. package/lib/types/affinity.d.ts.map +1 -1
  13. package/lib/types/affinity.js +14 -0
  14. package/lib/types/client/PetDockEntry.d.ts +14 -12
  15. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  16. package/lib/types/client/PetDockEntry.js +13 -8
  17. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  18. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PetSettingsCard.js +42 -7
  20. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  21. package/lib/types/client/PetSprite.d.ts.map +1 -0
  22. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  23. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  24. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  25. package/lib/types/client/PluginSettingsCard.js +30 -8
  26. package/lib/types/client/index.d.ts +11 -8
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +32 -14
  29. package/lib/types/client/locales.d.ts +10 -10
  30. package/lib/types/client/locales.js +10 -10
  31. package/lib/types/client/pet-store.d.ts +5 -0
  32. package/lib/types/client/pet-store.d.ts.map +1 -1
  33. package/lib/types/client/pet-store.js +4 -0
  34. package/lib/types/client/settings-form.d.ts +76 -5
  35. package/lib/types/client/settings-form.d.ts.map +1 -1
  36. package/lib/types/client/settings-form.js +97 -20
  37. package/lib/types/client/spritesheet.d.ts +14 -48
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +13 -81
  40. package/lib/types/dsh-home.d.ts +17 -0
  41. package/lib/types/dsh-home.d.ts.map +1 -0
  42. package/lib/types/dsh-home.js +34 -0
  43. package/lib/types/event-projection.d.ts +36 -0
  44. package/lib/types/event-projection.d.ts.map +1 -0
  45. package/lib/types/event-projection.js +98 -0
  46. package/lib/types/index.d.ts +24 -11
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +53 -33
  49. package/lib/types/ledger.d.ts +79 -0
  50. package/lib/types/ledger.d.ts.map +1 -0
  51. package/lib/types/ledger.js +146 -0
  52. package/lib/types/persist.d.ts +15 -4
  53. package/lib/types/persist.d.ts.map +1 -1
  54. package/lib/types/persist.js +39 -8
  55. package/lib/types/registry.d.ts +158 -0
  56. package/lib/types/registry.d.ts.map +1 -0
  57. package/lib/types/registry.js +276 -0
  58. package/lib/types/routes.d.ts +10 -10
  59. package/lib/types/routes.d.ts.map +1 -1
  60. package/lib/types/routes.js +169 -51
  61. package/lib/types/service.d.ts +59 -57
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +111 -211
  64. package/package.json +2 -2
  65. package/src/affinity.ts +33 -0
  66. package/src/client/PetDockEntry.tsx +34 -26
  67. package/src/client/PetSettingsCard.tsx +67 -22
  68. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  69. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  70. package/src/client/PluginSettingsCard.tsx +90 -18
  71. package/src/client/index.ts +38 -16
  72. package/src/client/locales.ts +10 -10
  73. package/src/client/pet-store.ts +9 -0
  74. package/src/client/settings-card.module.css +9 -0
  75. package/src/client/settings-form.ts +153 -19
  76. package/src/client/spritesheet.ts +16 -92
  77. package/src/dsh-home.test.ts +35 -0
  78. package/src/dsh-home.ts +36 -0
  79. package/src/event-projection.ts +128 -0
  80. package/src/index.test.ts +23 -0
  81. package/src/index.ts +89 -45
  82. package/src/ledger.test.ts +69 -0
  83. package/src/ledger.ts +190 -0
  84. package/src/persist.test.ts +52 -3
  85. package/src/persist.ts +49 -11
  86. package/src/registry.test.ts +132 -0
  87. package/src/registry.ts +400 -0
  88. package/src/routes.ts +166 -53
  89. package/src/service.ts +153 -285
  90. package/lib/types/client/WhalePet.d.ts.map +0 -1
@@ -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
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
9
  import { join } from 'node:path'
10
- import { homedir } from 'node:os'
10
+ import { dshHome } from './dsh-home.ts'
11
11
  import { AFFINITY_MAX, emptyAffinity, type AffinityState } from './affinity.ts'
12
12
  import { defaultTreatConfig, emptyTreatLedger, type TreatLedger } from './treats.ts'
13
13
 
@@ -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,16 +61,21 @@ 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 },
60
69
  }
61
70
  }
62
71
 
63
- /** Resolve the persistence directory ($DSH_HOME or ~/.dsh). */
72
+ /**
73
+ * Resolve the persistence directory ($DSH_HOME or ~/.dsh). Delegates to the
74
+ * shared {@link dshHome} resolution so the plugin family keeps one DSH_HOME
75
+ * definition (env override, ~ expansion, cwd-joined relative values).
76
+ */
64
77
  export function petHomeDir(): string {
65
- return process.env.DSH_HOME ?? join(homedir(), '.dsh')
78
+ return dshHome()
66
79
  }
67
80
 
68
81
  /** Numeric field guard: finite numbers only, else the fallback. */
@@ -70,6 +83,22 @@ function finiteNum(value: unknown, fallback: number): number {
70
83
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback
71
84
  }
72
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
+
73
102
  /** Clamp one count/score into [0, max]. */
74
103
  function clamp(value: number, max: number): number {
75
104
  return Math.min(max, Math.max(0, value))
@@ -79,7 +108,7 @@ function clamp(value: number, max: number): number {
79
108
  export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
80
109
  try {
81
110
  const raw = readFileSync(join(dir, 'pet.json'), 'utf8')
82
- const parsed = JSON.parse(raw) as Partial<PetPersist>
111
+ const parsed = JSON.parse(raw) as PetPersistDocument
83
112
  const base = emptyPersist()
84
113
  const rawAffinity = (parsed.affinity ?? {}) as Partial<AffinityState>
85
114
  const affinity: AffinityState = {
@@ -105,10 +134,19 @@ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
105
134
  right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
106
135
  bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX)),
107
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
+ }
108
147
  return {
109
- name: typeof parsed.name === 'string' && parsed.name.trim() !== ''
110
- ? parsed.name
111
- : base.name,
148
+ petId,
149
+ names,
112
150
  affinity,
113
151
  treats,
114
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
+ })