@linxin666/dsh-pet 0.1.20 → 0.2.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/README.i18n.yaml +2 -2
- package/README.md +18 -11
- package/README.zh.md +18 -11
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +394 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +1007 -43
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-DrMX22GL.js} +86 -28
- package/lib/types/affinity.d.ts +4 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +32 -6
- package/lib/types/chatter.d.ts +111 -0
- package/lib/types/chatter.d.ts.map +1 -0
- package/lib/types/chatter.js +756 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +133 -16
- package/lib/types/client/PluginSettingsCard.d.ts +28 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +147 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +1 -0
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/sequences.d.ts +10 -0
- package/lib/types/client/sequences.d.ts.map +1 -0
- package/lib/types/client/sequences.js +20 -0
- package/lib/types/event-projection.d.ts +14 -1
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +38 -17
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +15 -7
- package/lib/types/loopback.d.ts +25 -0
- package/lib/types/loopback.d.ts.map +1 -0
- package/lib/types/loopback.js +68 -0
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +3 -1
- package/lib/types/registry.d.ts +7 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +47 -3
- package/lib/types/remarks.d.ts +2 -0
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +35 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +14 -0
- package/lib/types/service.d.ts +6 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +15 -2
- package/lib/types/state.d.ts +7 -4
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/state.js +20 -20
- package/package.json +9 -9
- package/src/affinity.test.ts +4 -2
- package/src/affinity.ts +37 -6
- package/src/chatter.test.ts +154 -0
- package/src/chatter.ts +787 -0
- package/src/client/PetDockEntry.test.tsx +93 -0
- package/src/client/PetDockEntry.tsx +1 -0
- package/src/client/PetSprite.test.tsx +253 -3
- package/src/client/PetSprite.tsx +192 -37
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/index.test.tsx +95 -0
- package/src/client/index.ts +1 -0
- package/src/client/locales.ts +4 -0
- package/src/client/pet-css.test.ts +54 -0
- package/src/client/pet.module.css +215 -57
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/client/settings-card.module.css +90 -1
- package/src/event-projection.ts +49 -16
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/loopback.ts +63 -0
- package/src/persist.test.ts +18 -1
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +79 -4
- package/src/registry.ts +58 -6
- package/src/remarks.ts +36 -0
- package/src/routes.ts +11 -0
- package/src/service.ts +27 -2
- package/src/state.test.ts +13 -0
- package/src/state.ts +24 -18
|
@@ -290,6 +290,92 @@
|
|
|
290
290
|
border-color: var(--dsw-alias-label-error);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
.selectWrap {
|
|
294
|
+
position: relative;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.selectButton {
|
|
298
|
+
appearance: none;
|
|
299
|
+
width: 100%;
|
|
300
|
+
text-align: left;
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: space-between;
|
|
304
|
+
gap: 8px;
|
|
305
|
+
display: flex;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.selectLabel {
|
|
309
|
+
min-width: 0;
|
|
310
|
+
overflow: hidden;
|
|
311
|
+
text-overflow: ellipsis;
|
|
312
|
+
white-space: nowrap;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.selectChevron {
|
|
316
|
+
color: var(--dsw-alias-label-tertiary);
|
|
317
|
+
flex: none;
|
|
318
|
+
transition: transform 0.16s;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.selectChevronOpen {
|
|
322
|
+
transform: rotate(180deg);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.selectPopup {
|
|
326
|
+
position: absolute;
|
|
327
|
+
top: calc(100% + 4px);
|
|
328
|
+
left: 0;
|
|
329
|
+
right: 0;
|
|
330
|
+
z-index: 40;
|
|
331
|
+
flex-direction: column;
|
|
332
|
+
max-height: 240px;
|
|
333
|
+
overflow-y: auto;
|
|
334
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
335
|
+
border-radius: 8px;
|
|
336
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
337
|
+
box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-2);
|
|
338
|
+
padding: 4px;
|
|
339
|
+
opacity: 0;
|
|
340
|
+
transform: translateY(-4px);
|
|
341
|
+
transition: opacity 0.1s ease, transform 0.1s ease;
|
|
342
|
+
display: flex;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.selectPopupOpen {
|
|
346
|
+
opacity: 1;
|
|
347
|
+
transform: none;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.selectPopupClose {
|
|
351
|
+
opacity: 0;
|
|
352
|
+
transform: translateY(-4px);
|
|
353
|
+
pointer-events: none;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.selectOption {
|
|
357
|
+
border-radius: 6px;
|
|
358
|
+
padding: 6px 10px;
|
|
359
|
+
font-size: 13px;
|
|
360
|
+
line-height: 1.5;
|
|
361
|
+
color: var(--dsw-alias-label-primary);
|
|
362
|
+
cursor: pointer;
|
|
363
|
+
white-space: nowrap;
|
|
364
|
+
overflow: hidden;
|
|
365
|
+
text-overflow: ellipsis;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.selectOption:hover,
|
|
369
|
+
.selectOptionActive {
|
|
370
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.selectOptionSelected {
|
|
374
|
+
color: var(--dsw-alias-brand-primary);
|
|
375
|
+
font-weight: 500;
|
|
376
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);
|
|
377
|
+
}
|
|
378
|
+
|
|
293
379
|
.invalid {
|
|
294
380
|
color: var(--dsw-alias-label-error);
|
|
295
381
|
margin: 0;
|
|
@@ -310,7 +396,10 @@
|
|
|
310
396
|
.chevron,
|
|
311
397
|
.chevronOpen,
|
|
312
398
|
.discard,
|
|
313
|
-
.save
|
|
399
|
+
.save,
|
|
400
|
+
.selectChevron,
|
|
401
|
+
.selectChevronOpen,
|
|
402
|
+
.selectPopup {
|
|
314
403
|
transition: none;
|
|
315
404
|
}
|
|
316
405
|
}
|
package/src/event-projection.ts
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
* vocabulary onto the pet's visual phases and carries an optional completed-
|
|
4
4
|
* turn reward for the ledger. Holds no state of its own; callers keep a
|
|
5
5
|
* {@link ProjectionRuntime} per session and feed events in arrival order.
|
|
6
|
+
*
|
|
7
|
+
* Status copy comes from the chatter voice (big rotating pools, per-tool
|
|
8
|
+
* families, real-argument hints), and streamed model output feeds the murmur
|
|
9
|
+
* engine so the pet can whisper its inner voice (碎碎念). The wall clock is
|
|
10
|
+
* injected by the caller, keeping every projection reproducible.
|
|
6
11
|
* @module @linxin666/dsh-pet/event-projection
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
10
15
|
import type { PetStateInput } from './state.ts'
|
|
16
|
+
import { StatusVoice, toolArgHint, WhisperEngine } from './chatter.ts'
|
|
11
17
|
|
|
12
18
|
/** Runtime shape of the optional legacy activity event. */
|
|
13
19
|
export interface ActivityStatusEventLike {
|
|
@@ -21,23 +27,35 @@ export interface ProjectionRuntime {
|
|
|
21
27
|
activeTools: Set<string>
|
|
22
28
|
officialEventsSeen: boolean
|
|
23
29
|
stepHadFailure: boolean
|
|
30
|
+
/** Round-robin status copy voice (scene-stable, cadence-rotated). */
|
|
31
|
+
voice: StatusVoice
|
|
32
|
+
/** Inner-whisper engine fed by the model's own streamed output. */
|
|
33
|
+
whispers: WhisperEngine
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
/** One official event projection, optionally carrying a completed turn reward. */
|
|
27
37
|
export interface PetActivityTransition {
|
|
28
38
|
input: PetStateInput
|
|
29
39
|
completedTurn?: number
|
|
40
|
+
/** A fresh inner whisper woken by this event's model output, when any. */
|
|
41
|
+
whisper?: string
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
/** Fresh projection runtime for a newly seen session. */
|
|
33
45
|
export function emptyProjectionRuntime(): ProjectionRuntime {
|
|
34
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
activeTools: new Set(),
|
|
48
|
+
officialEventsSeen: false,
|
|
49
|
+
stepHadFailure: false,
|
|
50
|
+
voice: new StatusVoice(),
|
|
51
|
+
whispers: new WhisperEngine(),
|
|
52
|
+
}
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
/** Keep tool names readable inside the compact status bubble. */
|
|
38
56
|
function displayToolName(name: string): string {
|
|
39
57
|
const compact = name.replace(/\s+/g, ' ').trim() || '工具'
|
|
40
|
-
return compact.length <= 24 ? compact :
|
|
58
|
+
return compact.length <= 24 ? compact : compact.slice(0, 21) + '...'
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
/** Whether a legacy phase is part of the pet's supported vocabulary. */
|
|
@@ -48,38 +66,53 @@ export function isActivityPhase(phase: string): phase is PetStateInput['phase']
|
|
|
48
66
|
/**
|
|
49
67
|
* Project the durable DSH session vocabulary into the pet's visual phases.
|
|
50
68
|
* Unknown and log-only events do not disturb the last meaningful activity.
|
|
69
|
+
* @param nowMs - injected wall clock for copy rotation and whisper pacing.
|
|
51
70
|
*/
|
|
52
71
|
export function projectOfficialEvent(
|
|
53
72
|
event: SessionEvent,
|
|
54
73
|
runtime: ProjectionRuntime,
|
|
74
|
+
nowMs: number = Date.now(),
|
|
55
75
|
): PetActivityTransition | undefined {
|
|
56
76
|
switch (event.type) {
|
|
57
77
|
case 'turn/start':
|
|
58
78
|
runtime.activeTools.clear()
|
|
59
79
|
runtime.stepHadFailure = false
|
|
60
|
-
return { input: { phase: 'waiting', line: '
|
|
80
|
+
return { input: { phase: 'waiting', line: runtime.voice.scene('prepare', nowMs) } }
|
|
61
81
|
case 'step/start':
|
|
62
82
|
runtime.activeTools.clear()
|
|
63
83
|
runtime.stepHadFailure = false
|
|
64
|
-
return { input: { phase: 'waiting', line: '
|
|
84
|
+
return { input: { phase: 'waiting', line: runtime.voice.scene('waiting', nowMs) } }
|
|
65
85
|
case 'assistant/chunk': {
|
|
66
86
|
const { chunk } = event.data
|
|
67
87
|
if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
|
|
68
|
-
|
|
88
|
+
const whisper = runtime.whispers.feed(chunk.text, nowMs)
|
|
89
|
+
return {
|
|
90
|
+
input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
|
|
91
|
+
...(whisper === undefined ? {} : { whisper }),
|
|
92
|
+
}
|
|
69
93
|
}
|
|
70
94
|
if (chunk.type === 'text-delta' && chunk.text.length > 0) {
|
|
71
|
-
|
|
95
|
+
const whisper = runtime.whispers.feed(chunk.text, nowMs)
|
|
96
|
+
return {
|
|
97
|
+
input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
|
|
98
|
+
...(whisper === undefined ? {} : { whisper }),
|
|
99
|
+
}
|
|
72
100
|
}
|
|
73
101
|
return undefined
|
|
74
102
|
}
|
|
75
103
|
case 'assistant/message':
|
|
76
|
-
return { input: { phase: 'review', line: '
|
|
104
|
+
return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } }
|
|
77
105
|
case 'tool/call':
|
|
78
106
|
runtime.activeTools.add(String(event.data.callId))
|
|
79
107
|
return {
|
|
80
108
|
input: {
|
|
81
109
|
phase: 'tool',
|
|
82
|
-
line:
|
|
110
|
+
line: runtime.voice.tool(
|
|
111
|
+
event.data.name,
|
|
112
|
+
displayToolName(event.data.name),
|
|
113
|
+
toolArgHint(event.data.name, event.data.arguments),
|
|
114
|
+
nowMs,
|
|
115
|
+
),
|
|
83
116
|
},
|
|
84
117
|
}
|
|
85
118
|
case 'tool/result': {
|
|
@@ -90,30 +123,30 @@ export function projectOfficialEvent(
|
|
|
90
123
|
return {
|
|
91
124
|
input: {
|
|
92
125
|
phase: 'tool',
|
|
93
|
-
line:
|
|
126
|
+
line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs),
|
|
94
127
|
},
|
|
95
128
|
}
|
|
96
129
|
}
|
|
97
130
|
return runtime.stepHadFailure
|
|
98
|
-
? { input: { phase: 'failed', line: '
|
|
99
|
-
: { input: { phase: 'thinking', line: '
|
|
131
|
+
? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) } }
|
|
132
|
+
: { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) } }
|
|
100
133
|
}
|
|
101
134
|
case 'turn/end': {
|
|
102
135
|
runtime.activeTools.clear()
|
|
103
136
|
switch (event.data.reason.kind) {
|
|
104
137
|
case 'completed':
|
|
105
138
|
return {
|
|
106
|
-
input: { phase: 'done', line: '
|
|
139
|
+
input: { phase: 'done', line: runtime.voice.scene('done', nowMs) },
|
|
107
140
|
completedTurn: event.data.turn,
|
|
108
141
|
}
|
|
109
142
|
case 'error':
|
|
110
|
-
return { input: { phase: 'failed', line: '
|
|
143
|
+
return { input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) } }
|
|
111
144
|
case 'max-tokens':
|
|
112
|
-
return { input: { phase: 'failed', line: '
|
|
145
|
+
return { input: { phase: 'failed', line: runtime.voice.scene('maxTokens', nowMs) } }
|
|
113
146
|
case 'interrupted':
|
|
114
|
-
return { input: { phase: 'failed', line: '
|
|
147
|
+
return { input: { phase: 'failed', line: runtime.voice.scene('interrupted', nowMs) } }
|
|
115
148
|
case 'blocked':
|
|
116
|
-
return { input: { phase: 'waiting', line: '
|
|
149
|
+
return { input: { phase: 'waiting', line: runtime.voice.scene('blocked', nowMs) } }
|
|
117
150
|
case 'aborted':
|
|
118
151
|
// A stopped session settles to idle without a bubble: the pet
|
|
119
152
|
// visibly calms down and the session drops out of the bubble stack.
|
package/src/ledger.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { defaultTreatConfig } from './treats.ts'
|
|
|
3
3
|
import { defaultAffinityConfig } from './affinity.ts'
|
|
4
4
|
import { emptyPersist } from './persist.ts'
|
|
5
5
|
import { PetLedger } from './ledger.ts'
|
|
6
|
+
import { BUILTIN_REMARKS } from './remarks.ts'
|
|
6
7
|
|
|
7
8
|
describe('PetLedger', () => {
|
|
8
9
|
it('settles the economy on completed turns (work treat per 30 turns)', () => {
|
|
@@ -66,13 +67,53 @@ describe('PetLedger', () => {
|
|
|
66
67
|
expect(ledger.interact('feed', n + 2).reaction).toBe('没有小鱼干了,多陪我工作一会儿吧~')
|
|
67
68
|
})
|
|
68
69
|
|
|
70
|
+
it('selects success remarks from persisted lifetime interaction counts', () => {
|
|
71
|
+
const n = 1_000_000
|
|
72
|
+
const persist = emptyPersist()
|
|
73
|
+
persist.affinity.pets = 1
|
|
74
|
+
persist.affinity.feeds = 1
|
|
75
|
+
persist.treats.treats = 1
|
|
76
|
+
persist.treats.lastTreatGrantAt = n
|
|
77
|
+
const ledger = new PetLedger(persist, {
|
|
78
|
+
remarks: {
|
|
79
|
+
pet: ['摸头一', '摸头二'],
|
|
80
|
+
feed: ['喂食一', '喂食二'],
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
expect(ledger.interact('pet', n).reaction).toBe('摸头二')
|
|
84
|
+
expect(ledger.interact('feed', n).reaction).toBe('喂食二')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('rotates cooldown remarks by persisted rejection counts', () => {
|
|
88
|
+
const n = 1_000_000
|
|
89
|
+
const remarks = {
|
|
90
|
+
petCooldown: ['摸头冷却一', '摸头冷却二', '摸头冷却三'],
|
|
91
|
+
feedCooldown: ['喂食冷却一', '喂食冷却二', '喂食冷却三'],
|
|
92
|
+
}
|
|
93
|
+
const persist = emptyPersist()
|
|
94
|
+
persist.affinity.lastPetAt = n
|
|
95
|
+
persist.affinity.lastFeedAt = n
|
|
96
|
+
const ledger = new PetLedger(persist, { remarks })
|
|
97
|
+
expect(ledger.interact('pet', n + 1).reaction).toBe('摸头冷却一')
|
|
98
|
+
expect(ledger.interact('pet', n + 2).reaction).toBe('摸头冷却二')
|
|
99
|
+
expect(ledger.interact('feed', n + 1).reaction).toBe('喂食冷却一')
|
|
100
|
+
expect(ledger.interact('feed', n + 2).reaction).toBe('喂食冷却二')
|
|
101
|
+
expect(ledger.snapshot.affinity.petRejects).toBe(2)
|
|
102
|
+
expect(ledger.snapshot.affinity.feedRejects).toBe(2)
|
|
103
|
+
|
|
104
|
+
const restored = new PetLedger(ledger.snapshot, { remarks })
|
|
105
|
+
expect(restored.interact('pet', n + 3).reaction).toBe('摸头冷却三')
|
|
106
|
+
expect(restored.interact('feed', n + 3).reaction).toBe('喂食冷却三')
|
|
107
|
+
})
|
|
108
|
+
|
|
69
109
|
it('swaps remark pools when the selected pet changes', () => {
|
|
70
110
|
const ledger = new PetLedger(emptyPersist(), { remarks: { pet: ['旧宠物台词'] } })
|
|
71
111
|
expect(ledger.interact('pet', 1_000_000).reaction).toBe('旧宠物台词')
|
|
72
112
|
ledger.setRemarks({ pet: ['新宠物台词'] })
|
|
73
113
|
expect(ledger.interact('pet', 1_011_000).reaction).toBe('新宠物台词')
|
|
74
114
|
ledger.setRemarks(undefined)
|
|
75
|
-
|
|
115
|
+
// Changing pools does not reset the persisted lifetime interaction count.
|
|
116
|
+
expect(ledger.interact('pet', 1_022_000).reaction).toBe(BUILTIN_REMARKS.pet[2])
|
|
76
117
|
})
|
|
77
118
|
|
|
78
119
|
it('refuses a feed on an empty stock without burning anything', () => {
|
package/src/ledger.ts
CHANGED
|
@@ -182,12 +182,19 @@ export class PetLedger {
|
|
|
182
182
|
*/
|
|
183
183
|
interact(kind: PetInteraction, nowMs: number): LedgerInteractionResult {
|
|
184
184
|
if (kind === 'feed') this.settleTreats(nowMs)
|
|
185
|
-
const
|
|
185
|
+
const before = this.current.affinity
|
|
186
|
+
const outcome = applyInteraction(before, kind, nowMs, this.affinityConfig)
|
|
186
187
|
// Reactions come from the picker pools (custom per-pet slots override
|
|
187
188
|
// the built-in library per slot); outcome.reaction stays the fallback
|
|
188
189
|
// copy for direct applyInteraction callers.
|
|
189
190
|
if (kind === 'feed' && !outcome.accepted) {
|
|
190
|
-
|
|
191
|
+
this.current = { ...this.current, affinity: outcome.affinity }
|
|
192
|
+
this.dirty = true
|
|
193
|
+
return {
|
|
194
|
+
reaction: this.picker.pickAt('feedCooldown', before.feedRejects),
|
|
195
|
+
delta: 0,
|
|
196
|
+
affinity: this.affinityView(nowMs),
|
|
197
|
+
}
|
|
191
198
|
}
|
|
192
199
|
if (kind === 'feed') {
|
|
193
200
|
const consume = consumeTreat(this.current.treats)
|
|
@@ -201,12 +208,13 @@ export class PetLedger {
|
|
|
201
208
|
this.current = { ...this.current, treats: consume.ledger }
|
|
202
209
|
this.dirty = true
|
|
203
210
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
211
|
+
this.current = { ...this.current, affinity: outcome.affinity }
|
|
212
|
+
this.dirty = true
|
|
213
|
+
const count = kind === 'pet'
|
|
214
|
+
? outcome.accepted ? before.pets : before.petRejects
|
|
215
|
+
: before.feeds
|
|
208
216
|
return {
|
|
209
|
-
reaction: this.picker.
|
|
217
|
+
reaction: this.picker.pickAt(outcome.accepted ? kind : 'petCooldown', count),
|
|
210
218
|
delta: outcome.delta,
|
|
211
219
|
affinity: this.affinityView(nowMs),
|
|
212
220
|
}
|
package/src/loopback.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
4
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
5
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
6
|
+
* re-run the sync instead of editing a copy.
|
|
7
|
+
*
|
|
8
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
9
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
10
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
11
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
|
|
16
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
17
|
+
export function isIPv4Loopback(v4: string): boolean {
|
|
18
|
+
const parts = v4.split('.')
|
|
19
|
+
return parts.length === 4
|
|
20
|
+
&& parts[0] === '127'
|
|
21
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
25
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
26
|
+
if (address === undefined) return false
|
|
27
|
+
const normalized = address.toLowerCase()
|
|
28
|
+
if (normalized === '::1') return true
|
|
29
|
+
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
|
|
30
|
+
return isIPv4Loopback(normalized)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
34
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
35
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
36
|
+
return isIPv4Loopback(hostname)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
41
|
+
* header, plus browser same-origin markers. The socket address is
|
|
42
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
43
|
+
*/
|
|
44
|
+
export function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
45
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false
|
|
46
|
+
const host = request.headers.host
|
|
47
|
+
if (typeof host !== 'string') return false
|
|
48
|
+
let hostUrl: URL
|
|
49
|
+
try {
|
|
50
|
+
hostUrl = new URL('http://' + host)
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
55
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
56
|
+
const origin = request.headers.origin
|
|
57
|
+
if (origin === undefined) return true
|
|
58
|
+
try {
|
|
59
|
+
return new URL(origin).host === hostUrl.host
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/persist.test.ts
CHANGED
|
@@ -20,6 +20,10 @@ function tempDir(): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
describe('loadPetPersist', () => {
|
|
23
|
+
it('reserves enough default space for the hover panel below the pet', () => {
|
|
24
|
+
expect(defaultDisplayConfig.bottom).toBeGreaterThanOrEqual(100)
|
|
25
|
+
})
|
|
26
|
+
|
|
23
27
|
it('falls back to defaults when the file is missing', () => {
|
|
24
28
|
const dir = tempDir()
|
|
25
29
|
try {
|
|
@@ -67,6 +71,8 @@ describe('loadPetPersist', () => {
|
|
|
67
71
|
expect(loaded.petId).toBe(DEFAULT_PET_ID)
|
|
68
72
|
expect(loaded.names).toEqual({ [DEFAULT_PET_ID]: '泡泡' })
|
|
69
73
|
expect(loaded.affinity.points).toBe(5)
|
|
74
|
+
expect(loaded.affinity.petRejects).toBe(0)
|
|
75
|
+
expect(loaded.affinity.feedRejects).toBe(0)
|
|
70
76
|
expect(loaded.display).toEqual(defaultDisplayConfig)
|
|
71
77
|
} finally {
|
|
72
78
|
rmSync(dir, { recursive: true, force: true })
|
|
@@ -108,7 +114,16 @@ describe('loadPetPersist', () => {
|
|
|
108
114
|
writeFileSync(join(dir, 'pet.json'), JSON.stringify({
|
|
109
115
|
name: ' ',
|
|
110
116
|
names: { bad: ' ' },
|
|
111
|
-
affinity: {
|
|
117
|
+
affinity: {
|
|
118
|
+
points: AFFINITY_MAX + 5000,
|
|
119
|
+
lastPetAt: -5,
|
|
120
|
+
lastFeedAt: 'x',
|
|
121
|
+
pets: -1,
|
|
122
|
+
feeds: 1.5,
|
|
123
|
+
turns: 0,
|
|
124
|
+
petRejects: 4,
|
|
125
|
+
feedRejects: -2,
|
|
126
|
+
},
|
|
112
127
|
treats: { treats: 150, lastTreatGrantAt: -1, turnsAtLastTreatGrant: 0 },
|
|
113
128
|
display: { visible: 'yes', size: -10, right: 1e12, bottom: 20 },
|
|
114
129
|
}), 'utf8')
|
|
@@ -120,6 +135,8 @@ describe('loadPetPersist', () => {
|
|
|
120
135
|
expect(loaded.affinity.lastFeedAt).toBe(0)
|
|
121
136
|
expect(loaded.affinity.pets).toBe(0)
|
|
122
137
|
expect(loaded.affinity.feeds).toBe(1.5) // finite numbers pass through
|
|
138
|
+
expect(loaded.affinity.petRejects).toBe(4)
|
|
139
|
+
expect(loaded.affinity.feedRejects).toBe(0)
|
|
123
140
|
expect(loaded.treats.treats).toBe(defaultTreatConfig.maxTreats)
|
|
124
141
|
expect(loaded.treats.lastTreatGrantAt).toBe(0)
|
|
125
142
|
expect(loaded.display.visible).toBe(defaultDisplayConfig.visible)
|
package/src/persist.ts
CHANGED
|
@@ -27,7 +27,7 @@ export const defaultDisplayConfig: PetDisplayConfig = {
|
|
|
27
27
|
visible: true,
|
|
28
28
|
size: 160,
|
|
29
29
|
right: 24,
|
|
30
|
-
bottom:
|
|
30
|
+
bottom: 120,
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** Display value bounds (shared by load-time validation and setConfig). */
|
|
@@ -117,6 +117,8 @@ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
|
|
|
117
117
|
lastFeedAt: clamp(finiteNum(rawAffinity.lastFeedAt, 0), Number.MAX_SAFE_INTEGER),
|
|
118
118
|
pets: clamp(finiteNum(rawAffinity.pets, 0), Number.MAX_SAFE_INTEGER),
|
|
119
119
|
feeds: clamp(finiteNum(rawAffinity.feeds, 0), Number.MAX_SAFE_INTEGER),
|
|
120
|
+
petRejects: clamp(finiteNum(rawAffinity.petRejects, 0), Number.MAX_SAFE_INTEGER),
|
|
121
|
+
feedRejects: clamp(finiteNum(rawAffinity.feedRejects, 0), Number.MAX_SAFE_INTEGER),
|
|
120
122
|
turns: clamp(finiteNum(rawAffinity.turns, 0), Number.MAX_SAFE_INTEGER),
|
|
121
123
|
}
|
|
122
124
|
const rawTreats = (parsed.treats ?? {}) as Partial<TreatLedger>
|
package/src/registry.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
DEFAULT_PET_CELL,
|
|
8
8
|
codexPetsDir,
|
|
9
9
|
loadPetRegistry,
|
|
10
|
+
petAtlasFile,
|
|
10
11
|
resolvePetManifest,
|
|
11
12
|
} from './registry.ts'
|
|
12
13
|
|
|
@@ -25,6 +26,7 @@ describe('resolvePetManifest', () => {
|
|
|
25
26
|
expect(entry!.id).toBe('otter')
|
|
26
27
|
expect(entry!.cell).toEqual(DEFAULT_PET_CELL)
|
|
27
28
|
expect(entry!.columns).toBe(8)
|
|
29
|
+
expect(entry!.atlasRows).toBe(9)
|
|
28
30
|
expect(entry!.rows).toEqual([...DEFAULT_FRAME_COUNTS])
|
|
29
31
|
expect(entry!.atlasUrl).toBe('/pet/otter/spritesheet.webp')
|
|
30
32
|
expect(entry!.manifestUrl).toBe('/pet/otter/pet.json')
|
|
@@ -37,6 +39,21 @@ describe('resolvePetManifest', () => {
|
|
|
37
39
|
expect(entry!.tracks.running.loop).toBe(true)
|
|
38
40
|
})
|
|
39
41
|
|
|
42
|
+
it('marks v2 (spriteVersionNumber 2) atlases with 11 rows', () => {
|
|
43
|
+
const entry = resolvePetManifest({
|
|
44
|
+
id: 'firefly',
|
|
45
|
+
displayName: 'Firefly',
|
|
46
|
+
spritesheetPath: 'spritesheet.webp',
|
|
47
|
+
spriteVersionNumber: 2,
|
|
48
|
+
}, join(tmpdir(), 'firefly'))
|
|
49
|
+
expect(entry).toBeDefined()
|
|
50
|
+
// v2 atlases carry 11 rows: the 9 animation rows plus 2 look rows.
|
|
51
|
+
expect(entry!.atlasRows).toBe(11)
|
|
52
|
+
// The 9 animation rows still resolve the hatch-pet contract.
|
|
53
|
+
expect(entry!.rows).toEqual([...DEFAULT_FRAME_COUNTS])
|
|
54
|
+
expect(entry!.tracks.idle.frames.length).toBe(entry!.rows[0])
|
|
55
|
+
})
|
|
56
|
+
|
|
40
57
|
it('keeps the legacy whale-girl frame counts and its own durations', () => {
|
|
41
58
|
const entry = resolvePetManifest({
|
|
42
59
|
id: 'whale-girl',
|
|
@@ -51,6 +68,36 @@ describe('resolvePetManifest', () => {
|
|
|
51
68
|
expect(entry!.tracks['running-right'].durations.length).toBe(8)
|
|
52
69
|
})
|
|
53
70
|
|
|
71
|
+
it('normalizes valid per-scene animation sequences', () => {
|
|
72
|
+
const entry = resolvePetManifest({
|
|
73
|
+
id: 'whale-girl',
|
|
74
|
+
displayName: '鲸鱼娘',
|
|
75
|
+
spritesheetPath: 'spritesheet.webp',
|
|
76
|
+
sequences: {
|
|
77
|
+
thinking: ['running', 'running-right', 'running', 'running-left', 'waiting'],
|
|
78
|
+
},
|
|
79
|
+
}, join(tmpdir(), 'whale'))
|
|
80
|
+
expect(entry!.sequences).toEqual({
|
|
81
|
+
thinking: ['running', 'running-right', 'running', 'running-left', 'waiting'],
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('drops invalid or undersized per-scene animation sequences', () => {
|
|
86
|
+
const warnings: string[] = []
|
|
87
|
+
const entry = resolvePetManifest({
|
|
88
|
+
id: 'whale-girl',
|
|
89
|
+
displayName: '鲸鱼娘',
|
|
90
|
+
spritesheetPath: 'spritesheet.webp',
|
|
91
|
+
sequences: {
|
|
92
|
+
waiting: ['waiting', 'idle'],
|
|
93
|
+
thinking: ['running', 'bogus', 'running', 'running-left', 'waiting'],
|
|
94
|
+
},
|
|
95
|
+
}, join(tmpdir(), 'whale'), { warnings })
|
|
96
|
+
expect(entry!.sequences).toBeUndefined()
|
|
97
|
+
expect(warnings).toContain('manifest whale-girl: sequence waiting must contain at least 5 animations')
|
|
98
|
+
expect(warnings).toContain('manifest whale-girl: sequence thinking contains unknown animation "bogus"')
|
|
99
|
+
})
|
|
100
|
+
|
|
54
101
|
it('cycles short override durations up to the row frame count', () => {
|
|
55
102
|
const entry = resolvePetManifest({
|
|
56
103
|
id: 'fox',
|
|
@@ -134,6 +181,33 @@ describe('loadPetRegistry', () => {
|
|
|
134
181
|
}
|
|
135
182
|
})
|
|
136
183
|
|
|
184
|
+
it('resolves a composed extra atlas to the real file (no doubled directory)', () => {
|
|
185
|
+
const root = tempDir()
|
|
186
|
+
try {
|
|
187
|
+
// The atlas sits at <root>/pets/otter/spritesheet.webp.
|
|
188
|
+
mkdirSync(join(root, 'pets', 'otter'), { recursive: true })
|
|
189
|
+
writeFileSync(join(root, 'pets', 'otter', 'spritesheet.webp'), 'png', 'utf8')
|
|
190
|
+
|
|
191
|
+
const registry = loadPetRegistry({
|
|
192
|
+
packageRoot: root,
|
|
193
|
+
petsDir: '',
|
|
194
|
+
extra: [{ id: 'otter', displayName: '水獭', spritesheetPath: 'pets/otter/spritesheet.webp' }],
|
|
195
|
+
})
|
|
196
|
+
const entry = registry.byId('otter')
|
|
197
|
+
expect(entry).toBeDefined()
|
|
198
|
+
// dir is the spritesheet's parent; the stored path is its basename, so
|
|
199
|
+
// joining them resolves to the real file instead of applying the
|
|
200
|
+
// directory twice.
|
|
201
|
+
expect(entry!.dir).toBe(join(root, 'pets', 'otter'))
|
|
202
|
+
expect(entry!.spritesheetPath).toBe('spritesheet.webp')
|
|
203
|
+
const atlas = petAtlasFile(entry!)
|
|
204
|
+
expect(atlas).toBe(join(root, 'pets', 'otter', 'spritesheet.webp'))
|
|
205
|
+
expect(existsSync(atlas)).toBe(true)
|
|
206
|
+
} finally {
|
|
207
|
+
rmSync(root, { recursive: true, force: true })
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
137
211
|
it('defaults to the built-in pet even when custom pets sort first', () => {
|
|
138
212
|
const root = tempDir()
|
|
139
213
|
try {
|
|
@@ -152,8 +226,9 @@ describe('loadPetRegistry', () => {
|
|
|
152
226
|
|
|
153
227
|
describe('codexPetsDir', () => {
|
|
154
228
|
it('honors CODEX_HOME and expands a leading tilde', () => {
|
|
155
|
-
|
|
156
|
-
expect(codexPetsDir({ CODEX_HOME: '
|
|
157
|
-
expect(codexPetsDir({}, '/home/user')).toBe('/home/user
|
|
229
|
+
// Expected values join through the platform separator (POSIX on CI).
|
|
230
|
+
expect(codexPetsDir({ CODEX_HOME: '/opt/codex' }, '/home/user')).toBe(join('/opt/codex', 'pets'))
|
|
231
|
+
expect(codexPetsDir({ CODEX_HOME: '~/codex' }, '/home/user')).toBe(join('/home/user', 'codex', 'pets'))
|
|
232
|
+
expect(codexPetsDir({}, '/home/user')).toBe(join('/home/user', '.codex', 'pets'))
|
|
158
233
|
})
|
|
159
234
|
})
|