@linxin666/dsh-pet 0.3.13 → 0.3.15
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/lib/client.js +47 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +72 -26
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +18 -7
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +1 -0
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +18 -0
- package/lib/types/client/sequences.d.ts +10 -0
- package/lib/types/client/sequences.d.ts.map +1 -1
- package/lib/types/client/sequences.js +24 -16
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +27 -9
- package/lib/types/routes.d.ts +2 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +58 -10
- package/package.json +14 -14
- package/src/client/PetSprite.tsx +20 -10
- package/src/client/index.test.tsx +31 -3
- package/src/client/index.ts +1 -0
- package/src/client/pet-store.test.ts +87 -0
- package/src/client/pet-store.ts +18 -0
- package/src/client/sequences.test.ts +21 -1
- package/src/client/sequences.ts +37 -16
- package/src/index.ts +27 -11
- package/src/routes.ts +60 -11
package/src/client/PetSprite.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import type { PetDefinition } from '../registry.ts'
|
|
|
21
21
|
import type { DecorationView } from '../contracts/status-decoration.ts'
|
|
22
22
|
import type { PetFeedback } from './pet-store.ts'
|
|
23
23
|
import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
|
|
24
|
-
import {
|
|
24
|
+
import { createSequenceTimeline } from './sequences.ts'
|
|
25
25
|
import { animationForPhase, type ActivityPhase, type PetAnimation } from '../state.ts'
|
|
26
26
|
import { NS } from './locales.ts'
|
|
27
27
|
import styles from './pet.module.css'
|
|
@@ -339,6 +339,20 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
339
339
|
const reduceMotion = typeof window !== 'undefined'
|
|
340
340
|
&& window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
341
341
|
const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
|
|
342
|
+
// Sequence state hoisted into the effect scope: the cumulative duration
|
|
343
|
+
// table and each item's trimmed track would otherwise be recomputed every
|
|
344
|
+
// tick (map/reduce plus two slices per frame), the same waste the
|
|
345
|
+
// single-track branch below avoids.
|
|
346
|
+
const timeline = sequence === undefined ? undefined : createSequenceTimeline(sequence, tracks)
|
|
347
|
+
const sequenceItems = sequence === undefined ? undefined : new Map(
|
|
348
|
+
sequence.map(itemAnimation => {
|
|
349
|
+
const itemRow = rowOfTrack(itemAnimation)
|
|
350
|
+
return [itemAnimation, {
|
|
351
|
+
row: itemRow,
|
|
352
|
+
track: trimTrack(tracks[itemAnimation], rows[itemRow] ?? tracks[itemAnimation].frames.length),
|
|
353
|
+
}]
|
|
354
|
+
}),
|
|
355
|
+
)
|
|
342
356
|
const leadAnimation = sequence?.[0] ?? animation
|
|
343
357
|
const row = rowOfTrack(leadAnimation)
|
|
344
358
|
const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length)
|
|
@@ -357,16 +371,12 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
357
371
|
const tick = (ts: number): void => {
|
|
358
372
|
const delta = ts - last
|
|
359
373
|
last = ts
|
|
360
|
-
if (
|
|
374
|
+
if (timeline !== undefined && sequenceItems !== undefined) {
|
|
361
375
|
sequenceElapsed += delta
|
|
362
|
-
const current =
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
rows[currentRow] ?? tracks[current.animation].frames.length,
|
|
367
|
-
)
|
|
368
|
-
const col = currentTrack.frames[current.frameIndex]!
|
|
369
|
-
const pos = framePosition(cell, currentRow, col, scaleRef.current)
|
|
376
|
+
const current = timeline.frameAt(sequenceElapsed)
|
|
377
|
+
const item = sequenceItems.get(current.animation)!
|
|
378
|
+
const col = item.track.frames[current.frameIndex]!
|
|
379
|
+
const pos = framePosition(cell, item.row, col, scaleRef.current)
|
|
370
380
|
const posStr = pos.x + 'px ' + pos.y + 'px'
|
|
371
381
|
if (posStr !== lastPosStr) {
|
|
372
382
|
lastPosStr = posStr
|
|
@@ -61,6 +61,8 @@ interface FakeClientLifecycle {
|
|
|
61
61
|
dispose(): void
|
|
62
62
|
settingsListenerCount(): number
|
|
63
63
|
emitSettings(): void
|
|
64
|
+
sessionsListenerCount(): number
|
|
65
|
+
setEnabled(enabled: boolean): void
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const activeLifecycles: FakeClientLifecycle[] = []
|
|
@@ -73,11 +75,13 @@ afterEach(() => {
|
|
|
73
75
|
function fakeContext(): FakeClientLifecycle {
|
|
74
76
|
const disposers: (() => void)[] = []
|
|
75
77
|
const settingsListeners = new Set<() => void>()
|
|
78
|
+
const sessionListeners = new Set<() => void>()
|
|
79
|
+
let settingsValue: { enabled?: boolean } | undefined
|
|
76
80
|
const scope = {
|
|
77
81
|
getSnapshot: () => ({
|
|
78
82
|
status: 'ready',
|
|
79
83
|
writable: true,
|
|
80
|
-
value:
|
|
84
|
+
value: settingsValue,
|
|
81
85
|
base: undefined,
|
|
82
86
|
user: {},
|
|
83
87
|
revision: 1,
|
|
@@ -114,8 +118,8 @@ function fakeContext(): FakeClientLifecycle {
|
|
|
114
118
|
list: {
|
|
115
119
|
getSnapshot: () => ({ current: undefined, byId: {} }),
|
|
116
120
|
subscribe: (listener: () => void) => {
|
|
117
|
-
|
|
118
|
-
return () => {
|
|
121
|
+
sessionListeners.add(listener)
|
|
122
|
+
return () => { sessionListeners.delete(listener) }
|
|
119
123
|
},
|
|
120
124
|
},
|
|
121
125
|
open: () => {},
|
|
@@ -133,6 +137,8 @@ function fakeContext(): FakeClientLifecycle {
|
|
|
133
137
|
emitSettings: () => {
|
|
134
138
|
for (const listener of settingsListeners) listener()
|
|
135
139
|
},
|
|
140
|
+
sessionsListenerCount: () => sessionListeners.size,
|
|
141
|
+
setEnabled: (enabled: boolean) => { settingsValue = { enabled } },
|
|
136
142
|
}
|
|
137
143
|
activeLifecycles.push(lifecycle)
|
|
138
144
|
return lifecycle
|
|
@@ -208,4 +214,26 @@ describe('pet client apply', () => {
|
|
|
208
214
|
expect(roots).toHaveLength(1)
|
|
209
215
|
expect(stale.isConnected).toBe(false)
|
|
210
216
|
})
|
|
217
|
+
|
|
218
|
+
it('unsubscribes the session watch when the pet is disabled from settings', () => {
|
|
219
|
+
const lifecycle = fakeContext()
|
|
220
|
+
apply(lifecycle.ctx)
|
|
221
|
+
// The poll loop and the current-session watch both subscribe through
|
|
222
|
+
// ctx.effect, so exactly one sessions.list listener is live per mount.
|
|
223
|
+
expect(lifecycle.sessionsListenerCount()).toBe(1)
|
|
224
|
+
|
|
225
|
+
// Toggling the plugin off tears the UI down without disposing the fiber:
|
|
226
|
+
// the session watch must go with it, or every later session-store
|
|
227
|
+
// notification keeps polling a dead pet forever.
|
|
228
|
+
lifecycle.setEnabled(false)
|
|
229
|
+
lifecycle.emitSettings()
|
|
230
|
+
expect(lifecycle.sessionsListenerCount()).toBe(0)
|
|
231
|
+
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(0)
|
|
232
|
+
|
|
233
|
+
// Re-enabling mounts a fresh UI with a fresh watch.
|
|
234
|
+
lifecycle.setEnabled(true)
|
|
235
|
+
lifecycle.emitSettings()
|
|
236
|
+
expect(lifecycle.sessionsListenerCount()).toBe(1)
|
|
237
|
+
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(1)
|
|
238
|
+
})
|
|
211
239
|
})
|
package/src/client/index.ts
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pet store's poll-publish contract against the real client-store engine:
|
|
3
|
+
* an unchanged snapshot must skip the write (immer records no modification,
|
|
4
|
+
* zustand never notifies), while any transition that matters still publishes.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import type { PetStateView } from '../service.ts'
|
|
8
|
+
import { createPetStore } from './pet-store.ts'
|
|
9
|
+
|
|
10
|
+
const snapshot = (over: Partial<PetStateView> = {}): PetStateView => ({
|
|
11
|
+
animation: 'idle',
|
|
12
|
+
phase: 'idle',
|
|
13
|
+
sessionActive: false,
|
|
14
|
+
affinity: {
|
|
15
|
+
points: 3,
|
|
16
|
+
rank: '好奇',
|
|
17
|
+
rankEmoji: '',
|
|
18
|
+
pets: 1,
|
|
19
|
+
feeds: 0,
|
|
20
|
+
turns: 0,
|
|
21
|
+
petCooldown: false,
|
|
22
|
+
feedCooldown: false,
|
|
23
|
+
},
|
|
24
|
+
display: { visible: true, size: 160, right: 24, bottom: 120 },
|
|
25
|
+
pet: { id: 'whale-girl', displayName: 'Whale', description: '' },
|
|
26
|
+
name: 'Whale',
|
|
27
|
+
treats: { stocked: 2, max: 10 },
|
|
28
|
+
...over,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('pet store setSnapshot publish skipping', () => {
|
|
32
|
+
it('does not notify when a poll republishes an unchanged snapshot', () => {
|
|
33
|
+
const store = createPetStore().create()
|
|
34
|
+
const listener = vi.fn()
|
|
35
|
+
store.subscribe(listener)
|
|
36
|
+
|
|
37
|
+
const first = snapshot()
|
|
38
|
+
store.actions.setSnapshot(first)
|
|
39
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
40
|
+
expect(store.getSnapshot().state).toBe('ready')
|
|
41
|
+
|
|
42
|
+
// A fresh, deep-equal object is what every poll tick delivers.
|
|
43
|
+
store.actions.setSnapshot(snapshot())
|
|
44
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
45
|
+
expect(store.getSnapshot().snapshot).toBe(first)
|
|
46
|
+
|
|
47
|
+
// Any content change publishes again.
|
|
48
|
+
store.actions.setSnapshot(snapshot({ phase: 'thinking', animation: 'running' }))
|
|
49
|
+
expect(listener).toHaveBeenCalledTimes(2)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('publishes after an error state even when the payload is unchanged', () => {
|
|
53
|
+
const store = createPetStore().create()
|
|
54
|
+
const listener = vi.fn()
|
|
55
|
+
store.subscribe(listener)
|
|
56
|
+
store.actions.setSnapshot(snapshot())
|
|
57
|
+
|
|
58
|
+
store.actions.setState('error', 'pet.state transport error')
|
|
59
|
+
expect(listener).toHaveBeenCalledTimes(2)
|
|
60
|
+
|
|
61
|
+
// The success transition (error -> ready) must land despite the equal
|
|
62
|
+
// payload, or the UI would stay stuck on the transport-error banner.
|
|
63
|
+
store.actions.setSnapshot(snapshot())
|
|
64
|
+
expect(listener).toHaveBeenCalledTimes(3)
|
|
65
|
+
expect(store.getSnapshot().state).toBe('ready')
|
|
66
|
+
expect(store.getSnapshot().error).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('still patches the gameplay view and feedback on top of a skipped poll', () => {
|
|
70
|
+
const store = createPetStore().create()
|
|
71
|
+
store.actions.setSnapshot(snapshot())
|
|
72
|
+
const base = store.getSnapshot().snapshot!
|
|
73
|
+
|
|
74
|
+
store.actions.setGameplayView({ stats: { mood: 70 }, mode: 'work' })
|
|
75
|
+
expect(store.getSnapshot().snapshot).not.toBe(base)
|
|
76
|
+
expect(store.getSnapshot().snapshot?.gameplay).toEqual({ stats: { mood: 70 }, mode: 'work' })
|
|
77
|
+
|
|
78
|
+
store.actions.setFeedback({ text: '喵', kind: 'pet', at: 1 })
|
|
79
|
+
expect(store.getSnapshot().feedback).toEqual({ text: '喵', kind: 'pet', at: 1 })
|
|
80
|
+
|
|
81
|
+
// The next identical poll (a fresh but deep-equal object) skips the
|
|
82
|
+
// publish and keeps the exact snapshot reference the UI already holds.
|
|
83
|
+
const before = store.getSnapshot().snapshot
|
|
84
|
+
store.actions.setSnapshot(snapshot({ gameplay: { stats: { mood: 70 }, mode: 'work' } }))
|
|
85
|
+
expect(store.getSnapshot().snapshot).toBe(before)
|
|
86
|
+
})
|
|
87
|
+
})
|
package/src/client/pet-store.ts
CHANGED
|
@@ -66,6 +66,14 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
|
|
|
66
66
|
}),
|
|
67
67
|
actions: {
|
|
68
68
|
setSnapshot: (draft, snapshot) => {
|
|
69
|
+
// The 2 s poll republishes the full snapshot even while the pet is
|
|
70
|
+
// idle. Skipping an unchanged payload keeps immer's produce at zero
|
|
71
|
+
// modifications, so zustand never notifies and the whole sprite tree
|
|
72
|
+
// skips the re-render. Equality is JSON-based and skip-only: both
|
|
73
|
+
// sides come from the same host serializer, and any mismatch falls
|
|
74
|
+
// through to the normal publish — the failure mode is one extra
|
|
75
|
+
// render, never a stale pet.
|
|
76
|
+
if (draft.state === 'ready' && draft.error === null && sameSnapshot(draft.snapshot, snapshot)) return
|
|
69
77
|
draft.snapshot = snapshot
|
|
70
78
|
draft.state = 'ready'
|
|
71
79
|
draft.error = null
|
|
@@ -89,6 +97,16 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
|
|
|
89
97
|
|
|
90
98
|
export type { PetInteraction }
|
|
91
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Content equality for consecutive poll snapshots. JSON compare, not field
|
|
102
|
+
* enumeration: an exact string match is the only way to skip the publish, so
|
|
103
|
+
* a missed field can never freeze the UI — it can only cost the render the
|
|
104
|
+
* optimization exists to save.
|
|
105
|
+
*/
|
|
106
|
+
function sameSnapshot(previous: PetStateView | null, next: PetStateView): boolean {
|
|
107
|
+
return previous !== null && JSON.stringify(previous) === JSON.stringify(next)
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
/**
|
|
93
111
|
* A live pet store instance (one per host, owned by the plugin apply body —
|
|
94
112
|
* the pet itself is host-global, so its UI state must not ride the slot
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import type { PetTrackDef } from '../registry.ts'
|
|
3
3
|
import type { PetAnimation } from '../state.ts'
|
|
4
|
-
import { sequenceFrameAt } from './sequences.ts'
|
|
4
|
+
import { createSequenceTimeline, sequenceFrameAt } from './sequences.ts'
|
|
5
5
|
|
|
6
6
|
const track = (durations: number[]): PetTrackDef => ({
|
|
7
7
|
frames: durations.map((_, index) => index),
|
|
@@ -31,3 +31,23 @@ describe('sequenceFrameAt', () => {
|
|
|
31
31
|
expect(sequenceFrameAt(sequence, tracks, 1_420)).toEqual({ animation: 'running', frameIndex: 0 })
|
|
32
32
|
})
|
|
33
33
|
})
|
|
34
|
+
|
|
35
|
+
describe('createSequenceTimeline', () => {
|
|
36
|
+
const sequence: PetAnimation[] = ['running', 'waiting']
|
|
37
|
+
|
|
38
|
+
it('resolves identically to the per-call helper at animation-sample elapsed values', () => {
|
|
39
|
+
// The sprite frame loop asks once per rAF tick with accumulated
|
|
40
|
+
// millisecond offsets; the precomputed table must agree everywhere.
|
|
41
|
+
const timeline = createSequenceTimeline(sequence, tracks)
|
|
42
|
+
for (let elapsed = 0; elapsed <= 1_500; elapsed += 7) {
|
|
43
|
+
expect(timeline.frameAt(elapsed)).toEqual(sequenceFrameAt(sequence, tracks, elapsed))
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('stays deterministic across repeated queries with the same table', () => {
|
|
48
|
+
const timeline = createSequenceTimeline(sequence, tracks)
|
|
49
|
+
expect(timeline.frameAt(350)).toEqual(timeline.frameAt(350))
|
|
50
|
+
expect(timeline.frameAt(0)).toEqual({ animation: 'running', frameIndex: 0 })
|
|
51
|
+
expect(timeline.frameAt(1_420)).toEqual({ animation: 'running', frameIndex: 0 })
|
|
52
|
+
})
|
|
53
|
+
})
|
package/src/client/sequences.ts
CHANGED
|
@@ -8,26 +8,47 @@ export interface SequenceFrame {
|
|
|
8
8
|
frameIndex: number
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Precomputed timeline for one looping sequence: the per-item duration table
|
|
13
|
+
* is built once instead of per query. The sprite frame loop asks at
|
|
14
|
+
* animation rate (~60 Hz), where the per-call map/reduce is pure waste.
|
|
15
|
+
*/
|
|
16
|
+
export interface SequenceTimeline {
|
|
17
|
+
frameAt(elapsedMs: number): SequenceFrame
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Build a {@link SequenceTimeline} over one manifest sequence. */
|
|
21
|
+
export function createSequenceTimeline(
|
|
22
|
+
sequence: readonly PetAnimation[],
|
|
23
|
+
tracks: Record<PetAnimation, PetTrackDef>,
|
|
24
|
+
): SequenceTimeline {
|
|
25
|
+
const itemDurations = sequence.map(animation => tracks[animation].durations.reduce((sum, value) => sum + value, 0))
|
|
26
|
+
const sequenceDuration = itemDurations.reduce((sum, value) => sum + value, 0)
|
|
27
|
+
return {
|
|
28
|
+
frameAt(elapsedMs: number): SequenceFrame {
|
|
29
|
+
let offset = Math.max(0, elapsedMs) % sequenceDuration
|
|
30
|
+
let itemIndex = 0
|
|
31
|
+
while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]!) {
|
|
32
|
+
offset -= itemDurations[itemIndex]!
|
|
33
|
+
itemIndex += 1
|
|
34
|
+
}
|
|
35
|
+
const animation = sequence[itemIndex]!
|
|
36
|
+
const track = tracks[animation]
|
|
37
|
+
let frameIndex = 0
|
|
38
|
+
while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]!) {
|
|
39
|
+
offset -= track.durations[frameIndex]!
|
|
40
|
+
frameIndex += 1
|
|
41
|
+
}
|
|
42
|
+
return { animation, frameIndex }
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
11
47
|
/** Resolve the active track and frame after elapsed milliseconds of a looping sequence. */
|
|
12
48
|
export function sequenceFrameAt(
|
|
13
49
|
sequence: readonly PetAnimation[],
|
|
14
50
|
tracks: Record<PetAnimation, PetTrackDef>,
|
|
15
51
|
elapsedMs: number,
|
|
16
52
|
): SequenceFrame {
|
|
17
|
-
|
|
18
|
-
const sequenceDuration = itemDurations.reduce((sum, value) => sum + value, 0)
|
|
19
|
-
let offset = Math.max(0, elapsedMs) % sequenceDuration
|
|
20
|
-
let itemIndex = 0
|
|
21
|
-
while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]!) {
|
|
22
|
-
offset -= itemDurations[itemIndex]!
|
|
23
|
-
itemIndex += 1
|
|
24
|
-
}
|
|
25
|
-
const animation = sequence[itemIndex]!
|
|
26
|
-
const track = tracks[animation]
|
|
27
|
-
let frameIndex = 0
|
|
28
|
-
while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]!) {
|
|
29
|
-
offset -= track.durations[frameIndex]!
|
|
30
|
-
frameIndex += 1
|
|
31
|
-
}
|
|
32
|
-
return { animation, frameIndex }
|
|
53
|
+
return createSequenceTimeline(sequence, tracks).frameAt(elapsedMs)
|
|
33
54
|
}
|
package/src/index.ts
CHANGED
|
@@ -190,21 +190,37 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
|
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
193
|
+
try {
|
|
194
|
+
const schema = makePetSettingsSchema(service.selectedPetId())
|
|
195
|
+
if (typeof settingsCtx.settings?.installSection === 'function') {
|
|
196
|
+
settingsCtx.settings.installSection(
|
|
197
|
+
ctx,
|
|
198
|
+
PET_SETTINGS_NAMESPACE as SettingsNamespace,
|
|
199
|
+
schema,
|
|
200
|
+
base,
|
|
201
|
+
{
|
|
202
|
+
setSource: (source) => { current = source },
|
|
203
|
+
onChange: () => {
|
|
204
|
+
const section = current()
|
|
205
|
+
service.applySettingsSection(section)
|
|
206
|
+
service.setEnabled(section.enabled ?? true)
|
|
207
|
+
syncRoutes()
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
} else if (typeof settingsCtx.settings?.register === 'function') {
|
|
212
|
+
const scope = settingsCtx.settings.register(PET_SETTINGS_NAMESPACE as SettingsNamespace, schema, { base })
|
|
213
|
+
current = () => scope?.get?.() ?? base
|
|
214
|
+
scope?.watch?.(() => {
|
|
201
215
|
const section = current()
|
|
202
216
|
service.applySettingsSection(section)
|
|
203
217
|
service.setEnabled(section.enabled ?? true)
|
|
204
218
|
syncRoutes()
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// Defensive fallback against settings registration differences
|
|
223
|
+
}
|
|
208
224
|
})
|
|
209
225
|
syncRoutes()
|
|
210
226
|
}
|
package/src/routes.ts
CHANGED
|
@@ -65,14 +65,30 @@ function extensionOf(file: string): string {
|
|
|
65
65
|
return dot < 0 ? '' : file.slice(dot).toLowerCase()
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Realpaths of containment bases, resolved once per base instead of once per
|
|
70
|
+
* request: every asset/runtime/decoration request used to pay a base
|
|
71
|
+
* realpathSync on the hot path. Bases are registry entry directories and the
|
|
72
|
+
* runtime roots — an immutable, registry-bounded set. Only successes are
|
|
73
|
+
* cached (a missing base keeps failing per request), and a symlinked base
|
|
74
|
+
* re-pointed mid-process fails containment until restart, which is the
|
|
75
|
+
* deny-safe direction.
|
|
76
|
+
*/
|
|
77
|
+
const REAL_BASE_CACHE = new Map<string, string>()
|
|
78
|
+
|
|
68
79
|
/**
|
|
69
80
|
* realpath containment: resolve both sides and require the candidate to stay
|
|
70
81
|
* inside the base directory. A pet directory (or an atlas/preview inside it)
|
|
71
|
-
* that is a symlink escaping its root is rejected, never followed.
|
|
82
|
+
* that is a symlink escaping its root is rejected, never followed. The
|
|
83
|
+
* candidate is realpath'ed live on every call; only the base side is cached.
|
|
72
84
|
*/
|
|
73
85
|
export function containedRealpath(base: string, candidate: string): string | undefined {
|
|
74
86
|
try {
|
|
75
|
-
|
|
87
|
+
let realBase = REAL_BASE_CACHE.get(base)
|
|
88
|
+
if (realBase === undefined) {
|
|
89
|
+
realBase = realpathSync(base)
|
|
90
|
+
REAL_BASE_CACHE.set(base, realBase)
|
|
91
|
+
}
|
|
76
92
|
const realCandidate = realpathSync(candidate)
|
|
77
93
|
return realCandidate === realBase || realCandidate.startsWith(realBase + sep)
|
|
78
94
|
? realCandidate
|
|
@@ -98,6 +114,19 @@ function mimeFor(file: string): string {
|
|
|
98
114
|
return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream'
|
|
99
115
|
}
|
|
100
116
|
|
|
117
|
+
/** Weak validator from size + mtime, shared by the three file routes. */
|
|
118
|
+
function weakEtag(stat: { size: number; mtimeMs: number }): string {
|
|
119
|
+
return '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Answer 304 when If-None-Match matches the etag; true when handled. */
|
|
123
|
+
function revalidated(req: IncomingMessage, res: ServerResponse, etag: string): boolean {
|
|
124
|
+
if (req.headers['if-none-match'] !== etag) return false
|
|
125
|
+
res.writeHead(304, { etag, 'cache-control': 'no-cache' })
|
|
126
|
+
res.end()
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
/** Require the method or answer 405. */
|
|
102
131
|
function requireMethod(req: IncomingMessage, res: ServerResponse, method: string): boolean {
|
|
103
132
|
if (req.method === method) return true
|
|
@@ -177,6 +206,13 @@ function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
|
|
|
177
206
|
*/
|
|
178
207
|
function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
|
|
179
208
|
const aliases = dirAliases(registry)
|
|
209
|
+
// The servable match is a Set probe instead of a linear scan: a full live2d
|
|
210
|
+
// closure routinely exceeds a hundred files, each fetched through this
|
|
211
|
+
// handler per mount. The registry is an immutable snapshot, so the sets are
|
|
212
|
+
// built once per handler.
|
|
213
|
+
const servableById = new Map<string, ReadonlySet<string>>(
|
|
214
|
+
registry.entries.map(entry => [entry.id, new Set(entry.servable)]),
|
|
215
|
+
)
|
|
180
216
|
return ((req: IncomingMessage, res: ServerResponse) => {
|
|
181
217
|
if (!guard(ctx, req, res)) return
|
|
182
218
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
@@ -231,7 +267,7 @@ function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps):
|
|
|
231
267
|
const manifestFile = join(entry.dir, MANIFEST_FILE)
|
|
232
268
|
file = existsSync(manifestFile) ? manifestFile : undefined
|
|
233
269
|
if (file === undefined) synthesized = true
|
|
234
|
-
} else if (rest.length > 0 && entry.
|
|
270
|
+
} else if (rest.length > 0 && servableById.get(entry.id)?.has(rel)) {
|
|
235
271
|
file = join(entry.dir, rel)
|
|
236
272
|
} else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1]!)) {
|
|
237
273
|
const preview = join(entry.dir, PREVIEW_DIR, rest[1]!)
|
|
@@ -267,8 +303,10 @@ function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps):
|
|
|
267
303
|
const cap = rest.length === 1 && rest[0] === MANIFEST_FILE
|
|
268
304
|
? caps.manifest
|
|
269
305
|
: IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model
|
|
306
|
+
let stat: ReturnType<typeof statSync>
|
|
270
307
|
try {
|
|
271
|
-
|
|
308
|
+
stat = statSync(resolved)
|
|
309
|
+
if (stat.size > cap) {
|
|
272
310
|
res.writeHead(413)
|
|
273
311
|
res.end()
|
|
274
312
|
return
|
|
@@ -278,11 +316,18 @@ function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps):
|
|
|
278
316
|
res.end()
|
|
279
317
|
return
|
|
280
318
|
}
|
|
319
|
+
// 'no-cache' forces revalidation before every reuse, and the validator
|
|
320
|
+
// lets repeat requests settle as 304 — atlases and frames are the largest
|
|
321
|
+
// payloads the plugin serves (up to the 20 MB image cap), and without a
|
|
322
|
+
// validator every remount or page load would re-download them in full.
|
|
323
|
+
const etag = weakEtag(stat)
|
|
324
|
+
if (revalidated(req, res, etag)) return
|
|
281
325
|
return readFile(resolved).then((body) => {
|
|
282
326
|
res.writeHead(200, {
|
|
283
327
|
'content-type': mimeFor(resolved),
|
|
284
328
|
'content-length': String(body.byteLength),
|
|
285
329
|
'cache-control': 'no-cache',
|
|
330
|
+
etag,
|
|
286
331
|
})
|
|
287
332
|
if (req.method === 'HEAD') {
|
|
288
333
|
res.end()
|
|
@@ -373,8 +418,10 @@ function runtimeHandler(ctx: Context, roots: { runtimeDir: string; vendorDir: st
|
|
|
373
418
|
res.end()
|
|
374
419
|
return
|
|
375
420
|
}
|
|
421
|
+
let stat: ReturnType<typeof statSync>
|
|
376
422
|
try {
|
|
377
|
-
|
|
423
|
+
stat = statSync(resolved)
|
|
424
|
+
if (stat.size > PET_RUNTIME_CAP) {
|
|
378
425
|
res.writeHead(413)
|
|
379
426
|
res.end()
|
|
380
427
|
return
|
|
@@ -384,11 +431,17 @@ function runtimeHandler(ctx: Context, roots: { runtimeDir: string; vendorDir: st
|
|
|
384
431
|
res.end()
|
|
385
432
|
return
|
|
386
433
|
}
|
|
434
|
+
// Same validator as the asset routes: the Cubism Core and vendor bundle
|
|
435
|
+
// ride every page load of a live2d pet, so revalidation should settle as
|
|
436
|
+
// 304 instead of re-downloading the full bodies.
|
|
437
|
+
const etag = weakEtag(stat)
|
|
438
|
+
if (revalidated(req, res, etag)) return
|
|
387
439
|
return readFile(resolved).then((body) => {
|
|
388
440
|
res.writeHead(200, {
|
|
389
441
|
'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
|
|
390
442
|
'content-length': String(body.byteLength),
|
|
391
443
|
'cache-control': 'no-cache',
|
|
444
|
+
etag,
|
|
392
445
|
})
|
|
393
446
|
if (req.method === 'HEAD') {
|
|
394
447
|
res.end()
|
|
@@ -496,12 +549,8 @@ function decorationHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCa
|
|
|
496
549
|
// validator lets repeat requests settle as 304 — the ornament remounts
|
|
497
550
|
// on whisper and display-session flips, and without a validator each
|
|
498
551
|
// remount would re-download the full strip body.
|
|
499
|
-
const etag =
|
|
500
|
-
if (req
|
|
501
|
-
res.writeHead(304, { etag, 'cache-control': 'no-cache' })
|
|
502
|
-
res.end()
|
|
503
|
-
return
|
|
504
|
-
}
|
|
552
|
+
const etag = weakEtag(stat)
|
|
553
|
+
if (revalidated(req, res, etag)) return
|
|
505
554
|
readFile(resolved).then((body) => {
|
|
506
555
|
res.writeHead(200, {
|
|
507
556
|
'content-type': mimeFor(resolved),
|