@linxin666/dsh-pet 0.1.1

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 (79) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +106 -0
  3. package/assets/whale/pet.json +7 -0
  4. package/assets/whale/previews/failed.gif +0 -0
  5. package/assets/whale/previews/idle.gif +0 -0
  6. package/assets/whale/previews/jumping.gif +0 -0
  7. package/assets/whale/previews/review.gif +0 -0
  8. package/assets/whale/previews/running-left.gif +0 -0
  9. package/assets/whale/previews/running-right.gif +0 -0
  10. package/assets/whale/previews/running.gif +0 -0
  11. package/assets/whale/previews/waiting.gif +0 -0
  12. package/assets/whale/previews/waving.gif +0 -0
  13. package/assets/whale/spritesheet.webp +0 -0
  14. package/cordis.patch.yml +10 -0
  15. package/lib/client.js +1515 -0
  16. package/lib/index.js +642 -0
  17. package/lib/invariant.js +34 -0
  18. package/lib/types/affinity.d.ts +83 -0
  19. package/lib/types/affinity.d.ts.map +1 -0
  20. package/lib/types/client/PetDockEntry.d.ts +44 -0
  21. package/lib/types/client/PetDockEntry.d.ts.map +1 -0
  22. package/lib/types/client/PetSettingsCard.d.ts +67 -0
  23. package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
  24. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  26. package/lib/types/client/WhalePet.d.ts +49 -0
  27. package/lib/types/client/WhalePet.d.ts.map +1 -0
  28. package/lib/types/client/index.d.ts +46 -0
  29. package/lib/types/client/index.d.ts.map +1 -0
  30. package/lib/types/client/locales.d.ts +101 -0
  31. package/lib/types/client/locales.d.ts.map +1 -0
  32. package/lib/types/client/pet-store.d.ts +49 -0
  33. package/lib/types/client/pet-store.d.ts.map +1 -0
  34. package/lib/types/client/settings-form.d.ts +119 -0
  35. package/lib/types/client/settings-form.d.ts.map +1 -0
  36. package/lib/types/client/slots-augment.d.ts +19 -0
  37. package/lib/types/client/slots-augment.d.ts.map +1 -0
  38. package/lib/types/client/spritesheet.d.ts +69 -0
  39. package/lib/types/client/spritesheet.d.ts.map +1 -0
  40. package/lib/types/index.d.ts +44 -0
  41. package/lib/types/index.d.ts.map +1 -0
  42. package/lib/types/invariant.d.ts +10 -0
  43. package/lib/types/invariant.d.ts.map +1 -0
  44. package/lib/types/persist.d.ts +45 -0
  45. package/lib/types/persist.d.ts.map +1 -0
  46. package/lib/types/routes.d.ts +22 -0
  47. package/lib/types/routes.d.ts.map +1 -0
  48. package/lib/types/service.d.ts +161 -0
  49. package/lib/types/service.d.ts.map +1 -0
  50. package/lib/types/state.d.ts +80 -0
  51. package/lib/types/state.d.ts.map +1 -0
  52. package/lib/types/treats.d.ts +59 -0
  53. package/lib/types/treats.d.ts.map +1 -0
  54. package/package.json +100 -0
  55. package/src/affinity.test.ts +74 -0
  56. package/src/affinity.ts +144 -0
  57. package/src/client/PetDockEntry.tsx +95 -0
  58. package/src/client/PetSettingsCard.tsx +186 -0
  59. package/src/client/PluginSettingsCard.tsx +207 -0
  60. package/src/client/WhalePet.tsx +342 -0
  61. package/src/client/css-modules.d.ts +6 -0
  62. package/src/client/index.ts +261 -0
  63. package/src/client/locales.ts +108 -0
  64. package/src/client/pet-store.ts +80 -0
  65. package/src/client/pet.module.css +185 -0
  66. package/src/client/settings-card.module.css +277 -0
  67. package/src/client/settings-form.ts +294 -0
  68. package/src/client/slots-augment.ts +27 -0
  69. package/src/client/spritesheet.ts +138 -0
  70. package/src/index.ts +148 -0
  71. package/src/invariant.ts +40 -0
  72. package/src/persist.test.ts +96 -0
  73. package/src/persist.ts +128 -0
  74. package/src/routes.ts +171 -0
  75. package/src/service.ts +389 -0
  76. package/src/state.test.ts +65 -0
  77. package/src/state.ts +156 -0
  78. package/src/treats.test.ts +86 -0
  79. package/src/treats.ts +101 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Whale-girl spritesheet geometry and animation tracks.
3
+ *
4
+ * The atlas follows the Codex/hatch-pet contract: 8 columns × 9 rows of
5
+ * 192×208 cells (1536×1872 total), rows in this order:
6
+ * 0 idle, 1 running-right, 2 running-left, 3 waving, 4 jumping,
7
+ * 5 failed, 6 waiting, 7 running, 8 review
8
+ *
9
+ * Frame counts and per-frame durations are per-track definitions below; the
10
+ * whale-girl atlas is produced by the hatch-pet pipeline, so calibrate
11
+ * `TRACKS` against the actual run (`pet_request.json` frame counts) when the
12
+ * asset lands. Tracks that do not loop hand off to `fallback`.
13
+ * @module @linxin666/dsh-pet/client/spritesheet
14
+ */
15
+
16
+ import type { PetAnimation } from '../state.ts'
17
+
18
+ /** Atlas cell size in px (Codex contract). */
19
+ export const FRAME_WIDTH = 192
20
+ export const FRAME_HEIGHT = 208
21
+ /** Columns per row (max frames per track). */
22
+ export const FRAME_COLUMNS = 8
23
+
24
+ /** One animation track: frame indices into the row + per-frame durations. */
25
+ export interface TrackDef {
26
+ /** Frame indices (columns) played in order; must be < FRAME_COLUMNS. */
27
+ frames: readonly number[]
28
+ /** Per-frame duration in ms; same length as frames. */
29
+ durations: readonly number[]
30
+ /** Whether the track loops; a non-looping track hands off to fallback. */
31
+ loop: boolean
32
+ /** Track to play after a non-looping track finishes. */
33
+ fallback?: PetAnimation
34
+ }
35
+
36
+ /**
37
+ * Track definitions for the whale-girl. Durations are tuned for a soft,
38
+ * slow-healing feel (roughly 2.5× the earlier fast draft — the pet should
39
+ * breathe, not race); calibrate frame counts against the hatch-pet run when
40
+ * the asset lands (rows may carry 4–8 frames).
41
+ */
42
+ export const TRACKS: Record<PetAnimation, TrackDef> = {
43
+ idle: { frames: [0, 1, 2, 3, 4, 5], durations: [400, 400, 500, 400, 400, 500], loop: true },
44
+ 'running-right': { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [225, 225, 225, 225, 225, 225, 225, 225], loop: true },
45
+ 'running-left': { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [225, 225, 225, 225, 225, 225, 225, 225], loop: true },
46
+ waving: { frames: [0, 1, 2, 3], durations: [350, 350, 350, 350], loop: true },
47
+ jumping: { frames: [0, 1, 2, 3, 4], durations: [300, 300, 300, 350, 350], loop: false, fallback: 'idle' },
48
+ failed: { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [450, 450, 450, 500, 550, 600, 450, 450], loop: false, fallback: 'idle' },
49
+ waiting: { frames: [0, 1, 2, 3, 4, 5], durations: [450, 450, 500, 450, 450, 500], loop: true },
50
+ running: { frames: [0, 1, 2, 3, 4, 5], durations: [250, 250, 250, 250, 250, 250], loop: true },
51
+ review: { frames: [0, 1, 2, 3, 4, 5], durations: [550, 550, 550, 550, 550, 550], loop: true },
52
+ }
53
+
54
+ /** Row index of one animation track (mirrors state.ts rowOf). */
55
+ export function rowOfTrack(animation: PetAnimation): number {
56
+ const rows: Record<PetAnimation, number> = {
57
+ idle: 0,
58
+ 'running-right': 1,
59
+ 'running-left': 2,
60
+ waving: 3,
61
+ jumping: 4,
62
+ failed: 5,
63
+ waiting: 6,
64
+ running: 7,
65
+ review: 8,
66
+ }
67
+ return rows[animation]
68
+ }
69
+
70
+ /**
71
+ * Background-position (px) of one frame cell within the scaled atlas.
72
+ * The background image is scaled by `scale` (element size ÷ cell size), and
73
+ * background-position offsets are applied in SCALED coordinates — using raw
74
+ * atlas coordinates here would drift each frame by the scale factor and
75
+ * render torn/overlapping frames.
76
+ */
77
+ export function framePosition(row: number, col: number, scale = 1): { x: number; y: number } {
78
+ return { x: -col * FRAME_WIDTH * scale, y: -row * FRAME_HEIGHT * scale }
79
+ }
80
+
81
+ /** Total duration of one track, ms. */
82
+ export function trackDuration(track: TrackDef): number {
83
+ return track.durations.reduce((sum, d) => sum + d, 0)
84
+ }
85
+
86
+ /**
87
+ * Detect how many frames each row actually carries by scanning the decoded
88
+ * atlas for non-transparent cells (hatch-pet rows may hold 4–8 frames; the
89
+ * unused trailing cells are fully transparent). Rows whose every sample is
90
+ * transparent report 0.
91
+ * @param image - the fully decoded spritesheet (natural size 1536×1872).
92
+ * @returns per-row frame counts, length 9.
93
+ */
94
+ export function detectFrameCounts(image: HTMLImageElement): number[] {
95
+ const canvas = document.createElement('canvas')
96
+ canvas.width = image.naturalWidth
97
+ canvas.height = image.naturalHeight
98
+ const ctx = canvas.getContext('2d')
99
+ if (ctx === null) return Array.from({ length: 9 }, () => FRAME_COLUMNS)
100
+ ctx.drawImage(image, 0, 0)
101
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data
102
+ const counts: number[] = []
103
+ const stride = FRAME_COLUMNS * FRAME_WIDTH
104
+ const probeStep = 8
105
+ const margin = 12
106
+ for (let row = 0; row < 9; row++) {
107
+ let count = 0
108
+ for (let col = 0; col < FRAME_COLUMNS; col++) {
109
+ let hasContent = false
110
+ const x0 = col * FRAME_WIDTH
111
+ const y0 = row * FRAME_HEIGHT
112
+ for (let y = y0 + margin; y < y0 + FRAME_HEIGHT - margin && !hasContent; y += probeStep) {
113
+ for (let x = x0 + margin; x < x0 + FRAME_WIDTH - margin && !hasContent; x += probeStep) {
114
+ const idx = (y * stride + x) * 4
115
+ if ((data[idx + 3] ?? 0) > 8) hasContent = true
116
+ }
117
+ }
118
+ if (hasContent) count += 1
119
+ }
120
+ counts.push(count)
121
+ }
122
+ return counts
123
+ }
124
+
125
+ /**
126
+ * Trim a track to the actual frame count of its row. A row with 0 detected
127
+ * frames degrades to the first frame (the atlas is still loading or corrupt)
128
+ * so the pet never renders blank.
129
+ */
130
+ export function trimTrack(track: TrackDef, frameCount: number): TrackDef {
131
+ const n = Math.max(1, Math.min(frameCount, track.frames.length))
132
+ return {
133
+ frames: track.frames.slice(0, n),
134
+ durations: track.durations.slice(0, n),
135
+ loop: track.loop,
136
+ ...(track.fallback === undefined ? {} : { fallback: track.fallback }),
137
+ }
138
+ }
package/src/index.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
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.
7
+ * @module @linxin666/dsh-pet
8
+ */
9
+
10
+ import { Context } from '@deepseek-ai/cordis'
11
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
12
+ import type {} from '@deepseek-ai/dsh-host-webserver'
13
+ import z from 'schemastery'
14
+ 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'
23
+
24
+ export { PetService } from './service.ts'
25
+ export type {
26
+ PetConfig,
27
+ PetInteractResult,
28
+ PetStateView,
29
+ } from './service.ts'
30
+ export {
31
+ AFFINITY_MAX,
32
+ AFFINITY_RANKS,
33
+ applyInteraction,
34
+ applyTurnReward,
35
+ emptyAffinity,
36
+ rankOf,
37
+ } from './affinity.ts'
38
+ export type {
39
+ AffinityConfig,
40
+ AffinityState,
41
+ InteractionOutcome,
42
+ PetInteraction,
43
+ } from './affinity.ts'
44
+ export {
45
+ animationForPhase,
46
+ PetStateMachine,
47
+ rowOf,
48
+ } from './state.ts'
49
+ export type {
50
+ ActivityPhase,
51
+ PetAnimation,
52
+ PetStateConfig,
53
+ PetStateInput,
54
+ PetStateSnapshot,
55
+ } from './state.ts'
56
+ export {
57
+ consumeTreat,
58
+ defaultTreatConfig,
59
+ emptyTreatLedger,
60
+ settleTreatGrants,
61
+ } from './treats.ts'
62
+ export type { TreatConfig, TreatLedger, TreatSettlement } from './treats.ts'
63
+ export {
64
+ defaultDisplayConfig,
65
+ emptyPersist,
66
+ loadPetPersist,
67
+ petHomeDir,
68
+ savePetPersist,
69
+ } from './persist.ts'
70
+ export type { PetDisplayConfig, PetPersist } from './persist.ts'
71
+
72
+ export {
73
+ makePetRoutes,
74
+ petPackageRoot,
75
+ PET_API_PREFIX,
76
+ PET_ASSET_PREFIX,
77
+ } from './routes.ts'
78
+
79
+ /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
80
+ export const name = 'pet'
81
+
82
+ /** Services required before the pet can mount its surfaces. */
83
+ export const inject = ['webServer']
84
+
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
+ })
94
+
95
+ /** Register the pet service and its API + asset routes on the context. */
96
+ export function apply(ctx: Context, config: PetConfig = {}): void {
97
+ const service = new PetService(ctx, config)
98
+
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.
106
+ let current: () => PetSettingsSection = () => base
107
+ const base: PetSettingsSection = {
108
+ visible: service.display().visible,
109
+ size: service.display().size,
110
+ right: service.display().right,
111
+ bottom: service.display().bottom,
112
+ name: service.petName(),
113
+ enabled: config.enabled ?? true,
114
+ }
115
+ // 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) })
122
+ let disposeRoutes: (() => void) | undefined
123
+ const syncRoutes = (): void => {
124
+ const enabled = current().enabled ?? true
125
+ if (disposeRoutes === undefined && enabled) {
126
+ disposeRoutes = ctx.effect(
127
+ () => {
128
+ const disposers = routes.map((route) => ctx.webServer.register(route))
129
+ return () => { for (const dispose of disposers) dispose() }
130
+ },
131
+ 'pet: routes',
132
+ )
133
+ } else if (disposeRoutes !== undefined && !enabled) {
134
+ disposeRoutes()
135
+ disposeRoutes = undefined
136
+ }
137
+ }
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()
145
+ },
146
+ })
147
+ syncRoutes()
148
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Package invariants — cheap structural checks run at import time on the
3
+ * host side. Mirrors the pattern used by other dsh plugin packages.
4
+ * @module @linxin666/dsh-pet/invariant
5
+ */
6
+
7
+ import { AFFINITY_MAX, AFFINITY_RANKS, defaultAffinityConfig } from './affinity.ts'
8
+ import { animationForPhase } from './state.ts'
9
+ import type { ActivityPhase } from './state.ts'
10
+
11
+ /** Assert a condition; throws a descriptive Error when violated. */
12
+ export function invariant(condition: unknown, message: string): asserts condition {
13
+ if (!condition) {
14
+ throw new Error(`[dsh-pet] ${message}`)
15
+ }
16
+ }
17
+
18
+ /** Run every package invariant once; throws on the first violation. */
19
+ export function runPetInvariants(): void {
20
+ invariant(AFFINITY_MAX > 0, 'AFFINITY_MAX must be positive')
21
+ invariant(
22
+ AFFINITY_RANKS.length > 0 && AFFINITY_RANKS[0]!.min === 0,
23
+ 'AFFINITY_RANKS must start at 0',
24
+ )
25
+ invariant(defaultAffinityConfig.turnReward > 0, 'turnReward must be positive')
26
+ invariant(defaultAffinityConfig.feedCooldownMs > defaultAffinityConfig.petCooldownMs,
27
+ 'feed cooldown must exceed pet cooldown')
28
+
29
+ // Every activity phase must map onto a known animation track.
30
+ const phases: readonly ActivityPhase[] = ['idle', 'waiting', 'thinking', 'tool', 'done']
31
+ for (const phase of phases) {
32
+ invariant(
33
+ ['idle', 'running', 'running-right', 'waiting', 'jumping'].includes(animationForPhase(phase)),
34
+ `phase ${phase} maps outside the animation contract`,
35
+ )
36
+ }
37
+ }
38
+
39
+ // Run once on import (host half only; cheap and side-effect free).
40
+ runPetInvariants()
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { AFFINITY_MAX, emptyAffinity } from './affinity.ts'
6
+ import { defaultTreatConfig, emptyTreatLedger } from './treats.ts'
7
+ import {
8
+ DEFAULT_PET_NAME,
9
+ DISPLAY_INSET_MAX,
10
+ DISPLAY_SIZE_MAX,
11
+ DISPLAY_SIZE_MIN,
12
+ defaultDisplayConfig,
13
+ emptyPersist,
14
+ loadPetPersist,
15
+ savePetPersist,
16
+ } from './persist.ts'
17
+
18
+ function tempDir(): string {
19
+ return mkdtempSync(join(tmpdir(), 'dsh-pet-test-'))
20
+ }
21
+
22
+ describe('loadPetPersist', () => {
23
+ it('falls back to defaults when the file is missing', () => {
24
+ const dir = tempDir()
25
+ try {
26
+ expect(loadPetPersist(dir)).toEqual(emptyPersist())
27
+ } finally {
28
+ rmSync(dir, { recursive: true, force: true })
29
+ }
30
+ })
31
+
32
+ it('falls back to defaults on corrupt JSON', () => {
33
+ const dir = tempDir()
34
+ try {
35
+ writeFileSync(join(dir, 'pet.json'), '{ not json', 'utf8')
36
+ expect(loadPetPersist(dir)).toEqual(emptyPersist())
37
+ } finally {
38
+ rmSync(dir, { recursive: true, force: true })
39
+ }
40
+ })
41
+
42
+ it('round-trips a saved persist file', () => {
43
+ const dir = tempDir()
44
+ try {
45
+ const data = {
46
+ name: '泡泡',
47
+ affinity: { ...emptyAffinity(), points: 42, pets: 3, feeds: 1, turns: 10 },
48
+ treats: { ...emptyTreatLedger(), treats: 7, lastTreatGrantAt: 1234, turnsAtLastTreatGrant: 9 },
49
+ display: { visible: false, size: 200, right: 10, bottom: 40 },
50
+ }
51
+ savePetPersist(data, dir)
52
+ expect(loadPetPersist(dir)).toEqual(data)
53
+ } finally {
54
+ rmSync(dir, { recursive: true, force: true })
55
+ }
56
+ })
57
+
58
+ it('clamps out-of-range and non-numeric fields', () => {
59
+ const dir = tempDir()
60
+ try {
61
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({
62
+ name: ' ',
63
+ affinity: { points: 9999, lastPetAt: -5, lastFeedAt: 'x', pets: -1, feeds: 1.5, turns: 0 },
64
+ treats: { treats: 150, lastTreatGrantAt: -1, turnsAtLastTreatGrant: 0 },
65
+ display: { visible: 'yes', size: -10, right: 1e12, bottom: 20 },
66
+ }), 'utf8')
67
+ const loaded = loadPetPersist(dir)
68
+ expect(loaded.name).toBe(DEFAULT_PET_NAME)
69
+ expect(loaded.affinity.points).toBe(AFFINITY_MAX)
70
+ expect(loaded.affinity.lastPetAt).toBe(0)
71
+ expect(loaded.affinity.lastFeedAt).toBe(0)
72
+ expect(loaded.affinity.pets).toBe(0)
73
+ expect(loaded.affinity.feeds).toBe(1.5) // finite numbers pass through
74
+ expect(loaded.treats.treats).toBe(defaultTreatConfig.maxTreats)
75
+ expect(loaded.treats.lastTreatGrantAt).toBe(0)
76
+ expect(loaded.display.visible).toBe(defaultDisplayConfig.visible)
77
+ expect(loaded.display.size).toBe(DISPLAY_SIZE_MIN) // -10 clamped to min
78
+ expect(loaded.display.right).toBe(DISPLAY_INSET_MAX) // 1e12 clamped to max
79
+ expect(loaded.display.bottom).toBe(20) // finite in-range passes through
80
+ } finally {
81
+ rmSync(dir, { recursive: true, force: true })
82
+ }
83
+ })
84
+
85
+ it('clamps oversized display size to the max', () => {
86
+ const dir = tempDir()
87
+ try {
88
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({
89
+ display: { visible: true, size: 1e9, right: 0, bottom: 0 },
90
+ }), 'utf8')
91
+ expect(loadPetPersist(dir).display.size).toBe(DISPLAY_SIZE_MAX)
92
+ } finally {
93
+ rmSync(dir, { recursive: true, force: true })
94
+ }
95
+ })
96
+ })
package/src/persist.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Pet persistence — tiny JSON store for affinity + display config, written
3
+ * under $DSH_HOME (defaults to ~/.dsh) as `pet.json`. Deliberately minimal:
4
+ * one file, atomic rename write, tolerant read (corrupt file → defaults).
5
+ * @module @linxin666/dsh-pet/persist
6
+ */
7
+
8
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+ import { AFFINITY_MAX, emptyAffinity, type AffinityState } from './affinity.ts'
12
+ import { defaultTreatConfig, emptyTreatLedger, type TreatLedger } from './treats.ts'
13
+
14
+ /** Display configuration the user can tweak. */
15
+ export interface PetDisplayConfig {
16
+ /** Master switch. */
17
+ visible: boolean
18
+ /** Scale of the rendered pet in px (sprite cell height). */
19
+ size: number
20
+ /** Horizontal inset from the viewport right edge, px. */
21
+ right: number
22
+ /** Vertical inset from the viewport bottom edge, px. */
23
+ bottom: number
24
+ }
25
+
26
+ export const defaultDisplayConfig: PetDisplayConfig = {
27
+ visible: true,
28
+ size: 160,
29
+ right: 24,
30
+ bottom: 20,
31
+ }
32
+
33
+ /** Display value bounds (shared by load-time validation and setConfig). */
34
+ export const DISPLAY_SIZE_MIN = 32
35
+ export const DISPLAY_SIZE_MAX = 512
36
+ export const DISPLAY_INSET_MAX = 10_000
37
+
38
+ /** Everything persisted for the pet. */
39
+ export interface PetPersist {
40
+ /** User-customizable pet display name. */
41
+ name: string
42
+ affinity: AffinityState
43
+ /** Treat (小鱼干) stock ledger. */
44
+ treats: TreatLedger
45
+ display: PetDisplayConfig
46
+ }
47
+
48
+ /** Default pet name (used until the user renames the pet). */
49
+ export const DEFAULT_PET_NAME = '鲸鱼娘'
50
+
51
+ /** Name constraints. */
52
+ export const PET_NAME_MAX_LENGTH = 20
53
+
54
+ export function emptyPersist(): PetPersist {
55
+ return {
56
+ name: DEFAULT_PET_NAME,
57
+ affinity: emptyAffinity(),
58
+ treats: emptyTreatLedger(),
59
+ display: { ...defaultDisplayConfig },
60
+ }
61
+ }
62
+
63
+ /** Resolve the persistence directory ($DSH_HOME or ~/.dsh). */
64
+ export function petHomeDir(): string {
65
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh')
66
+ }
67
+
68
+ /** Numeric field guard: finite numbers only, else the fallback. */
69
+ function finiteNum(value: unknown, fallback: number): number {
70
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
71
+ }
72
+
73
+ /** Clamp one count/score into [0, max]. */
74
+ function clamp(value: number, max: number): number {
75
+ return Math.min(max, Math.max(0, value))
76
+ }
77
+
78
+ /** Load persisted state; missing or corrupt files fall back to defaults. */
79
+ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
80
+ try {
81
+ const raw = readFileSync(join(dir, 'pet.json'), 'utf8')
82
+ const parsed = JSON.parse(raw) as Partial<PetPersist>
83
+ const base = emptyPersist()
84
+ const rawAffinity = (parsed.affinity ?? {}) as Partial<AffinityState>
85
+ const affinity: AffinityState = {
86
+ points: clamp(finiteNum(rawAffinity.points, 0), AFFINITY_MAX),
87
+ lastPetAt: clamp(finiteNum(rawAffinity.lastPetAt, 0), Number.MAX_SAFE_INTEGER),
88
+ lastFeedAt: clamp(finiteNum(rawAffinity.lastFeedAt, 0), Number.MAX_SAFE_INTEGER),
89
+ pets: clamp(finiteNum(rawAffinity.pets, 0), Number.MAX_SAFE_INTEGER),
90
+ feeds: clamp(finiteNum(rawAffinity.feeds, 0), Number.MAX_SAFE_INTEGER),
91
+ turns: clamp(finiteNum(rawAffinity.turns, 0), Number.MAX_SAFE_INTEGER),
92
+ }
93
+ const rawTreats = (parsed.treats ?? {}) as Partial<TreatLedger>
94
+ const treats: TreatLedger = {
95
+ treats: clamp(finiteNum(rawTreats.treats, 0), defaultTreatConfig.maxTreats),
96
+ lastTreatGrantAt: clamp(finiteNum(rawTreats.lastTreatGrantAt, 0), Number.MAX_SAFE_INTEGER),
97
+ turnsAtLastTreatGrant: clamp(finiteNum(rawTreats.turnsAtLastTreatGrant, 0), Number.MAX_SAFE_INTEGER),
98
+ }
99
+ const rawDisplay = (parsed.display ?? {}) as Partial<PetDisplayConfig>
100
+ const display: PetDisplayConfig = {
101
+ visible: typeof rawDisplay.visible === 'boolean' ? rawDisplay.visible : base.display.visible,
102
+ // The settings schema requires whole pixels; drag positions are
103
+ // clamped but not integral, so round at the persistence boundary.
104
+ size: Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, finiteNum(rawDisplay.size, base.display.size)))),
105
+ right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
106
+ bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX)),
107
+ }
108
+ return {
109
+ name: typeof parsed.name === 'string' && parsed.name.trim() !== ''
110
+ ? parsed.name
111
+ : base.name,
112
+ affinity,
113
+ treats,
114
+ display,
115
+ }
116
+ } catch {
117
+ return emptyPersist()
118
+ }
119
+ }
120
+
121
+ /** Atomically persist state (write temp + rename). */
122
+ export function savePetPersist(data: PetPersist, dir: string = petHomeDir()): void {
123
+ mkdirSync(dir, { recursive: true })
124
+ const target = join(dir, 'pet.json')
125
+ const tmp = `${target}.tmp`
126
+ writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8')
127
+ renameSync(tmp, target)
128
+ }