@linxin666/dsh-pet 0.1.18 → 0.1.19

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 (66) 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 +48 -11
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +100 -30
  7. package/lib/invariant.js +2 -2
  8. package/lib/state-Diiq8vjk.js +394 -0
  9. package/lib/types/affinity.d.ts +31 -3
  10. package/lib/types/affinity.d.ts.map +1 -1
  11. package/lib/types/affinity.js +21 -7
  12. package/lib/types/client/PetSettingsCard.d.ts +5 -0
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +7 -0
  15. package/lib/types/client/PetSprite.d.ts.map +1 -1
  16. package/lib/types/client/PetSprite.js +8 -2
  17. package/lib/types/client/index.d.ts.map +1 -1
  18. package/lib/types/client/index.js +14 -8
  19. package/lib/types/client/settings-form.d.ts +8 -0
  20. package/lib/types/client/settings-form.d.ts.map +1 -1
  21. package/lib/types/client/settings-form.js +15 -1
  22. package/lib/types/event-projection.d.ts.map +1 -1
  23. package/lib/types/event-projection.js +3 -1
  24. package/lib/types/index.d.ts +4 -2
  25. package/lib/types/index.d.ts.map +1 -1
  26. package/lib/types/index.js +2 -1
  27. package/lib/types/ledger.d.ts +16 -1
  28. package/lib/types/ledger.d.ts.map +1 -1
  29. package/lib/types/ledger.js +29 -4
  30. package/lib/types/registry.d.ts +10 -0
  31. package/lib/types/registry.d.ts.map +1 -1
  32. package/lib/types/registry.js +3 -0
  33. package/lib/types/remarks.d.ts +56 -0
  34. package/lib/types/remarks.d.ts.map +1 -0
  35. package/lib/types/remarks.js +157 -0
  36. package/lib/types/service.d.ts +40 -3
  37. package/lib/types/service.d.ts.map +1 -1
  38. package/lib/types/service.js +90 -18
  39. package/lib/types/state.js +5 -5
  40. package/lib/types/treats.d.ts +3 -0
  41. package/lib/types/treats.d.ts.map +1 -1
  42. package/lib/types/treats.js +5 -2
  43. package/package.json +1 -1
  44. package/src/affinity.test.ts +19 -0
  45. package/src/affinity.ts +22 -7
  46. package/src/client/PetSettingsCard.tsx +8 -0
  47. package/src/client/PetSprite.test.tsx +33 -0
  48. package/src/client/PetSprite.tsx +16 -1
  49. package/src/client/index.ts +14 -8
  50. package/src/client/pet.module.css +19 -0
  51. package/src/client/settings-form.ts +15 -1
  52. package/src/event-projection.ts +3 -1
  53. package/src/index.ts +12 -1
  54. package/src/ledger.test.ts +40 -9
  55. package/src/ledger.ts +33 -4
  56. package/src/persist.test.ts +1 -1
  57. package/src/registry.test.ts +27 -0
  58. package/src/registry.ts +12 -0
  59. package/src/remarks.test.ts +89 -0
  60. package/src/remarks.ts +177 -0
  61. package/src/service.ts +116 -18
  62. package/src/state.test.ts +9 -0
  63. package/src/state.ts +5 -5
  64. package/src/treats.test.ts +13 -11
  65. package/src/treats.ts +5 -2
  66. 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,37 @@ 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 session as the pet displays it. Sessions run in parallel, so
100
+ * each gets its own bubble while the sprite itself follows the most recent
101
+ * meaningful event (the display session).
102
+ */
103
+ export interface PetSessionView {
104
+ /** Session identity (stringified for the wire; never exposed as a key). */
105
+ sessionId: string
106
+ /** The animation this session's activity maps onto. */
107
+ animation: PetStateSnapshot['animation']
108
+ /** This session's status bubble copy. */
109
+ bubble: string
110
+ /** This session's raw activity phase. */
111
+ phase: PetStateSnapshot['phase']
112
+ }
113
+
114
+ /** Hard cap on simultaneously displayed session bubbles (most recent first). */
115
+ export const MAX_SESSION_BUBBLES = 12
116
+
94
117
  /** Snapshot returned by `pet.state`. */
95
118
  export interface PetStateView {
96
119
  animation: PetStateSnapshot['animation']
97
120
  bubble?: string
98
121
  phase: PetStateSnapshot['phase']
99
122
  sessionActive: boolean
123
+ /**
124
+ * Per-session bubbles for every concurrently active session, most recent
125
+ * first; optional so older hosts without the multi-session view stay
126
+ * consumable. The single 'bubble' above mirrors the display session.
127
+ */
128
+ sessions?: PetSessionView[]
100
129
  /** Affinity ledger snapshot. */
101
130
  affinity: PetAffinityView
102
131
  /** Display configuration. */
@@ -130,6 +159,14 @@ declare module '@deepseek-ai/cordis' {
130
159
  }
131
160
  }
132
161
 
162
+ /** Per-session pet activity: projection runtime plus the session's own machine. */
163
+ interface SessionActivity {
164
+ runtime: ProjectionRuntime
165
+ machine: PetStateMachine
166
+ /** The session's most recent meaningful input (for display fallback). */
167
+ lastInput?: PetStateInput
168
+ }
169
+
133
170
  /**
134
171
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
135
172
  * written until an economic event or interaction arrives; event listeners
@@ -140,6 +177,7 @@ export class PetService extends Service {
140
177
  static inject: string[] = []
141
178
 
142
179
  private readonly machine: PetStateMachine
180
+ private readonly stateConfig: PetStateConfig
143
181
  private readonly ledger: PetLedger
144
182
  private readonly registry: PetRegistry
145
183
  private readonly persistDir: string
@@ -147,7 +185,12 @@ export class PetService extends Service {
147
185
  private disposeActivity: (() => void) | undefined
148
186
  /** Session whose most recent meaningful event currently drives the global pet. */
149
187
  private displaySession: Session | undefined
150
- private readonly sessionActivity = new WeakMap<Session, ProjectionRuntime>()
188
+ /**
189
+ * Per-session activity, most recent last (Map insertion order). Bounded by
190
+ * MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
191
+ * disposed sessions are removed by the 'session/disposed' listener.
192
+ */
193
+ private readonly sessionActivity = new Map<Session, SessionActivity>()
151
194
 
152
195
  constructor(ctx: Context, config: PetConfig = {}) {
153
196
  super(ctx, 'pet')
@@ -166,12 +209,15 @@ export class PetService extends Service {
166
209
  // copied pet.json): fall back to the registry default.
167
210
  persist = { ...persist, petId: this.registry.defaultEntry().id }
168
211
  }
169
- const ledgerConfig: LedgerConfig = { affinity: config.affinity, treats: config.treats }
212
+ const selected = this.registry.byId(persist.petId) ?? this.registry.defaultEntry()
213
+ const ledgerConfig: LedgerConfig = {
214
+ affinity: config.affinity,
215
+ treats: config.treats,
216
+ remarks: selected.remarks,
217
+ }
170
218
  this.ledger = new PetLedger(persist, ledgerConfig)
171
- this.machine = new PetStateMachine({
172
- ...defaultPetStateConfig,
173
- ...(config.state ?? {}),
174
- })
219
+ this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
220
+ this.machine = new PetStateMachine(this.stateConfig)
175
221
  this.enabled = config.enabled ?? true
176
222
 
177
223
  this.syncActivity()
@@ -224,6 +270,7 @@ export class PetService extends Service {
224
270
  const entry = this.registry.byId(petId)
225
271
  if (entry === undefined) return { ok: false, error: 'unknown-pet' }
226
272
  this.ledger.setPetId(entry.id)
273
+ this.ledger.setRemarks(entry.remarks)
227
274
  this.flush()
228
275
  this.syncSettingsFromPet()
229
276
  return { ok: true, petId: entry.id }
@@ -244,7 +291,7 @@ export class PetService extends Service {
244
291
  this.disposeActivity = (() => {
245
292
  const disposers = [
246
293
  this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
247
- const runtime = this.activityRuntime(session)
294
+ const runtime = this.activityOf(session).runtime
248
295
  // `activity/status` is an optional compatibility input. It is not
249
296
  // declared as a durable event type by this package because current
250
297
  // Harness installations publish the official session vocabulary.
@@ -274,27 +321,60 @@ export class PetService extends Service {
274
321
  }
275
322
  }),
276
323
  this.ctx.on('session/disposed', (session: Session) => {
324
+ this.ledger.forgetSession(String(session.id))
325
+ this.sessionActivity.delete(session)
277
326
  if (session !== this.displaySession) return
327
+ // The display session is gone: fall back to the most recent
328
+ // remaining session's last input, or settle to idle when none.
278
329
  this.displaySession = undefined
279
- this.machine.onSessionDisposed()
330
+ const remaining = [...this.sessionActivity.entries()].at(-1)
331
+ if (remaining !== undefined) {
332
+ const [nextSession, activity] = remaining
333
+ this.displaySession = nextSession
334
+ if (activity.lastInput !== undefined) this.machine.onActivityStatus(activity.lastInput)
335
+ this.machine.onSessionActive()
336
+ } else {
337
+ this.machine.onSessionDisposed()
338
+ }
280
339
  }),
281
340
  ]
282
341
  return () => { for (const dispose of disposers) dispose() }
283
342
  })()
284
343
  }
285
344
 
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)
345
+ /** Return the per-session activity record, creating it on first sight. */
346
+ private activityOf(session: Session): SessionActivity {
347
+ let activity = this.sessionActivity.get(session)
348
+ if (activity === undefined) {
349
+ activity = {
350
+ runtime: emptyProjectionRuntime(),
351
+ machine: new PetStateMachine(this.stateConfig),
352
+ }
353
+ this.sessionActivity.set(session, activity)
292
354
  }
293
- return runtime
355
+ return activity
294
356
  }
295
357
 
296
- /** Commit one activity as the host-global pet's most recent display state. */
358
+ /**
359
+ * Commit one activity: the session's own machine renders its bubble, and
360
+ * the session becomes the host-global display session (most recent
361
+ * meaningful event wins the sprite animation).
362
+ */
297
363
  private applyActivity(session: Session, input: PetStateInput): void {
364
+ const activity = this.activityOf(session)
365
+ activity.lastInput = input
366
+ activity.machine.onActivityStatus(input)
367
+ activity.machine.onSessionActive()
368
+ // Move to the tail so map order reads most-recent-last, then trim the
369
+ // oldest session states beyond the bubble cap. The display session is
370
+ // reassigned below, so trimming its stale predecessor is safe.
371
+ this.sessionActivity.delete(session)
372
+ this.sessionActivity.set(session, activity)
373
+ while (this.sessionActivity.size > MAX_SESSION_BUBBLES) {
374
+ const oldest = this.sessionActivity.keys().next().value
375
+ if (oldest === undefined) break
376
+ this.sessionActivity.delete(oldest)
377
+ }
298
378
  this.displaySession = session
299
379
  this.machine.onActivityStatus(input)
300
380
  this.machine.onSessionActive()
@@ -345,8 +425,10 @@ export class PetService extends Service {
345
425
  * @param section - the resolved settings section.
346
426
  */
347
427
  applySettingsSection(section: PetSettingsSection): void {
348
- if (typeof section.petId === 'string' && this.registry.byId(section.petId) !== undefined) {
349
- this.ledger.setPetId(section.petId)
428
+ const selected = typeof section.petId === 'string' ? this.registry.byId(section.petId) : undefined
429
+ if (selected !== undefined) {
430
+ this.ledger.setPetId(selected.id)
431
+ this.ledger.setRemarks(selected.remarks)
350
432
  } else if (section.petId !== undefined) {
351
433
  // The stored selection names a pet the registry no longer has: keep the
352
434
  // current selection and repair the settings document.
@@ -390,6 +472,21 @@ export class PetService extends Service {
390
472
  private view(): PetStateView {
391
473
  const snapshot = this.machine.render()
392
474
  const entry = this.activeEntry()
475
+ // One bubble per concurrently active session, most recent first.
476
+ // Sessions whose own machine has settled (no bubble copy) drop out, so a
477
+ // finished turn does not leave a stale bubble behind.
478
+ const sessions: PetSessionView[] = []
479
+ for (const [session, activity] of [...this.sessionActivity.entries()].reverse()) {
480
+ if (sessions.length >= MAX_SESSION_BUBBLES) break
481
+ const perSession = activity.machine.render()
482
+ if (perSession.bubble === undefined) continue
483
+ sessions.push({
484
+ sessionId: String(session.id),
485
+ animation: perSession.animation,
486
+ bubble: perSession.bubble,
487
+ phase: perSession.phase,
488
+ })
489
+ }
393
490
  // Read-only: the ledger settles on economic events only, never on a read,
394
491
  // so polling the state cannot trigger pet.json writes.
395
492
  return {
@@ -397,6 +494,7 @@ export class PetService extends Service {
397
494
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
398
495
  phase: snapshot.phase,
399
496
  sessionActive: snapshot.sessionActive,
497
+ sessions,
400
498
  affinity: this.ledger.affinityView(Date.now()),
401
499
  display: { ...this.ledger.snapshot.display },
402
500
  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 };