@linxin666/dsh-pet 0.1.19 → 0.2.0

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 (95) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +19 -12
  3. package/README.zh.md +19 -12
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +202 -38
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +897 -42
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
  10. package/lib/types/affinity.d.ts +22 -18
  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 +666 -0
  16. package/lib/types/client/PetDockEntry.d.ts +2 -0
  17. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  18. package/lib/types/client/PetDockEntry.js +2 -2
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +2 -2
  21. package/lib/types/client/PetSprite.d.ts +2 -0
  22. package/lib/types/client/PetSprite.d.ts.map +1 -1
  23. package/lib/types/client/PetSprite.js +100 -25
  24. package/lib/types/client/PluginSettingsCard.d.ts +7 -1
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  26. package/lib/types/client/PluginSettingsCard.js +5 -3
  27. package/lib/types/client/index.d.ts +1 -1
  28. package/lib/types/client/index.d.ts.map +1 -1
  29. package/lib/types/client/index.js +31 -6
  30. package/lib/types/client/locales.d.ts +82 -80
  31. package/lib/types/client/locales.d.ts.map +1 -1
  32. package/lib/types/client/locales.js +2 -0
  33. package/lib/types/client/sequences.d.ts +10 -0
  34. package/lib/types/client/sequences.d.ts.map +1 -0
  35. package/lib/types/client/sequences.js +20 -0
  36. package/lib/types/client/settings-form.d.ts.map +1 -1
  37. package/lib/types/client/spritesheet.d.ts +1 -1
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +3 -12
  40. package/lib/types/event-projection.d.ts +14 -1
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +38 -17
  43. package/lib/types/index.d.ts +2 -1
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/index.js +15 -13
  46. package/lib/types/invariant.js +2 -2
  47. package/lib/types/ledger.d.ts.map +1 -1
  48. package/lib/types/ledger.js +18 -10
  49. package/lib/types/mount-once.d.ts +25 -0
  50. package/lib/types/mount-once.d.ts.map +1 -0
  51. package/lib/types/mount-once.js +42 -0
  52. package/lib/types/persist.d.ts.map +1 -1
  53. package/lib/types/persist.js +6 -4
  54. package/lib/types/registry.d.ts +7 -1
  55. package/lib/types/registry.d.ts.map +1 -1
  56. package/lib/types/registry.js +37 -1
  57. package/lib/types/remarks.d.ts +2 -0
  58. package/lib/types/remarks.d.ts.map +1 -1
  59. package/lib/types/remarks.js +5 -0
  60. package/lib/types/routes.js +2 -2
  61. package/lib/types/service.d.ts +16 -6
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +26 -8
  64. package/lib/types/state.d.ts +7 -4
  65. package/lib/types/state.d.ts.map +1 -1
  66. package/lib/types/state.js +20 -20
  67. package/package.json +9 -9
  68. package/src/affinity.test.ts +4 -2
  69. package/src/affinity.ts +37 -6
  70. package/src/chatter.test.ts +154 -0
  71. package/src/chatter.ts +697 -0
  72. package/src/client/PetDockEntry.tsx +3 -0
  73. package/src/client/PetSprite.test.tsx +246 -4
  74. package/src/client/PetSprite.tsx +132 -36
  75. package/src/client/PluginSettingsCard.tsx +10 -0
  76. package/src/client/index.ts +26 -3
  77. package/src/client/locales.ts +2 -0
  78. package/src/client/pet-css.test.ts +42 -0
  79. package/src/client/pet.module.css +173 -22
  80. package/src/client/sequences.test.ts +33 -0
  81. package/src/client/sequences.ts +33 -0
  82. package/src/client/spritesheet.ts +3 -13
  83. package/src/event-projection.ts +49 -16
  84. package/src/index.ts +4 -1
  85. package/src/ledger.test.ts +42 -1
  86. package/src/ledger.ts +15 -7
  87. package/src/mount-once.ts +48 -0
  88. package/src/persist.test.ts +18 -1
  89. package/src/persist.ts +3 -1
  90. package/src/registry.test.ts +50 -3
  91. package/src/registry.ts +47 -3
  92. package/src/remarks.ts +6 -0
  93. package/src/service.ts +42 -9
  94. package/src/state.test.ts +13 -0
  95. package/src/state.ts +24 -18
@@ -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,48 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Host single-instance guard shared by the plugin family. The family bundle
4
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
5
+ * the loader accepts a standalone install of the same package side by side;
6
+ * without this guard the second instance would still re-register the same
7
+ * webserver routes, tools, settings namespaces, and system-prompt sections
8
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
9
+ * lifetime of the first instance (the browser half is already deduped by
10
+ * package name in the client module host).
11
+ *
12
+ * The registry rides a global symbol so two module instances of the same
13
+ * package (npm copy vs repository link) still share one verdict. cordis
14
+ * `ctx.effect` runs its callback immediately and treats the callback's
15
+ * return value as the fiber disposer, so the unmarker is returned, not run.
16
+ */
17
+
18
+ const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
19
+
20
+ interface MountRegistry {
21
+ [MOUNTED]?: Set<string>
22
+ }
23
+
24
+ function mountedSet(): Set<string> {
25
+ const registry = globalThis as MountRegistry
26
+ return (registry[MOUNTED] ??= new Set())
27
+ }
28
+
29
+ /**
30
+ * Wrap a cordis plugin apply so the package runs at most once per process.
31
+ * The first mount registers normally and unmarks when its fiber disposes;
32
+ * any later mount of the same package name is a no-op.
33
+ * @param packageName - npm package identity shared by every install source.
34
+ * @param fn - the original plugin apply.
35
+ * @returns an apply of the same shape.
36
+ */
37
+ export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
+ return ((...args: unknown[]) => {
39
+ const mounted = mountedSet()
40
+ if (mounted.has(packageName)) return
41
+ mounted.add(packageName)
42
+ const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
+ ctx?.effect?.(() => () => {
44
+ mounted.delete(packageName)
45
+ })
46
+ return fn(...args)
47
+ }) as T
48
+ }
@@ -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>
@@ -25,6 +25,7 @@ describe('resolvePetManifest', () => {
25
25
  expect(entry!.id).toBe('otter')
26
26
  expect(entry!.cell).toEqual(DEFAULT_PET_CELL)
27
27
  expect(entry!.columns).toBe(8)
28
+ expect(entry!.atlasRows).toBe(9)
28
29
  expect(entry!.rows).toEqual([...DEFAULT_FRAME_COUNTS])
29
30
  expect(entry!.atlasUrl).toBe('/pet/otter/spritesheet.webp')
30
31
  expect(entry!.manifestUrl).toBe('/pet/otter/pet.json')
@@ -37,6 +38,21 @@ describe('resolvePetManifest', () => {
37
38
  expect(entry!.tracks.running.loop).toBe(true)
38
39
  })
39
40
 
41
+ it('marks v2 (spriteVersionNumber 2) atlases with 11 rows', () => {
42
+ const entry = resolvePetManifest({
43
+ id: 'firefly',
44
+ displayName: 'Firefly',
45
+ spritesheetPath: 'spritesheet.webp',
46
+ spriteVersionNumber: 2,
47
+ }, join(tmpdir(), 'firefly'))
48
+ expect(entry).toBeDefined()
49
+ // v2 atlases carry 11 rows: the 9 animation rows plus 2 look rows.
50
+ expect(entry!.atlasRows).toBe(11)
51
+ // The 9 animation rows still resolve the hatch-pet contract.
52
+ expect(entry!.rows).toEqual([...DEFAULT_FRAME_COUNTS])
53
+ expect(entry!.tracks.idle.frames.length).toBe(entry!.rows[0])
54
+ })
55
+
40
56
  it('keeps the legacy whale-girl frame counts and its own durations', () => {
41
57
  const entry = resolvePetManifest({
42
58
  id: 'whale-girl',
@@ -51,6 +67,36 @@ describe('resolvePetManifest', () => {
51
67
  expect(entry!.tracks['running-right'].durations.length).toBe(8)
52
68
  })
53
69
 
70
+ it('normalizes valid per-scene animation sequences', () => {
71
+ const entry = resolvePetManifest({
72
+ id: 'whale-girl',
73
+ displayName: '鲸鱼娘',
74
+ spritesheetPath: 'spritesheet.webp',
75
+ sequences: {
76
+ thinking: ['running', 'running-right', 'running', 'running-left', 'waiting'],
77
+ },
78
+ }, join(tmpdir(), 'whale'))
79
+ expect(entry!.sequences).toEqual({
80
+ thinking: ['running', 'running-right', 'running', 'running-left', 'waiting'],
81
+ })
82
+ })
83
+
84
+ it('drops invalid or undersized per-scene animation sequences', () => {
85
+ const warnings: string[] = []
86
+ const entry = resolvePetManifest({
87
+ id: 'whale-girl',
88
+ displayName: '鲸鱼娘',
89
+ spritesheetPath: 'spritesheet.webp',
90
+ sequences: {
91
+ waiting: ['waiting', 'idle'],
92
+ thinking: ['running', 'bogus', 'running', 'running-left', 'waiting'],
93
+ },
94
+ }, join(tmpdir(), 'whale'), { warnings })
95
+ expect(entry!.sequences).toBeUndefined()
96
+ expect(warnings).toContain('manifest whale-girl: sequence waiting must contain at least 5 animations')
97
+ expect(warnings).toContain('manifest whale-girl: sequence thinking contains unknown animation "bogus"')
98
+ })
99
+
54
100
  it('cycles short override durations up to the row frame count', () => {
55
101
  const entry = resolvePetManifest({
56
102
  id: 'fox',
@@ -152,8 +198,9 @@ describe('loadPetRegistry', () => {
152
198
 
153
199
  describe('codexPetsDir', () => {
154
200
  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')
201
+ // Expected values join through the platform separator (POSIX on CI).
202
+ expect(codexPetsDir({ CODEX_HOME: '/opt/codex' }, '/home/user')).toBe(join('/opt/codex', 'pets'))
203
+ expect(codexPetsDir({ CODEX_HOME: '~/codex' }, '/home/user')).toBe(join('/home/user', 'codex', 'pets'))
204
+ expect(codexPetsDir({}, '/home/user')).toBe(join('/home/user', '.codex', 'pets'))
158
205
  })
159
206
  })
package/src/registry.ts CHANGED
@@ -21,7 +21,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'
21
21
  import { homedir } from 'node:os'
22
22
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
23
23
  import { fileURLToPath } from 'node:url'
24
- import type { PetAnimation } from './state.ts'
24
+ import type { ActivityPhase, PetAnimation } from './state.ts'
25
25
  import { normalizePetRemarks, type PetRemarks, type PetRemarksManifest } from './remarks.ts'
26
26
 
27
27
  /** Fixed row order of the 9-state animation contract. */
@@ -135,6 +135,8 @@ export interface PetManifest {
135
135
  frames?: number[]
136
136
  /** Optional per-track rhythm overrides; omitted tracks use the defaults. */
137
137
  tracks?: Partial<Record<PetAnimation, PetTrackOverride>>
138
+ /** Optional per-scene track sequences; every declared sequence has at least 5 items. */
139
+ sequences?: Partial<Record<ActivityPhase, PetAnimation[]>>
138
140
  /**
139
141
  * Optional witty-remark overrides the pet speaks on interactions
140
142
  * (community contributions use this to give their pet its own voice).
@@ -165,8 +167,12 @@ export interface PetDefinition {
165
167
  columns: number
166
168
  /** Per-row frame counts (length 9, row order above). */
167
169
  rows: number[]
170
+ /** Total atlas rows (9 for v1, 11 for v2 look-row atlases). */
171
+ atlasRows: number
168
172
  /** Fully resolved animation tracks (frames + durations + loop/fallback). */
169
173
  tracks: Record<PetAnimation, PetTrackDef>
174
+ /** Validated per-scene track sequences; omitted scenes keep single-track playback. */
175
+ sequences?: Partial<Record<ActivityPhase, PetAnimation[]>>
170
176
  /** Browser URL of the atlas (served by the host asset route). */
171
177
  atlasUrl: string
172
178
  /** Browser URL of the manifest (served by the host asset route). */
@@ -209,6 +215,39 @@ const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
209
215
  /** Safe path-segment charset for atlas files. */
210
216
  const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/
211
217
  const PET_NAME_MAX_LENGTH = 80
218
+ const PET_PHASES: readonly ActivityPhase[] = ['idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed']
219
+
220
+ /** Validate optional scene sequences without rejecting an otherwise usable pet. */
221
+ function normalizeSequences(
222
+ raw: unknown,
223
+ id: string,
224
+ warn: (message: string) => void,
225
+ ): Partial<Record<ActivityPhase, PetAnimation[]>> | undefined {
226
+ if (raw === undefined) return undefined
227
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
228
+ warn('manifest ' + id + ': sequences must be an object keyed by activity phase')
229
+ return undefined
230
+ }
231
+ const sequences: Partial<Record<ActivityPhase, PetAnimation[]>> = {}
232
+ for (const [phase, value] of Object.entries(raw as Record<string, unknown>)) {
233
+ if (!PET_PHASES.includes(phase as ActivityPhase)) {
234
+ warn('manifest ' + id + ': unknown sequence phase ' + JSON.stringify(phase))
235
+ continue
236
+ }
237
+ if (!Array.isArray(value) || value.length < 5) {
238
+ warn('manifest ' + id + ': sequence ' + phase + ' must contain at least 5 animations')
239
+ continue
240
+ }
241
+ const unknownIndex = value.findIndex(animation => typeof animation !== 'string' || !PET_ROW_ORDER.includes(animation as PetAnimation))
242
+ if (unknownIndex !== -1) {
243
+ const unknown = value[unknownIndex]
244
+ warn('manifest ' + id + ': sequence ' + phase + ' contains unknown animation ' + JSON.stringify(unknown))
245
+ continue
246
+ }
247
+ sequences[phase as ActivityPhase] = value as PetAnimation[]
248
+ }
249
+ return Object.keys(sequences).length === 0 ? undefined : sequences
250
+ }
212
251
 
213
252
  /**
214
253
  * Normalize one parsed manifest into a renderable pet entry, or undefined
@@ -256,11 +295,14 @@ export function resolvePetManifest(
256
295
  height: finiteInt(rawCell.height, DEFAULT_PET_CELL.height, 2048),
257
296
  }
258
297
  const columns = finiteInt(source.columns, DEFAULT_PET_COLUMNS, 32)
298
+ // v2 atlases (spriteVersionNumber 2) hold 11 rows: 9 animation rows + 2 look rows.
299
+ const atlasRowCount = source.spriteVersionNumber === 2 ? 11 : DEFAULT_PET_ROW_COUNT
259
300
  const rows = DEFAULT_FRAME_COUNTS.map((fallback, index) => {
260
301
  const value = Array.isArray(source.frames) ? source.frames[index] : undefined
261
302
  return finiteInt(value, fallback, columns)
262
303
  })
263
304
  const remarks = normalizePetRemarks(source.remarks, message => warn('manifest ' + id + ': ' + message))
305
+ const sequences = normalizeSequences(source.sequences, id, warn)
264
306
  const trackOverrides = (typeof source.tracks === 'object' && source.tracks !== null ? source.tracks : {}) as Partial<Record<PetAnimation, PetTrackOverride>>
265
307
  const tracks = {} as Record<PetAnimation, PetTrackDef>
266
308
  for (const [row, animation] of PET_ROW_ORDER.entries()) {
@@ -295,7 +337,9 @@ export function resolvePetManifest(
295
337
  cell,
296
338
  columns,
297
339
  rows,
340
+ atlasRows: atlasRowCount,
298
341
  tracks,
342
+ ...(sequences === undefined ? {} : { sequences }),
299
343
  atlasUrl: assetUrl(assetPrefix, id, spritesheet),
300
344
  manifestUrl: assetUrl(assetPrefix, id, 'pet.json'),
301
345
  dir,
@@ -393,7 +437,9 @@ export function petEntryView(entry: PetEntry): PetDefinition {
393
437
  cell: entry.cell,
394
438
  columns: entry.columns,
395
439
  rows: entry.rows,
440
+ atlasRows: entry.atlasRows,
396
441
  tracks: entry.tracks,
442
+ ...(entry.sequences === undefined ? {} : { sequences: entry.sequences }),
397
443
  atlasUrl: entry.atlasUrl,
398
444
  manifestUrl: entry.manifestUrl,
399
445
  }
@@ -408,5 +454,3 @@ export function petAtlasFile(entry: PetEntry): string {
408
454
  export function petDirAlias(entry: PetEntry): string {
409
455
  return basename(entry.dir)
410
456
  }
411
-
412
-
package/src/remarks.ts CHANGED
@@ -174,4 +174,10 @@ export class RemarkPicker {
174
174
  this.counters.set(kind, index + 1)
175
175
  return pool[index]!
176
176
  }
177
+
178
+ /** Select a line from a stable external counter without changing local picker state. */
179
+ pickAt(kind: RemarkKind, count: number): string {
180
+ const pool = this.pools[kind]
181
+ return pool[Math.max(0, Math.floor(count)) % pool.length]!
182
+ }
177
183
  }
package/src/service.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  type PetManifest,
45
45
  type PetRegistry,
46
46
  } from './registry.ts'
47
+ import { WHISPER_TTL_MS } from './chatter.ts'
47
48
  import {
48
49
  defaultPetStateConfig,
49
50
  PetStateMachine,
@@ -96,9 +97,12 @@ export interface PetSettingsSection {
96
97
  export const PET_SETTINGS_NAMESPACE = 'pet'
97
98
 
98
99
  /**
99
- * One active session as the pet displays it. Sessions run in parallel, so
100
- * each gets its own bubble while the sprite itself follows the most recent
101
- * meaningful event (the display session).
100
+ * One active TOP-LEVEL session as the pet displays it. Sessions run in
101
+ * parallel, so each gets its own bubble while the sprite itself follows the
102
+ * most recent meaningful event (the display session). Subagent children
103
+ * report no bubble of their own: their work is already reflected by the
104
+ * bubble of the conversation that spawned them, and the bubble buttons
105
+ * navigate to GUI sessions, which subagents are not.
102
106
  */
103
107
  export interface PetSessionView {
104
108
  /** Session identity (stringified for the wire; never exposed as a key). */
@@ -121,9 +125,10 @@ export interface PetStateView {
121
125
  phase: PetStateSnapshot['phase']
122
126
  sessionActive: boolean
123
127
  /**
124
- * Per-session bubbles for every concurrently active session, most recent
125
- * first; optional so older hosts without the multi-session view stay
126
- * consumable. The single 'bubble' above mirrors the display session.
128
+ * Per-session bubbles for every concurrently active TOP-LEVEL session,
129
+ * most recent first; optional so older hosts without the multi-session
130
+ * view stay consumable. The single 'bubble' above mirrors the display
131
+ * session.
127
132
  */
128
133
  sessions?: PetSessionView[]
129
134
  /** Affinity ledger snapshot. */
@@ -148,6 +153,12 @@ export interface PetStateView {
148
153
  /** Stock cap. */
149
154
  max: number
150
155
  }
156
+ /**
157
+ * The display session's fresh inner whisper (碎碎念), when one is within
158
+ * its TTL — short inner-voice copy woken by the model's own output,
159
+ * rendered by the client as a distinct whisper bubble.
160
+ */
161
+ whisper?: string
151
162
  }
152
163
 
153
164
  /** Result of `pet.interact`. */
@@ -165,6 +176,13 @@ interface SessionActivity {
165
176
  machine: PetStateMachine
166
177
  /** The session's most recent meaningful input (for display fallback). */
167
178
  lastInput?: PetStateInput
179
+ /** Latest inner whisper woken by this session's model output (碎碎念). */
180
+ whisper?: {
181
+ /** Whisper copy. */
182
+ text: string
183
+ /** Epoch ms when it appeared (view-side TTL applies). */
184
+ at: number
185
+ }
168
186
  }
169
187
 
170
188
  /**
@@ -315,7 +333,7 @@ export class PetService extends Service {
315
333
  const transition = projectOfficialEvent(event, runtime)
316
334
  if (transition === undefined) return
317
335
  runtime.officialEventsSeen = true
318
- this.applyActivity(session, transition.input)
336
+ this.applyActivity(session, transition.input, transition.whisper)
319
337
  if (transition.completedTurn !== undefined) {
320
338
  this.rewardTurn(String(session.id), transition.completedTurn)
321
339
  }
@@ -360,9 +378,10 @@ export class PetService extends Service {
360
378
  * the session becomes the host-global display session (most recent
361
379
  * meaningful event wins the sprite animation).
362
380
  */
363
- private applyActivity(session: Session, input: PetStateInput): void {
381
+ private applyActivity(session: Session, input: PetStateInput, whisper?: string): void {
364
382
  const activity = this.activityOf(session)
365
383
  activity.lastInput = input
384
+ if (whisper !== undefined) activity.whisper = { text: whisper, at: Date.now() }
366
385
  activity.machine.onActivityStatus(input)
367
386
  activity.machine.onSessionActive()
368
387
  // Move to the tail so map order reads most-recent-last, then trim the
@@ -472,12 +491,16 @@ export class PetService extends Service {
472
491
  private view(): PetStateView {
473
492
  const snapshot = this.machine.render()
474
493
  const entry = this.activeEntry()
475
- // One bubble per concurrently active session, most recent first.
494
+ // One bubble per concurrently active TOP-LEVEL session, most recent
495
+ // first. Subagent children render no bubble of their own (their activity
496
+ // already shows through the spawning conversation's bubble/display, and
497
+ // the bubble buttons navigate to GUI sessions, which subagents are not).
476
498
  // Sessions whose own machine has settled (no bubble copy) drop out, so a
477
499
  // finished turn does not leave a stale bubble behind.
478
500
  const sessions: PetSessionView[] = []
479
501
  for (const [session, activity] of [...this.sessionActivity.entries()].reverse()) {
480
502
  if (sessions.length >= MAX_SESSION_BUBBLES) break
503
+ if (session.header?.origin === 'subagent') continue
481
504
  const perSession = activity.machine.render()
482
505
  if (perSession.bubble === undefined) continue
483
506
  sessions.push({
@@ -487,6 +510,15 @@ export class PetService extends Service {
487
510
  phase: perSession.phase,
488
511
  })
489
512
  }
513
+ // The display session's inner whisper rides the global view while fresh;
514
+ // an expired whisper simply stops appearing (the client's 2s poll drops it).
515
+ const displayActivity = this.displaySession === undefined
516
+ ? undefined
517
+ : this.sessionActivity.get(this.displaySession)
518
+ const whisper = displayActivity?.whisper
519
+ const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
520
+ ? whisper.text
521
+ : undefined
490
522
  // Read-only: the ledger settles on economic events only, never on a read,
491
523
  // so polling the state cannot trigger pet.json writes.
492
524
  return {
@@ -495,6 +527,7 @@ export class PetService extends Service {
495
527
  phase: snapshot.phase,
496
528
  sessionActive: snapshot.sessionActive,
497
529
  sessions,
530
+ ...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
498
531
  affinity: this.ledger.affinityView(Date.now()),
499
532
  display: { ...this.ledger.snapshot.display },
500
533
  pet: {
package/src/state.test.ts CHANGED
@@ -33,6 +33,19 @@ describe('PetStateMachine', () => {
33
33
  expect(machine.render().bubble).toBeUndefined()
34
34
  })
35
35
 
36
+ it('shows failure briefly, then clears its bubble', () => {
37
+ let now = 1_000_000
38
+ const machine = new PetStateMachine({ celebrateMs: 2400, failureMs: 2400 }, () => now)
39
+ machine.onSessionActive()
40
+ machine.onActivityStatus({ phase: 'failed', line: '执行失败' })
41
+ expect(machine.render()).toMatchObject({ animation: 'failed', bubble: '执行失败' })
42
+ now += 2399
43
+ expect(machine.render()).toMatchObject({ animation: 'failed', bubble: '执行失败' })
44
+ now += 1
45
+ expect(machine.render()).toMatchObject({ animation: 'idle' })
46
+ expect(machine.render().bubble).toBeUndefined()
47
+ })
48
+
36
49
  it('shows the phrase bubble when present, else the line', () => {
37
50
  const machine = new PetStateMachine(defaultPetStateConfig, () => 1_000)
38
51
  machine.onActivityStatus({ phase: 'thinking', phrase: '查资料中', line: 'tool: grep' })
package/src/state.ts CHANGED
@@ -53,9 +53,11 @@ export interface PetStateSnapshot {
53
53
  export interface PetStateConfig {
54
54
  /** Celebration window after `done` before settling to idle, ms (default 2400). */
55
55
  celebrateMs: number
56
+ /** Failure display window before settling to idle, ms (default 2400). */
57
+ failureMs: number
56
58
  }
57
59
 
58
- export const defaultPetStateConfig: PetStateConfig = { celebrateMs: 2400 }
60
+ export const defaultPetStateConfig: PetStateConfig = { celebrateMs: 2400, failureMs: 2400 }
59
61
 
60
62
  /**
61
63
  * Map one activity phase onto the animation contract.
@@ -63,7 +65,7 @@ export const defaultPetStateConfig: PetStateConfig = { celebrateMs: 2400 }
63
65
  * - review → `review` while answer text is streaming.
64
66
  * - waiting → `waiting` (expectant pose, needs user input).
65
67
  * - done → `jumping` (celebration), then back to `idle` after the window.
66
- * - failed → `failed` until another activity event arrives.
68
+ * - failed → `failed` briefly, then back to `idle`.
67
69
  * - idle → `idle` (calm breathing loop).
68
70
  */
69
71
  export function animationForPhase(phase: ActivityPhase): PetAnimation {
@@ -96,7 +98,7 @@ export function rowOf(animation: PetAnimation): number {
96
98
 
97
99
  /**
98
100
  * PetStateMachine — one instance per host process. Holds only the latest
99
- * input snapshot and the celebration timing; no storage, no side effects.
101
+ * input snapshot and terminal-state timing; no storage, no side effects.
100
102
  */
101
103
  export class PetStateMachine {
102
104
  private phase: ActivityPhase = 'idle'
@@ -104,18 +106,23 @@ export class PetStateMachine {
104
106
  private phrase: string | undefined
105
107
  private sessionActive = false
106
108
  private doneAt: number | undefined
109
+ private failedAt: number | undefined
110
+ private readonly config: PetStateConfig
107
111
 
108
112
  constructor(
109
- private readonly config: PetStateConfig = defaultPetStateConfig,
113
+ config: Partial<PetStateConfig> = defaultPetStateConfig,
110
114
  private readonly now: () => number = Date.now,
111
- ) {}
115
+ ) {
116
+ this.config = { ...defaultPetStateConfig, ...config }
117
+ }
112
118
 
113
119
  /** Consume one projected activity update. */
114
120
  onActivityStatus(input: PetStateInput): void {
115
121
  this.phase = input.phase
116
122
  this.line = input.line
117
123
  this.phrase = input.phrase
118
- if (input.phase === 'done') this.doneAt = this.now()
124
+ this.doneAt = input.phase === 'done' ? this.now() : undefined
125
+ this.failedAt = input.phase === 'failed' ? this.now() : undefined
119
126
  }
120
127
 
121
128
  /** A session became the active one (or a fresh session started). */
@@ -130,24 +137,23 @@ export class PetStateMachine {
130
137
  this.line = undefined
131
138
  this.phrase = undefined
132
139
  this.doneAt = undefined
140
+ this.failedAt = undefined
133
141
  }
134
142
 
135
143
  /** Render the current animation decision. */
136
144
  render(): PetStateSnapshot {
137
145
  const nowMs = this.now()
138
146
  let animation = animationForPhase(this.phase)
139
- // Celebration window: after `done`, jump for celebrateMs then settle idle.
140
- if (this.phase === 'done' && this.doneAt !== undefined) {
141
- if (nowMs - this.doneAt < this.config.celebrateMs) {
142
- animation = 'jumping'
143
- } else {
144
- animation = 'idle'
145
- }
146
- }
147
- // Settled sessions never bubble: idle (e.g. an aborted/stopped turn)
148
- // and done past its celebration window both fall silent.
149
- const settled = this.phase === 'idle'
150
- || (this.phase === 'done' && this.doneAt !== undefined && nowMs - this.doneAt >= this.config.celebrateMs)
147
+ const doneSettled = this.phase === 'done'
148
+ && this.doneAt !== undefined
149
+ && nowMs - this.doneAt >= this.config.celebrateMs
150
+ const failedSettled = this.phase === 'failed'
151
+ && this.failedAt !== undefined
152
+ && nowMs - this.failedAt >= this.config.failureMs
153
+ if (doneSettled || failedSettled) animation = 'idle'
154
+ // Settled sessions never bubble: idle (e.g. an aborted/stopped turn),
155
+ // completed celebration expiry, and failed display expiry all fall silent.
156
+ const settled = this.phase === 'idle' || doneSettled || failedSettled
151
157
  const bubble = settled ? undefined : this.phrase ?? this.line
152
158
  return {
153
159
  animation,