@linxin666/dsh-pet 0.1.10 → 0.1.12

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.
@@ -0,0 +1,138 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * WhalePet rename-box keyboard handling. The rename input must treat
4
+ * Enter/Escape keydowns that arrive during IME composition (candidate
5
+ * selection) as composition input, never as submit/cancel (issue #89).
6
+ */
7
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
9
+ import { WhalePet, type WhalePetProps } from './WhalePet.tsx'
10
+ import { t } from './locales.ts'
11
+ import type { PetStateView } from '../service.ts'
12
+
13
+ /** Snapshot fixture: idle whale girl named 泡泡. */
14
+ const snapshot: PetStateView = {
15
+ animation: 'idle',
16
+ phase: 'idle',
17
+ sessionActive: true,
18
+ affinity: {
19
+ points: 0,
20
+ rank: '幼鲸',
21
+ rankEmoji: '*',
22
+ pets: 0,
23
+ feeds: 0,
24
+ turns: 0,
25
+ petCooldown: false,
26
+ feedCooldown: false,
27
+ },
28
+ display: { visible: true, size: 160, right: 24, bottom: 20 },
29
+ name: '泡泡',
30
+ treats: { stocked: 3, max: 5 },
31
+ }
32
+
33
+ beforeAll(() => {
34
+ // Deterministic zh copy for button labels.
35
+ document.documentElement.lang = 'zh'
36
+ // Prefer-reduced-motion matches: the sprite loop then never schedules
37
+ // requestAnimationFrame, keeping the test free of animation timers.
38
+ Object.defineProperty(window, 'matchMedia', {
39
+ configurable: true,
40
+ value: (query: string) => ({
41
+ matches: query.includes('prefers-reduced-motion'),
42
+ media: query,
43
+ onchange: null,
44
+ addEventListener: () => {},
45
+ removeEventListener: () => {},
46
+ addListener: () => {},
47
+ removeListener: () => {},
48
+ dispatchEvent: () => false,
49
+ }),
50
+ })
51
+ })
52
+
53
+ afterEach(() => {
54
+ cleanup()
55
+ })
56
+
57
+ /** Render the pet with mocked callbacks; returns the rename spy. */
58
+ function renderPet(): { onRename: ReturnType<typeof vi.fn> } {
59
+ const onRename = vi.fn()
60
+ const props: WhalePetProps = {
61
+ snapshot,
62
+ display: snapshot.display,
63
+ feedback: null,
64
+ onPet: vi.fn(),
65
+ onFeed: vi.fn(),
66
+ onHide: vi.fn(),
67
+ onDragEnd: vi.fn(),
68
+ onRename,
69
+ onFeedbackDone: vi.fn(),
70
+ t,
71
+ }
72
+ render(<WhalePet {...props} />)
73
+ return { onRename }
74
+ }
75
+
76
+ /** Hover the sprite to open the panel, then click the rename button. */
77
+ function openRename(): HTMLInputElement {
78
+ fireEvent.pointerOver(screen.getByRole('button', { name: 'whale girl' }))
79
+ fireEvent.click(screen.getByText('改名'))
80
+ return screen.getByPlaceholderText('输入新名字') as HTMLInputElement
81
+ }
82
+
83
+ /**
84
+ * Fire a keydown whose native event reports an active IME composition, the
85
+ * way Chromium marks Enter/Escape pressed to select or dismiss a candidate.
86
+ */
87
+ function fireComposingKeydown(target: Element, key: string): void {
88
+ fireEvent.compositionStart(target)
89
+ const native = new window.KeyboardEvent('keydown', {
90
+ key,
91
+ bubbles: true,
92
+ cancelable: true,
93
+ isComposing: true,
94
+ })
95
+ // jsdom does not implement KeyboardEvent.isComposing, so pin the flag on
96
+ // the dispatched native event exactly as the browser would report it.
97
+ Object.defineProperty(native, 'isComposing', { value: true })
98
+ fireEvent(target, native)
99
+ fireEvent.compositionEnd(target)
100
+ }
101
+
102
+ describe('WhalePet rename input', () => {
103
+ it('submits the draft on Enter outside composition', () => {
104
+ const { onRename } = renderPet()
105
+ const input = openRename()
106
+ fireEvent.change(input, { target: { value: ' 小鲸 ' } })
107
+ fireEvent.keyDown(input, { key: 'Enter' })
108
+ expect(onRename).toHaveBeenCalledTimes(1)
109
+ expect(onRename).toHaveBeenCalledWith('小鲸')
110
+ expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
111
+ })
112
+
113
+ it('ignores Enter while an IME composition is active', () => {
114
+ const { onRename } = renderPet()
115
+ const input = openRename()
116
+ fireEvent.change(input, { target: { value: '泡泡酱' } })
117
+ fireComposingKeydown(input, 'Enter')
118
+ expect(onRename).not.toHaveBeenCalled()
119
+ expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
120
+ expect(input.value).toBe('泡泡酱')
121
+ // Once the composition is over, Enter submits normally.
122
+ fireEvent.keyDown(input, { key: 'Enter' })
123
+ expect(onRename).toHaveBeenCalledWith('泡泡酱')
124
+ expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
125
+ })
126
+
127
+ it('ignores Escape while an IME composition is active', () => {
128
+ const { onRename } = renderPet()
129
+ const input = openRename()
130
+ fireEvent.change(input, { target: { value: 'abc' } })
131
+ fireComposingKeydown(input, 'Escape')
132
+ expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
133
+ // A real Escape outside composition closes the box without renaming.
134
+ fireEvent.keyDown(input, { key: 'Escape' })
135
+ expect(onRename).not.toHaveBeenCalled()
136
+ expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
137
+ })
138
+ })
@@ -301,6 +301,11 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
301
301
  autoFocus
302
302
  onChange={(e) => setNameDraft(e.target.value)}
303
303
  onKeyDown={(e) => {
304
+ // While an IME composition is active (e.g. selecting a
305
+ // Chinese candidate), Enter/Escape keydowns belong to the
306
+ // input method: ignore them so candidate selection can
307
+ // neither submit the draft nor close the rename box.
308
+ if (e.nativeEvent.isComposing) return
304
309
  if (e.key === 'Enter') {
305
310
  const trimmed = nameDraft.trim()
306
311
  if (trimmed !== '') {
@@ -10,7 +10,7 @@
10
10
  * @module @linxin666/dsh-pet/client
11
11
  */
12
12
 
13
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
13
+ import type { ClientContext, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
14
14
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
15
15
  import type {} from '@deepseek-ai/dsh-client-locale/client'
16
16
  // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
@@ -92,6 +92,18 @@ export interface SettingsPluginItemOwnerProps {
92
92
  children?: never
93
93
  }
94
94
 
95
+ declare module '@deepseek-ai/cordis' {
96
+ interface Context {
97
+ /**
98
+ * Optional rc.6 compatibility binder provided by dsh-web-ui-settings;
99
+ * absent when that group plugin is not installed, so callers fall back to
100
+ * the official settings scope.
101
+ */
102
+ webUiSettings?: { bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S> }
103
+ }
104
+ }
105
+
106
+
95
107
  /**
96
108
  * Client plugin body: register dictionaries, mount the global pet entry and
97
109
  * poll loop while the plugin is enabled, and seat the settings card in the
@@ -101,7 +113,8 @@ export interface SettingsPluginItemOwnerProps {
101
113
  export function apply(ctx: ClientContext): void {
102
114
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'pet: dictionaries')
103
115
 
104
- const settingsScope = ctx.settingsScope.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
116
+ const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
117
+ const settingsScope = binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
105
118
  const enabled = (): boolean => {
106
119
  const snapshot = settingsScope.getSnapshot()
107
120
  return snapshot.status === 'ready'
package/src/service.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Pet host service — the `pet.*` RPC domain. Owns the state machine wiring
3
- * (consumes `activity/status` session events and session lifecycle), 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.
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.
7
8
  * @module @linxin666/dsh-pet/service
8
9
  */
9
10
 
10
11
  import { Context, Service } from '@deepseek-ai/cordis'
11
- import type { Session } from '@deepseek-ai/dsh-session'
12
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
12
13
  import {
13
14
  applyInteraction,
14
15
  applyTurnReward,
@@ -127,13 +128,6 @@ declare module '@deepseek-ai/cordis' {
127
128
  }
128
129
  }
129
130
 
130
- /** One session/event guard: only the latest activity snapshot matters. */
131
- interface ActivityStatusEventLike {
132
- phase?: string
133
- line?: string
134
- phrase?: string
135
- }
136
-
137
131
  /**
138
132
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
139
133
  * written until a query or interaction arrives; event listeners update only
@@ -148,7 +142,8 @@ export class PetService extends Service {
148
142
  private readonly treatConfig: TreatConfig
149
143
  private readonly persistDir: string
150
144
  private persist: PetPersist
151
- private lastTurnRewardAt = 0
145
+ /** Completed turns already rewarded, per session (turn numbers are per-session). */
146
+ private rewardedTurns = new Map<string, number>()
152
147
  private enabled: boolean
153
148
  private disposeActivity: (() => void) | undefined
154
149
 
@@ -201,20 +196,36 @@ export class PetService extends Service {
201
196
  if (!this.enabled) return
202
197
  this.disposeActivity = (() => {
203
198
  const disposers = [
204
- this.ctx.on('session/event', (_session: Session, event: { type: string; data?: unknown }) => {
205
- if (event.type !== 'activity/status') return
206
- const payload = (event.data ?? {}) as ActivityStatusEventLike
207
- if (payload.phase === undefined) return
208
- const phase = payload.phase as PetStateSnapshot['phase']
209
- // Guard against unknown phases from newer activity trackers.
210
- if (!['idle', 'waiting', 'thinking', 'tool', 'done'].includes(phase)) return
211
- this.machine.onActivityStatus({
212
- phase,
213
- ...(typeof payload.line === 'string' ? { line: payload.line } : {}),
214
- ...(typeof payload.phrase === 'string' ? { phrase: payload.phrase } : {}),
215
- })
216
- this.machine.onSessionActive()
217
- if (phase === 'done') this.rewardTurn()
199
+ 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
228
+ }
218
229
  }),
219
230
  this.ctx.on('session/disposed', () => {
220
231
  this.machine.onSessionDisposed()
@@ -318,19 +329,20 @@ export class PetService extends Service {
318
329
  })
319
330
  }
320
331
 
321
- /** Award the turn reward once per done phase (idempotent per transition). */
322
- private rewardTurn(): void {
323
- const nowMs = Date.now()
324
- // A done phase can repeat while celebrating; only reward the first.
325
- if (nowMs - this.lastTurnRewardAt < 5_000) return
326
- this.lastTurnRewardAt = nowMs
332
+ /** Award the turn reward once per completed turn (idempotent per session + turn). */
333
+ private rewardTurn(sessionId: string, turn: number): void {
334
+ const last = this.rewardedTurns.get(sessionId) ?? 0
335
+ if (turn <= last) return
336
+ this.rewardedTurns.set(sessionId, turn)
327
337
  this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) }
328
338
  this.flush()
329
339
  }
330
340
 
331
341
  /**
332
- * Settle the treat economy (work + time output since the last
333
- * settlement); persists only when treats were actually granted.
342
+ * Settle the treat economy (work + time output since the last settlement)
343
+ * and persist whenever the ledger changed. A zero-gain first settlement
344
+ * still starts the time clock (anchor write), which is what lets the
345
+ * 30-minute time output ever accrue.
334
346
  */
335
347
  private settleTreats(nowMs: number): void {
336
348
  const settlement = settleTreatGrants(
@@ -339,7 +351,7 @@ export class PetService extends Service {
339
351
  nowMs,
340
352
  this.treatConfig,
341
353
  )
342
- if (settlement.gained > 0) {
354
+ if (settlement.ledger !== this.persist.treats) {
343
355
  this.persist = { ...this.persist, treats: settlement.ledger }
344
356
  this.flush()
345
357
  }
package/src/state.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Pet state machine — pure, clock-injected. Maps the DSH `activity/status`
3
- * phase vocabulary (session events) onto the 9-state Codex pet
4
- * animation contract, plus the session lifecycle transitions the web UI
5
- * exposes (turn end celebration, no-session idle).
2
+ * Pet state machine — pure, clock-injected. Maps the pet's working-phase
3
+ * vocabulary (the service derives it from core session events) onto the
4
+ * 9-state Codex pet animation contract, plus the session lifecycle
5
+ * transitions the web UI exposes (turn end celebration, no-session idle).
6
6
  *
7
7
  * The machine is deliberately dumb: it holds the last input phase, the
8
8
  * animation decision, and a one-shot "celebration" window after `done` so the
@@ -11,7 +11,7 @@
11
11
  * @module @linxin666/dsh-pet/state
12
12
  */
13
13
 
14
- /** The DSH `activity/status` phase vocabulary (wire contract of session events). */
14
+ /** The pet's working-phase vocabulary (derived from core session events by the service). */
15
15
  export type ActivityPhase = 'idle' | 'waiting' | 'thinking' | 'tool' | 'done'
16
16
 
17
17
  /** The Codex-compatible 9-state animation contract (spritesheet rows). */
@@ -28,7 +28,7 @@ export type PetAnimation =
28
28
 
29
29
  /** One input snapshot consumed by the machine. */
30
30
  export interface PetStateInput {
31
- /** Current activity/status phase of the active session. */
31
+ /** Current working phase of the active session. */
32
32
  phase: ActivityPhase
33
33
  /** Human-readable status line (plain text). */
34
34
  line?: string
@@ -110,7 +110,7 @@ export class PetStateMachine {
110
110
  private readonly now: () => number = Date.now,
111
111
  ) {}
112
112
 
113
- /** Consume one `activity/status` session event. */
113
+ /** Consume one phase snapshot (fed by the service from session events). */
114
114
  onActivityStatus(input: PetStateInput): void {
115
115
  this.phase = input.phase
116
116
  this.line = input.line
@@ -23,11 +23,34 @@ describe('settleTreatGrants', () => {
23
23
  expect(s.ledger.lastTreatGrantAt).toBe(1_000 + 90 * 60_000)
24
24
  })
25
25
 
26
- it('does not backfill time output before the first settlement', () => {
26
+ it('does not backfill time output before the first settlement, but starts the clock', () => {
27
27
  const ledger = emptyTreatLedger() // lastTreatGrantAt === 0
28
28
  const s = settleTreatGrants(ledger, 0, 1_000 + 10 * 60 * 60_000, defaultTreatConfig)
29
29
  expect(s.gained).toBe(0)
30
- expect(s.ledger).toBe(ledger)
30
+ expect(s.ledger.treats).toBe(0)
31
+ expect(s.ledger.lastTreatGrantAt).toBe(1_000 + 10 * 60 * 60_000)
32
+ })
33
+
34
+ it('starts the time clock on a zero-gain settlement so later time output accrues (anchor deadlock)', () => {
35
+ // Regression for issue #99: the old code returned the ledger untouched
36
+ // when nothing was due, so lastTreatGrantAt stayed 0 forever and the
37
+ // 30-minute time output could never begin.
38
+ let ledger = emptyTreatLedger()
39
+ const first = settleTreatGrants(ledger, 0, 1_000, defaultTreatConfig)
40
+ expect(first.gained).toBe(0)
41
+ expect(first.ledger.lastTreatGrantAt).toBe(1_000)
42
+ expect(first.ledger.treats).toBe(0)
43
+ ledger = first.ledger
44
+ // Anchored and nothing due: the same object comes back (no persistence
45
+ // churn) and the anchor must not move.
46
+ const same = settleTreatGrants(ledger, 0, 1_000 + 10_000, defaultTreatConfig)
47
+ expect(same.gained).toBe(0)
48
+ expect(same.ledger).toBe(ledger)
49
+ // One full period after the anchor: the time treat finally lands.
50
+ const grant = settleTreatGrants(ledger, 0, 1_000 + defaultTreatConfig.timeTreatMs, defaultTreatConfig)
51
+ expect(grant.gained).toBe(1)
52
+ expect(grant.ledger.treats).toBe(1)
53
+ expect(grant.ledger.lastTreatGrantAt).toBe(1_000 + defaultTreatConfig.timeTreatMs)
31
54
  })
32
55
 
33
56
  it('caps stocked treats at maxTreats', () => {
package/src/treats.ts CHANGED
@@ -58,8 +58,12 @@ function cap(treats: number, max: number): number {
58
58
  * time output counts whole periods since the time anchor
59
59
  * (`lastTreatGrantAt`) and advances only the time anchor. The two sources
60
60
  * are independent so a continuously working user still earns time treats.
61
- * 0 time history never backfills — the clock starts at the first settlement.
62
- * Both sources are clamped by the stock cap.
61
+ * 0 time history never backfills — the clock starts at the first settlement,
62
+ * and even a zero-gain first settlement writes the time anchor so the next
63
+ * elapsed period can accrue (anchor deadlock fix). Both sources are clamped
64
+ * by the stock cap. When the anchor is already set and nothing is due, the
65
+ * input ledger is returned unchanged (same object), so callers can skip
66
+ * persistence cheaply.
63
67
  */
64
68
  export function settleTreatGrants(
65
69
  ledger: TreatLedger,
@@ -74,7 +78,15 @@ export function settleTreatGrants(
74
78
  const timeAnchor = ledger.lastTreatGrantAt === 0 ? nowMs : ledger.lastTreatGrantAt
75
79
  const timeGrants = Math.floor(Math.max(0, nowMs - timeAnchor) / config.timeTreatMs)
76
80
  const gained = workGrants + timeGrants
77
- if (gained <= 0) return { ledger, gained: 0 }
81
+ if (gained <= 0) {
82
+ if (ledger.lastTreatGrantAt === 0) {
83
+ // Zero-gain first settlement: persist the clock start anyway, so the
84
+ // 30-minute time output can begin. Before this fix the anchor stayed 0
85
+ // forever (the deadlock: no grant means no anchor write means no grant).
86
+ return { ledger: { ...ledger, lastTreatGrantAt: nowMs }, gained: 0 }
87
+ }
88
+ return { ledger, gained: 0 }
89
+ }
78
90
  return {
79
91
  ledger: {
80
92
  treats: cap(ledger.treats + gained, config.maxTreats),