@linxin666/dsh-pet 0.1.1 → 0.1.3

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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Staged form model behind the plugin settings card. A card stages what the
3
+ * user types and writes it only when they save — the settings write is a
4
+ * durable, revision-fenced document mutation, so staging keeps what is on
5
+ * screen exactly what a save would store. Mirrors the official
6
+ * ui-plugin-config card-store pattern in a self-contained slice: this
7
+ * package must not depend on a sibling UI package.
8
+ */
9
+ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
10
+ /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
11
+ export function numberField(field) {
12
+ return {
13
+ field,
14
+ format: value => typeof value === 'number' ? String(value) : '',
15
+ parse: (text) => {
16
+ const trimmed = text.trim();
17
+ if (trimmed === '')
18
+ return { kind: 'clear' };
19
+ const parsed = Number(trimmed);
20
+ return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined;
21
+ },
22
+ };
23
+ }
24
+ /** A free-text field. An empty draft clears the field. */
25
+ export function textField(field) {
26
+ return {
27
+ field,
28
+ format: value => typeof value === 'string' ? value : '',
29
+ parse: (text) => {
30
+ const trimmed = text.trim();
31
+ return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed };
32
+ },
33
+ };
34
+ }
35
+ /** A boolean field, edited through true/false draft text. */
36
+ export function booleanField(field) {
37
+ return {
38
+ field,
39
+ format: value => typeof value === 'boolean' ? String(value) : '',
40
+ parse: (text) => {
41
+ if (text === 'true')
42
+ return { kind: 'set', value: true };
43
+ if (text === 'false')
44
+ return { kind: 'set', value: false };
45
+ return undefined;
46
+ },
47
+ };
48
+ }
49
+ /**
50
+ * Stages one card's edits over one settings namespace and writes them on save.
51
+ *
52
+ * The Host is the only authority on whether a value was accepted — its
53
+ * validators own the constraints no schema can express — so the outcome is
54
+ * read back from the section rather than predicted here. A save that did not
55
+ * land keeps its drafts, so the user can correct them instead of retyping.
56
+ */
57
+ export class CardForm {
58
+ scope;
59
+ specs;
60
+ staged = new Map();
61
+ listeners = new Set();
62
+ saving = false;
63
+ failed = false;
64
+ /** @param scope - the bound settings scope for this card's namespace. */
65
+ constructor(scope, specs) {
66
+ this.scope = scope;
67
+ this.specs = new Map(specs.map(spec => [spec.field, spec]));
68
+ scope.subscribe(() => { this.publish(); });
69
+ }
70
+ /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
71
+ bind(project) {
72
+ const store = createSnapshotStore(project());
73
+ this.listeners.add(() => { store.set(project()); });
74
+ return store;
75
+ }
76
+ /** Read the card-level state: what the Host serves, and what a save would do. */
77
+ shell() {
78
+ const snapshot = this.scope.getSnapshot();
79
+ const plan = this.plan();
80
+ return {
81
+ available: snapshot.status === 'ready',
82
+ writable: snapshot.writable,
83
+ dirty: plan.length > 0,
84
+ invalid: plan.some(item => item.run === undefined),
85
+ saving: this.saving,
86
+ failed: this.failed,
87
+ };
88
+ }
89
+ /** Read one field's state from the effective section and its staged draft. */
90
+ field(field) {
91
+ const spec = this.specOf(field);
92
+ const staged = this.staged.get(field);
93
+ if (staged === undefined) {
94
+ return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false };
95
+ }
96
+ const write = staged.clear ? { kind: 'clear' } : spec.parse(staged.text);
97
+ return {
98
+ text: staged.text,
99
+ overridden: write?.kind === 'set',
100
+ invalid: write === undefined,
101
+ };
102
+ }
103
+ /** The actions the card's slot registration injects. */
104
+ actions() {
105
+ return {
106
+ edit: (field, text) => { this.stage(field, { text, clear: false }); },
107
+ resetField: (field) => {
108
+ this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true });
109
+ },
110
+ save: () => { void this.save(); },
111
+ discard: () => {
112
+ if (this.staged.size === 0 && !this.failed)
113
+ return;
114
+ this.staged.clear();
115
+ this.failed = false;
116
+ this.publish();
117
+ },
118
+ };
119
+ }
120
+ /**
121
+ * Write every staged edit, then re-seed from what the Host accepted.
122
+ * @returns settlement after every write and the read-back.
123
+ */
124
+ async save() {
125
+ const plan = this.plan();
126
+ const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run]);
127
+ if (plan.length === 0 || this.saving || writes.length !== plan.length)
128
+ return;
129
+ this.saving = true;
130
+ this.failed = false;
131
+ this.publish();
132
+ let landed = true;
133
+ for (const write of writes) {
134
+ landed = await write() && landed;
135
+ }
136
+ if (landed)
137
+ this.staged.clear();
138
+ this.saving = false;
139
+ this.failed = !landed;
140
+ this.publish();
141
+ }
142
+ /**
143
+ * Every staged edit a save would write. An entry whose draft is not a value
144
+ * its field accepts carries no write: the form is still dirty, and the save
145
+ * refuses rather than dropping the edit. A staged edit that matches the
146
+ * effective section is not a write at all.
147
+ * @returns the planned writes, in the order the fields were staged.
148
+ */
149
+ plan() {
150
+ const plan = [];
151
+ for (const [field, staged] of this.staged) {
152
+ const spec = this.specOf(field);
153
+ if (staged.clear) {
154
+ if (this.stored(field))
155
+ plan.push({ field, run: () => this.clear(field) });
156
+ continue;
157
+ }
158
+ if (staged.text === spec.format(this.sectionValue(field)))
159
+ continue;
160
+ const write = spec.parse(staged.text);
161
+ if (write === undefined)
162
+ plan.push({ field, run: undefined });
163
+ else if (write.kind === 'clear')
164
+ plan.push({ field, run: () => this.clear(field) });
165
+ else
166
+ plan.push({ field, run: () => this.store(field, write.value) });
167
+ }
168
+ return plan;
169
+ }
170
+ async clear(field) {
171
+ await this.scope.unset(field);
172
+ return !this.stored(field);
173
+ }
174
+ async store(field, value) {
175
+ await this.scope.set(field, value);
176
+ return this.userLayer()?.[field] === value;
177
+ }
178
+ stage(field, edit) {
179
+ this.staged.set(field, edit);
180
+ this.failed = false;
181
+ this.publish();
182
+ }
183
+ specOf(field) {
184
+ const spec = this.specs.get(field);
185
+ // Every call site names a field this card declared; a missing one is a
186
+ // wiring mistake that must not degrade into a silently inert control.
187
+ if (spec === undefined)
188
+ throw new Error(`settings card has no field ${field}`);
189
+ return spec;
190
+ }
191
+ snapshotOf() {
192
+ return this.scope.getSnapshot();
193
+ }
194
+ sectionValue(field) {
195
+ return this.snapshotOf().value?.[field];
196
+ }
197
+ baseValue(field) {
198
+ return this.snapshotOf().base?.[field];
199
+ }
200
+ userLayer() {
201
+ return this.snapshotOf().user;
202
+ }
203
+ stored(field) {
204
+ const user = this.userLayer();
205
+ return user !== undefined && Object.hasOwn(user, field);
206
+ }
207
+ publish() {
208
+ for (const listener of this.listeners)
209
+ listener();
210
+ }
211
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,120 @@
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
+ /** Atlas cell size in px (Codex contract). */
16
+ export const FRAME_WIDTH = 192;
17
+ export const FRAME_HEIGHT = 208;
18
+ /** Columns per row (max frames per track). */
19
+ export const FRAME_COLUMNS = 8;
20
+ /**
21
+ * Track definitions for the whale-girl. Durations are tuned for a soft,
22
+ * slow-healing feel (roughly 2.5× the earlier fast draft — the pet should
23
+ * breathe, not race); calibrate frame counts against the hatch-pet run when
24
+ * the asset lands (rows may carry 4–8 frames).
25
+ */
26
+ export const TRACKS = {
27
+ idle: { frames: [0, 1, 2, 3, 4, 5], durations: [400, 400, 500, 400, 400, 500], loop: true },
28
+ 'running-right': { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [225, 225, 225, 225, 225, 225, 225, 225], loop: true },
29
+ 'running-left': { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [225, 225, 225, 225, 225, 225, 225, 225], loop: true },
30
+ waving: { frames: [0, 1, 2, 3], durations: [350, 350, 350, 350], loop: true },
31
+ jumping: { frames: [0, 1, 2, 3, 4], durations: [300, 300, 300, 350, 350], loop: false, fallback: 'idle' },
32
+ failed: { frames: [0, 1, 2, 3, 4, 5, 6, 7], durations: [450, 450, 450, 500, 550, 600, 450, 450], loop: false, fallback: 'idle' },
33
+ waiting: { frames: [0, 1, 2, 3, 4, 5], durations: [450, 450, 500, 450, 450, 500], loop: true },
34
+ running: { frames: [0, 1, 2, 3, 4, 5], durations: [250, 250, 250, 250, 250, 250], loop: true },
35
+ review: { frames: [0, 1, 2, 3, 4, 5], durations: [550, 550, 550, 550, 550, 550], loop: true },
36
+ };
37
+ /** Row index of one animation track (mirrors state.ts rowOf). */
38
+ export function rowOfTrack(animation) {
39
+ const rows = {
40
+ idle: 0,
41
+ 'running-right': 1,
42
+ 'running-left': 2,
43
+ waving: 3,
44
+ jumping: 4,
45
+ failed: 5,
46
+ waiting: 6,
47
+ running: 7,
48
+ review: 8,
49
+ };
50
+ return rows[animation];
51
+ }
52
+ /**
53
+ * Background-position (px) of one frame cell within the scaled atlas.
54
+ * The background image is scaled by `scale` (element size ÷ cell size), and
55
+ * background-position offsets are applied in SCALED coordinates — using raw
56
+ * atlas coordinates here would drift each frame by the scale factor and
57
+ * render torn/overlapping frames.
58
+ */
59
+ export function framePosition(row, col, scale = 1) {
60
+ return { x: -col * FRAME_WIDTH * scale, y: -row * FRAME_HEIGHT * scale };
61
+ }
62
+ /** Total duration of one track, ms. */
63
+ export function trackDuration(track) {
64
+ return track.durations.reduce((sum, d) => sum + d, 0);
65
+ }
66
+ /**
67
+ * Detect how many frames each row actually carries by scanning the decoded
68
+ * atlas for non-transparent cells (hatch-pet rows may hold 4–8 frames; the
69
+ * unused trailing cells are fully transparent). Rows whose every sample is
70
+ * transparent report 0.
71
+ * @param image - the fully decoded spritesheet (natural size 1536×1872).
72
+ * @returns per-row frame counts, length 9.
73
+ */
74
+ export function detectFrameCounts(image) {
75
+ const canvas = document.createElement('canvas');
76
+ canvas.width = image.naturalWidth;
77
+ canvas.height = image.naturalHeight;
78
+ const ctx = canvas.getContext('2d');
79
+ if (ctx === null)
80
+ return Array.from({ length: 9 }, () => FRAME_COLUMNS);
81
+ ctx.drawImage(image, 0, 0);
82
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
83
+ const counts = [];
84
+ const stride = FRAME_COLUMNS * FRAME_WIDTH;
85
+ const probeStep = 8;
86
+ const margin = 12;
87
+ for (let row = 0; row < 9; row++) {
88
+ let count = 0;
89
+ for (let col = 0; col < FRAME_COLUMNS; col++) {
90
+ let hasContent = false;
91
+ const x0 = col * FRAME_WIDTH;
92
+ const y0 = row * FRAME_HEIGHT;
93
+ for (let y = y0 + margin; y < y0 + FRAME_HEIGHT - margin && !hasContent; y += probeStep) {
94
+ for (let x = x0 + margin; x < x0 + FRAME_WIDTH - margin && !hasContent; x += probeStep) {
95
+ const idx = (y * stride + x) * 4;
96
+ if ((data[idx + 3] ?? 0) > 8)
97
+ hasContent = true;
98
+ }
99
+ }
100
+ if (hasContent)
101
+ count += 1;
102
+ }
103
+ counts.push(count);
104
+ }
105
+ return counts;
106
+ }
107
+ /**
108
+ * Trim a track to the actual frame count of its row. A row with 0 detected
109
+ * frames degrades to the first frame (the atlas is still loading or corrupt)
110
+ * so the pet never renders blank.
111
+ */
112
+ export function trimTrack(track, frameCount) {
113
+ const n = Math.max(1, Math.min(frameCount, track.frames.length));
114
+ return {
115
+ frames: track.frames.slice(0, n),
116
+ durations: track.durations.slice(0, n),
117
+ loop: track.loop,
118
+ ...(track.fallback === undefined ? {} : { fallback: track.fallback }),
119
+ };
120
+ }
@@ -0,0 +1,84 @@
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
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
10
+ import z from 'schemastery';
11
+ import { PetService, PET_SETTINGS_NAMESPACE } from "./service.js";
12
+ import { makePetRoutes, petPackageRoot } from "./routes.js";
13
+ import { DEFAULT_PET_NAME, DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN, PET_NAME_MAX_LENGTH, } from "./persist.js";
14
+ export { PetService } from "./service.js";
15
+ export { AFFINITY_MAX, AFFINITY_RANKS, applyInteraction, applyTurnReward, emptyAffinity, rankOf, } from "./affinity.js";
16
+ export { animationForPhase, PetStateMachine, rowOf, } from "./state.js";
17
+ export { consumeTreat, defaultTreatConfig, emptyTreatLedger, settleTreatGrants, } from "./treats.js";
18
+ export { defaultDisplayConfig, emptyPersist, loadPetPersist, petHomeDir, savePetPersist, } from "./persist.js";
19
+ export { makePetRoutes, petPackageRoot, PET_API_PREFIX, PET_ASSET_PREFIX, } from "./routes.js";
20
+ /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
21
+ export const name = 'pet';
22
+ /** Services required before the pet can mount its surfaces. */
23
+ export const inject = ['webServer'];
24
+ /** Settings section schema: the display fields and name the web settings surface edits. */
25
+ export const PET_SETTINGS_SCHEMA = z.object({
26
+ visible: z.boolean().default(true),
27
+ size: z.number().step(1).min(DISPLAY_SIZE_MIN).max(DISPLAY_SIZE_MAX).default(160),
28
+ right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
29
+ bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
30
+ name: z.string().min(1).max(PET_NAME_MAX_LENGTH).pattern(/\S/).default(DEFAULT_PET_NAME),
31
+ enabled: z.boolean().default(true),
32
+ });
33
+ /** Register the pet service and its API + asset routes on the context. */
34
+ export function apply(ctx, config = {}) {
35
+ const service = new PetService(ctx, config);
36
+ // The settings surface edits the display config through the `pet`
37
+ // namespace. The composition `base` starts as the persisted pet.json
38
+ // values (clamped to schema bounds), so an empty user layer resolves to
39
+ // exactly what the pet already shows — a fresh deployment never
40
+ // overwrites a customized layout, and reset re-inherits it. Runtime drag
41
+ // interactions mirror back into the settings document through the service
42
+ // (see syncSettingsFromPet), keeping both views consistent.
43
+ let current = () => base;
44
+ const base = {
45
+ visible: service.display().visible,
46
+ size: service.display().size,
47
+ right: service.display().right,
48
+ bottom: service.display().bottom,
49
+ name: service.petName(),
50
+ enabled: config.enabled ?? true,
51
+ };
52
+ // The browser half talks to the pet through same-origin JSON endpoints and
53
+ // loads the atlas from the pet's own media route (RPC domains are
54
+ // platform-registered, so the pet serves its own API — the same pattern as
55
+ // dsh-remote-web-ui's /api/pair family). The routes are registered while
56
+ // the plugin is enabled; toggling the setting off makes the pet API
57
+ // disappear until it is re-enabled.
58
+ const routes = makePetRoutes({ service, packageRoot: petPackageRoot(import.meta.url) });
59
+ let disposeRoutes;
60
+ const syncRoutes = () => {
61
+ const enabled = current().enabled ?? true;
62
+ if (disposeRoutes === undefined && enabled) {
63
+ disposeRoutes = ctx.effect(() => {
64
+ const disposers = routes.map((route) => ctx.webServer.register(route));
65
+ return () => { for (const dispose of disposers)
66
+ dispose(); };
67
+ }, 'pet: routes');
68
+ }
69
+ else if (disposeRoutes !== undefined && !enabled) {
70
+ disposeRoutes();
71
+ disposeRoutes = undefined;
72
+ }
73
+ };
74
+ installSettingsSection(ctx, settingsNamespace(PET_SETTINGS_NAMESPACE), PET_SETTINGS_SCHEMA, base, {
75
+ setSource: (source) => { current = source; },
76
+ onChange: () => {
77
+ const section = current();
78
+ service.applySettingsSection(section);
79
+ service.setEnabled(section.enabled ?? true);
80
+ syncRoutes();
81
+ },
82
+ });
83
+ syncRoutes();
84
+ }
@@ -0,0 +1,27 @@
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
+ import { AFFINITY_MAX, AFFINITY_RANKS, defaultAffinityConfig } from "./affinity.js";
7
+ import { animationForPhase } from "./state.js";
8
+ /** Assert a condition; throws a descriptive Error when violated. */
9
+ export function invariant(condition, message) {
10
+ if (!condition) {
11
+ throw new Error(`[dsh-pet] ${message}`);
12
+ }
13
+ }
14
+ /** Run every package invariant once; throws on the first violation. */
15
+ export function runPetInvariants() {
16
+ invariant(AFFINITY_MAX > 0, 'AFFINITY_MAX must be positive');
17
+ invariant(AFFINITY_RANKS.length > 0 && AFFINITY_RANKS[0].min === 0, 'AFFINITY_RANKS must start at 0');
18
+ invariant(defaultAffinityConfig.turnReward > 0, 'turnReward must be positive');
19
+ invariant(defaultAffinityConfig.feedCooldownMs > defaultAffinityConfig.petCooldownMs, 'feed cooldown must exceed pet cooldown');
20
+ // Every activity phase must map onto a known animation track.
21
+ const phases = ['idle', 'waiting', 'thinking', 'tool', 'done'];
22
+ for (const phase of phases) {
23
+ invariant(['idle', 'running', 'running-right', 'waiting', 'jumping'].includes(animationForPhase(phase)), `phase ${phase} maps outside the animation contract`);
24
+ }
25
+ }
26
+ // Run once on import (host half only; cheap and side-effect free).
27
+ runPetInvariants();
@@ -0,0 +1,96 @@
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
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { AFFINITY_MAX, emptyAffinity } from "./affinity.js";
11
+ import { defaultTreatConfig, emptyTreatLedger } from "./treats.js";
12
+ export const defaultDisplayConfig = {
13
+ visible: true,
14
+ size: 160,
15
+ right: 24,
16
+ bottom: 20,
17
+ };
18
+ /** Display value bounds (shared by load-time validation and setConfig). */
19
+ export const DISPLAY_SIZE_MIN = 32;
20
+ export const DISPLAY_SIZE_MAX = 512;
21
+ export const DISPLAY_INSET_MAX = 10_000;
22
+ /** Default pet name (used until the user renames the pet). */
23
+ export const DEFAULT_PET_NAME = '鲸鱼娘';
24
+ /** Name constraints. */
25
+ export const PET_NAME_MAX_LENGTH = 20;
26
+ export function emptyPersist() {
27
+ return {
28
+ name: DEFAULT_PET_NAME,
29
+ affinity: emptyAffinity(),
30
+ treats: emptyTreatLedger(),
31
+ display: { ...defaultDisplayConfig },
32
+ };
33
+ }
34
+ /** Resolve the persistence directory ($DSH_HOME or ~/.dsh). */
35
+ export function petHomeDir() {
36
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh');
37
+ }
38
+ /** Numeric field guard: finite numbers only, else the fallback. */
39
+ function finiteNum(value, fallback) {
40
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
41
+ }
42
+ /** Clamp one count/score into [0, max]. */
43
+ function clamp(value, max) {
44
+ return Math.min(max, Math.max(0, value));
45
+ }
46
+ /** Load persisted state; missing or corrupt files fall back to defaults. */
47
+ export function loadPetPersist(dir = petHomeDir()) {
48
+ try {
49
+ const raw = readFileSync(join(dir, 'pet.json'), 'utf8');
50
+ const parsed = JSON.parse(raw);
51
+ const base = emptyPersist();
52
+ const rawAffinity = (parsed.affinity ?? {});
53
+ const affinity = {
54
+ points: clamp(finiteNum(rawAffinity.points, 0), AFFINITY_MAX),
55
+ lastPetAt: clamp(finiteNum(rawAffinity.lastPetAt, 0), Number.MAX_SAFE_INTEGER),
56
+ lastFeedAt: clamp(finiteNum(rawAffinity.lastFeedAt, 0), Number.MAX_SAFE_INTEGER),
57
+ pets: clamp(finiteNum(rawAffinity.pets, 0), Number.MAX_SAFE_INTEGER),
58
+ feeds: clamp(finiteNum(rawAffinity.feeds, 0), Number.MAX_SAFE_INTEGER),
59
+ turns: clamp(finiteNum(rawAffinity.turns, 0), Number.MAX_SAFE_INTEGER),
60
+ };
61
+ const rawTreats = (parsed.treats ?? {});
62
+ const treats = {
63
+ treats: clamp(finiteNum(rawTreats.treats, 0), defaultTreatConfig.maxTreats),
64
+ lastTreatGrantAt: clamp(finiteNum(rawTreats.lastTreatGrantAt, 0), Number.MAX_SAFE_INTEGER),
65
+ turnsAtLastTreatGrant: clamp(finiteNum(rawTreats.turnsAtLastTreatGrant, 0), Number.MAX_SAFE_INTEGER),
66
+ };
67
+ const rawDisplay = (parsed.display ?? {});
68
+ const display = {
69
+ visible: typeof rawDisplay.visible === 'boolean' ? rawDisplay.visible : base.display.visible,
70
+ // The settings schema requires whole pixels; drag positions are
71
+ // clamped but not integral, so round at the persistence boundary.
72
+ size: Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, finiteNum(rawDisplay.size, base.display.size)))),
73
+ right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
74
+ bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX)),
75
+ };
76
+ return {
77
+ name: typeof parsed.name === 'string' && parsed.name.trim() !== ''
78
+ ? parsed.name
79
+ : base.name,
80
+ affinity,
81
+ treats,
82
+ display,
83
+ };
84
+ }
85
+ catch {
86
+ return emptyPersist();
87
+ }
88
+ }
89
+ /** Atomically persist state (write temp + rename). */
90
+ export function savePetPersist(data, dir = petHomeDir()) {
91
+ mkdirSync(dir, { recursive: true });
92
+ const target = join(dir, 'pet.json');
93
+ const tmp = `${target}.tmp`;
94
+ writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
95
+ renameSync(tmp, target);
96
+ }