@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.
Files changed (123) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -13
  3. package/README.zh.md +30 -13
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/contracts/voice-pack-v1.schema.json +23 -28
  17. package/lib/client.js +781 -40
  18. package/lib/client.js.map +1 -1
  19. package/lib/index.js +1567 -482
  20. package/lib/types/chatter.d.ts +68 -46
  21. package/lib/types/chatter.d.ts.map +1 -1
  22. package/lib/types/chatter.js +243 -301
  23. package/lib/types/client/PetDockEntry.d.ts +3 -0
  24. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  25. package/lib/types/client/PetDockEntry.js +18 -2
  26. package/lib/types/client/PetSprite.d.ts +23 -0
  27. package/lib/types/client/PetSprite.d.ts.map +1 -1
  28. package/lib/types/client/PetSprite.js +49 -45
  29. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  30. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  31. package/lib/types/client/PluginSettingsCard.js +2 -2
  32. package/lib/types/client/drag-stream.d.ts +19 -0
  33. package/lib/types/client/drag-stream.d.ts.map +1 -0
  34. package/lib/types/client/drag-stream.js +26 -0
  35. package/lib/types/client/gameplay-hud.d.ts +50 -0
  36. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  37. package/lib/types/client/gameplay-hud.js +274 -0
  38. package/lib/types/client/index.d.ts.map +1 -1
  39. package/lib/types/client/index.js +38 -9
  40. package/lib/types/client/locales.d.ts +34 -0
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/locales.js +36 -0
  43. package/lib/types/client/pet-store.d.ts +7 -1
  44. package/lib/types/client/pet-store.d.ts.map +1 -1
  45. package/lib/types/client/pet-store.js +4 -0
  46. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  47. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  48. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  49. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  50. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  51. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  52. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  53. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  54. package/lib/types/client/renderers/frames2d.js +196 -0
  55. package/lib/types/defaults.d.ts +6 -0
  56. package/lib/types/defaults.d.ts.map +1 -0
  57. package/lib/types/defaults.js +5 -0
  58. package/lib/types/dsh-home.d.ts.map +1 -1
  59. package/lib/types/dsh-home.js +9 -3
  60. package/lib/types/event-projection.d.ts +9 -4
  61. package/lib/types/event-projection.d.ts.map +1 -1
  62. package/lib/types/event-projection.js +46 -14
  63. package/lib/types/gameplay.d.ts +182 -0
  64. package/lib/types/gameplay.d.ts.map +1 -0
  65. package/lib/types/gameplay.js +618 -0
  66. package/lib/types/ledger.d.ts +14 -0
  67. package/lib/types/ledger.d.ts.map +1 -1
  68. package/lib/types/ledger.js +30 -0
  69. package/lib/types/manifest-v2.d.ts +38 -1
  70. package/lib/types/manifest-v2.d.ts.map +1 -1
  71. package/lib/types/manifest-v2.js +182 -2
  72. package/lib/types/persist.d.ts +4 -4
  73. package/lib/types/persist.d.ts.map +1 -1
  74. package/lib/types/persist.js +41 -4
  75. package/lib/types/registry.d.ts +29 -0
  76. package/lib/types/registry.d.ts.map +1 -1
  77. package/lib/types/registry.js +170 -1
  78. package/lib/types/routes.d.ts.map +1 -1
  79. package/lib/types/routes.js +30 -3
  80. package/lib/types/service.d.ts +63 -11
  81. package/lib/types/service.d.ts.map +1 -1
  82. package/lib/types/service.js +208 -19
  83. package/lib/types/voice-pack.d.ts +6 -7
  84. package/lib/types/voice-pack.d.ts.map +1 -1
  85. package/lib/types/voice-pack.js +44 -47
  86. package/package.json +5 -2
  87. package/src/chatter.test.ts +68 -38
  88. package/src/chatter.ts +269 -316
  89. package/src/client/PetDockEntry.test.tsx +6 -0
  90. package/src/client/PetDockEntry.tsx +32 -1
  91. package/src/client/PetSprite.test.tsx +68 -21
  92. package/src/client/PetSprite.tsx +63 -45
  93. package/src/client/PluginSettingsCard.tsx +4 -2
  94. package/src/client/drag-stream.ts +35 -0
  95. package/src/client/gameplay-hud.test.tsx +338 -0
  96. package/src/client/gameplay-hud.tsx +403 -0
  97. package/src/client/index.test.tsx +10 -1
  98. package/src/client/index.ts +46 -11
  99. package/src/client/locales.ts +36 -0
  100. package/src/client/pet-store.ts +10 -1
  101. package/src/client/pet.module.css +192 -29
  102. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  103. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  104. package/src/client/renderers/frames2d.test.ts +137 -0
  105. package/src/client/renderers/frames2d.ts +216 -0
  106. package/src/client/settings-card.module.css +5 -0
  107. package/src/defaults.ts +7 -0
  108. package/src/dsh-home.ts +9 -3
  109. package/src/event-projection.ts +57 -16
  110. package/src/gameplay.test.ts +239 -0
  111. package/src/gameplay.ts +692 -0
  112. package/src/ledger.test.ts +16 -0
  113. package/src/ledger.ts +31 -0
  114. package/src/manifest-v2.test.ts +130 -1
  115. package/src/manifest-v2.ts +181 -2
  116. package/src/persist.test.ts +3 -0
  117. package/src/persist.ts +43 -6
  118. package/src/registry.test.ts +168 -1
  119. package/src/registry.ts +199 -1
  120. package/src/routes.ts +28 -4
  121. package/src/service.ts +240 -29
  122. package/src/voice-pack.test.ts +48 -25
  123. 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
- * most recent first; optional so older hosts without the multi-session
141
- * view stay consumable. The single 'bubble' above mirrors the display
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, most recent
572
- // first. Subagent children render no bubble of their own (their activity
573
- // already shows through the spawning conversation's bubble/display, and
574
- // the bubble buttons navigate to GUI sessions, which subagents are not).
575
- // Sessions whose own machine has settled (no bubble copy) drop out, so a
576
- // finished turn does not leave a stale bubble behind.
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 display session's inner whisper rides the global view while fresh;
591
- // an expired whisper simply stops appearing (the client's 2s poll drops it).
592
- const displayActivity = this.displaySession === undefined
593
- ? undefined
594
- : this.sessionActivity.get(this.displaySession)
595
- const whisper = displayActivity?.whisper
596
- const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
597
- ? whisper.text
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
 
@@ -12,7 +12,8 @@ import {
12
12
  normalizePanel,
13
13
  normalizePool,
14
14
  normalizeVoicePack,
15
- normalizeWhisperRules,
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([], 'whisperGeneric')).toEqual([])
89
- expect(normalizePool(undefined, 'whisperGeneric')).toBeUndefined()
89
+ expect(normalizePool([], 'whisperCategory')).toEqual([])
90
+ expect(normalizePool(undefined, 'whisperCategory')).toBeUndefined()
90
91
  })
91
92
  })
92
93
 
93
- describe('normalizeWhisperRules', () => {
94
- it('lowercases and trims keywords, drops unusable rules', () => {
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 rules = normalizeWhisperRules([
97
- { keywords: [' 测试通过 ', 7], pool: ['全绿'] },
98
- { keywords: [], pool: ['没有关键词'] },
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('caps the rule count and keyword count', () => {
106
- const many = Array.from({ length: 40 }, (_, i) => ({ keywords: ['k' + i], pool: ['p'] }))
107
- expect(normalizeWhisperRules(many)).toHaveLength(32)
108
- expect(normalizeWhisperRules([{ keywords: Array.from({ length: 20 }, (_, i) => 'k' + i), pool: ['p'] }])![0]!.keywords).toHaveLength(16)
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 an explicit empty array (disables the keyword channel)', () => {
112
- expect(normalizeWhisperRules([])).toEqual([])
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 sections while the global stays', () => {
157
- const global = normalizeVoicePack({ whispers: { generic: ['全局碎碎念'] } })
158
- const pet = normalizeVoicePack({ whispers: { rules: [{ keywords: ['测试'], pool: ['宠物全绿'] }] } })
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?.generic).toEqual(['全局碎碎念'])
161
- expect(merged?.overrides.whispers?.rules).toEqual([{ keywords: ['测试'], pool: ['宠物全绿'] }])
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
- expect(new Set(Object.keys(props.whispers?.properties ?? {}))).toEqual(WHISPER_KEYS)
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 WhisperRule,
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' | 'whisperGeneric' | 'whisperRule' | 'label' | 'stat'
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 the ordered keyword-rule list ([] disables the keyword channel). */
155
- export function normalizeWhisperRules(
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
- ): WhisperRule[] | undefined {
158
+ ): Partial<Record<WhisperCategory, readonly string[]>> | undefined {
159
159
  if (raw === undefined) return undefined
160
- if (!Array.isArray(raw)) {
161
- onWarning('whispers.rules must be an array')
160
+ if (!isRecord(raw)) {
161
+ onWarning('whispers.categories must be an object')
162
162
  return undefined
163
163
  }
164
- if (raw.length > VOICE_RULES_MAX) {
165
- onWarning('whispers.rules has more than ' + VOICE_RULES_MAX + ' rules; extra rules are ignored')
166
- }
167
- const rules: WhisperRule[] = []
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
- for (const key of Object.keys(item)) {
174
- if (key !== 'keywords' && key !== 'pool') onWarning('unknown whisper rule field ' + key + ' ignored')
175
- }
176
- const keywordsRaw: unknown = item.keywords
177
- const keywords: string[] = []
178
- if (Array.isArray(keywordsRaw)) {
179
- for (const entry of keywordsRaw.slice(0, VOICE_KEYWORDS_PER_RULE_MAX)) {
180
- if (typeof entry !== 'string') {
181
- onWarning('non-string keyword dropped')
182
- continue
183
- }
184
- const trimmed = entry.trim().toLowerCase().slice(0, VOICE_KEYWORD_MAX)
185
- if (trimmed !== '') keywords.push(trimmed)
186
- }
187
- if (keywordsRaw.length > VOICE_KEYWORDS_PER_RULE_MAX) {
188
- onWarning('rule has more than ' + VOICE_KEYWORDS_PER_RULE_MAX + ' keywords; extra keywords are ignored')
189
- }
190
- }
191
- const pool = normalizePool(item.pool, 'whisperRule', onWarning)
192
- if (keywords.length === 0 || pool === undefined || pool.length === 0) {
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
- rules.push({ keywords, pool })
192
+ const pool = normalizePool(raw[key], 'whisperResult', onWarning)
193
+ if (pool !== undefined) pools[key as WhisperResult] = pool
197
194
  }
198
- return rules
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(['generic', 'rules'])
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 (!WHISPER_KEYS.has(key)) onWarning('unknown whispers field ' + key + ' ignored')
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 generic = normalizePool(whispersRaw.generic, 'whisperGeneric', onWarning)
346
- const rules = normalizeWhisperRules(whispersRaw.rules, onWarning)
347
- if (generic !== undefined || rules !== undefined) {
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
- ...(generic === undefined ? {} : { generic }),
350
- ...(rules === undefined ? {} : { rules }),
352
+ ...(categories === undefined ? {} : { categories }),
353
+ ...(results === undefined ? {} : { results }),
351
354
  }
352
355
  }
353
356
  }