@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.
- package/README.i18n.yaml +2 -2
- package/README.md +19 -12
- package/README.zh.md +19 -12
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +202 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +897 -42
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
- package/lib/types/affinity.d.ts +22 -18
- 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 +666 -0
- package/lib/types/client/PetDockEntry.d.ts +2 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -2
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +2 -2
- package/lib/types/client/PetSprite.d.ts +2 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +100 -25
- package/lib/types/client/PluginSettingsCard.d.ts +7 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +5 -3
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +31 -6
- package/lib/types/client/locales.d.ts +82 -80
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +2 -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/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/spritesheet.d.ts +1 -1
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +3 -12
- 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/index.d.ts +2 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +15 -13
- package/lib/types/invariant.js +2 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +18 -10
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/lib/types/mount-once.js +42 -0
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +6 -4
- package/lib/types/registry.d.ts +7 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +37 -1
- package/lib/types/remarks.d.ts +2 -0
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +5 -0
- package/lib/types/routes.js +2 -2
- package/lib/types/service.d.ts +16 -6
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +26 -8
- 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 +697 -0
- package/src/client/PetDockEntry.tsx +3 -0
- package/src/client/PetSprite.test.tsx +246 -4
- package/src/client/PetSprite.tsx +132 -36
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/index.ts +26 -3
- package/src/client/locales.ts +2 -0
- package/src/client/pet-css.test.ts +42 -0
- package/src/client/pet.module.css +173 -22
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/client/spritesheet.ts +3 -13
- package/src/event-projection.ts +49 -16
- package/src/index.ts +4 -1
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/mount-once.ts +48 -0
- package/src/persist.test.ts +18 -1
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +50 -3
- package/src/registry.ts +47 -3
- package/src/remarks.ts +6 -0
- package/src/service.ts +42 -9
- package/src/state.test.ts +13 -0
- package/src/state.ts +24 -18
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
|
}
|
|
@@ -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
|
+
}
|
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
|
@@ -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
|
-
|
|
156
|
-
expect(codexPetsDir({ CODEX_HOME: '
|
|
157
|
-
expect(codexPetsDir({}, '/home/user')).toBe('/home/user
|
|
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
|
|
100
|
-
* each gets its own bubble while the sprite itself follows the
|
|
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,
|
|
125
|
-
* first; optional so older hosts without the multi-session
|
|
126
|
-
* consumable. The single 'bubble' above mirrors the display
|
|
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
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
|
|
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,
|