@linxin666/dsh-pet 0.1.12 → 0.1.14

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.
@@ -1,71 +1,83 @@
1
+ /* Plugin settings card chrome + staged form fields.
2
+ * Aligned with the official ui-settings-plugins PluginCard / fields CSS:
3
+ * same semantic tokens, radius, typography and states so family cards read
4
+ * as siblings of the built-in Shell / Agent loop / Web search cards. */
5
+
1
6
  .card {
2
- list-style: none;
3
7
  border: 1px solid var(--dsw-alias-border-l2);
4
- border-radius: 8px;
5
8
  background: var(--dsw-alias-bg-layer-3);
6
- overflow: hidden;
9
+ border-radius: 12px;
10
+ list-style: none;
7
11
  transition: border-color 0.16s, background 0.16s;
8
12
  }
9
13
 
14
+ .card:hover {
15
+ border-color: var(--dsw-alias-label-dimmed);
16
+ }
17
+
10
18
  .cardOpen {
11
19
  background: var(--dsw-alias-bg-layer-2);
12
20
  border-color: var(--dsw-alias-label-dimmed);
13
21
  }
14
22
 
15
23
  .header {
16
- display: flex;
17
- align-items: center;
18
- gap: 8px;
24
+ appearance: none;
19
25
  width: 100%;
20
- padding: 10px 14px;
21
- border: 0;
22
- background: transparent;
23
- cursor: pointer;
24
- text-align: left;
25
26
  font: inherit;
26
- transition: background 120ms ease;
27
- }
28
-
29
- .header:hover {
30
- background: var(--dsw-alias-interactive-bg-hover);
31
- }
32
-
33
- .header:active {
34
- background: var(--dsw-alias-interactive-bg-hover-solid);
27
+ color: inherit;
28
+ text-align: left;
29
+ cursor: pointer;
30
+ background: transparent;
31
+ border: 0;
32
+ border-radius: 12px;
33
+ align-items: center;
34
+ gap: 12px;
35
+ padding: 14px 16px;
36
+ display: flex;
35
37
  }
36
38
 
37
39
  .header:focus-visible {
38
- outline: none;
39
- /* Inset ring so it is not clipped by the card's overflow:hidden. */
40
- box-shadow: inset 0 0 0 2px var(--dsw-alias-button-info-fill);
40
+ outline: 2px solid var(--dsw-alias-brand-primary);
41
+ outline-offset: -2px;
41
42
  }
42
43
 
43
44
  .headText {
44
- display: flex;
45
45
  flex-direction: column;
46
- gap: 2px;
47
46
  flex: 1;
47
+ gap: 4px;
48
48
  min-width: 0;
49
+ display: flex;
49
50
  }
50
51
 
51
52
  .name {
52
- font-weight: 600;
53
53
  color: var(--dsw-alias-label-primary);
54
+ font-size: 15px;
55
+ font-weight: 600;
56
+ line-height: 1.4;
54
57
  }
55
58
 
56
59
  .description {
57
- font-size: 12px;
58
60
  color: var(--dsw-alias-label-tertiary);
61
+ font-size: 13px;
62
+ line-height: 1.5;
59
63
  }
60
64
 
61
65
  .pending {
62
- font-size: 12px;
63
- color: var(--dsw-alias-state-warn-primary);
66
+ white-space: nowrap;
67
+ background: var(--dsw-alias-bg-module-platform);
68
+ color: var(--dsw-alias-label-secondary);
69
+ border-radius: 999px;
70
+ flex: none;
71
+ padding: 1px 8px;
72
+ font-size: 11px;
73
+ font-weight: 500;
74
+ line-height: 17px;
64
75
  }
65
76
 
66
77
  .chevron {
67
- transition: transform 120ms ease;
68
78
  color: var(--dsw-alias-label-tertiary);
79
+ flex: none;
80
+ transition: transform 0.16s;
69
81
  }
70
82
 
71
83
  .chevronOpen {
@@ -73,53 +85,59 @@
73
85
  }
74
86
 
75
87
  .body {
76
- padding: 0 14px 14px;
77
- display: flex;
78
- flex-direction: column;
79
- gap: 14px;
88
+ border-top: 1px solid var(--dsw-alias-border-l2);
89
+ margin: 0 16px;
90
+ padding-bottom: 8px;
80
91
  }
81
92
 
82
93
  .readOnly {
83
- margin: 0;
84
- font-size: 12px;
85
94
  color: var(--dsw-alias-label-tertiary);
95
+ margin: 12px 0 0;
96
+ font-size: 12px;
97
+ line-height: 1.5;
86
98
  }
87
99
 
88
100
  .notExposed {
89
- margin: 0;
101
+ color: var(--dsw-alias-state-warn-primary);
102
+ margin: 12px 0 0;
90
103
  font-size: 12px;
91
104
  line-height: 1.5;
92
- color: var(--dsw-alias-state-warn-primary);
93
105
  }
94
106
 
95
107
  .footer {
96
- display: flex;
97
- align-items: center;
108
+ border-top: 1px solid var(--dsw-alias-border-l2);
98
109
  justify-content: flex-end;
110
+ align-items: center;
99
111
  gap: 8px;
112
+ padding: 12px 0 4px;
113
+ display: flex;
100
114
  }
101
115
 
102
116
  .failed {
103
- margin: 0 auto 0 0;
117
+ min-width: 0;
118
+ color: var(--dsw-alias-label-error);
119
+ flex: 1;
120
+ margin: 0;
104
121
  font-size: 12px;
105
- color: var(--dsw-alias-state-error-primary);
122
+ line-height: 1.5;
106
123
  }
107
124
 
108
125
  .discard,
109
126
  .save {
110
- padding: 5px 12px;
111
- border-radius: 6px;
127
+ appearance: none;
112
128
  font: inherit;
113
- font-size: 13px;
114
129
  cursor: pointer;
115
- transition: color 120ms ease, border-color 120ms ease,
116
- background 120ms ease, box-shadow 120ms ease;
130
+ border: 1px solid transparent;
131
+ border-radius: 8px;
132
+ padding: 5px 14px;
133
+ font-size: 13px;
134
+ line-height: 1.5;
117
135
  }
118
136
 
119
137
  .discard {
120
- border: 1px solid var(--dsw-alias-border-l2);
121
- background: transparent;
138
+ border-color: var(--dsw-alias-border-l2);
122
139
  color: var(--dsw-alias-label-secondary);
140
+ background: transparent;
123
141
  }
124
142
 
125
143
  .discard:hover:not(:disabled) {
@@ -127,156 +145,152 @@
127
145
  border-color: var(--dsw-alias-label-dimmed);
128
146
  }
129
147
 
130
- .discard:active:not(:disabled) {
131
- background: var(--dsw-alias-interactive-bg-hover);
132
- }
133
-
134
148
  .save {
135
- border: 1px solid var(--dsw-alias-button-info-fill);
136
- background: var(--dsw-alias-button-info-fill);
137
- color: var(--dsw-alias-label-primary-foreground);
138
- }
139
-
140
- .save:hover:not(:disabled) {
141
- border-color: var(--dsw-alias-button-info-hover);
142
- background: var(--dsw-alias-button-info-hover);
143
- }
144
-
145
- .save:active:not(:disabled) {
146
- filter: brightness(0.94);
147
- }
148
-
149
- .discard:focus-visible:not(:disabled),
150
- .save:focus-visible:not(:disabled) {
151
- outline: none;
152
- box-shadow: 0 0 0 2px var(--dsw-alias-button-info-fill);
149
+ background: var(--dsw-alias-label-primary);
150
+ color: var(--dsw-alias-bg-layer-3);
153
151
  }
154
152
 
155
153
  .discard:disabled,
156
154
  .save:disabled {
157
- opacity: 0.5;
155
+ opacity: 0.4;
158
156
  cursor: default;
159
157
  }
160
158
 
159
+ .discard:focus-visible,
160
+ .save:focus-visible {
161
+ outline: 2px solid var(--dsw-alias-brand-primary);
162
+ outline-offset: 1px;
163
+ }
164
+
161
165
  .field {
162
- display: flex;
163
166
  flex-direction: column;
164
- gap: 4px;
167
+ gap: 6px;
168
+ padding: 12px 0;
169
+ display: flex;
170
+ }
171
+
172
+ .field + .field {
173
+ border-top: 1px solid var(--dsw-alias-border-l2);
165
174
  }
166
175
 
167
176
  .head {
168
- display: flex;
169
177
  align-items: center;
170
178
  gap: 8px;
179
+ display: flex;
171
180
  }
172
181
 
173
182
  .label {
183
+ min-width: 0;
184
+ color: var(--dsw-alias-label-primary);
185
+ flex: 1;
174
186
  font-size: 13px;
175
187
  font-weight: 500;
176
- color: var(--dsw-alias-label-primary);
188
+ line-height: 1.5;
177
189
  }
178
190
 
179
191
  .badges {
180
- display: flex;
181
192
  align-items: center;
182
- gap: 6px;
193
+ gap: 8px;
194
+ display: inline-flex;
183
195
  }
184
196
 
185
197
  .badge {
186
- font-size: 11px;
187
- padding: 1px 6px;
198
+ white-space: nowrap;
199
+ background: var(--dsw-alias-bg-module-platform);
200
+ color: var(--dsw-alias-label-secondary);
188
201
  border-radius: 999px;
189
- background: var(--dsw-alias-interactive-bg-hover-accent);
190
- color: var(--dsw-alias-state-business-primary);
202
+ padding: 1px 8px;
203
+ font-size: 11px;
204
+ font-weight: 500;
205
+ line-height: 17px;
191
206
  }
192
207
 
193
208
  .reset {
194
- font-size: 11px;
195
- border: 0;
196
- background: transparent;
197
- color: var(--dsw-alias-state-business-primary);
209
+ font: inherit;
210
+ color: var(--dsw-alias-label-secondary);
198
211
  cursor: pointer;
212
+ background: transparent;
213
+ border: none;
199
214
  padding: 0;
200
- border-radius: 3px;
201
- transition: color 120ms ease, box-shadow 120ms ease;
215
+ font-size: 12px;
216
+ line-height: 1.5;
202
217
  }
203
218
 
204
219
  .reset:hover:not(:disabled) {
205
- color: var(--dsw-alias-label-primary-bluish);
206
- text-decoration: underline;
207
- }
208
-
209
- .reset:active:not(:disabled) {
210
- color: var(--dsw-alias-state-business-primary);
211
- }
212
-
213
- .reset:focus-visible:not(:disabled) {
214
- outline: none;
215
- box-shadow: 0 0 0 2px var(--dsw-alias-button-info-fill);
220
+ color: var(--dsw-alias-label-primary);
216
221
  }
217
222
 
218
223
  .reset:disabled {
219
- opacity: 0.5;
220
224
  cursor: default;
221
225
  }
222
226
 
227
+ .reset:focus-visible {
228
+ outline: 2px solid var(--dsw-alias-brand-primary);
229
+ outline-offset: 2px;
230
+ }
231
+
223
232
  .input,
224
233
  .select {
225
- padding: 6px 8px;
226
234
  border: 1px solid var(--dsw-alias-border-l2);
227
- border-radius: 6px;
235
+ background: var(--dsw-alias-bg-layer-3);
236
+ height: 34px;
228
237
  font: inherit;
229
- font-size: 13px;
230
238
  color: var(--dsw-alias-label-primary);
231
- background: var(--dsw-specific-input-major);
239
+ border-radius: 8px;
240
+ padding: 0 12px;
241
+ font-size: 13px;
242
+ line-height: 1.5;
232
243
  }
233
244
 
234
- .inputInvalid {
235
- padding: 6px 8px;
236
- border: 1px solid var(--dsw-alias-state-error-primary);
237
- border-radius: 6px;
238
- font: inherit;
239
- font-size: 13px;
240
- color: var(--dsw-alias-label-primary);
245
+ .input:focus-visible,
246
+ .select:focus-visible {
247
+ border-color: var(--dsw-alias-brand-primary);
248
+ outline: none;
241
249
  }
242
250
 
243
251
  .input:disabled,
244
252
  .select:disabled {
245
- opacity: 0.6;
253
+ color: var(--dsw-alias-label-tertiary);
254
+ cursor: default;
246
255
  }
247
256
 
248
- .input:focus,
249
- .select:focus {
250
- outline: none;
251
- border-color: var(--dsw-alias-button-info-fill);
252
- box-shadow: 0 0 0 2px var(--dsw-alias-button-info-fill);
257
+ .inputInvalid {
258
+ border: 1px solid var(--dsw-alias-label-error);
259
+ background: var(--dsw-alias-bg-layer-3);
260
+ height: 34px;
261
+ font: inherit;
262
+ color: var(--dsw-alias-label-primary);
263
+ border-radius: 8px;
264
+ padding: 0 12px;
265
+ font-size: 13px;
266
+ line-height: 1.5;
253
267
  }
254
268
 
255
- .inputInvalid:focus {
256
- outline: none;
257
- box-shadow: 0 0 0 2px var(--dsw-alias-state-error-primary);
269
+ .inputInvalid:focus-visible {
270
+ outline: 2px solid var(--dsw-alias-label-error);
271
+ outline-offset: 1px;
272
+ border-color: var(--dsw-alias-label-error);
258
273
  }
259
274
 
260
- .hint {
275
+ .invalid {
276
+ color: var(--dsw-alias-label-error);
261
277
  margin: 0;
262
278
  font-size: 12px;
263
- color: var(--dsw-alias-label-secondary);
279
+ line-height: 1.5;
264
280
  }
265
281
 
266
- .invalid {
282
+ .hint {
283
+ color: var(--dsw-alias-label-tertiary);
267
284
  margin: 0;
268
285
  font-size: 12px;
269
- color: var(--dsw-alias-state-error-primary);
286
+ line-height: 1.5;
270
287
  }
271
288
 
272
- /* Reduced motion: drop the card/button transitions. Token colors and the
273
- focus rings stay, so interactive states remain visible without motion. */
274
289
  @media (prefers-reduced-motion: reduce) {
275
290
  .card,
276
291
  .header,
277
292
  .chevron,
278
293
  .chevronOpen,
279
- .reset,
280
294
  .discard,
281
295
  .save {
282
296
  transition: none;
package/src/service.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Pet host service — the `pet.*` RPC domain. Owns the state machine wiring
3
- * (maps core rc.6 session events — turn/step/tool boundaries — and the
4
- * session lifecycle onto the pet phases), the affinity ledger, and the
5
- * persisted display config. The API gateway maps this service's methods onto
6
- * `pet.state` / `pet.interact` / `pet.setVisible` / `pet.setConfig`
7
- * for browser consumers.
3
+ * (projects official session events and accepts legacy `activity/status`), the
4
+ * affinity ledger, and the persisted display config. The API gateway maps
5
+ * this service's methods onto `pet.state` / `pet.interact` /
6
+ * `pet.setVisible` / `pet.setConfig` for browser consumers.
8
7
  * @module @linxin666/dsh-pet/service
9
8
  */
10
9
 
@@ -40,6 +39,7 @@ import {
40
39
  defaultPetStateConfig,
41
40
  PetStateMachine,
42
41
  type PetStateConfig,
42
+ type PetStateInput,
43
43
  type PetStateSnapshot,
44
44
  } from './state.ts'
45
45
 
@@ -128,6 +128,119 @@ declare module '@deepseek-ai/cordis' {
128
128
  }
129
129
  }
130
130
 
131
+ /** Runtime shape of the optional legacy activity event. */
132
+ interface ActivityStatusEventLike {
133
+ phase?: string
134
+ line?: string
135
+ phrase?: string
136
+ }
137
+
138
+ /** Per-session facts needed to project the official event stream. */
139
+ interface SessionActivityRuntime {
140
+ activeTools: Set<string>
141
+ officialEventsSeen: boolean
142
+ stepHadFailure: boolean
143
+ }
144
+
145
+ /** One official event projection, optionally carrying a completed turn reward. */
146
+ interface PetActivityTransition {
147
+ input: PetStateInput
148
+ completedTurn?: number
149
+ }
150
+
151
+ /** Keep tool names readable inside the compact status bubble. */
152
+ function displayToolName(name: string): string {
153
+ const compact = name.replace(/\s+/g, ' ').trim() || '工具'
154
+ return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`
155
+ }
156
+
157
+ /** Whether a legacy phase is part of the pet's supported vocabulary. */
158
+ function isActivityPhase(phase: string): phase is PetStateInput['phase'] {
159
+ return ['idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed'].includes(phase)
160
+ }
161
+
162
+ /**
163
+ * Project the durable DSH session vocabulary into the pet's visual phases.
164
+ * Unknown and log-only events do not disturb the last meaningful activity.
165
+ */
166
+ function projectOfficialEvent(
167
+ event: SessionEvent,
168
+ runtime: SessionActivityRuntime,
169
+ ): PetActivityTransition | undefined {
170
+ switch (event.type) {
171
+ case 'turn/start':
172
+ runtime.activeTools.clear()
173
+ runtime.stepHadFailure = false
174
+ return { input: { phase: 'waiting', line: '准备开始' } }
175
+ case 'step/start':
176
+ runtime.activeTools.clear()
177
+ runtime.stepHadFailure = false
178
+ return { input: { phase: 'waiting', line: '等待模型响应' } }
179
+ case 'assistant/chunk': {
180
+ const { chunk } = event.data
181
+ if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
182
+ return { input: { phase: 'thinking', line: '正在思考' } }
183
+ }
184
+ if (chunk.type === 'text-delta' && chunk.text.length > 0) {
185
+ return { input: { phase: 'review', line: '整理回复中' } }
186
+ }
187
+ return undefined
188
+ }
189
+ case 'assistant/message':
190
+ return { input: { phase: 'review', line: '整理回复中' } }
191
+ case 'tool/call':
192
+ runtime.activeTools.add(String(event.data.callId))
193
+ return {
194
+ input: {
195
+ phase: 'tool',
196
+ line: `正在使用 ${displayToolName(event.data.name)}`,
197
+ },
198
+ }
199
+ case 'tool/result': {
200
+ const block = event.data.message.content[0]
201
+ runtime.activeTools.delete(String(event.data.message.source.callId))
202
+ runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true
203
+ if (runtime.activeTools.size > 0) {
204
+ return {
205
+ input: {
206
+ phase: 'tool',
207
+ line: `还有 ${runtime.activeTools.size} 个工具运行中`,
208
+ },
209
+ }
210
+ }
211
+ return runtime.stepHadFailure
212
+ ? { input: { phase: 'failed', line: '工具执行失败' } }
213
+ : { input: { phase: 'thinking', line: '处理工具结果' } }
214
+ }
215
+ case 'turn/end': {
216
+ runtime.activeTools.clear()
217
+ switch (event.data.reason.kind) {
218
+ case 'completed':
219
+ return {
220
+ input: { phase: 'done', line: '完成啦' },
221
+ completedTurn: event.data.turn,
222
+ }
223
+ case 'error':
224
+ return { input: { phase: 'failed', line: '执行失败' } }
225
+ case 'max-tokens':
226
+ return { input: { phase: 'failed', line: '达到输出上限' } }
227
+ case 'interrupted':
228
+ return { input: { phase: 'failed', line: '执行意外中断' } }
229
+ case 'blocked':
230
+ return { input: { phase: 'waiting', line: '等待继续' } }
231
+ case 'aborted':
232
+ return { input: { phase: 'idle', line: '已停止' } }
233
+ default:
234
+ // TurnEndReasonMap is merge-extensible; a newer ending must not
235
+ // leave the pet showing stale in-progress work.
236
+ return { input: { phase: 'idle' } }
237
+ }
238
+ }
239
+ default:
240
+ return undefined
241
+ }
242
+ }
243
+
131
244
  /**
132
245
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
133
246
  * written until a query or interaction arrives; event listeners update only
@@ -146,6 +259,10 @@ export class PetService extends Service {
146
259
  private rewardedTurns = new Map<string, number>()
147
260
  private enabled: boolean
148
261
  private disposeActivity: (() => void) | undefined
262
+ /** Session whose most recent meaningful event currently drives the global pet. */
263
+ private displaySession: Session | undefined
264
+ private readonly sessionActivity = new WeakMap<Session, SessionActivityRuntime>()
265
+ private lastLegacyTurnRewardAt = 0
149
266
 
150
267
  constructor(ctx: Context, config: PetConfig = {}) {
151
268
  super(ctx, 'pet')
@@ -197,37 +314,38 @@ export class PetService extends Service {
197
314
  this.disposeActivity = (() => {
198
315
  const disposers = [
199
316
  this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
200
- // rc.6 publishes no 'activity/status' event (the working-activity
201
- // tracker is gone), so the pet derives its phases from the core
202
- // session vocabulary instead.
203
- switch (event.type) {
204
- case 'turn/start':
205
- this.machine.onSessionActive()
206
- break
207
- case 'step/start':
208
- this.machine.onSessionActive()
209
- this.machine.onActivityStatus({ phase: 'thinking' })
210
- break
211
- case 'tool/call':
212
- this.machine.onSessionActive()
213
- this.machine.onActivityStatus({ phase: 'tool', line: 'tool: ' + event.data.name })
214
- break
215
- case 'turn/end':
216
- this.machine.onSessionActive()
217
- if (event.data.reason.kind === 'completed') {
218
- this.machine.onActivityStatus({ phase: 'done' })
219
- this.rewardTurn(String(session.id), event.data.turn)
220
- } else {
221
- // Aborted / failed turns clear the working pose instead of
222
- // freezing the pet on its last phase.
223
- this.machine.onActivityStatus({ phase: 'idle' })
224
- }
225
- break
226
- default:
227
- break
317
+ const runtime = this.activityRuntime(session)
318
+ // `activity/status` is an optional compatibility input. It is not
319
+ // declared as a durable event type by this package because current
320
+ // Harness installations publish the official session vocabulary.
321
+ if ((event.type as string) === 'activity/status') {
322
+ const payload = ((event as unknown as { data?: unknown }).data ?? {}) as ActivityStatusEventLike
323
+ if (typeof payload.phase !== 'string' || !isActivityPhase(payload.phase)) return
324
+ this.applyActivity(session, {
325
+ phase: payload.phase,
326
+ ...(typeof payload.line === 'string' ? { line: payload.line } : {}),
327
+ ...(typeof payload.phrase === 'string' ? { phrase: payload.phrase } : {}),
328
+ })
329
+ // On a legacy-only stream the compatibility event owns turn
330
+ // rewards. Once any official activity is observed, turn/end owns
331
+ // them and a derived legacy `done` cannot double-count.
332
+ if (payload.phase === 'done' && !runtime.officialEventsSeen) {
333
+ this.rewardLegacyTurn()
334
+ }
335
+ return
336
+ }
337
+
338
+ const transition = projectOfficialEvent(event, runtime)
339
+ if (transition === undefined) return
340
+ runtime.officialEventsSeen = true
341
+ this.applyActivity(session, transition.input)
342
+ if (transition.completedTurn !== undefined) {
343
+ this.rewardTurn(String(session.id), transition.completedTurn)
228
344
  }
229
345
  }),
230
- this.ctx.on('session/disposed', () => {
346
+ this.ctx.on('session/disposed', (session: Session) => {
347
+ if (session !== this.displaySession) return
348
+ this.displaySession = undefined
231
349
  this.machine.onSessionDisposed()
232
350
  }),
233
351
  ]
@@ -235,6 +353,27 @@ export class PetService extends Service {
235
353
  })()
236
354
  }
237
355
 
356
+ /** Return the projection state associated with one live session. */
357
+ private activityRuntime(session: Session): SessionActivityRuntime {
358
+ let runtime = this.sessionActivity.get(session)
359
+ if (runtime === undefined) {
360
+ runtime = {
361
+ activeTools: new Set(),
362
+ officialEventsSeen: false,
363
+ stepHadFailure: false,
364
+ }
365
+ this.sessionActivity.set(session, runtime)
366
+ }
367
+ return runtime
368
+ }
369
+
370
+ /** Commit one activity as the host-global pet's most recent display state. */
371
+ private applyActivity(session: Session, input: PetStateInput): void {
372
+ this.displaySession = session
373
+ this.machine.onActivityStatus(input)
374
+ this.machine.onSessionActive()
375
+ }
376
+
238
377
  /** RPC: pet or feed the pet. */
239
378
  async interact(kind: PetInteraction): Promise<PetInteractResult> {
240
379
  const nowMs = Date.now()
@@ -334,6 +473,20 @@ export class PetService extends Service {
334
473
  const last = this.rewardedTurns.get(sessionId) ?? 0
335
474
  if (turn <= last) return
336
475
  this.rewardedTurns.set(sessionId, turn)
476
+ this.applyTurnReward()
477
+ }
478
+
479
+ /** Preserve turn rewards for installations that only emit legacy activity. */
480
+ private rewardLegacyTurn(): void {
481
+ const nowMs = Date.now()
482
+ // A legacy `done` snapshot may repeat during the celebration window.
483
+ if (nowMs - this.lastLegacyTurnRewardAt < 5_000) return
484
+ this.lastLegacyTurnRewardAt = nowMs
485
+ this.applyTurnReward()
486
+ }
487
+
488
+ /** Persist one accepted completed-turn reward. */
489
+ private applyTurnReward(): void {
337
490
  this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) }
338
491
  this.flush()
339
492
  }