@linxin666/dsh-pet 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +25 -7
- package/README.zh.md +25 -7
- package/assets/ouo-neko/pet.json +31 -0
- package/assets/ouo-neko/previews/failed.gif +0 -0
- package/assets/ouo-neko/previews/idle.gif +0 -0
- package/assets/ouo-neko/previews/jumping.gif +0 -0
- package/assets/ouo-neko/previews/review.gif +0 -0
- package/assets/ouo-neko/previews/running-left.gif +0 -0
- package/assets/ouo-neko/previews/running-right.gif +0 -0
- package/assets/ouo-neko/previews/running.gif +0 -0
- package/assets/ouo-neko/previews/waiting.gif +0 -0
- package/assets/ouo-neko/previews/waving.gif +0 -0
- package/assets/ouo-neko/spritesheet.webp +0 -0
- package/contracts/pet-manifest-v2.schema.json +102 -1
- package/lib/client.js +830 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +1226 -24
- package/lib/types/client/PetDockEntry.d.ts +3 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +18 -2
- package/lib/types/client/PetSprite.d.ts +23 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +34 -29
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/drag-stream.d.ts +19 -0
- package/lib/types/client/drag-stream.d.ts.map +1 -0
- package/lib/types/client/drag-stream.js +26 -0
- package/lib/types/client/gameplay-hud.d.ts +50 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -0
- package/lib/types/client/gameplay-hud.js +274 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +16 -0
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/pet-store.d.ts +7 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
- package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
- package/lib/types/client/renderers/frames2d.d.ts +36 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
- package/lib/types/client/renderers/frames2d.js +196 -0
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/client/settings-form.js +1 -1
- package/lib/types/client/telemetry.d.ts +27 -0
- package/lib/types/client/telemetry.d.ts.map +1 -0
- package/lib/types/client/telemetry.js +97 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/defaults.js +5 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/gameplay.d.ts +182 -0
- package/lib/types/gameplay.d.ts.map +1 -0
- package/lib/types/gameplay.js +618 -0
- package/lib/types/http.d.ts +1 -1
- package/lib/types/http.js +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -1
- package/lib/types/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- package/lib/types/loopback.d.ts +1 -1
- package/lib/types/loopback.js +1 -1
- package/lib/types/manifest-v2.d.ts +38 -1
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +182 -2
- package/lib/types/mount-once.d.ts +1 -1
- package/lib/types/mount-once.js +2 -2
- package/lib/types/persist.d.ts +4 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +41 -4
- package/lib/types/registry.d.ts +29 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +170 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +22 -0
- package/lib/types/service.d.ts +56 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +181 -0
- package/package.json +6 -3
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +36 -6
- package/src/client/PetSprite.tsx +42 -21
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/drag-stream.ts +35 -0
- package/src/client/gameplay-hud.test.tsx +338 -0
- package/src/client/gameplay-hud.tsx +403 -0
- package/src/client/index.ts +23 -2
- package/src/client/locales.ts +36 -0
- package/src/client/pet-store.ts +10 -1
- package/src/client/pet.module.css +192 -29
- package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
- package/src/client/renderers/PetRendererSwitch.tsx +28 -1
- package/src/client/renderers/frames2d.test.ts +137 -0
- package/src/client/renderers/frames2d.ts +216 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/client/settings-form.ts +1 -1
- package/src/client/telemetry.ts +105 -0
- package/src/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/http.ts +1 -1
- package/src/index.ts +1 -1
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/loopback.ts +1 -1
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/mount-once.ts +2 -2
- package/src/persist.test.ts +3 -0
- package/src/persist.ts +43 -6
- package/src/registry.test.ts +168 -1
- package/src/registry.ts +199 -1
- package/src/routes.ts +19 -0
- package/src/service.ts +208 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frames2d renderer — plays the free-form named frame-sequence tracks of a
|
|
3
|
+
* frames2d pet (manifest v2 'frames2d' block). The pet center picks the
|
|
4
|
+
* track from the ActivityPhase stream (phase -> track map, idle fallback);
|
|
5
|
+
* the gameplay driver may force a track through the handle's setState
|
|
6
|
+
* override (drag/work/sleep/shop...), and a finished non-loop track settles
|
|
7
|
+
* into its fallback, releasing the override when the fallback matches the
|
|
8
|
+
* phase-mapped track. Rendering never throws: a broken track list degrades
|
|
9
|
+
* to the first decodable frame, and the 1.2 s stall watchdog re-kicks the
|
|
10
|
+
* playback chain after timer throttling.
|
|
11
|
+
* @module @linxin666/dsh-pet/client/renderers/frames2d
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { PET_RENDERER_API_VERSION, type PetRenderer, type PetRendererContext, type PetRendererHandle } from '../../contracts/renderer.ts'
|
|
15
|
+
import type { ActivityPhase } from '../../state.ts'
|
|
16
|
+
|
|
17
|
+
/** One track as served inside the pet definition (browser URLs). */
|
|
18
|
+
export interface Frames2dTrackConfig {
|
|
19
|
+
frames: string[]
|
|
20
|
+
durations: number[]
|
|
21
|
+
loop: boolean
|
|
22
|
+
fallback?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The frames2d block as served inside the pet definition. */
|
|
26
|
+
export interface PetFrames2dConfig {
|
|
27
|
+
tracks: Record<string, Frames2dTrackConfig>
|
|
28
|
+
phases: Partial<Record<ActivityPhase, string>> & { idle: string }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Frames2dRendererHandle extends PetRendererHandle {
|
|
32
|
+
/** Force a track id (gameplay override); undefined returns to phase mapping. */
|
|
33
|
+
setState(track: string | undefined): void
|
|
34
|
+
/** The track currently playing (diagnostics and tests). */
|
|
35
|
+
currentTrack(): string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Stall watchdog period: a looping track stuck longer re-kicks its chain. */
|
|
39
|
+
const WATCHDOG_MS = 1200
|
|
40
|
+
|
|
41
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Fail-closed client-side validation of the served frames2d block. */
|
|
46
|
+
function validateFrames2dConfig(config: unknown): PetFrames2dConfig {
|
|
47
|
+
if (!isRecord(config) || !isRecord(config.tracks) || !isRecord(config.phases)) {
|
|
48
|
+
throw new Error('frames2d config requires tracks and phases objects')
|
|
49
|
+
}
|
|
50
|
+
const tracks: Record<string, Frames2dTrackConfig> = {}
|
|
51
|
+
for (const [name, raw] of Object.entries(config.tracks)) {
|
|
52
|
+
if (!isRecord(raw) || !Array.isArray(raw.frames) || raw.frames.length === 0
|
|
53
|
+
|| raw.frames.some(f => typeof f !== 'string' || f === '')
|
|
54
|
+
|| !Array.isArray(raw.durations) || raw.durations.length !== raw.frames.length
|
|
55
|
+
|| raw.durations.some(d => typeof d !== 'number' || !(d > 0))) {
|
|
56
|
+
throw new Error('frames2d track ' + JSON.stringify(name) + ' needs same-length frames/durations')
|
|
57
|
+
}
|
|
58
|
+
tracks[name] = {
|
|
59
|
+
frames: raw.frames as string[],
|
|
60
|
+
durations: raw.durations as number[],
|
|
61
|
+
loop: raw.loop !== false,
|
|
62
|
+
...(typeof raw.fallback === 'string' ? { fallback: raw.fallback } : {}),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const phases = config.phases as Partial<Record<ActivityPhase, string>>
|
|
66
|
+
if (typeof phases.idle !== 'string' || tracks[phases.idle] === undefined) {
|
|
67
|
+
throw new Error('frames2d phases.idle must name an existing track')
|
|
68
|
+
}
|
|
69
|
+
return { tracks, phases: phases as PetFrames2dConfig['phases'] }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
73
|
+
id: 'frames2d',
|
|
74
|
+
apiVersion: PET_RENDERER_API_VERSION,
|
|
75
|
+
validateConfig: validateFrames2dConfig,
|
|
76
|
+
|
|
77
|
+
mount(ctx: PetRendererContext, config: PetFrames2dConfig): Frames2dRendererHandle {
|
|
78
|
+
const reducedMotion = typeof window !== 'undefined'
|
|
79
|
+
&& typeof window.matchMedia === 'function'
|
|
80
|
+
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
81
|
+
|
|
82
|
+
const img = document.createElement('img')
|
|
83
|
+
img.dataset.dshPetFrames2d = ctx.petId
|
|
84
|
+
img.alt = ''
|
|
85
|
+
img.draggable = false
|
|
86
|
+
img.style.width = '100%'
|
|
87
|
+
img.style.height = '100%'
|
|
88
|
+
img.style.objectFit = 'contain'
|
|
89
|
+
img.style.pointerEvents = 'none'
|
|
90
|
+
ctx.container.appendChild(img)
|
|
91
|
+
|
|
92
|
+
// Warm the browser cache for every frame up front (same-origin, small
|
|
93
|
+
// webp files); playback itself swaps img.src without React state.
|
|
94
|
+
for (const track of Object.values(config.tracks)) {
|
|
95
|
+
for (const url of track.frames) {
|
|
96
|
+
const pre = new Image()
|
|
97
|
+
pre.src = url
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let disposed = false
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
103
|
+
let watchdog: ReturnType<typeof setInterval> | undefined
|
|
104
|
+
let track: string = config.phases.idle
|
|
105
|
+
let frameIndex = 0
|
|
106
|
+
let lastAdvance = Date.now()
|
|
107
|
+
let override: string | undefined
|
|
108
|
+
|
|
109
|
+
const trackForPhase = (phase: ActivityPhase): string => {
|
|
110
|
+
const mapped = config.phases[phase]
|
|
111
|
+
return mapped !== undefined && config.tracks[mapped] !== undefined ? mapped : config.phases.idle
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const show = (trackId: string, index: number): void => {
|
|
115
|
+
const def = config.tracks[trackId]
|
|
116
|
+
const url = def?.frames[index]
|
|
117
|
+
if (url !== undefined && img.getAttribute('src') !== url) img.src = url
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const schedule = (ms: number): void => {
|
|
121
|
+
if (disposed) return
|
|
122
|
+
timer = setTimeout(tick, ms)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tick(): void {
|
|
126
|
+
if (disposed) return
|
|
127
|
+
const def = config.tracks[track]
|
|
128
|
+
if (def === undefined || def.frames.length === 0) return
|
|
129
|
+
lastAdvance = Date.now()
|
|
130
|
+
const next = frameIndex + 1
|
|
131
|
+
if (next < def.frames.length) {
|
|
132
|
+
frameIndex = next
|
|
133
|
+
show(track, frameIndex)
|
|
134
|
+
schedule(def.durations[frameIndex] ?? 200)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (def.loop) {
|
|
138
|
+
frameIndex = 0
|
|
139
|
+
show(track, frameIndex)
|
|
140
|
+
schedule(def.durations[frameIndex] ?? 200)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
// Non-loop completion: settle into the fallback; when the fallback is
|
|
144
|
+
// what the phase map would play anyway, release the gameplay override.
|
|
145
|
+
const target = def.fallback !== undefined && config.tracks[def.fallback] !== undefined
|
|
146
|
+
? def.fallback
|
|
147
|
+
: config.phases.idle
|
|
148
|
+
if (target === trackForPhase(ctx.phase.get())) override = undefined
|
|
149
|
+
play(target)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function play(trackId: string): void {
|
|
153
|
+
if (disposed) return
|
|
154
|
+
if (config.tracks[trackId] === undefined) trackId = config.phases.idle
|
|
155
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
156
|
+
track = trackId
|
|
157
|
+
frameIndex = 0
|
|
158
|
+
lastAdvance = Date.now()
|
|
159
|
+
show(track, frameIndex)
|
|
160
|
+
if (reducedMotion) return
|
|
161
|
+
const def = config.tracks[track]!
|
|
162
|
+
schedule(def.durations[0] ?? 200)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const unsubscribe = ctx.phase.subscribe((phase) => {
|
|
166
|
+
if (override !== undefined) return
|
|
167
|
+
const target = trackForPhase(phase)
|
|
168
|
+
if (target !== track) play(target)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Watchdog: a throttled-away timer (background tab) leaves the chain
|
|
172
|
+
// dead; re-kick when a looping track has not advanced on schedule.
|
|
173
|
+
if (!reducedMotion) {
|
|
174
|
+
watchdog = setInterval(() => {
|
|
175
|
+
if (disposed) return
|
|
176
|
+
const def = config.tracks[track]
|
|
177
|
+
if (def === undefined || !def.loop) return
|
|
178
|
+
const expected = (def.durations[frameIndex] ?? 200) + WATCHDOG_MS
|
|
179
|
+
if (Date.now() - lastAdvance > expected) tick()
|
|
180
|
+
}, WATCHDOG_MS)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
play(track)
|
|
184
|
+
|
|
185
|
+
let disposedOnce = false
|
|
186
|
+
const dispose = (): void => {
|
|
187
|
+
if (disposedOnce) return
|
|
188
|
+
disposedOnce = true
|
|
189
|
+
disposed = true
|
|
190
|
+
unsubscribe()
|
|
191
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
192
|
+
if (watchdog !== undefined) clearInterval(watchdog)
|
|
193
|
+
img.remove()
|
|
194
|
+
}
|
|
195
|
+
ctx.onCleanup(dispose)
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
dispose,
|
|
199
|
+
setState(next: string | undefined): void {
|
|
200
|
+
if (disposed) return
|
|
201
|
+
if (next === undefined) {
|
|
202
|
+
override = undefined
|
|
203
|
+
const target = trackForPhase(ctx.phase.get())
|
|
204
|
+
if (target !== track) play(target)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (config.tracks[next] === undefined) return
|
|
208
|
+
override = next
|
|
209
|
+
if (next !== track) play(next)
|
|
210
|
+
},
|
|
211
|
+
currentTrack(): string {
|
|
212
|
+
return track
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
}
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
.header {
|
|
25
25
|
appearance: none;
|
|
26
26
|
width: 100%;
|
|
27
|
+
/* The suite does not force a global border-box, so width:100% + padding
|
|
28
|
+
would stretch the row 32px past the card and push the trailing pending
|
|
29
|
+
badge (last flex item) outside the card border. */
|
|
30
|
+
box-sizing: border-box;
|
|
27
31
|
font: inherit;
|
|
28
32
|
color: inherit;
|
|
29
33
|
text-align: left;
|
|
@@ -44,6 +48,7 @@
|
|
|
44
48
|
|
|
45
49
|
.headerStatic {
|
|
46
50
|
width: 100%;
|
|
51
|
+
box-sizing: border-box;
|
|
47
52
|
border-radius: 12px;
|
|
48
53
|
align-items: center;
|
|
49
54
|
gap: 12px;
|
|
@@ -320,7 +320,7 @@ export class CardForm<T> {
|
|
|
320
320
|
/**
|
|
321
321
|
* Write every staged edit, then re-seed from what the Host accepted.
|
|
322
322
|
*
|
|
323
|
-
* When the scope carries the optional batch surface (the dsh-web
|
|
323
|
+
* When the scope carries the optional batch surface (the dsh-web
|
|
324
324
|
* bridge scope), every planned write rides one mutation so cross-field
|
|
325
325
|
* validate hooks (baseURL+model) judge the batch as a unit instead of
|
|
326
326
|
* deadlocking on per-field writes. Otherwise the per-field loop runs.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/telemetry.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Anonymous install telemetry shared by the DSH Web UI family plugins.
|
|
4
|
+
*
|
|
5
|
+
* Once per UTC day per browser, each wired plugin sends one heartbeat to
|
|
6
|
+
* the dsh-market edge API listing its package name (and version when known).
|
|
7
|
+
* The payload carries no conversation data and no identifiers beyond a random
|
|
8
|
+
* UUID generated in localStorage; the server hashes it with a deployment salt
|
|
9
|
+
* before storage and never persists IP addresses. Sends are fire-and-forget:
|
|
10
|
+
* failures stay silent and simply retry on a later mount or day. The system
|
|
11
|
+
* is documented in docs/telemetry.md.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type TelemetryChannel = 'market' | 'npm' | 'unknown'
|
|
15
|
+
|
|
16
|
+
export interface TelemetryItem {
|
|
17
|
+
/** npm package name or asset id, e.g. "@linxin666/dsh-pet" or "skin:harbor". */
|
|
18
|
+
name: string
|
|
19
|
+
/** Installed version when known; omitted otherwise. */
|
|
20
|
+
version?: string
|
|
21
|
+
/** Install channel when determinable (market = Workshop install). */
|
|
22
|
+
channel?: TelemetryChannel
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const VISITOR_KEY = 'dsh-web-ui-telemetry-visitor'
|
|
26
|
+
const DAY_KEY_PREFIX = 'dsh-web-ui-telemetry-day:'
|
|
27
|
+
const ENDPOINT = 'https://dsh-market.com/api/telemetry/event'
|
|
28
|
+
|
|
29
|
+
// Baked into each client bundle by shared/tsdown.client.ts (the package's own
|
|
30
|
+
// package.json version at build time). Undefined in dev/test builds.
|
|
31
|
+
declare const __DSH_PKG_VERSION__: string | undefined
|
|
32
|
+
|
|
33
|
+
/** The building package's version, when the bundle carries it. */
|
|
34
|
+
function bakedVersion(): string | undefined {
|
|
35
|
+
try {
|
|
36
|
+
return typeof __DSH_PKG_VERSION__ === 'string' && __DSH_PKG_VERSION__ !== ''
|
|
37
|
+
? __DSH_PKG_VERSION__
|
|
38
|
+
: undefined
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read or lazily create the anonymous visitor id; null when storage is unavailable. */
|
|
45
|
+
function visitorId(): string | null {
|
|
46
|
+
try {
|
|
47
|
+
const existing = localStorage.getItem(VISITOR_KEY)
|
|
48
|
+
if (existing && /^[A-Za-z0-9_-]{16,64}$/.test(existing)) return existing
|
|
49
|
+
const fresh = crypto.randomUUID().replaceAll('-', '')
|
|
50
|
+
localStorage.setItem(VISITOR_KEY, fresh)
|
|
51
|
+
return fresh
|
|
52
|
+
} catch {
|
|
53
|
+
// Storage unavailable (privacy mode, sandboxed frame): report nothing.
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drop stale per-day dedup keys so localStorage does not grow forever. */
|
|
59
|
+
function pruneDayKeys(today: string): void {
|
|
60
|
+
try {
|
|
61
|
+
for (let index = localStorage.length - 1; index >= 0; index -= 1) {
|
|
62
|
+
const key = localStorage.key(index)
|
|
63
|
+
if (key !== null && key.startsWith(DAY_KEY_PREFIX) && key !== DAY_KEY_PREFIX + today) {
|
|
64
|
+
localStorage.removeItem(key)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch { /* best effort */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fire the daily heartbeat for the given items at most once per UTC day per
|
|
72
|
+
* browser. Never throws and never blocks the caller. Items without an explicit
|
|
73
|
+
* version inherit the bundle's baked build version.
|
|
74
|
+
*/
|
|
75
|
+
export function reportDailyHeartbeat(items: readonly TelemetryItem[]): void {
|
|
76
|
+
try {
|
|
77
|
+
if (items.length === 0) return
|
|
78
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
79
|
+
// Automation contexts (CDP-driven QA browsers, fresh isolated profiles)
|
|
80
|
+
// mint a new visitor id per run and would inflate instance counts.
|
|
81
|
+
if (navigator.webdriver) return
|
|
82
|
+
if (localStorage.getItem(DAY_KEY_PREFIX + today) !== null) return
|
|
83
|
+
const visitor = visitorId()
|
|
84
|
+
if (visitor === null) return
|
|
85
|
+
pruneDayKeys(today)
|
|
86
|
+
const payloadItems = items.map((item) => {
|
|
87
|
+
const out: Record<string, string> = { name: item.name }
|
|
88
|
+
const version = item.version ?? bakedVersion()
|
|
89
|
+
if (version !== undefined) out.version = version
|
|
90
|
+
if (item.channel !== undefined) out.channel = item.channel
|
|
91
|
+
return out
|
|
92
|
+
})
|
|
93
|
+
const body = JSON.stringify({ kind: 'heartbeat', visitor, items: payloadItems })
|
|
94
|
+
void fetch(ENDPOINT, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'content-type': 'application/json' },
|
|
97
|
+
body,
|
|
98
|
+
keepalive: true,
|
|
99
|
+
}).then((response) => {
|
|
100
|
+
// Mark the day only after an accepted send, so offline browsers retry
|
|
101
|
+
// on a later mount instead of going dark until tomorrow.
|
|
102
|
+
if (response.ok) localStorage.setItem(DAY_KEY_PREFIX + today, '1')
|
|
103
|
+
}).catch(() => { /* unreachable endpoint: stay silent */ })
|
|
104
|
+
} catch { /* never break the host UI over telemetry */ }
|
|
105
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Shared defaults used by registry selection and persisted-state migration. */
|
|
2
|
+
|
|
3
|
+
/** Pet id that legacy single-pet installs and fresh registries resolve to. */
|
|
4
|
+
export const DEFAULT_PET_ID = 'whale-girl'
|
|
5
|
+
|
|
6
|
+
/** Default pet name used only when a manifest carries no displayName. */
|
|
7
|
+
export const DEFAULT_PET_NAME = '鲸鱼娘'
|
package/src/dsh-home.ts
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { isAbsolute, join } from 'node:path'
|
|
10
|
+
import { isAbsolute as posixIsAbsolute, join as posixJoin } from 'node:path/posix'
|
|
10
11
|
|
|
11
12
|
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
12
13
|
export function expandHome(path: string, home: string = homedir()): string {
|
|
14
|
+
const isPosix = home.startsWith('/')
|
|
15
|
+
const j = isPosix ? posixJoin : join
|
|
13
16
|
if (path === '~') return home
|
|
14
|
-
if (path.startsWith('~/') || path.startsWith('~\\')) return
|
|
17
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return j(home, path.slice(2))
|
|
15
18
|
return path
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -22,12 +25,15 @@ export function expandHome(path: string, home: string = homedir()): string {
|
|
|
22
25
|
* @returns the absolute DSH home path.
|
|
23
26
|
*/
|
|
24
27
|
export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
|
|
28
|
+
const isPosix = home.startsWith('/')
|
|
29
|
+
const j = isPosix ? posixJoin : join
|
|
30
|
+
const isAbs = isPosix ? posixIsAbsolute : isAbsolute
|
|
25
31
|
const raw = env.DSH_HOME
|
|
26
32
|
if (raw !== undefined && raw.trim() !== '') {
|
|
27
33
|
const expanded = expandHome(raw.trim(), home)
|
|
28
|
-
return
|
|
34
|
+
return isAbs(expanded) ? expanded : j(process.cwd(), expanded)
|
|
29
35
|
}
|
|
30
|
-
return
|
|
36
|
+
return j(home, '.dsh')
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/** Resolve the DSH home directory from the live environment. */
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gameplay tests — the manifest block (fail-closed structure, parsed through
|
|
3
|
+
* parsePetManifest) and the host engine (lazy settle, effects, rolls) with
|
|
4
|
+
* injected clock/rng for determinism.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { parsePetManifest } from './manifest-v2.ts'
|
|
8
|
+
import {
|
|
9
|
+
applyGameplayEffects,
|
|
10
|
+
clampGameplay,
|
|
11
|
+
drawLotteryTier,
|
|
12
|
+
initialGameplayState,
|
|
13
|
+
rollTouchBranch,
|
|
14
|
+
rollWorkOutcome,
|
|
15
|
+
settleGameplay,
|
|
16
|
+
touchZoneAt,
|
|
17
|
+
type PetGameplayManifest,
|
|
18
|
+
} from './gameplay.ts'
|
|
19
|
+
|
|
20
|
+
/** A miku-shaped frames2d manifest carrying the full gameplay block. */
|
|
21
|
+
function mikuManifest(gameplay: Record<string, unknown>): Record<string, unknown> {
|
|
22
|
+
return {
|
|
23
|
+
petManifestVersion: 2,
|
|
24
|
+
id: 'miku',
|
|
25
|
+
displayName: 'Miku',
|
|
26
|
+
license: 'MIT',
|
|
27
|
+
renderer: 'frames2d',
|
|
28
|
+
frames2d: {
|
|
29
|
+
dir: 'thumb',
|
|
30
|
+
tracks: {
|
|
31
|
+
idle: {}, drag: {}, standup: {}, sleep: {}, work: {}, success: {}, fail: {},
|
|
32
|
+
happy: {}, shy: {}, flirty: {}, angry: {}, scratch: {}, blink1: {}, blink2: {}, eat: {}, shop: {},
|
|
33
|
+
},
|
|
34
|
+
phases: { idle: 'idle', done: 'success', failed: 'fail' },
|
|
35
|
+
},
|
|
36
|
+
gameplay,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FULL_GAMEPLAY: Record<string, unknown> = {
|
|
41
|
+
idleDirector: {
|
|
42
|
+
intervalMs: 5000,
|
|
43
|
+
maxMiss: 2,
|
|
44
|
+
idleWeight: 40,
|
|
45
|
+
acts: [
|
|
46
|
+
{ track: 'scratch', weight: 18 },
|
|
47
|
+
{ track: 'blink1', weight: 9 },
|
|
48
|
+
{ track: 'blink2', weight: 9 },
|
|
49
|
+
{ track: 'eat', weight: 24 },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
stats: {
|
|
53
|
+
hunger: { max: 100, initial: 100, decayPerMinute: 1, workingDecayPerMinute: 5 },
|
|
54
|
+
mood: { max: 100, initial: 100, decayPerMinute: 0.5 },
|
|
55
|
+
energy: { max: 100, initial: 100, decayPerMinute: 0.25 },
|
|
56
|
+
affection: { max: 500, initial: 100, idleDecayPerMinute: 0.2 },
|
|
57
|
+
},
|
|
58
|
+
hitBox: { x0: 0.2, y0: 0.05, x1: 0.42, y1: 0.56 },
|
|
59
|
+
touch: {
|
|
60
|
+
zones: [
|
|
61
|
+
{ name: 'head', y0: 0, y1: 0.55, branches: [{ probability: 0.05, effects: [{ stat: 'affection', amount: 5 }], state: 'happy', stateMs: 3000, phrases: ['hi'] }] },
|
|
62
|
+
{ name: 'body', y0: 0.55, y1: 0.75, branches: [{ probability: 0.1, effects: [{ stat: 'affection', amount: 10 }], state: 'shy', stateMs: 3000 }] },
|
|
63
|
+
{
|
|
64
|
+
name: 'legs', y0: 0.75, y1: 1, branches: [
|
|
65
|
+
{ probability: 0.1, effects: [{ stat: 'affection', amount: 30 }], state: 'flirty', stateMs: 3000 },
|
|
66
|
+
{ probability: 0.9, effects: [{ stat: 'affection', amount: -5 }], state: 'angry', stateMs: 3000 },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
work: {
|
|
72
|
+
state: 'work',
|
|
73
|
+
successState: 'success',
|
|
74
|
+
failState: 'fail',
|
|
75
|
+
tickMs: 10_000,
|
|
76
|
+
resultMs: { success: 1300, fail: 1900 },
|
|
77
|
+
successProbability: 0.5,
|
|
78
|
+
success: { effects: [{ currency: 'coins', amount: 3 }] },
|
|
79
|
+
fail: { effects: [{ currency: 'coins', amount: -1 }] },
|
|
80
|
+
},
|
|
81
|
+
sleep: { state: 'sleep', wakeState: 'standup', restore: { stat: 'energy', amount: 4, intervalMs: 30_000 } },
|
|
82
|
+
passiveIncome: { currency: 'coins', amount: 1, intervalMs: 60_000 },
|
|
83
|
+
shop: {
|
|
84
|
+
state: 'shop',
|
|
85
|
+
items: [
|
|
86
|
+
{ id: 'food1', label: 'bread', price: 5, currency: 'coins', effects: [{ stat: 'hunger', amount: 40 }] },
|
|
87
|
+
{ id: 'gamecoin', label: 'coin', price: 10, currency: 'coins', effects: [{ currency: 'gamecoins', amount: 1 }] },
|
|
88
|
+
{
|
|
89
|
+
id: 'lottery', label: 'ticket', price: 10, currency: 'gamecoins',
|
|
90
|
+
lottery: {
|
|
91
|
+
effects: [{ stat: 'hunger', amount: 10 }, { stat: 'mood', amount: 10 }, { stat: 'energy', amount: 10 }, { stat: 'affection', amount: 10 }],
|
|
92
|
+
tiers: [
|
|
93
|
+
{ probability: 0.0001, prize: 1_000_000 },
|
|
94
|
+
{ probability: 0.9836, prize: 50 },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
dragState: 'drag',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('gameplay manifest', () => {
|
|
104
|
+
it('accepts the full miku-shaped block', () => {
|
|
105
|
+
const result = parsePetManifest(mikuManifest(FULL_GAMEPLAY), 'mem')
|
|
106
|
+
expect(result.ok).toBe(true)
|
|
107
|
+
if (!result.ok) return
|
|
108
|
+
const gameplay = result.manifest.gameplay!
|
|
109
|
+
expect(gameplay.idleDirector?.acts).toHaveLength(4)
|
|
110
|
+
expect(gameplay.stats?.affection?.max).toBe(500)
|
|
111
|
+
expect(gameplay.touch?.zones).toHaveLength(3)
|
|
112
|
+
expect(gameplay.work?.tickMs).toBe(10_000)
|
|
113
|
+
expect(gameplay.sleep?.restore).toEqual({ stat: 'energy', amount: 4, intervalMs: 30_000 })
|
|
114
|
+
expect(gameplay.shop?.items).toHaveLength(3)
|
|
115
|
+
expect(gameplay.dragState).toBe('drag')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('rejects gameplay on non-frames2d renderers', () => {
|
|
119
|
+
const sprite = {
|
|
120
|
+
petManifestVersion: 2, id: 'cat', displayName: 'Cat', license: 'CC0-1.0',
|
|
121
|
+
renderer: 'sprite2d', sprite2d: { spritesheetPath: 'spritesheet.webp' },
|
|
122
|
+
gameplay: {},
|
|
123
|
+
}
|
|
124
|
+
const result = parsePetManifest(sprite, 'mem')
|
|
125
|
+
expect(result.ok).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('rejects unknown fields and dangling references', () => {
|
|
129
|
+
expect(parsePetManifest(mikuManifest({ ...FULL_GAMEPLAY, casino: {} }), 'mem').ok).toBe(false)
|
|
130
|
+
const badStatRef = mikuManifest({ stats: { hunger: { max: 100 } }, shop: { items: [{ id: 'x', label: 'x', price: 1, currency: 'coins', effects: [{ stat: 'ghost', amount: 1 }] }] } })
|
|
131
|
+
expect(parsePetManifest(badStatRef, 'mem').ok).toBe(false)
|
|
132
|
+
const badTrackRef = mikuManifest({ work: { state: 'ghost', successState: 'success', failState: 'fail', tickMs: 1000, successProbability: 0.5 } })
|
|
133
|
+
expect(parsePetManifest(badTrackRef, 'mem').ok).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('rejects probability sums above 1 and duplicate shop ids', () => {
|
|
137
|
+
const overTouch = mikuManifest({ touch: { zones: [{ name: 'z', y0: 0, y1: 1, branches: [{ probability: 0.6 }, { probability: 0.6 }] }] } })
|
|
138
|
+
expect(parsePetManifest(overTouch, 'mem').ok).toBe(false)
|
|
139
|
+
const dupShop = mikuManifest({ shop: { items: [
|
|
140
|
+
{ id: 'a', label: 'a', price: 1, currency: 'c', effects: [{ currency: 'c', amount: 1 }] },
|
|
141
|
+
{ id: 'a', label: 'b', price: 1, currency: 'c', effects: [{ currency: 'c', amount: 1 }] },
|
|
142
|
+
] } })
|
|
143
|
+
expect(parsePetManifest(dupShop, 'mem').ok).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('rejects a shop item with neither effects nor lottery', () => {
|
|
147
|
+
const bare = mikuManifest({ shop: { items: [{ id: 'a', label: 'a', price: 1, currency: 'coins' }] } })
|
|
148
|
+
expect(parsePetManifest(bare, 'mem').ok).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('rejects bad ranges (tickMs, decay, zone slices, stateMs)', () => {
|
|
152
|
+
const badTick = mikuManifest({ work: { state: 'work', successState: 'success', failState: 'fail', tickMs: 50, successProbability: 0.5 } })
|
|
153
|
+
expect(parsePetManifest(badTick, 'mem').ok).toBe(false)
|
|
154
|
+
const badDecay = mikuManifest({ stats: { hunger: { max: 100, decayPerMinute: -1 } } })
|
|
155
|
+
expect(parsePetManifest(badDecay, 'mem').ok).toBe(false)
|
|
156
|
+
const badZone = mikuManifest({ touch: { zones: [{ name: 'z', y0: 0.8, y1: 0.2, branches: [{ probability: 1 }] }] } })
|
|
157
|
+
expect(parsePetManifest(badZone, 'mem').ok).toBe(false)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('gameplay engine', () => {
|
|
162
|
+
const manifest = parsePetManifest(mikuManifest(FULL_GAMEPLAY), 'mem')
|
|
163
|
+
if (!manifest.ok) throw new Error('fixture must parse')
|
|
164
|
+
const def = manifest.manifest.gameplay!
|
|
165
|
+
|
|
166
|
+
it('initializes stats at their declared initial (default max)', () => {
|
|
167
|
+
const state = initialGameplayState(def, 1000)
|
|
168
|
+
expect(state.stats).toEqual({ hunger: 100, mood: 100, energy: 100, affection: 100 })
|
|
169
|
+
expect(state.currencies).toEqual({})
|
|
170
|
+
expect(state.mode).toBeNull()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('lazy-settles decay, passive income and sleep restore', () => {
|
|
174
|
+
const state = initialGameplayState(def, 0)
|
|
175
|
+
state.stats.hunger = 50
|
|
176
|
+
state.mode = 'sleep'
|
|
177
|
+
// 10 minutes pass with an active session: hunger -10 (1/min), mood -5,
|
|
178
|
+
// energy -2.5 then +20 sleep restore (10 x 30s ticks x 4), coins +10.
|
|
179
|
+
const changed = settleGameplay(state, def, 600_000, { sessionActive: true })
|
|
180
|
+
expect(changed).toBe(true)
|
|
181
|
+
expect(state.stats.hunger).toBe(40)
|
|
182
|
+
expect(state.stats.mood).toBe(95)
|
|
183
|
+
expect(state.stats.energy).toBe(100) // 97.5 + 20 clamped to max
|
|
184
|
+
expect(state.currencies.coins).toBe(10)
|
|
185
|
+
expect(state.settledAt).toBe(600_000)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('applies the working decay variant while working and idle decay without a session', () => {
|
|
189
|
+
const state = initialGameplayState(def, 0)
|
|
190
|
+
state.mode = 'work'
|
|
191
|
+
settleGameplay(state, def, 60_000, { sessionActive: true })
|
|
192
|
+
expect(state.stats.hunger).toBe(95) // working: 5/min
|
|
193
|
+
const idle = initialGameplayState(def, 0)
|
|
194
|
+
settleGameplay(idle, def, 300_000, { sessionActive: false })
|
|
195
|
+
expect(idle.stats.affection).toBe(99) // idle decay 0.2/min over 5 min
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('clamps effects into stat max and currency floors', () => {
|
|
199
|
+
const state = initialGameplayState(def, 0)
|
|
200
|
+
applyGameplayEffects(state, def, [{ stat: 'affection', amount: 10_000 }, { currency: 'coins', amount: -5 }])
|
|
201
|
+
expect(state.stats.affection).toBe(500)
|
|
202
|
+
expect(state.currencies.coins).toBe(0)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('rolls touch branches by cumulative probability with no-op mass', () => {
|
|
206
|
+
const zone = def.touch!.zones[0]! // head: 5% hit
|
|
207
|
+
expect(rollTouchBranch(zone, () => 0.04)?.state).toBe('happy')
|
|
208
|
+
expect(rollTouchBranch(zone, () => 0.5)).toBeUndefined()
|
|
209
|
+
const legs = def.touch!.zones[2]!
|
|
210
|
+
expect(rollTouchBranch(legs, () => 0.05)?.state).toBe('flirty')
|
|
211
|
+
expect(rollTouchBranch(legs, () => 0.5)?.state).toBe('angry')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('rolls work outcomes and draws lottery tiers with fallthrough', () => {
|
|
215
|
+
expect(rollWorkOutcome(def.work!, () => 0.4)).toBe('success')
|
|
216
|
+
expect(rollWorkOutcome(def.work!, () => 0.6)).toBe('fail')
|
|
217
|
+
const lottery = def.shop!.items[2]!.lottery!
|
|
218
|
+
expect(drawLotteryTier(lottery, () => 0.00005).prize).toBe(1_000_000)
|
|
219
|
+
expect(drawLotteryTier(lottery, () => 0.5).prize).toBe(50)
|
|
220
|
+
// Sum < 1: the uncovered roll mass falls through to the last tier.
|
|
221
|
+
expect(drawLotteryTier(lottery, () => 0.9999).prize).toBe(50)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('locates the touch zone for a hit-box fraction', () => {
|
|
225
|
+
expect(touchZoneAt(def.touch!, 0.3)?.name).toBe('head')
|
|
226
|
+
expect(touchZoneAt(def.touch!, 0.6)?.name).toBe('body')
|
|
227
|
+
expect(touchZoneAt(def.touch!, 0.9)?.name).toBe('legs')
|
|
228
|
+
expect(touchZoneAt(def.touch!, 0.55)?.name).toBe('body')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('clamps gameplay state into declared maxima', () => {
|
|
232
|
+
const state = initialGameplayState(def, 0)
|
|
233
|
+
state.stats.hunger = 10_000
|
|
234
|
+
state.currencies.coins = 100_000_000
|
|
235
|
+
clampGameplay(state, def)
|
|
236
|
+
expect(state.stats.hunger).toBe(100)
|
|
237
|
+
expect(state.currencies.coins).toBe(9_999_999)
|
|
238
|
+
})
|
|
239
|
+
})
|