@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.
- package/LICENSE +29 -0
- package/README.md +106 -0
- package/assets/whale/pet.json +7 -0
- package/assets/whale/previews/failed.gif +0 -0
- package/assets/whale/previews/idle.gif +0 -0
- package/assets/whale/previews/jumping.gif +0 -0
- package/assets/whale/previews/review.gif +0 -0
- package/assets/whale/previews/running-left.gif +0 -0
- package/assets/whale/previews/running-right.gif +0 -0
- package/assets/whale/previews/running.gif +0 -0
- package/assets/whale/previews/waiting.gif +0 -0
- package/assets/whale/previews/waving.gif +0 -0
- package/assets/whale/spritesheet.webp +0 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1515 -0
- package/lib/index.js +642 -0
- package/lib/invariant.js +34 -0
- package/lib/types/affinity.d.ts +83 -0
- package/lib/types/affinity.d.ts.map +1 -0
- package/lib/types/client/PetDockEntry.d.ts +44 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -0
- package/lib/types/client/PetSettingsCard.d.ts +67 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/WhalePet.d.ts +49 -0
- package/lib/types/client/WhalePet.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +46 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +101 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pet-store.d.ts +49 -0
- package/lib/types/client/pet-store.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/slots-augment.d.ts +19 -0
- package/lib/types/client/slots-augment.d.ts.map +1 -0
- package/lib/types/client/spritesheet.d.ts +69 -0
- package/lib/types/client/spritesheet.d.ts.map +1 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +10 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/persist.d.ts +45 -0
- package/lib/types/persist.d.ts.map +1 -0
- package/lib/types/routes.d.ts +22 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/service.d.ts +161 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/state.d.ts +80 -0
- package/lib/types/state.d.ts.map +1 -0
- package/lib/types/treats.d.ts +59 -0
- package/lib/types/treats.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/affinity.test.ts +74 -0
- package/src/affinity.ts +144 -0
- package/src/client/PetDockEntry.tsx +95 -0
- package/src/client/PetSettingsCard.tsx +186 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/WhalePet.tsx +342 -0
- package/src/client/css-modules.d.ts +6 -0
- package/src/client/index.ts +261 -0
- package/src/client/locales.ts +108 -0
- package/src/client/pet-store.ts +80 -0
- package/src/client/pet.module.css +185 -0
- package/src/client/settings-card.module.css +277 -0
- package/src/client/settings-form.ts +294 -0
- package/src/client/slots-augment.ts +27 -0
- package/src/client/spritesheet.ts +138 -0
- package/src/index.ts +148 -0
- package/src/invariant.ts +40 -0
- package/src/persist.test.ts +96 -0
- package/src/persist.ts +128 -0
- package/src/routes.ts +171 -0
- package/src/service.ts +389 -0
- package/src/state.test.ts +65 -0
- package/src/state.ts +156 -0
- package/src/treats.test.ts +86 -0
- 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
|
+
}
|
package/src/invariant.ts
ADDED
|
@@ -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
|
+
}
|