@linxin666/dsh-pet 0.3.3 → 0.3.5
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 +30 -13
- package/README.zh.md +30 -13
- package/assets/ouo-neko/pet.json +31 -0
- package/assets/ouo-neko/previews/failed.gif +0 -0
- package/assets/ouo-neko/previews/idle.gif +0 -0
- package/assets/ouo-neko/previews/jumping.gif +0 -0
- package/assets/ouo-neko/previews/review.gif +0 -0
- package/assets/ouo-neko/previews/running-left.gif +0 -0
- package/assets/ouo-neko/previews/running-right.gif +0 -0
- package/assets/ouo-neko/previews/running.gif +0 -0
- package/assets/ouo-neko/previews/waiting.gif +0 -0
- package/assets/ouo-neko/previews/waving.gif +0 -0
- package/assets/ouo-neko/spritesheet.webp +0 -0
- package/contracts/pet-manifest-v2.schema.json +102 -1
- package/contracts/voice-pack-v1.schema.json +23 -28
- package/lib/client.js +781 -40
- package/lib/client.js.map +1 -1
- package/lib/index.js +1567 -482
- package/lib/types/chatter.d.ts +68 -46
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +243 -301
- package/lib/types/client/PetDockEntry.d.ts +3 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +18 -2
- package/lib/types/client/PetSprite.d.ts +23 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +49 -45
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/drag-stream.d.ts +19 -0
- package/lib/types/client/drag-stream.d.ts.map +1 -0
- package/lib/types/client/drag-stream.js +26 -0
- package/lib/types/client/gameplay-hud.d.ts +50 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -0
- package/lib/types/client/gameplay-hud.js +274 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +38 -9
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/pet-store.d.ts +7 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
- package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
- package/lib/types/client/renderers/frames2d.d.ts +36 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
- package/lib/types/client/renderers/frames2d.js +196 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/defaults.js +5 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/event-projection.d.ts +9 -4
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +46 -14
- package/lib/types/gameplay.d.ts +182 -0
- package/lib/types/gameplay.d.ts.map +1 -0
- package/lib/types/gameplay.js +618 -0
- package/lib/types/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- package/lib/types/manifest-v2.d.ts +38 -1
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +182 -2
- package/lib/types/persist.d.ts +4 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +41 -4
- package/lib/types/registry.d.ts +29 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +170 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +30 -3
- package/lib/types/service.d.ts +63 -11
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +208 -19
- package/lib/types/voice-pack.d.ts +6 -7
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +44 -47
- package/package.json +5 -2
- package/src/chatter.test.ts +68 -38
- package/src/chatter.ts +269 -316
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +68 -21
- package/src/client/PetSprite.tsx +63 -45
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/drag-stream.ts +35 -0
- package/src/client/gameplay-hud.test.tsx +338 -0
- package/src/client/gameplay-hud.tsx +403 -0
- package/src/client/index.test.tsx +10 -1
- package/src/client/index.ts +46 -11
- package/src/client/locales.ts +36 -0
- package/src/client/pet-store.ts +10 -1
- package/src/client/pet.module.css +192 -29
- package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
- package/src/client/renderers/PetRendererSwitch.tsx +28 -1
- package/src/client/renderers/frames2d.test.ts +137 -0
- package/src/client/renderers/frames2d.ts +216 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/event-projection.ts +57 -16
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/persist.test.ts +3 -0
- package/src/persist.ts +43 -6
- package/src/registry.test.ts +168 -1
- package/src/registry.ts +199 -1
- package/src/routes.ts +28 -4
- package/src/service.ts +240 -29
- package/src/voice-pack.test.ts +48 -25
- package/src/voice-pack.ts +50 -47
package/src/service.ts
CHANGED
|
@@ -56,6 +56,19 @@ import {
|
|
|
56
56
|
type PetStateInput,
|
|
57
57
|
type PetStateSnapshot,
|
|
58
58
|
} from './state.ts'
|
|
59
|
+
import {
|
|
60
|
+
applyGameplayEffects,
|
|
61
|
+
drawLotteryTier,
|
|
62
|
+
initialGameplayState,
|
|
63
|
+
rollTouchBranch,
|
|
64
|
+
rollWorkOutcome,
|
|
65
|
+
settleGameplay,
|
|
66
|
+
type PetGameplayManifest,
|
|
67
|
+
type PetGameplayState,
|
|
68
|
+
} from './gameplay.ts'
|
|
69
|
+
|
|
70
|
+
/** The unified gameplay currency: the shared treat (小鱼干) ledger. */
|
|
71
|
+
const GAMEPLAY_TREATS_CURRENCY = 'treats'
|
|
59
72
|
|
|
60
73
|
/** Plugin configuration. */
|
|
61
74
|
export interface PetConfig {
|
|
@@ -124,6 +137,8 @@ export interface PetSessionView {
|
|
|
124
137
|
bubble: string
|
|
125
138
|
/** This session's raw activity phase. */
|
|
126
139
|
phase: PetStateSnapshot['phase']
|
|
140
|
+
/** This session's fresh inner whisper (碎碎念), when one is within its TTL. */
|
|
141
|
+
whisper?: string
|
|
127
142
|
}
|
|
128
143
|
|
|
129
144
|
/** Hard cap on simultaneously displayed session bubbles (most recent first). */
|
|
@@ -136,10 +151,10 @@ export interface PetStateView {
|
|
|
136
151
|
phase: PetStateSnapshot['phase']
|
|
137
152
|
sessionActive: boolean
|
|
138
153
|
/**
|
|
139
|
-
* Per-session bubbles for every concurrently active TOP-LEVEL session
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* session.
|
|
154
|
+
* Per-session bubbles for every concurrently active TOP-LEVEL session:
|
|
155
|
+
* the GUI's current session first when reported, the rest most recently
|
|
156
|
+
* active first; optional so older hosts without the multi-session view
|
|
157
|
+
* stay consumable. The single 'bubble' above mirrors the display session.
|
|
143
158
|
*/
|
|
144
159
|
sessions?: PetSessionView[]
|
|
145
160
|
/** Affinity ledger snapshot. */
|
|
@@ -164,23 +179,46 @@ export interface PetStateView {
|
|
|
164
179
|
/** Stock cap. */
|
|
165
180
|
max: number
|
|
166
181
|
}
|
|
167
|
-
/**
|
|
168
|
-
* The display session's fresh inner whisper (碎碎念), when one is within
|
|
169
|
-
* its TTL — short inner-voice copy woken by the model's own output,
|
|
170
|
-
* rendered by the client as a distinct whisper bubble.
|
|
171
|
-
*/
|
|
172
|
-
whisper?: string
|
|
173
182
|
/**
|
|
174
183
|
* The active status decoration (pet-center M5, #567), when the master
|
|
175
184
|
* switch is on and the default decoration entry exists. Absent means the
|
|
176
185
|
* browser half renders no ornament.
|
|
177
186
|
*/
|
|
178
187
|
decoration?: DecorationView
|
|
188
|
+
/**
|
|
189
|
+
* The selected pet's gameplay view (miku-pet generalization), present
|
|
190
|
+
* when the pet declares a gameplay block. Read-only projection: polling
|
|
191
|
+
* settles the view in memory but never writes pet.json.
|
|
192
|
+
*/
|
|
193
|
+
gameplay?: PetGameplayStateView
|
|
179
194
|
}
|
|
180
195
|
|
|
181
196
|
/** Result of `pet.interact`. */
|
|
182
197
|
export type PetInteractResult = LedgerInteractionResult
|
|
183
198
|
|
|
199
|
+
/** The gameplay slice of the state view (dynamic state only; defs ride the pet definition). */
|
|
200
|
+
export interface PetGameplayStateView {
|
|
201
|
+
/** Stat values rounded for display. */
|
|
202
|
+
stats: Record<string, number>
|
|
203
|
+
mode: 'work' | 'sleep' | null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Result of the gameplay verbs (touch / setMode / workTick / buy). */
|
|
207
|
+
export interface PetGameplayVerbResult {
|
|
208
|
+
ok: boolean
|
|
209
|
+
error?: string
|
|
210
|
+
/** Touch: whether a branch hit, plus its presentation. */
|
|
211
|
+
hit?: boolean
|
|
212
|
+
state?: string
|
|
213
|
+
stateMs?: number
|
|
214
|
+
phrase?: string
|
|
215
|
+
/** Work tick outcome. */
|
|
216
|
+
outcome?: 'success' | 'fail'
|
|
217
|
+
/** Shop purchase: the drawn prize, when the item was a lottery. */
|
|
218
|
+
prize?: { amount: number; currency: string }
|
|
219
|
+
view?: PetGameplayStateView
|
|
220
|
+
}
|
|
221
|
+
|
|
184
222
|
declare module '@deepseek-ai/cordis' {
|
|
185
223
|
interface Context {
|
|
186
224
|
pet: PetService
|
|
@@ -296,8 +334,8 @@ export class PetService extends Service {
|
|
|
296
334
|
}
|
|
297
335
|
|
|
298
336
|
/** RPC: current pet state snapshot. */
|
|
299
|
-
async state(): Promise<PetStateView> {
|
|
300
|
-
return this.view()
|
|
337
|
+
async state(currentSessionId?: string): Promise<PetStateView> {
|
|
338
|
+
return this.view(currentSessionId)
|
|
301
339
|
}
|
|
302
340
|
|
|
303
341
|
/** Current persisted display config (read-only view). */
|
|
@@ -483,6 +521,162 @@ export class PetService extends Service {
|
|
|
483
521
|
return result
|
|
484
522
|
}
|
|
485
523
|
|
|
524
|
+
/* ---------------------------------------------------------------- *
|
|
525
|
+
* Gameplay verbs (miku-pet generalization). The manifest block lives
|
|
526
|
+
* on the registry entry; dynamic state persists per pet id. Verbs
|
|
527
|
+
* settle and persist; the state view projects without writing.
|
|
528
|
+
* ---------------------------------------------------------------- */
|
|
529
|
+
|
|
530
|
+
/** The active pet's gameplay block, if it declares one. */
|
|
531
|
+
private gameplayDef(): PetGameplayManifest | undefined {
|
|
532
|
+
return this.activeEntry().gameplay
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The persisted (or fresh) gameplay state of the selected pet. */
|
|
536
|
+
private gameplayState(def: PetGameplayManifest, now: number): { petId: string; state: PetGameplayState } {
|
|
537
|
+
const petId = this.selectedPetId()
|
|
538
|
+
const stored = this.ledger.snapshot.gameplay[petId]
|
|
539
|
+
return {
|
|
540
|
+
petId,
|
|
541
|
+
state: stored === undefined
|
|
542
|
+
? initialGameplayState(def, now)
|
|
543
|
+
: { stats: { ...stored.stats }, currencies: { ...stored.currencies }, mode: stored.mode, settledAt: stored.settledAt },
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Display view of one gameplay state (rounded stats; treats ride the shared treat ledger). */
|
|
548
|
+
private gameplayViewOf(state: PetGameplayState): PetGameplayStateView {
|
|
549
|
+
const stats: Record<string, number> = {}
|
|
550
|
+
for (const [name, value] of Object.entries(state.stats)) stats[name] = Math.round(value)
|
|
551
|
+
return { stats, mode: state.mode }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Move gameplay 'treats' currency (the unified post-wallet currency) from
|
|
556
|
+
* the engine's settle work area into the shared treat ledger, capped by
|
|
557
|
+
* the stock cap. The engine keeps its generic currency record for settle
|
|
558
|
+
* math; this drain is the only bridge to the wallet-free economy.
|
|
559
|
+
*/
|
|
560
|
+
private drainGameplayTreats(state: PetGameplayState): void {
|
|
561
|
+
const pending = Math.floor(state.currencies[GAMEPLAY_TREATS_CURRENCY] ?? 0)
|
|
562
|
+
delete state.currencies[GAMEPLAY_TREATS_CURRENCY]
|
|
563
|
+
if (pending > 0) this.ledger.grantTreats(pending)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Persist the mutated gameplay state of one verb call. */
|
|
567
|
+
private commitGameplay(petId: string, state: PetGameplayState): void {
|
|
568
|
+
this.ledger.setGameplay(petId, state)
|
|
569
|
+
if (this.ledger.takeDirty()) this.flush()
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* RPC: a touch on the pet. 'zone' names a touch zone (roll a branch);
|
|
574
|
+
* omitted means a plain click while a touch animation holds (clickBoost).
|
|
575
|
+
*/
|
|
576
|
+
async gameplayTouch(zone?: string): Promise<PetGameplayVerbResult> {
|
|
577
|
+
const def = this.gameplayDef()
|
|
578
|
+
if (def === undefined) return { ok: false, error: 'no-gameplay' }
|
|
579
|
+
const now = Date.now()
|
|
580
|
+
const { petId, state } = this.gameplayState(def, now)
|
|
581
|
+
settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive })
|
|
582
|
+
if (zone === undefined) {
|
|
583
|
+
const boost = def.touch?.clickBoost
|
|
584
|
+
if (boost === undefined) return { ok: false, error: 'no-touch' }
|
|
585
|
+
const amount = boost.min + Math.floor(Math.random() * (boost.max - boost.min + 1))
|
|
586
|
+
if (amount > 0) applyGameplayEffects(state, def, [{ stat: boost.stat, amount }])
|
|
587
|
+
this.drainGameplayTreats(state)
|
|
588
|
+
this.commitGameplay(petId, state)
|
|
589
|
+
return { ok: true, hit: false, view: this.gameplayViewOf(state) }
|
|
590
|
+
}
|
|
591
|
+
const target = def.touch?.zones.find(entry => entry.name === zone)
|
|
592
|
+
if (target === undefined) return { ok: false, error: 'unknown-zone' }
|
|
593
|
+
const branch = rollTouchBranch(target, Math.random)
|
|
594
|
+
if (branch === undefined) {
|
|
595
|
+
this.drainGameplayTreats(state)
|
|
596
|
+
this.commitGameplay(petId, state)
|
|
597
|
+
return { ok: true, hit: false, view: this.gameplayViewOf(state) }
|
|
598
|
+
}
|
|
599
|
+
if (branch.effects !== undefined) applyGameplayEffects(state, def, branch.effects)
|
|
600
|
+
const phrase = branch.phrases !== undefined && branch.phrases.length > 0
|
|
601
|
+
? branch.phrases[Math.floor(Math.random() * branch.phrases.length)]
|
|
602
|
+
: undefined
|
|
603
|
+
this.drainGameplayTreats(state)
|
|
604
|
+
this.commitGameplay(petId, state)
|
|
605
|
+
return {
|
|
606
|
+
ok: true,
|
|
607
|
+
hit: true,
|
|
608
|
+
...(branch.state === undefined ? {} : { state: branch.state }),
|
|
609
|
+
...(branch.stateMs === undefined ? {} : { stateMs: branch.stateMs }),
|
|
610
|
+
...(phrase === undefined ? {} : { phrase }),
|
|
611
|
+
view: this.gameplayViewOf(state),
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** RPC: enter or leave a gameplay mode ('work' | 'sleep' | null). */
|
|
616
|
+
async gameplaySetMode(mode: 'work' | 'sleep' | null): Promise<PetGameplayVerbResult> {
|
|
617
|
+
const def = this.gameplayDef()
|
|
618
|
+
if (def === undefined) return { ok: false, error: 'no-gameplay' }
|
|
619
|
+
if (mode === 'work' && def.work === undefined) return { ok: false, error: 'no-work' }
|
|
620
|
+
if (mode === 'sleep' && def.sleep === undefined) return { ok: false, error: 'no-sleep' }
|
|
621
|
+
const now = Date.now()
|
|
622
|
+
const { petId, state } = this.gameplayState(def, now)
|
|
623
|
+
settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive })
|
|
624
|
+
state.mode = mode
|
|
625
|
+
this.drainGameplayTreats(state)
|
|
626
|
+
this.commitGameplay(petId, state)
|
|
627
|
+
return { ok: true, view: this.gameplayViewOf(state) }
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** RPC: one work-round adjudication (only while the work mode holds). */
|
|
631
|
+
async gameplayWorkTick(): Promise<PetGameplayVerbResult> {
|
|
632
|
+
const def = this.gameplayDef()
|
|
633
|
+
if (def?.work === undefined) return { ok: false, error: 'no-work' }
|
|
634
|
+
const now = Date.now()
|
|
635
|
+
const { petId, state } = this.gameplayState(def, now)
|
|
636
|
+
if (state.mode !== 'work') return { ok: false, error: 'not-working' }
|
|
637
|
+
settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive })
|
|
638
|
+
const outcome = rollWorkOutcome(def.work, Math.random)
|
|
639
|
+
const effects = outcome === 'success' ? def.work.success?.effects : def.work.fail?.effects
|
|
640
|
+
if (effects !== undefined) applyGameplayEffects(state, def, effects)
|
|
641
|
+
this.drainGameplayTreats(state)
|
|
642
|
+
this.commitGameplay(petId, state)
|
|
643
|
+
return { ok: true, outcome, view: this.gameplayViewOf(state) }
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** RPC: buy one shop item (effects, currency swap, or a lottery draw). */
|
|
647
|
+
async gameplayBuy(itemId: string): Promise<PetGameplayVerbResult> {
|
|
648
|
+
const def = this.gameplayDef()
|
|
649
|
+
if (def === undefined) return { ok: false, error: 'no-gameplay' }
|
|
650
|
+
const item = def.shop?.items.find(entry => entry.id === itemId)
|
|
651
|
+
if (item === undefined) return { ok: false, error: 'unknown-item' }
|
|
652
|
+
const now = Date.now()
|
|
653
|
+
const { petId, state } = this.gameplayState(def, now)
|
|
654
|
+
settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive })
|
|
655
|
+
// The unified currency is the shared treat ledger (wallet removed): shop
|
|
656
|
+
// prices and prizes ride the same balance the panel shows and feeding
|
|
657
|
+
// consumes, capped by the stock cap.
|
|
658
|
+
const treats = item.currency === GAMEPLAY_TREATS_CURRENCY
|
|
659
|
+
const balance = treats ? this.ledger.snapshot.treats.treats : (state.currencies[item.currency] ?? 0)
|
|
660
|
+
if (balance < item.price) {
|
|
661
|
+
return { ok: false, error: 'insufficient-funds', view: this.gameplayViewOf(state) }
|
|
662
|
+
}
|
|
663
|
+
if (treats) this.ledger.spendTreats(item.price)
|
|
664
|
+
else state.currencies[item.currency] = balance - item.price
|
|
665
|
+
if (item.effects !== undefined) applyGameplayEffects(state, def, item.effects)
|
|
666
|
+
let prize: PetGameplayVerbResult['prize']
|
|
667
|
+
if (item.lottery !== undefined) {
|
|
668
|
+
if (item.lottery.effects !== undefined) applyGameplayEffects(state, def, item.lottery.effects)
|
|
669
|
+
const tier = drawLotteryTier(item.lottery, Math.random)
|
|
670
|
+
const prizeCurrency = tier.currency ?? item.lottery.currency ?? item.currency
|
|
671
|
+
if (prizeCurrency === GAMEPLAY_TREATS_CURRENCY) this.ledger.grantTreats(tier.prize)
|
|
672
|
+
else state.currencies[prizeCurrency] = (state.currencies[prizeCurrency] ?? 0) + tier.prize
|
|
673
|
+
prize = { amount: tier.prize, currency: prizeCurrency }
|
|
674
|
+
}
|
|
675
|
+
this.drainGameplayTreats(state)
|
|
676
|
+
this.commitGameplay(petId, state)
|
|
677
|
+
return { ok: true, ...(prize === undefined ? {} : { prize }), view: this.gameplayViewOf(state) }
|
|
678
|
+
}
|
|
679
|
+
|
|
486
680
|
/** RPC: show or hide the pet. */
|
|
487
681
|
async setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }> {
|
|
488
682
|
this.ledger.setDisplay({ ...this.ledger.snapshot.display, visible })
|
|
@@ -565,38 +759,55 @@ export class PetService extends Service {
|
|
|
565
759
|
if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
|
|
566
760
|
}
|
|
567
761
|
|
|
568
|
-
private view(): PetStateView {
|
|
762
|
+
private view(currentSessionId?: string): PetStateView {
|
|
569
763
|
const snapshot = this.machine.render()
|
|
570
764
|
const entry = this.activeEntry()
|
|
571
|
-
// One bubble per concurrently active TOP-LEVEL session
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
765
|
+
// One bubble per concurrently active TOP-LEVEL session. The GUI's current
|
|
766
|
+
// session leads the stack when reported (the browser half passes its
|
|
767
|
+
// session list's 'current'); everything else keeps the most recent
|
|
768
|
+
// meaningful event order. Subagent children render no bubble of their own
|
|
769
|
+
// (their activity already shows through the spawning conversation's
|
|
770
|
+
// bubble/display, and the bubble buttons navigate to GUI sessions, which
|
|
771
|
+
// subagents are not). Sessions whose own machine has settled (no bubble
|
|
772
|
+
// copy) drop out, so a finished turn does not leave a stale bubble behind.
|
|
577
773
|
const sessions: PetSessionView[] = []
|
|
578
774
|
for (const [session, activity] of [...this.sessionActivity.entries()].reverse()) {
|
|
579
775
|
if (sessions.length >= MAX_SESSION_BUBBLES) break
|
|
580
776
|
if (session.header?.origin === 'subagent') continue
|
|
581
777
|
const perSession = activity.machine.render()
|
|
582
778
|
if (perSession.bubble === undefined) continue
|
|
779
|
+
// Each session's whisper rides its own bubble while fresh; an expired
|
|
780
|
+
// whisper simply stops appearing (the client's 2 s poll drops it).
|
|
781
|
+
const whisper = activity.whisper
|
|
782
|
+
const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
|
|
783
|
+
? whisper.text
|
|
784
|
+
: undefined
|
|
583
785
|
sessions.push({
|
|
584
786
|
sessionId: String(session.id),
|
|
585
787
|
animation: perSession.animation,
|
|
586
788
|
bubble: perSession.bubble,
|
|
587
789
|
phase: perSession.phase,
|
|
790
|
+
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
588
791
|
})
|
|
589
792
|
}
|
|
590
|
-
// The
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
: undefined
|
|
793
|
+
// The browser half reports the session the user is currently on; that
|
|
794
|
+
// session's bubble leads the stack (its whisper then speaks on the
|
|
795
|
+
// collapsed single bubble). An unreported or absent session keeps the
|
|
796
|
+
// legacy most-recent-first order.
|
|
797
|
+
if (currentSessionId !== undefined) {
|
|
798
|
+
const index = sessions.findIndex(session => session.sessionId === currentSessionId)
|
|
799
|
+
if (index > 0) sessions.unshift(sessions.splice(index, 1)[0]!)
|
|
800
|
+
}
|
|
599
801
|
const decoration = this.activeDecoration()
|
|
802
|
+
// Gameplay view: a read-only projection. The settle runs on a copy, so
|
|
803
|
+
// polling never writes pet.json (verbs settle and persist).
|
|
804
|
+
const gameplayDef = this.gameplayDef()
|
|
805
|
+
let gameplay: PetGameplayStateView | undefined
|
|
806
|
+
if (gameplayDef !== undefined) {
|
|
807
|
+
const { state } = this.gameplayState(gameplayDef, Date.now())
|
|
808
|
+
settleGameplay(state, gameplayDef, Date.now(), { sessionActive: snapshot.sessionActive })
|
|
809
|
+
gameplay = this.gameplayViewOf(state)
|
|
810
|
+
}
|
|
600
811
|
// Read-only: the ledger settles on economic events only, never on a read,
|
|
601
812
|
// so polling the state cannot trigger pet.json writes.
|
|
602
813
|
return {
|
|
@@ -605,7 +816,6 @@ export class PetService extends Service {
|
|
|
605
816
|
phase: snapshot.phase,
|
|
606
817
|
sessionActive: snapshot.sessionActive,
|
|
607
818
|
sessions,
|
|
608
|
-
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
609
819
|
...(decoration === undefined ? {} : { decoration }),
|
|
610
820
|
affinity: this.ledger.affinityView(Date.now()),
|
|
611
821
|
display: { ...this.ledger.snapshot.display },
|
|
@@ -619,6 +829,7 @@ export class PetService extends Service {
|
|
|
619
829
|
stocked: this.ledger.snapshot.treats.treats,
|
|
620
830
|
max: this.ledger.treatMax,
|
|
621
831
|
},
|
|
832
|
+
...(gameplay === undefined ? {} : { gameplay }),
|
|
622
833
|
}
|
|
623
834
|
}
|
|
624
835
|
|
package/src/voice-pack.test.ts
CHANGED
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
normalizePanel,
|
|
13
13
|
normalizePool,
|
|
14
14
|
normalizeVoicePack,
|
|
15
|
-
|
|
15
|
+
normalizeWhisperCategories,
|
|
16
|
+
normalizeWhisperResults,
|
|
16
17
|
PANEL_ACTIONS,
|
|
17
18
|
PANEL_LABEL_KEYS,
|
|
18
19
|
PANEL_STAT_KEYS,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
VOICE_PACK_V1,
|
|
22
23
|
WHISPER_KEYS,
|
|
23
24
|
} from './voice-pack.ts'
|
|
24
|
-
import { STATUS_SCENES, TOOL_CATEGORIES } from './chatter.ts'
|
|
25
|
+
import { STATUS_SCENES, TOOL_CATEGORIES, WHISPER_CATEGORIES, WHISPER_RESULTS } from './chatter.ts'
|
|
25
26
|
import { petPackageRoot } from './registry.ts'
|
|
26
27
|
|
|
27
28
|
function collectWarnings(pack: unknown): { pack: ReturnType<typeof normalizeVoicePack>; warnings: string[] } {
|
|
@@ -85,31 +86,40 @@ describe('normalizePool', () => {
|
|
|
85
86
|
})
|
|
86
87
|
|
|
87
88
|
it('preserves an explicit empty array (mute semantics) but not absent', () => {
|
|
88
|
-
expect(normalizePool([], '
|
|
89
|
-
expect(normalizePool(undefined, '
|
|
89
|
+
expect(normalizePool([], 'whisperCategory')).toEqual([])
|
|
90
|
+
expect(normalizePool(undefined, 'whisperCategory')).toBeUndefined()
|
|
90
91
|
})
|
|
91
92
|
})
|
|
92
93
|
|
|
93
|
-
describe('
|
|
94
|
-
it('
|
|
94
|
+
describe('normalizeWhisperCategories', () => {
|
|
95
|
+
it('keeps known categories, keeps an explicit empty pool (mute) and warns on unknown keys', () => {
|
|
95
96
|
const warnings: string[] = []
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
{ keywords: ['有词'], pool: [] },
|
|
100
|
-
], m => warnings.push(m))
|
|
101
|
-
expect(rules).toEqual([{ keywords: ['测试通过'], pool: ['全绿'] }])
|
|
102
|
-
expect(warnings.length).toBeGreaterThan(0)
|
|
97
|
+
const pools = normalizeWhisperCategories({ thinking: ['自定义思考'], running: [], bogus: ['x'] }, m => warnings.push(m))
|
|
98
|
+
expect(pools).toEqual({ thinking: ['自定义思考'], running: [] })
|
|
99
|
+
expect(warnings.join('\n')).toContain('unknown whisper category')
|
|
103
100
|
})
|
|
104
101
|
|
|
105
|
-
it('
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
expect(
|
|
102
|
+
it('returns undefined for an absent or non-object section', () => {
|
|
103
|
+
expect(normalizeWhisperCategories(undefined)).toBeUndefined()
|
|
104
|
+
const warnings: string[] = []
|
|
105
|
+
expect(normalizeWhisperCategories(['x'], m => warnings.push(m))).toBeUndefined()
|
|
106
|
+
expect(warnings.join('\n')).toContain('must be an object')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('normalizeWhisperResults', () => {
|
|
111
|
+
it('keeps known outcomes, keeps an explicit empty pool (mute) and warns on unknown keys', () => {
|
|
112
|
+
const warnings: string[] = []
|
|
113
|
+
const pools = normalizeWhisperResults({ pass: ['自定义全绿'], done: [], bogus: ['x'] }, m => warnings.push(m))
|
|
114
|
+
expect(pools).toEqual({ pass: ['自定义全绿'], done: [] })
|
|
115
|
+
expect(warnings.join('\n')).toContain('unknown whisper result')
|
|
109
116
|
})
|
|
110
117
|
|
|
111
|
-
it('returns
|
|
112
|
-
expect(
|
|
118
|
+
it('returns undefined for an absent or non-object section', () => {
|
|
119
|
+
expect(normalizeWhisperResults(undefined)).toBeUndefined()
|
|
120
|
+
const warnings: string[] = []
|
|
121
|
+
expect(normalizeWhisperResults('x', m => warnings.push(m))).toBeUndefined()
|
|
122
|
+
expect(warnings.join('\n')).toContain('must be an object')
|
|
113
123
|
})
|
|
114
124
|
})
|
|
115
125
|
|
|
@@ -153,12 +163,22 @@ describe('mergeVoicePacks', () => {
|
|
|
153
163
|
expect(merged?.panel?.labels).toEqual({ feed: '宠物投喂', hide: '宠物藏' })
|
|
154
164
|
})
|
|
155
165
|
|
|
156
|
-
it('lets the pet pack replace whisper
|
|
157
|
-
const global = normalizeVoicePack({ whispers: {
|
|
158
|
-
const pet = normalizeVoicePack({ whispers: {
|
|
166
|
+
it('lets the pet pack replace whisper keys while the global stays', () => {
|
|
167
|
+
const global = normalizeVoicePack({ whispers: { categories: { thinking: ['全局思考'] }, results: { pass: ['全局全绿'] } } })
|
|
168
|
+
const pet = normalizeVoicePack({ whispers: { categories: { thinking: ['宠物思考'] } } })
|
|
159
169
|
const merged = mergeVoicePacks(global, pet)
|
|
160
|
-
expect(merged?.overrides.whispers?.
|
|
161
|
-
expect(merged?.overrides.whispers?.
|
|
170
|
+
expect(merged?.overrides.whispers?.categories?.thinking).toEqual(['宠物思考'])
|
|
171
|
+
expect(merged?.overrides.whispers?.results?.pass).toEqual(['全局全绿'])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('warns and ignores the legacy generic / rules whisper fields', () => {
|
|
175
|
+
const { pack, warnings } = collectWarnings({
|
|
176
|
+
whispers: { generic: ['老环境池'], rules: [{ keywords: ['测试'], pool: ['老全绿'] }], categories: { thinking: ['新思考'] } },
|
|
177
|
+
})
|
|
178
|
+
expect(pack?.overrides.whispers?.categories?.thinking).toEqual(['新思考'])
|
|
179
|
+
expect(pack?.overrides.whispers?.categories?.generic).toBeUndefined()
|
|
180
|
+
expect(warnings.join('\n')).toContain('generic')
|
|
181
|
+
expect(warnings.join('\n')).toContain('rules')
|
|
162
182
|
})
|
|
163
183
|
|
|
164
184
|
it('returns undefined when every layer is empty', () => {
|
|
@@ -200,7 +220,10 @@ describe('voice-pack schema file drift lock', () => {
|
|
|
200
220
|
const props = schema.properties
|
|
201
221
|
expect(new Set(Object.keys(props.status?.properties ?? {}))).toEqual(new Set(STATUS_SCENES))
|
|
202
222
|
expect(new Set(Object.keys(props.tools?.properties ?? {}))).toEqual(new Set(TOOL_CATEGORIES))
|
|
203
|
-
|
|
223
|
+
const whispers = props.whispers?.properties ?? {}
|
|
224
|
+
expect(new Set(Object.keys(whispers))).toEqual(WHISPER_KEYS)
|
|
225
|
+
expect(new Set(Object.keys((whispers.categories as { properties?: Record<string, unknown> })?.properties ?? {}))).toEqual(new Set(WHISPER_CATEGORIES))
|
|
226
|
+
expect(new Set(Object.keys((whispers.results as { properties?: Record<string, unknown> })?.properties ?? {}))).toEqual(new Set(WHISPER_RESULTS))
|
|
204
227
|
const panel = props.panel?.properties ?? {}
|
|
205
228
|
const labels = (panel.labels as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}
|
|
206
229
|
const stats = (panel.stats as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}
|
package/src/voice-pack.ts
CHANGED
|
@@ -31,8 +31,11 @@
|
|
|
31
31
|
import {
|
|
32
32
|
STATUS_SCENES,
|
|
33
33
|
TOOL_CATEGORIES,
|
|
34
|
+
WHISPER_CATEGORIES,
|
|
35
|
+
WHISPER_RESULTS,
|
|
34
36
|
type VoicePackOverrides,
|
|
35
|
-
type
|
|
37
|
+
type WhisperCategory,
|
|
38
|
+
type WhisperResult,
|
|
36
39
|
} from './chatter.ts'
|
|
37
40
|
|
|
38
41
|
/** Schema version this module normalizes (optional field; missing = 1). */
|
|
@@ -71,9 +74,6 @@ export interface VoicePack {
|
|
|
71
74
|
/** Hard caps shared by every pool slot (mirrors the remarks discipline). */
|
|
72
75
|
export const VOICE_POOL_LINES_MAX = 64
|
|
73
76
|
export const VOICE_LINE_MAX = 160
|
|
74
|
-
export const VOICE_KEYWORDS_PER_RULE_MAX = 16
|
|
75
|
-
export const VOICE_KEYWORD_MAX = 40
|
|
76
|
-
export const VOICE_RULES_MAX = 32
|
|
77
77
|
export const VOICE_LABEL_MAX = 40
|
|
78
78
|
export const VOICE_STAT_MAX = 80
|
|
79
79
|
|
|
@@ -87,7 +87,7 @@ const PLACEHOLDER_WHITELIST: Partial<Record<PoolKind, readonly string[]>> = {
|
|
|
87
87
|
stat: ['{rank}', '{n}', '{points}'],
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
type PoolKind = 'status' | 'tools' | 'toolRemaining' | '
|
|
90
|
+
type PoolKind = 'status' | 'tools' | 'toolRemaining' | 'whisperCategory' | 'whisperResult' | 'label' | 'stat'
|
|
91
91
|
|
|
92
92
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
93
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
@@ -151,51 +151,48 @@ export function normalizePool(
|
|
|
151
151
|
return pool
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
/** Normalize
|
|
155
|
-
export function
|
|
154
|
+
/** Normalize whisper category pools; a key replaces that category's built-in pool (an explicit empty pool mutes it). */
|
|
155
|
+
export function normalizeWhisperCategories(
|
|
156
156
|
raw: unknown,
|
|
157
157
|
onWarning: (message: string) => void = () => {},
|
|
158
|
-
):
|
|
158
|
+
): Partial<Record<WhisperCategory, readonly string[]>> | undefined {
|
|
159
159
|
if (raw === undefined) return undefined
|
|
160
|
-
if (!
|
|
161
|
-
onWarning('whispers.
|
|
160
|
+
if (!isRecord(raw)) {
|
|
161
|
+
onWarning('whispers.categories must be an object')
|
|
162
162
|
return undefined
|
|
163
163
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
for (const item of raw.slice(0, VOICE_RULES_MAX)) {
|
|
169
|
-
if (!isRecord(item)) {
|
|
170
|
-
onWarning('whisper rule must be an object')
|
|
164
|
+
const pools: Partial<Record<WhisperCategory, readonly string[]>> = {}
|
|
165
|
+
for (const key of Object.keys(raw)) {
|
|
166
|
+
if (!(WHISPER_CATEGORIES as readonly string[]).includes(key)) {
|
|
167
|
+
onWarning('unknown whisper category ' + key + ' ignored')
|
|
171
168
|
continue
|
|
172
169
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
onWarning('whisper rule dropped (needs keywords and a non-empty pool)')
|
|
170
|
+
const pool = normalizePool(raw[key], 'whisperCategory', onWarning)
|
|
171
|
+
if (pool !== undefined) pools[key as WhisperCategory] = pool
|
|
172
|
+
}
|
|
173
|
+
return Object.keys(pools).length > 0 ? pools : undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Normalize whisper outcome pools; a key replaces that outcome's built-in pool (an explicit empty pool mutes it). */
|
|
177
|
+
export function normalizeWhisperResults(
|
|
178
|
+
raw: unknown,
|
|
179
|
+
onWarning: (message: string) => void = () => {},
|
|
180
|
+
): Partial<Record<WhisperResult, readonly string[]>> | undefined {
|
|
181
|
+
if (raw === undefined) return undefined
|
|
182
|
+
if (!isRecord(raw)) {
|
|
183
|
+
onWarning('whispers.results must be an object')
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
const pools: Partial<Record<WhisperResult, readonly string[]>> = {}
|
|
187
|
+
for (const key of Object.keys(raw)) {
|
|
188
|
+
if (!(WHISPER_RESULTS as readonly string[]).includes(key)) {
|
|
189
|
+
onWarning('unknown whisper result ' + key + ' ignored')
|
|
194
190
|
continue
|
|
195
191
|
}
|
|
196
|
-
|
|
192
|
+
const pool = normalizePool(raw[key], 'whisperResult', onWarning)
|
|
193
|
+
if (pool !== undefined) pools[key as WhisperResult] = pool
|
|
197
194
|
}
|
|
198
|
-
return
|
|
195
|
+
return Object.keys(pools).length > 0 ? pools : undefined
|
|
199
196
|
}
|
|
200
197
|
|
|
201
198
|
/** Normalize the panel block (labels / stats / actions; warn-and-drop). */
|
|
@@ -271,7 +268,7 @@ export function normalizePanel(
|
|
|
271
268
|
export const VOICE_PACK_KEYS = new Set(['$schema', 'voicePackVersion', 'status', 'tools', 'toolRemaining', 'whispers', 'panel'])
|
|
272
269
|
|
|
273
270
|
/** Allowed whisper-section fields (drift-locked in tests). */
|
|
274
|
-
export const WHISPER_KEYS = new Set(['
|
|
271
|
+
export const WHISPER_KEYS = new Set(['categories', 'results'])
|
|
275
272
|
|
|
276
273
|
/**
|
|
277
274
|
* Normalize one raw voice.json document into a VoicePack, or undefined when
|
|
@@ -340,14 +337,20 @@ export function normalizeVoicePack(
|
|
|
340
337
|
onWarning('whispers must be an object')
|
|
341
338
|
} else {
|
|
342
339
|
for (const key of Object.keys(whispersRaw)) {
|
|
343
|
-
if (
|
|
340
|
+
if (key === 'generic' || key === 'rules') {
|
|
341
|
+
// pet M6: keyword and ambient whisper channels are gone; legacy
|
|
342
|
+
// fields are ignored with a warning, never mapped to the new shape.
|
|
343
|
+
onWarning('whispers.' + key + ' is no longer supported and was ignored')
|
|
344
|
+
} else if (!WHISPER_KEYS.has(key)) {
|
|
345
|
+
onWarning('unknown whispers field ' + key + ' ignored')
|
|
346
|
+
}
|
|
344
347
|
}
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
if (
|
|
348
|
+
const categories = normalizeWhisperCategories(whispersRaw.categories, onWarning)
|
|
349
|
+
const results = normalizeWhisperResults(whispersRaw.results, onWarning)
|
|
350
|
+
if (categories !== undefined || results !== undefined) {
|
|
348
351
|
overrides.whispers = {
|
|
349
|
-
...(
|
|
350
|
-
...(
|
|
352
|
+
...(categories === undefined ? {} : { categories }),
|
|
353
|
+
...(results === undefined ? {} : { results }),
|
|
351
354
|
}
|
|
352
355
|
}
|
|
353
356
|
}
|