@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.
- package/lib/client.js +2 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +56 -35
- package/lib/invariant.js +1 -1
- package/lib/{state-IyVnKymD.js → state-C9EyycwI.js} +1 -1
- package/lib/types/client/WhalePet.d.ts.map +1 -1
- package/lib/types/client/WhalePet.js +6 -0
- package/lib/types/client/index.d.ts +13 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +2 -1
- package/lib/types/service.d.ts +12 -8
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +48 -32
- package/lib/types/state.d.ts +7 -7
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/state.js +5 -5
- package/lib/types/treats.d.ts +6 -2
- package/lib/types/treats.d.ts.map +1 -1
- package/lib/types/treats.js +14 -3
- package/package.json +2 -2
- package/src/client/WhalePet.test.tsx +138 -0
- package/src/client/WhalePet.tsx +5 -0
- package/src/client/index.ts +15 -2
- package/src/service.ts +48 -36
- package/src/state.ts +7 -7
- package/src/treats.test.ts +25 -2
- package/src/treats.ts +15 -3
|
@@ -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
|
+
})
|
package/src/client/WhalePet.tsx
CHANGED
|
@@ -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 !== '') {
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
* (
|
|
4
|
-
*
|
|
5
|
-
* this service's methods onto
|
|
6
|
-
* `pet.
|
|
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
|
-
|
|
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', (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
322
|
-
private rewardTurn(): void {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
3
|
-
*
|
|
4
|
-
* animation contract, plus the session lifecycle
|
|
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
|
|
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
|
|
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
|
|
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
|
package/src/treats.test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
*
|
|
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)
|
|
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),
|