@linxin666/dsh-pet 0.2.0 → 0.2.2

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.
Files changed (59) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +15 -5
  3. package/README.zh.md +15 -5
  4. package/assets/whale/pet.json +1 -1
  5. package/assets/whale-refined/pet.json +7 -0
  6. package/assets/whale-refined/spritesheet.webp +0 -0
  7. package/lib/client.js +257 -53
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +235 -83
  10. package/lib/invariant.js +1 -1
  11. package/lib/{state-Ch3f4luZ.js → state-DrMX22GL.js} +35 -5
  12. package/lib/types/chatter.d.ts.map +1 -1
  13. package/lib/types/chatter.js +167 -77
  14. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  15. package/lib/types/client/PetDockEntry.js +1 -1
  16. package/lib/types/client/PetSprite.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.js +54 -11
  18. package/lib/types/client/PluginSettingsCard.d.ts +22 -0
  19. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PluginSettingsCard.js +142 -3
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +1 -0
  23. package/lib/types/client/locales.d.ts +4 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +4 -0
  26. package/lib/types/client/spritesheet.d.ts +1 -3
  27. package/lib/types/client/spritesheet.d.ts.map +1 -1
  28. package/lib/types/client/spritesheet.js +1 -5
  29. package/lib/types/loopback.d.ts +25 -0
  30. package/lib/types/loopback.d.ts.map +1 -0
  31. package/lib/types/loopback.js +68 -0
  32. package/lib/types/registry.d.ts +0 -2
  33. package/lib/types/registry.d.ts.map +1 -1
  34. package/lib/types/registry.js +11 -7
  35. package/lib/types/remarks.d.ts.map +1 -1
  36. package/lib/types/remarks.js +30 -0
  37. package/lib/types/routes.d.ts.map +1 -1
  38. package/lib/types/routes.js +14 -0
  39. package/lib/types/service.d.ts.map +1 -1
  40. package/package.json +1 -2
  41. package/src/chatter.ts +167 -77
  42. package/src/client/PetDockEntry.test.tsx +93 -0
  43. package/src/client/PetDockEntry.tsx +1 -0
  44. package/src/client/PetSprite.test.tsx +72 -11
  45. package/src/client/PetSprite.tsx +98 -30
  46. package/src/client/PluginSettingsCard.tsx +219 -18
  47. package/src/client/index.test.tsx +95 -0
  48. package/src/client/index.ts +1 -0
  49. package/src/client/locales.ts +4 -0
  50. package/src/client/pet-css.test.ts +15 -3
  51. package/src/client/pet.module.css +101 -71
  52. package/src/client/settings-card.module.css +90 -1
  53. package/src/client/spritesheet.ts +1 -6
  54. package/src/loopback.ts +63 -0
  55. package/src/registry.test.ts +49 -1
  56. package/src/registry.ts +11 -7
  57. package/src/remarks.ts +30 -0
  58. package/src/routes.ts +11 -0
  59. package/src/service.ts +0 -1
@@ -23,87 +23,115 @@
23
23
  border-radius: 999px;
24
24
  font-size: 12px;
25
25
  line-height: 1.4;
26
- color: #fff;
26
+ color: #f4f7ff;
27
27
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
28
28
  animation: pet-bubble-pop 2.6s ease-out forwards;
29
29
  pointer-events: none;
30
30
  }
31
31
 
32
+ /* 互动反馈气泡:同属蓝色家族 —— 抚摸是 DeepSeek 品牌蓝,
33
+ 喂食是深海晴空蓝,与状态气泡的蓝黑玻璃保持和谐。 */
32
34
  .bubblePet {
33
- background: rgba(244, 114, 182, 0.92);
35
+ background: rgba(77, 107, 254, 0.95);
34
36
  }
35
37
 
36
38
  .bubbleFeed {
37
- background: rgba(56, 189, 248, 0.92);
39
+ background: rgba(3, 105, 161, 0.95);
38
40
  }
39
41
 
40
- /* 状态气泡:深色渐变玻璃 + 青色描边辉光 + 进场浮现,
41
- 让工作汇报从“标签”升级为“会呼吸的状态条”。 */
42
+ /* 状态气泡:虎鲸蓝黑渐变玻璃 + DeepSeek 蓝描边微光 + 进场浮现。
43
+ 碎碎念不再单独渲染第二只气泡,也不换色调,而是以 .bubbleWhisper
44
+ 接管这同一只气泡的文案,气泡栈内所有气泡共用同一片玻璃。 */
42
45
  .bubbleStatus {
43
46
  max-width: min(280px, calc(100vw - 24px));
44
47
  overflow: hidden;
45
48
  text-overflow: ellipsis;
46
- background: linear-gradient(160deg, rgba(30, 41, 59, 0.88), rgba(15, 23, 42, 0.92));
47
- border: 1px solid rgba(125, 211, 252, 0.55);
49
+ padding: 5px 12px;
50
+ background: linear-gradient(160deg, rgba(19, 28, 54, 0.9), rgba(7, 11, 26, 0.94));
51
+ border: 1px solid rgba(126, 152, 255, 0.45);
48
52
  box-shadow:
49
- 0 4px 14px rgba(2, 6, 23, 0.4),
50
- 0 0 0 1px rgba(125, 211, 252, 0.12),
51
- 0 0 18px rgba(56, 189, 248, 0.18);
53
+ 0 4px 12px rgba(2, 6, 23, 0.35),
54
+ inset 0 1px 0 rgba(226, 232, 255, 0.1),
55
+ 0 0 12px rgba(77, 107, 254, 0.18);
52
56
  backdrop-filter: blur(8px);
53
57
  letter-spacing: 0.02em;
54
58
  animation: pet-bubble-in 240ms ease-out;
55
59
  }
56
60
 
57
- /* 碎碎念气泡:宠物的内心悄悄话。紫粉渐变玻璃、虚线描边与斜体,
58
- 配合「」引号,与青色的工作状态气泡一眼区分;不可点击、自动消散。 */
61
+ /* 碎碎念(与 .bubbleStatus 叠加使用):同一只气泡、同一片蓝黑玻璃,
62
+ 仅以「」引号、字距与入场动画表现内心独白 —— 弃用旧的深紫粉色调,
63
+ 多会话气泡栈里状态气泡与碎碎念气泡颜色保持统一,不再出现色差。
64
+ 规则顺序在 .bubbleStatus 之后,动画与字距声明方可胜出。 */
59
65
  .bubbleWhisper {
60
- position: relative;
61
- max-width: min(260px, calc(100vw - 24px));
62
- padding: 4px 12px;
63
- border-radius: 999px;
64
- font-size: 11.5px;
65
- font-style: italic;
66
- line-height: 1.45;
67
66
  letter-spacing: 0.03em;
68
- white-space: nowrap;
69
- overflow: hidden;
70
- text-overflow: ellipsis;
71
- color: #f5d0fe;
72
- background: linear-gradient(150deg, rgba(88, 28, 135, 0.52), rgba(157, 23, 77, 0.44));
73
- border: 1px dashed rgba(240, 171, 252, 0.55);
74
- box-shadow:
75
- 0 3px 12px rgba(46, 16, 101, 0.4),
76
- 0 0 14px rgba(217, 70, 239, 0.16);
77
- backdrop-filter: blur(8px);
78
- pointer-events: none;
79
67
  animation: pet-whisper-in 320ms cubic-bezier(0.22, 1, 0.36, 1);
80
68
  }
81
69
 
82
70
  .bubbleWhisper::before {
83
71
  content: '「';
84
72
  margin-right: 2px;
85
- opacity: 0.75;
73
+ opacity: 0.7;
86
74
  }
87
75
 
88
76
  .bubbleWhisper::after {
89
77
  content: '」';
90
78
  margin-left: 2px;
91
- opacity: 0.75;
79
+ opacity: 0.7;
92
80
  }
93
81
 
94
- /* 多会话气泡栈:并行会话各自一个气泡,自精灵头顶向上堆叠,
95
- 最近更新的会话在最上。栈内气泡改流入列布局,不再绝对定位
96
- 互相覆盖。气泡可点击:点击跳转到对应会话。 */
82
+ /* 多会话气泡栈:默认收起为「主会话一只气泡 + 右上角 +N 角标」,
83
+ 悬停或点按角标才展开整栈。反向列布局让 DOM 首位的主气泡钉在栈底
84
+ (紧贴精灵),展开时其余气泡从它上方向上生长,主气泡与角标原地
85
+ 不动,悬停目标不会抖动。气泡可点击:点击跳转到对应会话。 */
97
86
  .bubbleStack {
98
87
  position: absolute;
99
88
  bottom: 100%;
100
89
  display: flex;
101
- flex-direction: column;
90
+ flex-direction: column-reverse;
102
91
  align-items: center;
103
92
  gap: 4px;
104
93
  pointer-events: auto;
105
94
  }
106
95
 
96
+ /* 主气泡的定位锚:+N 角标相对它定位,展开整栈时随之固定不动。 */
97
+ .bubbleAnchor {
98
+ position: relative;
99
+ display: inline-flex;
100
+ }
101
+
102
+ /* +N 角标:收起态提示其余在忙会话数,点击/轻触展开或收起整栈。 */
103
+ .bubbleMore {
104
+ position: absolute;
105
+ top: -9px;
106
+ right: -10px;
107
+ z-index: 1;
108
+ min-width: 18px;
109
+ height: 18px;
110
+ padding: 0 5px;
111
+ border: 1px solid rgba(126, 152, 255, 0.55);
112
+ border-radius: 999px;
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ font-size: 10px;
117
+ font-weight: 600;
118
+ line-height: 1;
119
+ color: #eef2ff;
120
+ background: linear-gradient(160deg, rgba(74, 104, 245, 0.95), rgba(47, 68, 184, 0.95));
121
+ box-shadow: 0 2px 6px rgba(2, 6, 23, 0.4);
122
+ cursor: pointer;
123
+ transition: filter 120ms ease;
124
+ }
125
+
126
+ .bubbleMore:hover {
127
+ filter: brightness(1.15);
128
+ }
129
+
130
+ .bubbleMore:focus-visible {
131
+ outline: none;
132
+ box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.9);
133
+ }
134
+
107
135
  .bubbleStack .bubble {
108
136
  position: relative;
109
137
  bottom: auto;
@@ -125,8 +153,8 @@
125
153
 
126
154
  .bubbleClickable:focus-visible {
127
155
  outline: none;
128
- /* 2px accent ring on the pet's own palette, theme-agnostic. */
129
- box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
156
+ /* 2px DeepSeek-blue ring on the pet's own palette, theme-agnostic. */
157
+ box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.9);
130
158
  }
131
159
 
132
160
  @keyframes pet-bubble-in {
@@ -194,16 +222,16 @@
194
222
  position: absolute;
195
223
  top: 100%;
196
224
  margin-top: 8px;
197
- background: linear-gradient(165deg, rgba(30, 41, 59, 0.94), rgba(15, 23, 42, 0.96));
198
- border: 1px solid rgba(148, 163, 184, 0.4);
225
+ background: linear-gradient(165deg, rgba(19, 28, 54, 0.95), rgba(7, 11, 26, 0.97));
226
+ border: 1px solid rgba(126, 152, 255, 0.3);
199
227
  border-radius: 12px;
200
228
  padding: 10px 12px;
201
- color: #e2e8f0;
229
+ color: #e6ebf8;
202
230
  font-size: 12px;
203
231
  box-shadow:
204
232
  0 8px 24px rgba(2, 6, 23, 0.45),
205
- 0 0 0 1px rgba(125, 211, 252, 0.08),
206
- inset 0 1px 0 rgba(226, 232, 240, 0.08);
233
+ 0 0 0 1px rgba(77, 107, 254, 0.1),
234
+ inset 0 1px 0 rgba(226, 232, 255, 0.08);
207
235
  backdrop-filter: blur(10px);
208
236
  display: flex;
209
237
  flex-direction: column;
@@ -248,16 +276,16 @@
248
276
  .nameCell {
249
277
  font-weight: 600;
250
278
  letter-spacing: 0.02em;
251
- background: linear-gradient(90deg, #7dd3fc, #38bdf8 60%, #818cf8);
279
+ background: linear-gradient(90deg, #a9c1ff, #6c8bff 60%, #4d6bfe);
252
280
  -webkit-background-clip: text;
253
281
  background-clip: text;
254
282
  color: transparent;
255
283
  }
256
284
 
257
- /* 面板数值的专属配色:称号紫、小鱼干青、亲密度琥珀,
258
- 让两行数据扫一眼就能分辨。 */
285
+ /* 面板数值的专属配色:称号浅品牌蓝、小鱼干清蓝、亲密度暖金 ——
286
+ DeepSeek 蓝同族,仅留一枚金色点缀,两行数据扫一眼就能分辨。 */
259
287
  .statRank {
260
- color: #c4b5fd;
288
+ color: #9db4ff;
261
289
  font-weight: 600;
262
290
  }
263
291
 
@@ -280,19 +308,19 @@
280
308
  .nameInput {
281
309
  flex: 1;
282
310
  min-width: 0;
283
- border: 1px solid rgba(125, 211, 252, 0.5);
311
+ border: 1px solid rgba(126, 152, 255, 0.5);
284
312
  border-radius: 6px;
285
- background: rgba(30, 41, 59, 0.9);
286
- color: #e2e8f0;
313
+ background: rgba(19, 28, 54, 0.9);
314
+ color: #e6ebf8;
287
315
  font-size: 12px;
288
316
  padding: 3px 6px;
289
317
  outline: none;
290
318
  }
291
319
 
292
320
  .nameInput:focus {
293
- border-color: #38bdf8;
294
- /* 2px accent ring (theme-agnostic) replacing the removed default outline. */
295
- box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.45);
321
+ border-color: #4d6bfe;
322
+ /* 2px DeepSeek-blue ring (theme-agnostic) replacing the removed default outline. */
323
+ box-shadow: 0 0 0 2px rgba(77, 107, 254, 0.45);
296
324
  }
297
325
 
298
326
  .actions {
@@ -306,36 +334,37 @@
306
334
  border-radius: 6px;
307
335
  padding: 4px 8px;
308
336
  font-size: 12px;
337
+ font-weight: 600;
309
338
  cursor: pointer;
310
- color: #0f172a;
311
- background: linear-gradient(180deg, #7dd3fc, #38bdf8);
312
- box-shadow: 0 2px 6px rgba(56, 189, 248, 0.25);
339
+ color: #fff;
340
+ background: linear-gradient(180deg, #4a68f5, #3a55e0);
341
+ box-shadow: 0 2px 6px rgba(77, 107, 254, 0.3);
313
342
  transition: filter 120ms ease, box-shadow 120ms ease, transform 120ms ease;
314
343
  }
315
344
 
316
345
  .action:hover {
317
346
  filter: brightness(1.08);
318
347
  transform: translateY(-1px);
319
- box-shadow: 0 4px 10px rgba(56, 189, 248, 0.35);
348
+ box-shadow: 0 4px 10px rgba(77, 107, 254, 0.4);
320
349
  }
321
350
 
322
351
  .action:active {
323
352
  filter: brightness(0.94);
324
353
  transform: translateY(0);
325
- box-shadow: 0 1px 4px rgba(56, 189, 248, 0.25);
354
+ box-shadow: 0 1px 4px rgba(77, 107, 254, 0.3);
326
355
  }
327
356
 
328
357
  .action:focus-visible {
329
358
  outline: none;
330
- /* 2px accent ring on the pet's own palette; stays visible on the dark
331
- glass panel regardless of the GUI theme (theme-agnostic). */
332
- box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
359
+ /* 2px DeepSeek-blue ring on the pet's own palette; stays visible on the
360
+ dark glass panel regardless of the GUI theme (theme-agnostic). */
361
+ box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.9);
333
362
  }
334
363
 
335
364
  .summon {
336
- border: 1px dashed rgba(125, 211, 252, 0.6);
337
- background: rgba(15, 23, 42, 0.75);
338
- color: #7dd3fc;
365
+ border: 1px dashed rgba(126, 152, 255, 0.6);
366
+ background: rgba(7, 11, 26, 0.75);
367
+ color: #8ea6ff;
339
368
  border-radius: 999px;
340
369
  padding: 2px 10px;
341
370
  font-size: 11px;
@@ -345,19 +374,19 @@
345
374
  }
346
375
 
347
376
  .summon:hover {
348
- border-color: rgba(125, 211, 252, 0.95);
349
- color: #bae6fd;
350
- background: rgba(15, 23, 42, 0.9);
377
+ border-color: rgba(126, 152, 255, 0.95);
378
+ color: #c3d3ff;
379
+ background: rgba(7, 11, 26, 0.9);
351
380
  }
352
381
 
353
382
  .summon:active {
354
- color: #7dd3fc;
355
- border-color: rgba(125, 211, 252, 0.8);
383
+ color: #8ea6ff;
384
+ border-color: rgba(126, 152, 255, 0.8);
356
385
  }
357
386
 
358
387
  .summon:focus-visible {
359
388
  outline: none;
360
- box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
389
+ box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.9);
361
390
  }
362
391
 
363
392
  /* Reduced motion: freeze the reaction bubble and kill the overlay transitions
@@ -372,7 +401,8 @@
372
401
  }
373
402
 
374
403
  .action,
375
- .summon {
404
+ .summon,
405
+ .bubbleMore {
376
406
  transition: none;
377
407
  }
378
408
  }
@@ -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
  }
@@ -26,15 +26,10 @@ export function rowOfTrack(animation: PetAnimation): number {
26
26
  * atlas coordinates here would drift each frame by the scale factor and
27
27
  * render torn/overlapping frames.
28
28
  */
29
- export function framePosition(cell: PetCell, columns: number, row: number, col: number, scale = 1): { x: number; y: number } {
29
+ export function framePosition(cell: PetCell, row: number, col: number, scale = 1): { x: number; y: number } {
30
30
  return { x: -col * cell.width * scale, y: -row * cell.height * scale }
31
31
  }
32
32
 
33
- /** Total duration of one track, ms. */
34
- export function trackDuration(track: TrackDef): number {
35
- return track.durations.reduce((sum, d) => sum + d, 0)
36
- }
37
-
38
33
  /**
39
34
  * Trim a track to the actual frame count of its row (the manifest's per-row
40
35
  * counts are authoritative; this is a last-line guard against a definition
@@ -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
+ }
@@ -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, readFileSync, 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,8 @@ import {
7
7
  DEFAULT_PET_CELL,
8
8
  codexPetsDir,
9
9
  loadPetRegistry,
10
+ petAtlasFile,
11
+ petPackageRoot,
10
12
  resolvePetManifest,
11
13
  } from './registry.ts'
12
14
 
@@ -146,6 +148,25 @@ describe('resolvePetManifest', () => {
146
148
  })
147
149
 
148
150
  describe('loadPetRegistry', () => {
151
+ it('ships the original and refined whale variants while keeping the original default', () => {
152
+ const registry = loadPetRegistry({
153
+ packageRoot: petPackageRoot(import.meta.url),
154
+ petsDir: '',
155
+ })
156
+
157
+ expect(registry.entries.map(entry => entry.id)).toEqual([
158
+ 'whale-girl',
159
+ 'whale-girl-refined',
160
+ ])
161
+ expect(registry.byId('whale-girl')?.displayName).toBe('鲸鱼娘(原版)')
162
+ expect(registry.byId('whale-girl-refined')?.displayName).toBe('鲸鱼娘(精致版)')
163
+ expect(existsSync(petAtlasFile(registry.byId('whale-girl-refined')!))).toBe(true)
164
+ expect(readFileSync(petAtlasFile(registry.byId('whale-girl')!)).equals(
165
+ readFileSync(petAtlasFile(registry.byId('whale-girl-refined')!)),
166
+ )).toBe(false)
167
+ expect(registry.defaultEntry().id).toBe('whale-girl')
168
+ })
169
+
149
170
  it('scans built-in assets, the custom pets dir, and composed extras with precedence', () => {
150
171
  const root = tempDir()
151
172
  try {
@@ -180,6 +201,33 @@ describe('loadPetRegistry', () => {
180
201
  }
181
202
  })
182
203
 
204
+ it('resolves a composed extra atlas to the real file (no doubled directory)', () => {
205
+ const root = tempDir()
206
+ try {
207
+ // The atlas sits at <root>/pets/otter/spritesheet.webp.
208
+ mkdirSync(join(root, 'pets', 'otter'), { recursive: true })
209
+ writeFileSync(join(root, 'pets', 'otter', 'spritesheet.webp'), 'png', 'utf8')
210
+
211
+ const registry = loadPetRegistry({
212
+ packageRoot: root,
213
+ petsDir: '',
214
+ extra: [{ id: 'otter', displayName: '水獭', spritesheetPath: 'pets/otter/spritesheet.webp' }],
215
+ })
216
+ const entry = registry.byId('otter')
217
+ expect(entry).toBeDefined()
218
+ // dir is the spritesheet's parent; the stored path is its basename, so
219
+ // joining them resolves to the real file instead of applying the
220
+ // directory twice.
221
+ expect(entry!.dir).toBe(join(root, 'pets', 'otter'))
222
+ expect(entry!.spritesheetPath).toBe('spritesheet.webp')
223
+ const atlas = petAtlasFile(entry!)
224
+ expect(atlas).toBe(join(root, 'pets', 'otter', 'spritesheet.webp'))
225
+ expect(existsSync(atlas)).toBe(true)
226
+ } finally {
227
+ rmSync(root, { recursive: true, force: true })
228
+ }
229
+ })
230
+
183
231
  it('defaults to the built-in pet even when custom pets sort first', () => {
184
232
  const root = tempDir()
185
233
  try {
package/src/registry.ts CHANGED
@@ -410,10 +410,18 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
410
410
  }
411
411
 
412
412
  for (const manifest of options.extra ?? []) {
413
- const dir = manifest.spritesheetPath === undefined || isAbsolute(manifest.spritesheetPath)
413
+ const raw = manifest.spritesheetPath
414
+ const dir = raw === undefined || isAbsolute(raw)
414
415
  ? join(packageRoot, 'assets', 'extra')
415
- : dirname(resolve(packageRoot, manifest.spritesheetPath))
416
- const entry = resolvePetManifest(manifest, dir, { assetPrefix, warnings })
416
+ : dirname(resolve(packageRoot, raw))
417
+ // petAtlasFile joins entry.dir (already the spritesheet's parent when the
418
+ // path is package-relative) with entry.spritesheetPath, so the stored path
419
+ // must be the basename only — otherwise the directory segment is applied
420
+ // twice and the atlas 404s.
421
+ const source = raw === undefined || isAbsolute(raw)
422
+ ? manifest
423
+ : { ...manifest, spritesheetPath: basename(raw) }
424
+ const entry = resolvePetManifest(source, dir, { assetPrefix, warnings })
417
425
  if (entry === undefined) continue
418
426
  if (byId.has(entry.id)) warnings.push('composed pet ' + entry.id + ' overrides an earlier registration')
419
427
  byId.set(entry.id, entry)
@@ -450,7 +458,3 @@ export function petAtlasFile(entry: PetEntry): string {
450
458
  return join(entry.dir, entry.spritesheetPath)
451
459
  }
452
460
 
453
- /** The directory basename of one entry (legacy asset URL alias, e.g. whale). */
454
- export function petDirAlias(entry: PetEntry): string {
455
- return basename(entry.dir)
456
- }
package/src/remarks.ts CHANGED
@@ -53,6 +53,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
53
53
  '蹭蹭你的手心,这是回礼~',
54
54
  '多摸摸我,亲密度会涨哦!',
55
55
  '闭眼享受中,请勿打扰~',
56
+ '头再低一点,够不着了~',
57
+ '呼噜呼噜,声音都冒出来了',
58
+ '这手感,比小鱼干还上瘾',
59
+ '摸到第三下,满意',
60
+ '耳朵后面,别漏了……啊,舒服',
61
+ '被摸得尾巴都卷起来了',
56
62
  ],
57
63
  petCooldown: [
58
64
  '摸过头啦,让鲸鱼娘歇口气~',
@@ -65,6 +71,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
65
71
  '歇一歇,摸摸的手感会更好哦~',
66
72
  '咕……等我回个蓝~',
67
73
  '让我先消化一下刚才的爱!',
74
+ '痒痒的,先让我缓一下……',
75
+ '再摸就掉线了,真的',
76
+ '库存的呼噜声用完了',
77
+ '手歇会儿,我也要补个蓝',
78
+ '舒服归舒服,得缓缓呀',
79
+ '再摸下去,我就要融化了',
68
80
  ],
69
81
  feed: [
70
82
  '呜哇!小鱼干好好吃!',
@@ -77,6 +89,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
77
89
  '饱餐一顿,马上满血复活~',
78
90
  '这个味道,是幸福的味道!',
79
91
  '吃完了还不忘舔舔爪子~',
92
+ '这小鱼干,是今天的顶配',
93
+ '一口下去,精神头全回来了',
94
+ '脆!香!就是这个味儿',
95
+ '边吃边摇尾巴,形象不要了',
96
+ '好吃到眼睛都眯起来了',
97
+ '这块鱼干我记住了,懂我的',
80
98
  ],
81
99
  feedCooldown: [
82
100
  '吃饱啦,晚点再喂~',
@@ -89,6 +107,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
89
107
  '肚皮已经鼓鼓的啦~',
90
108
  '好吃是好吃,可也得节制呀~',
91
109
  '等我饿了会告诉你哦~',
110
+ '胃说它满了,脑子说还能吃',
111
+ '这条得留着慢慢品',
112
+ '先消消食,待会儿再战',
113
+ '塞不下了,真塞不下了',
114
+ '闻着香,可惜没地方放了',
115
+ '嗝……这顿值了',
92
116
  ],
93
117
  noTreats: [
94
118
  '没有小鱼干了,多陪我工作一会儿吧~',
@@ -101,6 +125,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
101
125
  '饿着肚子等你完成下一轮任务~',
102
126
  '小鱼干藏在你的工作里,去找找看!',
103
127
  '先工作后干饭,我们的约定哦~',
128
+ '粮仓见底,全靠感情撑着了',
129
+ '饿是真饿,活也是真得干',
130
+ '画饼充饥……不对,画鱼干充饥',
131
+ '没鱼干的日子,靠意志力过',
132
+ '下一轮任务,我闻到了鱼干味',
133
+ '先记账上,欠我两条,记住啦',
104
134
  ],
105
135
  }
106
136