@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.
Files changed (85) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +18 -11
  3. package/README.zh.md +18 -11
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +394 -58
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +1007 -43
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-DrMX22GL.js} +86 -28
  10. package/lib/types/affinity.d.ts +4 -0
  11. package/lib/types/affinity.d.ts.map +1 -1
  12. package/lib/types/affinity.js +32 -6
  13. package/lib/types/chatter.d.ts +111 -0
  14. package/lib/types/chatter.d.ts.map +1 -0
  15. package/lib/types/chatter.js +756 -0
  16. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  17. package/lib/types/client/PetDockEntry.js +1 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +133 -16
  20. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  21. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  22. package/lib/types/client/PluginSettingsCard.js +147 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +1 -0
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/client/sequences.d.ts +10 -0
  29. package/lib/types/client/sequences.d.ts.map +1 -0
  30. package/lib/types/client/sequences.js +20 -0
  31. package/lib/types/event-projection.d.ts +14 -1
  32. package/lib/types/event-projection.d.ts.map +1 -1
  33. package/lib/types/event-projection.js +38 -17
  34. package/lib/types/ledger.d.ts.map +1 -1
  35. package/lib/types/ledger.js +15 -7
  36. package/lib/types/loopback.d.ts +25 -0
  37. package/lib/types/loopback.d.ts.map +1 -0
  38. package/lib/types/loopback.js +68 -0
  39. package/lib/types/persist.d.ts.map +1 -1
  40. package/lib/types/persist.js +3 -1
  41. package/lib/types/registry.d.ts +7 -1
  42. package/lib/types/registry.d.ts.map +1 -1
  43. package/lib/types/registry.js +47 -3
  44. package/lib/types/remarks.d.ts +2 -0
  45. package/lib/types/remarks.d.ts.map +1 -1
  46. package/lib/types/remarks.js +35 -0
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +14 -0
  49. package/lib/types/service.d.ts +6 -0
  50. package/lib/types/service.d.ts.map +1 -1
  51. package/lib/types/service.js +15 -2
  52. package/lib/types/state.d.ts +7 -4
  53. package/lib/types/state.d.ts.map +1 -1
  54. package/lib/types/state.js +20 -20
  55. package/package.json +9 -9
  56. package/src/affinity.test.ts +4 -2
  57. package/src/affinity.ts +37 -6
  58. package/src/chatter.test.ts +154 -0
  59. package/src/chatter.ts +787 -0
  60. package/src/client/PetDockEntry.test.tsx +93 -0
  61. package/src/client/PetDockEntry.tsx +1 -0
  62. package/src/client/PetSprite.test.tsx +253 -3
  63. package/src/client/PetSprite.tsx +192 -37
  64. package/src/client/PluginSettingsCard.tsx +229 -18
  65. package/src/client/index.test.tsx +95 -0
  66. package/src/client/index.ts +1 -0
  67. package/src/client/locales.ts +4 -0
  68. package/src/client/pet-css.test.ts +54 -0
  69. package/src/client/pet.module.css +215 -57
  70. package/src/client/sequences.test.ts +33 -0
  71. package/src/client/sequences.ts +33 -0
  72. package/src/client/settings-card.module.css +90 -1
  73. package/src/event-projection.ts +49 -16
  74. package/src/ledger.test.ts +42 -1
  75. package/src/ledger.ts +15 -7
  76. package/src/loopback.ts +63 -0
  77. package/src/persist.test.ts +18 -1
  78. package/src/persist.ts +3 -1
  79. package/src/registry.test.ts +79 -4
  80. package/src/registry.ts +58 -6
  81. package/src/remarks.ts +36 -0
  82. package/src/routes.ts +11 -0
  83. package/src/service.ts +27 -2
  84. package/src/state.test.ts +13 -0
  85. 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
  }
@@ -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 { activeTools: new Set(), officialEventsSeen: false, stepHadFailure: false }
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 : `${compact.slice(0, 21)}...`
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
- return { input: { phase: 'thinking', line: '正在思考' } }
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
- return { input: { phase: 'review', line: '整理回复中' } }
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: `正在使用 ${displayToolName(event.data.name)}`,
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: `还有 ${runtime.activeTools.size} 个工具运行中`,
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.
@@ -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
- expect(ledger.interact('pet', 1_022_000).reaction).toBe('咕噜咕噜~被摸摸好舒服!')
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 outcome = applyInteraction(this.current.affinity, kind, nowMs, this.affinityConfig)
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
- return { reaction: this.picker.pick('feedCooldown'), delta: 0, affinity: this.affinityView(nowMs) }
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
- if (outcome.accepted) {
205
- this.current = { ...this.current, affinity: outcome.affinity }
206
- this.dirty = true
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.pick(outcome.accepted ? kind : 'petCooldown'),
217
+ reaction: this.picker.pickAt(outcome.accepted ? kind : 'petCooldown', count),
210
218
  delta: outcome.delta,
211
219
  affinity: this.affinityView(nowMs),
212
220
  }
@@ -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
+ }
@@ -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: { points: AFFINITY_MAX + 5000, lastPetAt: -5, lastFeedAt: 'x', pets: -1, feeds: 1.5, turns: 0 },
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: 20,
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>
@@ -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
- expect(codexPetsDir({ CODEX_HOME: '/opt/codex' }, '/home/user')).toBe('/opt/codex/pets')
156
- expect(codexPetsDir({ CODEX_HOME: '~/codex' }, '/home/user')).toBe('/home/user/codex/pets')
157
- expect(codexPetsDir({}, '/home/user')).toBe('/home/user/.codex/pets')
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
  })