@linxin666/dsh-pet 0.1.18 → 0.1.20

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 (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +12 -5
  3. package/README.zh.md +12 -5
  4. package/lib/client.js +95 -26
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +144 -31
  7. package/lib/invariant.js +2 -2
  8. package/lib/state-Diiq8vjk.js +394 -0
  9. package/lib/types/affinity.d.ts +39 -11
  10. package/lib/types/affinity.d.ts.map +1 -1
  11. package/lib/types/affinity.js +21 -7
  12. package/lib/types/client/PetDockEntry.d.ts +2 -0
  13. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  14. package/lib/types/client/PetDockEntry.js +2 -2
  15. package/lib/types/client/PetSettingsCard.d.ts +5 -0
  16. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/PetSettingsCard.js +9 -2
  18. package/lib/types/client/PetSprite.d.ts +2 -0
  19. package/lib/types/client/PetSprite.d.ts.map +1 -1
  20. package/lib/types/client/PetSprite.js +22 -15
  21. package/lib/types/client/PluginSettingsCard.d.ts +1 -1
  22. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  23. package/lib/types/client/index.d.ts +1 -1
  24. package/lib/types/client/index.d.ts.map +1 -1
  25. package/lib/types/client/index.js +45 -14
  26. package/lib/types/client/locales.d.ts +82 -80
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/locales.js +2 -0
  29. package/lib/types/client/settings-form.d.ts +8 -0
  30. package/lib/types/client/settings-form.d.ts.map +1 -1
  31. package/lib/types/client/settings-form.js +15 -1
  32. package/lib/types/client/spritesheet.d.ts +1 -1
  33. package/lib/types/client/spritesheet.d.ts.map +1 -1
  34. package/lib/types/client/spritesheet.js +3 -12
  35. package/lib/types/event-projection.d.ts.map +1 -1
  36. package/lib/types/event-projection.js +3 -1
  37. package/lib/types/index.d.ts +6 -3
  38. package/lib/types/index.d.ts.map +1 -1
  39. package/lib/types/index.js +15 -12
  40. package/lib/types/invariant.js +2 -2
  41. package/lib/types/ledger.d.ts +16 -1
  42. package/lib/types/ledger.d.ts.map +1 -1
  43. package/lib/types/ledger.js +31 -6
  44. package/lib/types/mount-once.d.ts +25 -0
  45. package/lib/types/mount-once.d.ts.map +1 -0
  46. package/lib/types/mount-once.js +42 -0
  47. package/lib/types/persist.js +3 -3
  48. package/lib/types/registry.d.ts +10 -0
  49. package/lib/types/registry.d.ts.map +1 -1
  50. package/lib/types/registry.js +3 -0
  51. package/lib/types/remarks.d.ts +56 -0
  52. package/lib/types/remarks.d.ts.map +1 -0
  53. package/lib/types/remarks.js +157 -0
  54. package/lib/types/routes.js +2 -2
  55. package/lib/types/service.d.ts +44 -3
  56. package/lib/types/service.d.ts.map +1 -1
  57. package/lib/types/service.js +100 -23
  58. package/lib/types/state.d.ts.map +1 -1
  59. package/lib/types/state.js +5 -5
  60. package/lib/types/treats.d.ts +3 -0
  61. package/lib/types/treats.d.ts.map +1 -1
  62. package/lib/types/treats.js +5 -2
  63. package/package.json +1 -1
  64. package/src/affinity.test.ts +19 -0
  65. package/src/affinity.ts +22 -7
  66. package/src/client/PetDockEntry.tsx +3 -0
  67. package/src/client/PetSettingsCard.tsx +8 -0
  68. package/src/client/PetSprite.test.tsx +89 -3
  69. package/src/client/PetSprite.tsx +36 -12
  70. package/src/client/index.ts +40 -11
  71. package/src/client/locales.ts +2 -0
  72. package/src/client/pet.module.css +52 -10
  73. package/src/client/settings-form.ts +15 -1
  74. package/src/client/spritesheet.ts +3 -13
  75. package/src/event-projection.ts +3 -1
  76. package/src/index.ts +16 -2
  77. package/src/ledger.test.ts +40 -9
  78. package/src/ledger.ts +33 -4
  79. package/src/mount-once.ts +48 -0
  80. package/src/persist.test.ts +1 -1
  81. package/src/registry.test.ts +27 -0
  82. package/src/registry.ts +12 -0
  83. package/src/remarks.test.ts +89 -0
  84. package/src/remarks.ts +177 -0
  85. package/src/service.ts +124 -18
  86. package/src/state.test.ts +9 -0
  87. package/src/state.ts +5 -5
  88. package/src/treats.test.ts +13 -11
  89. package/src/treats.ts +5 -2
  90. package/lib/state-CFyJv0sQ.js +0 -213
package/src/service.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  * persistence through `persist`. The API gateway maps these methods onto
6
6
  * `pet.state` / `pet.pets` / `pet.interact` / `pet.setVisible` /
7
7
  * `pet.setConfig` / `pet.setName` / `pet.setPet` for browser consumers.
8
+ *
9
+ * Concurrent sessions each keep their own machine: the sprite animation
10
+ * follows the most recent meaningful event (the display session) while the
11
+ * state view carries one bubble per active session.
8
12
  * @module @linxin666/dsh-pet/service
9
13
  */
10
14
 
@@ -91,12 +95,41 @@ export interface PetSettingsSection {
91
95
  /** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
92
96
  export const PET_SETTINGS_NAMESPACE = 'pet'
93
97
 
98
+ /**
99
+ * One active TOP-LEVEL session as the pet displays it. Sessions run in
100
+ * parallel, so each gets its own bubble while the sprite itself follows the
101
+ * most recent meaningful event (the display session). Subagent children
102
+ * report no bubble of their own: their work is already reflected by the
103
+ * bubble of the conversation that spawned them, and the bubble buttons
104
+ * navigate to GUI sessions, which subagents are not.
105
+ */
106
+ export interface PetSessionView {
107
+ /** Session identity (stringified for the wire; never exposed as a key). */
108
+ sessionId: string
109
+ /** The animation this session's activity maps onto. */
110
+ animation: PetStateSnapshot['animation']
111
+ /** This session's status bubble copy. */
112
+ bubble: string
113
+ /** This session's raw activity phase. */
114
+ phase: PetStateSnapshot['phase']
115
+ }
116
+
117
+ /** Hard cap on simultaneously displayed session bubbles (most recent first). */
118
+ export const MAX_SESSION_BUBBLES = 12
119
+
94
120
  /** Snapshot returned by `pet.state`. */
95
121
  export interface PetStateView {
96
122
  animation: PetStateSnapshot['animation']
97
123
  bubble?: string
98
124
  phase: PetStateSnapshot['phase']
99
125
  sessionActive: boolean
126
+ /**
127
+ * Per-session bubbles for every concurrently active TOP-LEVEL session,
128
+ * most recent first; optional so older hosts without the multi-session
129
+ * view stay consumable. The single 'bubble' above mirrors the display
130
+ * session.
131
+ */
132
+ sessions?: PetSessionView[]
100
133
  /** Affinity ledger snapshot. */
101
134
  affinity: PetAffinityView
102
135
  /** Display configuration. */
@@ -130,6 +163,14 @@ declare module '@deepseek-ai/cordis' {
130
163
  }
131
164
  }
132
165
 
166
+ /** Per-session pet activity: projection runtime plus the session's own machine. */
167
+ interface SessionActivity {
168
+ runtime: ProjectionRuntime
169
+ machine: PetStateMachine
170
+ /** The session's most recent meaningful input (for display fallback). */
171
+ lastInput?: PetStateInput
172
+ }
173
+
133
174
  /**
134
175
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
135
176
  * written until an economic event or interaction arrives; event listeners
@@ -140,6 +181,7 @@ export class PetService extends Service {
140
181
  static inject: string[] = []
141
182
 
142
183
  private readonly machine: PetStateMachine
184
+ private readonly stateConfig: PetStateConfig
143
185
  private readonly ledger: PetLedger
144
186
  private readonly registry: PetRegistry
145
187
  private readonly persistDir: string
@@ -147,7 +189,12 @@ export class PetService extends Service {
147
189
  private disposeActivity: (() => void) | undefined
148
190
  /** Session whose most recent meaningful event currently drives the global pet. */
149
191
  private displaySession: Session | undefined
150
- private readonly sessionActivity = new WeakMap<Session, ProjectionRuntime>()
192
+ /**
193
+ * Per-session activity, most recent last (Map insertion order). Bounded by
194
+ * MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
195
+ * disposed sessions are removed by the 'session/disposed' listener.
196
+ */
197
+ private readonly sessionActivity = new Map<Session, SessionActivity>()
151
198
 
152
199
  constructor(ctx: Context, config: PetConfig = {}) {
153
200
  super(ctx, 'pet')
@@ -166,12 +213,15 @@ export class PetService extends Service {
166
213
  // copied pet.json): fall back to the registry default.
167
214
  persist = { ...persist, petId: this.registry.defaultEntry().id }
168
215
  }
169
- const ledgerConfig: LedgerConfig = { affinity: config.affinity, treats: config.treats }
216
+ const selected = this.registry.byId(persist.petId) ?? this.registry.defaultEntry()
217
+ const ledgerConfig: LedgerConfig = {
218
+ affinity: config.affinity,
219
+ treats: config.treats,
220
+ remarks: selected.remarks,
221
+ }
170
222
  this.ledger = new PetLedger(persist, ledgerConfig)
171
- this.machine = new PetStateMachine({
172
- ...defaultPetStateConfig,
173
- ...(config.state ?? {}),
174
- })
223
+ this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
224
+ this.machine = new PetStateMachine(this.stateConfig)
175
225
  this.enabled = config.enabled ?? true
176
226
 
177
227
  this.syncActivity()
@@ -224,6 +274,7 @@ export class PetService extends Service {
224
274
  const entry = this.registry.byId(petId)
225
275
  if (entry === undefined) return { ok: false, error: 'unknown-pet' }
226
276
  this.ledger.setPetId(entry.id)
277
+ this.ledger.setRemarks(entry.remarks)
227
278
  this.flush()
228
279
  this.syncSettingsFromPet()
229
280
  return { ok: true, petId: entry.id }
@@ -244,7 +295,7 @@ export class PetService extends Service {
244
295
  this.disposeActivity = (() => {
245
296
  const disposers = [
246
297
  this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
247
- const runtime = this.activityRuntime(session)
298
+ const runtime = this.activityOf(session).runtime
248
299
  // `activity/status` is an optional compatibility input. It is not
249
300
  // declared as a durable event type by this package because current
250
301
  // Harness installations publish the official session vocabulary.
@@ -274,27 +325,60 @@ export class PetService extends Service {
274
325
  }
275
326
  }),
276
327
  this.ctx.on('session/disposed', (session: Session) => {
328
+ this.ledger.forgetSession(String(session.id))
329
+ this.sessionActivity.delete(session)
277
330
  if (session !== this.displaySession) return
331
+ // The display session is gone: fall back to the most recent
332
+ // remaining session's last input, or settle to idle when none.
278
333
  this.displaySession = undefined
279
- this.machine.onSessionDisposed()
334
+ const remaining = [...this.sessionActivity.entries()].at(-1)
335
+ if (remaining !== undefined) {
336
+ const [nextSession, activity] = remaining
337
+ this.displaySession = nextSession
338
+ if (activity.lastInput !== undefined) this.machine.onActivityStatus(activity.lastInput)
339
+ this.machine.onSessionActive()
340
+ } else {
341
+ this.machine.onSessionDisposed()
342
+ }
280
343
  }),
281
344
  ]
282
345
  return () => { for (const dispose of disposers) dispose() }
283
346
  })()
284
347
  }
285
348
 
286
- /** Return the projection state associated with one live session. */
287
- private activityRuntime(session: Session): ProjectionRuntime {
288
- let runtime = this.sessionActivity.get(session)
289
- if (runtime === undefined) {
290
- runtime = emptyProjectionRuntime()
291
- this.sessionActivity.set(session, runtime)
349
+ /** Return the per-session activity record, creating it on first sight. */
350
+ private activityOf(session: Session): SessionActivity {
351
+ let activity = this.sessionActivity.get(session)
352
+ if (activity === undefined) {
353
+ activity = {
354
+ runtime: emptyProjectionRuntime(),
355
+ machine: new PetStateMachine(this.stateConfig),
356
+ }
357
+ this.sessionActivity.set(session, activity)
292
358
  }
293
- return runtime
359
+ return activity
294
360
  }
295
361
 
296
- /** Commit one activity as the host-global pet's most recent display state. */
362
+ /**
363
+ * Commit one activity: the session's own machine renders its bubble, and
364
+ * the session becomes the host-global display session (most recent
365
+ * meaningful event wins the sprite animation).
366
+ */
297
367
  private applyActivity(session: Session, input: PetStateInput): void {
368
+ const activity = this.activityOf(session)
369
+ activity.lastInput = input
370
+ activity.machine.onActivityStatus(input)
371
+ activity.machine.onSessionActive()
372
+ // Move to the tail so map order reads most-recent-last, then trim the
373
+ // oldest session states beyond the bubble cap. The display session is
374
+ // reassigned below, so trimming its stale predecessor is safe.
375
+ this.sessionActivity.delete(session)
376
+ this.sessionActivity.set(session, activity)
377
+ while (this.sessionActivity.size > MAX_SESSION_BUBBLES) {
378
+ const oldest = this.sessionActivity.keys().next().value
379
+ if (oldest === undefined) break
380
+ this.sessionActivity.delete(oldest)
381
+ }
298
382
  this.displaySession = session
299
383
  this.machine.onActivityStatus(input)
300
384
  this.machine.onSessionActive()
@@ -345,8 +429,10 @@ export class PetService extends Service {
345
429
  * @param section - the resolved settings section.
346
430
  */
347
431
  applySettingsSection(section: PetSettingsSection): void {
348
- if (typeof section.petId === 'string' && this.registry.byId(section.petId) !== undefined) {
349
- this.ledger.setPetId(section.petId)
432
+ const selected = typeof section.petId === 'string' ? this.registry.byId(section.petId) : undefined
433
+ if (selected !== undefined) {
434
+ this.ledger.setPetId(selected.id)
435
+ this.ledger.setRemarks(selected.remarks)
350
436
  } else if (section.petId !== undefined) {
351
437
  // The stored selection names a pet the registry no longer has: keep the
352
438
  // current selection and repair the settings document.
@@ -390,6 +476,25 @@ export class PetService extends Service {
390
476
  private view(): PetStateView {
391
477
  const snapshot = this.machine.render()
392
478
  const entry = this.activeEntry()
479
+ // One bubble per concurrently active TOP-LEVEL session, most recent
480
+ // first. Subagent children render no bubble of their own (their activity
481
+ // already shows through the spawning conversation's bubble/display, and
482
+ // the bubble buttons navigate to GUI sessions, which subagents are not).
483
+ // Sessions whose own machine has settled (no bubble copy) drop out, so a
484
+ // finished turn does not leave a stale bubble behind.
485
+ const sessions: PetSessionView[] = []
486
+ for (const [session, activity] of [...this.sessionActivity.entries()].reverse()) {
487
+ if (sessions.length >= MAX_SESSION_BUBBLES) break
488
+ if (session.header?.origin === 'subagent') continue
489
+ const perSession = activity.machine.render()
490
+ if (perSession.bubble === undefined) continue
491
+ sessions.push({
492
+ sessionId: String(session.id),
493
+ animation: perSession.animation,
494
+ bubble: perSession.bubble,
495
+ phase: perSession.phase,
496
+ })
497
+ }
393
498
  // Read-only: the ledger settles on economic events only, never on a read,
394
499
  // so polling the state cannot trigger pet.json writes.
395
500
  return {
@@ -397,6 +502,7 @@ export class PetService extends Service {
397
502
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
398
503
  phase: snapshot.phase,
399
504
  sessionActive: snapshot.sessionActive,
505
+ sessions,
400
506
  affinity: this.ledger.affinityView(Date.now()),
401
507
  display: { ...this.ledger.snapshot.display },
402
508
  pet: {
package/src/state.test.ts CHANGED
@@ -43,6 +43,15 @@ describe('PetStateMachine', () => {
43
43
  expect(machine.render().bubble).toBeUndefined()
44
44
  })
45
45
 
46
+ it('never bubbles a settled idle session, even with a projected line', () => {
47
+ // An aborted turn projects idle + '已停止'; a stopped session must not
48
+ // linger in the bubble stack.
49
+ const machine = new PetStateMachine(defaultPetStateConfig, () => 1_000)
50
+ machine.onActivityStatus({ phase: 'idle', line: '已停止' })
51
+ expect(machine.render().bubble).toBeUndefined()
52
+ expect(machine.render()).toMatchObject({ animation: 'idle', phase: 'idle' })
53
+ })
54
+
46
55
  it('resets on session dispose', () => {
47
56
  const machine = new PetStateMachine(defaultPetStateConfig, () => 1_000)
48
57
  machine.onSessionActive()
package/src/state.ts CHANGED
@@ -144,11 +144,11 @@ export class PetStateMachine {
144
144
  animation = 'idle'
145
145
  }
146
146
  }
147
- const bubble = this.phase === 'done'
148
- && this.doneAt !== undefined
149
- && nowMs - this.doneAt >= this.config.celebrateMs
150
- ? undefined
151
- : this.phrase ?? this.line
147
+ // Settled sessions never bubble: idle (e.g. an aborted/stopped turn)
148
+ // and done past its celebration window both fall silent.
149
+ const settled = this.phase === 'idle'
150
+ || (this.phase === 'done' && this.doneAt !== undefined && nowMs - this.doneAt >= this.config.celebrateMs)
151
+ const bubble = settled ? undefined : this.phrase ?? this.line
152
152
  return {
153
153
  animation,
154
154
  ...(bubble === undefined ? {} : { bubble }),
@@ -7,20 +7,22 @@ import {
7
7
  } from './treats.ts'
8
8
 
9
9
  describe('settleTreatGrants', () => {
10
- it('grants one treat per turnsPerTreat completed turns', () => {
10
+ it('grants one treat per 30 completed turns (10x difficulty)', () => {
11
11
  const ledger = { ...emptyTreatLedger(), turnsAtLastTreatGrant: 0 }
12
- const s = settleTreatGrants(ledger, 6, 1_000, defaultTreatConfig)
12
+ const s = settleTreatGrants(ledger, 60, 1_000, defaultTreatConfig)
13
13
  expect(s.gained).toBe(2)
14
14
  expect(s.ledger.treats).toBe(2)
15
- expect(s.ledger.turnsAtLastTreatGrant).toBe(6)
15
+ expect(s.ledger.turnsAtLastTreatGrant).toBe(60)
16
+ // 29 turns are no longer enough for a treat.
17
+ expect(settleTreatGrants(emptyTreatLedger(), 29, 1_000, defaultTreatConfig).gained).toBe(0)
16
18
  })
17
19
 
18
- it('grants time-output treats per elapsed period', () => {
20
+ it('grants time-output treats per 300-minute period (10x difficulty)', () => {
19
21
  const ledger = { ...emptyTreatLedger(), lastTreatGrantAt: 1_000 }
20
- const s = settleTreatGrants(ledger, 0, 1_000 + 90 * 60_000, defaultTreatConfig)
22
+ const s = settleTreatGrants(ledger, 0, 1_000 + 900 * 60_000, defaultTreatConfig)
21
23
  expect(s.gained).toBe(3)
22
24
  expect(s.ledger.treats).toBe(3)
23
- expect(s.ledger.lastTreatGrantAt).toBe(1_000 + 90 * 60_000)
25
+ expect(s.ledger.lastTreatGrantAt).toBe(1_000 + 900 * 60_000)
24
26
  })
25
27
 
26
28
  it('does not backfill time output before the first settlement, but starts the clock', () => {
@@ -55,7 +57,7 @@ describe('settleTreatGrants', () => {
55
57
 
56
58
  it('caps stocked treats at maxTreats', () => {
57
59
  const ledger = { ...emptyTreatLedger(), treats: 19, lastTreatGrantAt: 1_000, turnsAtLastTreatGrant: 0 }
58
- const s = settleTreatGrants(ledger, 6, 1_000 + 30 * 60_000, defaultTreatConfig)
60
+ const s = settleTreatGrants(ledger, 60, 1_000 + 30 * 60_000, defaultTreatConfig)
59
61
  expect(s.ledger.treats).toBe(defaultTreatConfig.maxTreats)
60
62
  })
61
63
 
@@ -73,17 +75,17 @@ describe('settleTreatGrants', () => {
73
75
  })
74
76
 
75
77
  it('a continuously working user still earns time treats (work does not reset the time anchor)', () => {
76
- // First settlement: 3 completed turns grant one work treat and start the
78
+ // First settlement: 30 completed turns grant one work treat and start the
77
79
  // time clock without touching the work anchor's independence.
78
80
  let ledger = emptyTreatLedger()
79
- const first = settleTreatGrants(ledger, 3, 1_000, defaultTreatConfig)
81
+ const first = settleTreatGrants(ledger, 30, 1_000, defaultTreatConfig)
80
82
  expect(first.gained).toBe(1)
81
83
  ledger = first.ledger
82
- // Keep working in 3-turn steps well under one time period: every work
84
+ // Keep working in 30-turn steps well under one time period: every work
83
85
  // settlement must advance only the turn anchor, never the time anchor.
84
86
  const stepMs = defaultTreatConfig.timeTreatMs / 100
85
87
  for (let i = 0; i < 10; i++) {
86
- const s = settleTreatGrants(ledger, 3 + ((i + 1) * 3), 1_000 + ((i + 1) * stepMs), defaultTreatConfig)
88
+ const s = settleTreatGrants(ledger, 30 + ((i + 1) * 30), 1_000 + ((i + 1) * stepMs), defaultTreatConfig)
87
89
  expect(s.gained).toBe(1)
88
90
  expect(s.ledger.lastTreatGrantAt).toBe(1_000)
89
91
  ledger = s.ledger
package/src/treats.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  * two sources, both tied to companionship:
4
4
  * - work output: every N completed turns grant one treat;
5
5
  * - time output: every T minutes of wall-clock time grant one treat.
6
+ * Difficulty tuned deliberately high (10x the original cadence): treats are
7
+ * a rare delicacy the pet earns through sustained companionship, not a
8
+ * routine drop.
6
9
  * Feeding consumes one treat. Settlement is lazy: it runs whenever the host
7
10
  * serves a state snapshot or an interaction, so there is no timer and no
8
11
  * drift — elapsed periods are computed from the persisted last-grant marks.
@@ -20,8 +23,8 @@ export interface TreatConfig {
20
23
  }
21
24
 
22
25
  export const defaultTreatConfig: TreatConfig = {
23
- turnsPerTreat: 3,
24
- timeTreatMs: 30 * 60_000,
26
+ turnsPerTreat: 30,
27
+ timeTreatMs: 300 * 60_000,
25
28
  maxTreats: 20,
26
29
  }
27
30
 
@@ -1,213 +0,0 @@
1
- //#region src/affinity.ts
2
- const AFFINITY_MAX = 100;
3
- /** Affinity ranks by points; the pet visibly grows with its rank.
4
- * Marker glyphs are plain ASCII (the repo bans all emoji characters);
5
- * they read as a growing star trail alongside the rank name. */
6
- const AFFINITY_RANKS = [
7
- {
8
- min: 0,
9
- name: "幼鲸",
10
- emoji: "*"
11
- },
12
- {
13
- min: 25,
14
- name: "伙伴",
15
- emoji: "**"
16
- },
17
- {
18
- min: 50,
19
- name: "挚友",
20
- emoji: "***"
21
- },
22
- {
23
- min: 80,
24
- name: "深海羁绊",
25
- emoji: "****"
26
- }
27
- ];
28
- const defaultAffinityConfig = {
29
- turnReward: 1,
30
- petReward: 1,
31
- petCooldownMs: 1e4,
32
- feedReward: 5,
33
- feedCooldownMs: 3e4
34
- };
35
- function emptyAffinity() {
36
- return {
37
- points: 0,
38
- lastPetAt: 0,
39
- lastFeedAt: 0,
40
- pets: 0,
41
- feeds: 0,
42
- turns: 0
43
- };
44
- }
45
- /** Rank for a point total. */
46
- function rankOf(points) {
47
- let rank = AFFINITY_RANKS[0];
48
- for (const candidate of AFFINITY_RANKS) if (points >= candidate.min) rank = candidate;
49
- return rank;
50
- }
51
- /** Derive the read-only view of one affinity state at a wall-clock instant. */
52
- function affinityViewOf(state, nowMs, config = defaultAffinityConfig) {
53
- const rank = rankOf(state.points);
54
- return {
55
- points: state.points,
56
- rank: rank.name,
57
- rankEmoji: rank.emoji,
58
- pets: state.pets,
59
- feeds: state.feeds,
60
- turns: state.turns,
61
- petCooldown: nowMs - state.lastPetAt < config.petCooldownMs,
62
- feedCooldown: nowMs - state.lastFeedAt < config.feedCooldownMs
63
- };
64
- }
65
- function clamp(points) {
66
- return Math.min(100, Math.max(0, points));
67
- }
68
- /**
69
- * Apply one interaction to a copy of the state (immutable style: returns a
70
- * new object; the caller replaces the persisted state). Cooldowns only
71
- * apply once the pet has been interacted with at least once (last*At === 0
72
- * means "never", so the first pet/feed always lands).
73
- */
74
- function applyInteraction(state, kind, nowMs, config = defaultAffinityConfig) {
75
- const next = { ...state };
76
- if (kind === "pet") {
77
- if (state.lastPetAt !== 0 && nowMs - state.lastPetAt < config.petCooldownMs) return {
78
- affinity: state,
79
- delta: 0,
80
- reaction: "摸过头啦,让鲸鱼娘歇口气~",
81
- accepted: false
82
- };
83
- next.lastPetAt = nowMs;
84
- next.pets += 1;
85
- next.points = clamp(state.points + config.petReward);
86
- return {
87
- affinity: next,
88
- delta: config.petReward,
89
- reaction: "咕噜咕噜~被摸摸好舒服!",
90
- accepted: true
91
- };
92
- }
93
- if (kind === "feed") {
94
- if (state.lastFeedAt !== 0 && nowMs - state.lastFeedAt < config.feedCooldownMs) return {
95
- affinity: state,
96
- delta: 0,
97
- reaction: "吃饱啦,晚点再喂~",
98
- accepted: false
99
- };
100
- next.lastFeedAt = nowMs;
101
- next.feeds += 1;
102
- next.points = clamp(state.points + config.feedReward);
103
- return {
104
- affinity: next,
105
- delta: config.feedReward,
106
- reaction: "呜哇!小鱼干好好吃!",
107
- accepted: true
108
- };
109
- }
110
- return {
111
- affinity: state,
112
- delta: 0,
113
- reaction: "",
114
- accepted: false
115
- };
116
- }
117
- /** Reward one completed turn (called by the host on `done`). */
118
- function applyTurnReward(state, config = defaultAffinityConfig) {
119
- const next = { ...state };
120
- next.turns += 1;
121
- next.points = clamp(state.points + config.turnReward);
122
- return next;
123
- }
124
- //#endregion
125
- //#region src/state.ts
126
- const defaultPetStateConfig = { celebrateMs: 2400 };
127
- /**
128
- * Map one activity phase onto the animation contract.
129
- * - thinking → `running` and tool → `running-right` (focused work).
130
- * - review → `review` while answer text is streaming.
131
- * - waiting → `waiting` (expectant pose, needs user input).
132
- * - done → `jumping` (celebration), then back to `idle` after the window.
133
- * - failed → `failed` until another activity event arrives.
134
- * - idle → `idle` (calm breathing loop).
135
- */
136
- function animationForPhase(phase) {
137
- switch (phase) {
138
- case "thinking": return "running";
139
- case "tool": return "running-right";
140
- case "review": return "review";
141
- case "waiting": return "waiting";
142
- case "done": return "jumping";
143
- case "failed": return "failed";
144
- case "idle": return "idle";
145
- }
146
- }
147
- /** The spritesheet row index for one animation track. */
148
- function rowOf(animation) {
149
- return {
150
- "idle": 0,
151
- "running-right": 1,
152
- "running-left": 2,
153
- "waving": 3,
154
- "jumping": 4,
155
- "failed": 5,
156
- "waiting": 6,
157
- "running": 7,
158
- "review": 8
159
- }[animation];
160
- }
161
- /**
162
- * PetStateMachine — one instance per host process. Holds only the latest
163
- * input snapshot and the celebration timing; no storage, no side effects.
164
- */
165
- var PetStateMachine = class {
166
- config;
167
- now;
168
- phase = "idle";
169
- line;
170
- phrase;
171
- sessionActive = false;
172
- doneAt;
173
- constructor(config = defaultPetStateConfig, now = Date.now) {
174
- this.config = config;
175
- this.now = now;
176
- }
177
- /** Consume one projected activity update. */
178
- onActivityStatus(input) {
179
- this.phase = input.phase;
180
- this.line = input.line;
181
- this.phrase = input.phrase;
182
- if (input.phase === "done") this.doneAt = this.now();
183
- }
184
- /** A session became the active one (or a fresh session started). */
185
- onSessionActive() {
186
- this.sessionActive = true;
187
- }
188
- /** The active session was disposed (or none left). */
189
- onSessionDisposed() {
190
- this.sessionActive = false;
191
- this.phase = "idle";
192
- this.line = void 0;
193
- this.phrase = void 0;
194
- this.doneAt = void 0;
195
- }
196
- /** Render the current animation decision. */
197
- render() {
198
- const nowMs = this.now();
199
- let animation = animationForPhase(this.phase);
200
- if (this.phase === "done" && this.doneAt !== void 0) if (nowMs - this.doneAt < this.config.celebrateMs) animation = "jumping";
201
- else animation = "idle";
202
- const bubble = this.phase === "done" && this.doneAt !== void 0 && nowMs - this.doneAt >= this.config.celebrateMs ? void 0 : this.phrase ?? this.line;
203
- return {
204
- animation,
205
- ...bubble === void 0 ? {} : { bubble },
206
- animationStartedAt: nowMs,
207
- phase: this.phase,
208
- sessionActive: this.sessionActive
209
- };
210
- }
211
- };
212
- //#endregion
213
- export { AFFINITY_MAX as a, applyInteraction as c, emptyAffinity as d, rankOf as f, rowOf as i, applyTurnReward as l, animationForPhase as n, AFFINITY_RANKS as o, defaultPetStateConfig as r, affinityViewOf as s, PetStateMachine as t, defaultAffinityConfig as u };