@linxin666/dsh-pet 0.1.13 → 0.1.15
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/README.i18n.yaml +2 -2
- package/README.md +11 -7
- package/README.zh.md +11 -7
- package/lib/client.js +38 -17
- package/lib/client.js.map +1 -1
- package/lib/index.js +393 -150
- package/lib/invariant.js +1 -1
- package/lib/{state-C9EyycwI.js → state-CFyJv0sQ.js} +22 -7
- package/lib/types/affinity.d.ts +15 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +14 -0
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +27 -7
- package/lib/types/client/WhalePet.d.ts.map +1 -1
- package/lib/types/client/WhalePet.js +2 -1
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.js +3 -3
- package/lib/types/client/settings-form.d.ts +14 -5
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +38 -10
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/event-projection.d.ts +36 -0
- package/lib/types/event-projection.d.ts.map +1 -0
- package/lib/types/event-projection.js +98 -0
- package/lib/types/ledger.d.ts +77 -0
- package/lib/types/ledger.d.ts.map +1 -0
- package/lib/types/ledger.js +139 -0
- package/lib/types/persist.d.ts +5 -1
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +7 -3
- package/lib/types/service.d.ts +24 -44
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +98 -131
- package/lib/types/state.d.ts +10 -12
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/state.js +14 -10
- package/package.json +1 -1
- package/src/affinity.ts +33 -0
- package/src/client/PluginSettingsCard.tsx +83 -17
- package/src/client/WhalePet.test.tsx +25 -1
- package/src/client/WhalePet.tsx +6 -0
- package/src/client/index.ts +3 -3
- package/src/client/pet.module.css +9 -0
- package/src/client/settings-card.module.css +6 -0
- package/src/client/settings-form.ts +41 -9
- package/src/dsh-home.test.ts +35 -0
- package/src/dsh-home.ts +36 -0
- package/src/event-projection.ts +128 -0
- package/src/ledger.test.ts +67 -0
- package/src/ledger.ts +183 -0
- package/src/persist.ts +7 -3
- package/src/service.ts +110 -171
- package/src/state.test.ts +4 -1
- package/src/state.ts +17 -13
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet affinity economy (ledger) — composes the pure affinity and treats
|
|
3
|
+
* modules with the cooldown/dedup bookkeeping and emits updated persistence
|
|
4
|
+
* snapshots, marking dirty so the owning facade decides when to flush. Read
|
|
5
|
+
* paths (view) no longer settle the economy; settlements happen on explicit
|
|
6
|
+
* economic events: completed-turn rewards (official or legacy) and feeds.
|
|
7
|
+
* @module @linxin666/dsh-pet/ledger
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
applyInteraction,
|
|
12
|
+
affinityViewOf,
|
|
13
|
+
applyTurnReward,
|
|
14
|
+
defaultAffinityConfig,
|
|
15
|
+
type AffinityConfig,
|
|
16
|
+
type PetAffinityView,
|
|
17
|
+
type PetInteraction,
|
|
18
|
+
} from './affinity.ts'
|
|
19
|
+
import {
|
|
20
|
+
consumeTreat,
|
|
21
|
+
defaultTreatConfig,
|
|
22
|
+
settleTreatGrants,
|
|
23
|
+
type TreatConfig,
|
|
24
|
+
} from './treats.ts'
|
|
25
|
+
import type { PetDisplayConfig, PetPersist } from './persist.ts'
|
|
26
|
+
|
|
27
|
+
/** Tuning overrides for the affinity economy. */
|
|
28
|
+
export interface LedgerConfig {
|
|
29
|
+
affinity?: Partial<AffinityConfig>
|
|
30
|
+
treats?: Partial<TreatConfig>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Result of one ledger interaction (the shape the pet RPC returns). */
|
|
34
|
+
export interface LedgerInteractionResult {
|
|
35
|
+
/** Reaction copy bubble. */
|
|
36
|
+
reaction: string
|
|
37
|
+
/** Points gained (0 when inside the cooldown). */
|
|
38
|
+
delta: number
|
|
39
|
+
/** Full affinity snapshot (same shape as the state view). */
|
|
40
|
+
affinity: PetAffinityView
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Holds the current persistence snapshot and all economy bookkeeping. Every
|
|
45
|
+
* mutating call flags takeDirty so the facade persists exactly once per
|
|
46
|
+
* batch of changes; read methods (snapshot, affinityView) never write.
|
|
47
|
+
*/
|
|
48
|
+
export class PetLedger {
|
|
49
|
+
private readonly affinityConfig: AffinityConfig
|
|
50
|
+
private readonly treatConfig: TreatConfig
|
|
51
|
+
private current: PetPersist
|
|
52
|
+
/** Completed turns already rewarded, per session (turn numbers are per-session). */
|
|
53
|
+
private rewardedTurns = new Map<string, number>()
|
|
54
|
+
private lastLegacyTurnRewardAt = 0
|
|
55
|
+
private dirty = false
|
|
56
|
+
|
|
57
|
+
constructor(persist: PetPersist, config: LedgerConfig = {}) {
|
|
58
|
+
this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
|
|
59
|
+
this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
|
|
60
|
+
this.current = persist
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Affinity cooldown/rank tuning (read-only). */
|
|
64
|
+
get affinity(): AffinityConfig {
|
|
65
|
+
return this.affinityConfig
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The current persistence snapshot (trade a copy when mutating). */
|
|
69
|
+
get snapshot(): PetPersist {
|
|
70
|
+
return this.current
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Stock cap reported to clients. */
|
|
74
|
+
get treatMax(): number {
|
|
75
|
+
return this.treatConfig.maxTreats
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Consume the pending-write flag if any mutation occurred. */
|
|
79
|
+
takeDirty(): boolean {
|
|
80
|
+
const was = this.dirty
|
|
81
|
+
this.dirty = false
|
|
82
|
+
return was
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Replace the display block (clamping stays a caller concern). */
|
|
86
|
+
setDisplay(display: PetDisplayConfig): void {
|
|
87
|
+
this.current = { ...this.current, display }
|
|
88
|
+
this.dirty = true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Replace the pet display name (validation stays a caller concern). */
|
|
92
|
+
setName(name: string): void {
|
|
93
|
+
this.current = { ...this.current, name }
|
|
94
|
+
this.dirty = true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Settle the treat economy (work + time output since the last settlement).
|
|
99
|
+
* A zero-gain first settlement still starts the time clock (anchor write),
|
|
100
|
+
* which is how the 30-minute time output can ever accrue. Returns true when
|
|
101
|
+
* the in-memory ledger changed and should be persisted.
|
|
102
|
+
*/
|
|
103
|
+
settleTreats(nowMs: number): boolean {
|
|
104
|
+
const settlement = settleTreatGrants(
|
|
105
|
+
this.current.treats,
|
|
106
|
+
this.current.affinity.turns,
|
|
107
|
+
nowMs,
|
|
108
|
+
this.treatConfig,
|
|
109
|
+
)
|
|
110
|
+
if (settlement.ledger === this.current.treats) return false
|
|
111
|
+
this.current = { ...this.current, treats: settlement.ledger }
|
|
112
|
+
this.dirty = true
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Award the completed-turn reward once per session+turn (idempotent) and
|
|
118
|
+
* run the treat settlement that work output feeds. Returns true when the
|
|
119
|
+
* snapshot changed.
|
|
120
|
+
*/
|
|
121
|
+
rewardTurn(sessionId: string, turn: number, nowMs: number): boolean {
|
|
122
|
+
const last = this.rewardedTurns.get(sessionId) ?? 0
|
|
123
|
+
if (turn <= last) return false
|
|
124
|
+
this.rewardedTurns.set(sessionId, turn)
|
|
125
|
+
let changed = this.applyTurnReward()
|
|
126
|
+
if (this.settleTreats(nowMs)) changed = true
|
|
127
|
+
return changed
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Preserve turn rewards for installations that only emit legacy activity. */
|
|
131
|
+
rewardLegacyTurn(nowMs: number): boolean {
|
|
132
|
+
// A legacy done snapshot may repeat during the celebration window.
|
|
133
|
+
if (nowMs - this.lastLegacyTurnRewardAt < 5_000) return false
|
|
134
|
+
this.lastLegacyTurnRewardAt = nowMs
|
|
135
|
+
let changed = this.applyTurnReward()
|
|
136
|
+
if (this.settleTreats(nowMs)) changed = true
|
|
137
|
+
return changed
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private applyTurnReward(): boolean {
|
|
141
|
+
this.current = {
|
|
142
|
+
...this.current,
|
|
143
|
+
affinity: applyTurnReward(this.current.affinity, this.affinityConfig),
|
|
144
|
+
}
|
|
145
|
+
this.dirty = true
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Pet or feed the pet. Feeding settles first, then gates on the feed
|
|
151
|
+
* cooldown before spending stock — a feed inside the cooldown must not burn
|
|
152
|
+
* a treat for nothing.
|
|
153
|
+
*/
|
|
154
|
+
interact(kind: PetInteraction, nowMs: number): LedgerInteractionResult {
|
|
155
|
+
if (kind === 'feed') this.settleTreats(nowMs)
|
|
156
|
+
const outcome = applyInteraction(this.current.affinity, kind, nowMs, this.affinityConfig)
|
|
157
|
+
if (kind === 'feed' && !outcome.accepted) {
|
|
158
|
+
return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(nowMs) }
|
|
159
|
+
}
|
|
160
|
+
if (kind === 'feed') {
|
|
161
|
+
const consume = consumeTreat(this.current.treats)
|
|
162
|
+
if (!consume.ok) {
|
|
163
|
+
return {
|
|
164
|
+
reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
|
|
165
|
+
delta: 0,
|
|
166
|
+
affinity: this.affinityView(nowMs),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
this.current = { ...this.current, treats: consume.ledger }
|
|
170
|
+
this.dirty = true
|
|
171
|
+
}
|
|
172
|
+
if (outcome.accepted) {
|
|
173
|
+
this.current = { ...this.current, affinity: outcome.affinity }
|
|
174
|
+
this.dirty = true
|
|
175
|
+
}
|
|
176
|
+
return { reaction: outcome.reaction, delta: outcome.delta, affinity: this.affinityView(nowMs) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Current affinity view for the RPC snapshot. */
|
|
180
|
+
affinityView(nowMs: number): PetAffinityView {
|
|
181
|
+
return affinityViewOf(this.current.affinity, nowMs, this.affinityConfig)
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/persist.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
|
-
import {
|
|
10
|
+
import { dshHome } from './dsh-home.ts'
|
|
11
11
|
import { AFFINITY_MAX, emptyAffinity, type AffinityState } from './affinity.ts'
|
|
12
12
|
import { defaultTreatConfig, emptyTreatLedger, type TreatLedger } from './treats.ts'
|
|
13
13
|
|
|
@@ -60,9 +60,13 @@ export function emptyPersist(): PetPersist {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the persistence directory ($DSH_HOME or ~/.dsh). Delegates to the
|
|
65
|
+
* shared {@link dshHome} resolution so the plugin family keeps one DSH_HOME
|
|
66
|
+
* definition (env override, ~ expansion, cwd-joined relative values).
|
|
67
|
+
*/
|
|
64
68
|
export function petHomeDir(): string {
|
|
65
|
-
return
|
|
69
|
+
return dshHome()
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
/** Numeric field guard: finite numbers only, else the fallback. */
|
package/src/service.ts
CHANGED
|
@@ -1,45 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pet host service — the `pet.*` RPC domain.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `pet.state` / `pet.interact` / `pet.setVisible` / `pet.setConfig`
|
|
7
|
-
*
|
|
2
|
+
* Pet host service — the `pet.*` RPC domain. A composition facade: it wires
|
|
3
|
+
* the pure event projection (`event-projection`) onto the state machine,
|
|
4
|
+
* delegates the affinity economy to the ledger (`ledger`), and routes
|
|
5
|
+
* persistence through `persist`. The API gateway maps these methods onto
|
|
6
|
+
* `pet.state` / `pet.interact` / `pet.setVisible` / `pet.setConfig` for
|
|
7
|
+
* browser consumers.
|
|
8
8
|
* @module @linxin666/dsh-pet/service
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { Context, Service } from '@deepseek-ai/cordis'
|
|
12
12
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
13
|
+
import type { AffinityConfig, PetAffinityView, PetInteraction } from './affinity.ts'
|
|
14
|
+
import type { TreatConfig } from './treats.ts'
|
|
13
15
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
type
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
} from './affinity.ts'
|
|
16
|
+
emptyProjectionRuntime,
|
|
17
|
+
isActivityPhase,
|
|
18
|
+
projectOfficialEvent,
|
|
19
|
+
type ActivityStatusEventLike,
|
|
20
|
+
type ProjectionRuntime,
|
|
21
|
+
} from './event-projection.ts'
|
|
22
|
+
import { PetLedger, type LedgerConfig, type LedgerInteractionResult } from './ledger.ts'
|
|
22
23
|
import {
|
|
23
|
-
|
|
24
|
-
petHomeDir,
|
|
25
|
-
savePetPersist,
|
|
24
|
+
DISPLAY_INSET_MAX,
|
|
26
25
|
DISPLAY_SIZE_MAX,
|
|
27
26
|
DISPLAY_SIZE_MIN,
|
|
28
|
-
DISPLAY_INSET_MAX,
|
|
29
27
|
PET_NAME_MAX_LENGTH,
|
|
28
|
+
loadPetPersist,
|
|
29
|
+
petHomeDir,
|
|
30
|
+
savePetPersist,
|
|
30
31
|
type PetDisplayConfig,
|
|
31
32
|
type PetPersist,
|
|
32
33
|
} from './persist.ts'
|
|
33
|
-
import {
|
|
34
|
-
defaultTreatConfig,
|
|
35
|
-
settleTreatGrants,
|
|
36
|
-
consumeTreat,
|
|
37
|
-
type TreatConfig,
|
|
38
|
-
} from './treats.ts'
|
|
39
34
|
import {
|
|
40
35
|
defaultPetStateConfig,
|
|
41
36
|
PetStateMachine,
|
|
42
37
|
type PetStateConfig,
|
|
38
|
+
type PetStateInput,
|
|
43
39
|
type PetStateSnapshot,
|
|
44
40
|
} from './state.ts'
|
|
45
41
|
|
|
@@ -87,18 +83,7 @@ export interface PetStateView {
|
|
|
87
83
|
phase: PetStateSnapshot['phase']
|
|
88
84
|
sessionActive: boolean
|
|
89
85
|
/** Affinity ledger snapshot. */
|
|
90
|
-
affinity:
|
|
91
|
-
points: number
|
|
92
|
-
rank: string
|
|
93
|
-
rankEmoji: string
|
|
94
|
-
pets: number
|
|
95
|
-
feeds: number
|
|
96
|
-
turns: number
|
|
97
|
-
/** True while the pet interaction is inside its cooldown. */
|
|
98
|
-
petCooldown: boolean
|
|
99
|
-
/** True while the feed is inside its cooldown. */
|
|
100
|
-
feedCooldown: boolean
|
|
101
|
-
}
|
|
86
|
+
affinity: PetAffinityView
|
|
102
87
|
/** Display configuration. */
|
|
103
88
|
display: PetDisplayConfig
|
|
104
89
|
/** User-customizable pet display name. */
|
|
@@ -113,14 +98,7 @@ export interface PetStateView {
|
|
|
113
98
|
}
|
|
114
99
|
|
|
115
100
|
/** Result of `pet.interact`. */
|
|
116
|
-
export
|
|
117
|
-
/** Reaction copy bubble. */
|
|
118
|
-
reaction: string
|
|
119
|
-
/** Points gained (0 when inside the cooldown). */
|
|
120
|
-
delta: number
|
|
121
|
-
/** Full affinity snapshot (same shape as state view). */
|
|
122
|
-
affinity: PetStateView['affinity']
|
|
123
|
-
}
|
|
101
|
+
export type PetInteractResult = LedgerInteractionResult
|
|
124
102
|
|
|
125
103
|
declare module '@deepseek-ai/cordis' {
|
|
126
104
|
interface Context {
|
|
@@ -130,33 +108,31 @@ declare module '@deepseek-ai/cordis' {
|
|
|
130
108
|
|
|
131
109
|
/**
|
|
132
110
|
* Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
|
|
133
|
-
* written until
|
|
134
|
-
* in-memory state, and persistence happens on
|
|
135
|
-
*
|
|
111
|
+
* written until an economic event or interaction arrives; event listeners
|
|
112
|
+
* update only in-memory state, and persistence happens on economic changes
|
|
113
|
+
* (turn rewards, feeds, config/name changes) — never on a read.
|
|
136
114
|
*/
|
|
137
115
|
export class PetService extends Service {
|
|
138
116
|
static inject: string[] = []
|
|
139
117
|
|
|
140
118
|
private readonly machine: PetStateMachine
|
|
141
|
-
private readonly
|
|
142
|
-
private readonly treatConfig: TreatConfig
|
|
119
|
+
private readonly ledger: PetLedger
|
|
143
120
|
private readonly persistDir: string
|
|
144
|
-
private persist: PetPersist
|
|
145
|
-
/** Completed turns already rewarded, per session (turn numbers are per-session). */
|
|
146
|
-
private rewardedTurns = new Map<string, number>()
|
|
147
121
|
private enabled: boolean
|
|
148
122
|
private disposeActivity: (() => void) | undefined
|
|
123
|
+
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
124
|
+
private displaySession: Session | undefined
|
|
125
|
+
private readonly sessionActivity = new WeakMap<Session, ProjectionRuntime>()
|
|
149
126
|
|
|
150
127
|
constructor(ctx: Context, config: PetConfig = {}) {
|
|
151
128
|
super(ctx, 'pet')
|
|
152
129
|
this.persistDir = config.persistDir ?? petHomeDir()
|
|
153
|
-
|
|
154
|
-
this.
|
|
130
|
+
const ledgerConfig: LedgerConfig = { affinity: config.affinity, treats: config.treats }
|
|
131
|
+
this.ledger = new PetLedger(loadPetPersist(this.persistDir), ledgerConfig)
|
|
155
132
|
this.machine = new PetStateMachine({
|
|
156
133
|
...defaultPetStateConfig,
|
|
157
134
|
...(config.state ?? {}),
|
|
158
135
|
})
|
|
159
|
-
this.persist = loadPetPersist(this.persistDir)
|
|
160
136
|
this.enabled = config.enabled ?? true
|
|
161
137
|
|
|
162
138
|
this.syncActivity()
|
|
@@ -174,12 +150,12 @@ export class PetService extends Service {
|
|
|
174
150
|
|
|
175
151
|
/** Current persisted display config (read-only view). */
|
|
176
152
|
display(): PetDisplayConfig {
|
|
177
|
-
return { ...this.
|
|
153
|
+
return { ...this.ledger.snapshot.display }
|
|
178
154
|
}
|
|
179
155
|
|
|
180
156
|
/** Current persisted pet name (read-only view). */
|
|
181
157
|
petName(): string {
|
|
182
|
-
return this.
|
|
158
|
+
return this.ledger.snapshot.name
|
|
183
159
|
}
|
|
184
160
|
|
|
185
161
|
/** Start or stop the session-activity listeners that drive the pet. */
|
|
@@ -197,37 +173,38 @@ export class PetService extends Service {
|
|
|
197
173
|
this.disposeActivity = (() => {
|
|
198
174
|
const disposers = [
|
|
199
175
|
this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
this.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
break
|
|
176
|
+
const runtime = this.activityRuntime(session)
|
|
177
|
+
// `activity/status` is an optional compatibility input. It is not
|
|
178
|
+
// declared as a durable event type by this package because current
|
|
179
|
+
// Harness installations publish the official session vocabulary.
|
|
180
|
+
if ((event.type as string) === 'activity/status') {
|
|
181
|
+
const payload = ((event as unknown as { data?: unknown }).data ?? {}) as ActivityStatusEventLike
|
|
182
|
+
if (typeof payload.phase !== 'string' || !isActivityPhase(payload.phase)) return
|
|
183
|
+
this.applyActivity(session, {
|
|
184
|
+
phase: payload.phase,
|
|
185
|
+
...(typeof payload.line === 'string' ? { line: payload.line } : {}),
|
|
186
|
+
...(typeof payload.phrase === 'string' ? { phrase: payload.phrase } : {}),
|
|
187
|
+
})
|
|
188
|
+
// On a legacy-only stream the compatibility event owns turn
|
|
189
|
+
// rewards. Once any official activity is observed, turn/end owns
|
|
190
|
+
// them and a derived legacy `done` cannot double-count.
|
|
191
|
+
if (payload.phase === 'done' && !runtime.officialEventsSeen) {
|
|
192
|
+
this.rewardLegacyTurn()
|
|
193
|
+
}
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const transition = projectOfficialEvent(event, runtime)
|
|
198
|
+
if (transition === undefined) return
|
|
199
|
+
runtime.officialEventsSeen = true
|
|
200
|
+
this.applyActivity(session, transition.input)
|
|
201
|
+
if (transition.completedTurn !== undefined) {
|
|
202
|
+
this.rewardTurn(String(session.id), transition.completedTurn)
|
|
228
203
|
}
|
|
229
204
|
}),
|
|
230
|
-
this.ctx.on('session/disposed', () => {
|
|
205
|
+
this.ctx.on('session/disposed', (session: Session) => {
|
|
206
|
+
if (session !== this.displaySession) return
|
|
207
|
+
this.displaySession = undefined
|
|
231
208
|
this.machine.onSessionDisposed()
|
|
232
209
|
}),
|
|
233
210
|
]
|
|
@@ -235,56 +212,49 @@ export class PetService extends Service {
|
|
|
235
212
|
})()
|
|
236
213
|
}
|
|
237
214
|
|
|
215
|
+
/** Return the projection state associated with one live session. */
|
|
216
|
+
private activityRuntime(session: Session): ProjectionRuntime {
|
|
217
|
+
let runtime = this.sessionActivity.get(session)
|
|
218
|
+
if (runtime === undefined) {
|
|
219
|
+
runtime = emptyProjectionRuntime()
|
|
220
|
+
this.sessionActivity.set(session, runtime)
|
|
221
|
+
}
|
|
222
|
+
return runtime
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Commit one activity as the host-global pet's most recent display state. */
|
|
226
|
+
private applyActivity(session: Session, input: PetStateInput): void {
|
|
227
|
+
this.displaySession = session
|
|
228
|
+
this.machine.onActivityStatus(input)
|
|
229
|
+
this.machine.onSessionActive()
|
|
230
|
+
}
|
|
231
|
+
|
|
238
232
|
/** RPC: pet or feed the pet. */
|
|
239
233
|
async interact(kind: PetInteraction): Promise<PetInteractResult> {
|
|
240
234
|
const nowMs = Date.now()
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// treat for nothing.
|
|
245
|
-
if (kind === 'feed') this.settleTreats(nowMs)
|
|
246
|
-
const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig)
|
|
247
|
-
if (kind === 'feed' && !outcome.accepted) {
|
|
248
|
-
return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(this.persist.affinity) }
|
|
249
|
-
}
|
|
250
|
-
if (kind === 'feed') {
|
|
251
|
-
const consume = consumeTreat(this.persist.treats)
|
|
252
|
-
if (!consume.ok) {
|
|
253
|
-
const affinity = this.affinityView(this.persist.affinity)
|
|
254
|
-
return {
|
|
255
|
-
reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
|
|
256
|
-
delta: 0,
|
|
257
|
-
affinity,
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
this.persist = { ...this.persist, treats: consume.ledger }
|
|
261
|
-
}
|
|
262
|
-
if (outcome.accepted) {
|
|
263
|
-
this.persist = { ...this.persist, affinity: outcome.affinity }
|
|
264
|
-
this.flush()
|
|
265
|
-
}
|
|
266
|
-
const affinity = this.affinityView(outcome.affinity)
|
|
267
|
-
return { reaction: outcome.reaction, delta: outcome.delta, affinity }
|
|
235
|
+
const result = this.ledger.interact(kind, nowMs)
|
|
236
|
+
if (this.ledger.takeDirty()) this.flush()
|
|
237
|
+
return result
|
|
268
238
|
}
|
|
269
239
|
|
|
270
240
|
/** RPC: show or hide the pet. */
|
|
271
241
|
async setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }> {
|
|
272
|
-
this.
|
|
242
|
+
this.ledger.setDisplay({ ...this.ledger.snapshot.display, visible })
|
|
273
243
|
this.flush()
|
|
274
244
|
this.syncSettingsFromPet()
|
|
275
|
-
return { ok: true, display: this.
|
|
245
|
+
return { ok: true, display: this.ledger.snapshot.display }
|
|
276
246
|
}
|
|
277
247
|
|
|
278
248
|
/** RPC: update display config (size / position). Values are clamped to whole pixels. */
|
|
279
249
|
async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
|
|
280
|
-
const next = { ...this.
|
|
250
|
+
const next = { ...this.ledger.snapshot.display, ...patch }
|
|
281
251
|
next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)))
|
|
282
252
|
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)))
|
|
283
253
|
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)))
|
|
284
|
-
this.
|
|
254
|
+
this.ledger.setDisplay(next)
|
|
285
255
|
this.flush()
|
|
286
256
|
this.syncSettingsFromPet()
|
|
287
|
-
return { ok: true, display: this.
|
|
257
|
+
return { ok: true, display: this.ledger.snapshot.display }
|
|
288
258
|
}
|
|
289
259
|
|
|
290
260
|
/** RPC: rename the pet (trimmed, 1–20 chars). */
|
|
@@ -292,7 +262,7 @@ export class PetService extends Service {
|
|
|
292
262
|
const trimmed = name.trim()
|
|
293
263
|
if (trimmed === '') return { ok: false, error: 'name-empty' }
|
|
294
264
|
if (trimmed.length > PET_NAME_MAX_LENGTH) return { ok: false, error: 'name-too-long' }
|
|
295
|
-
this.
|
|
265
|
+
this.ledger.setName(trimmed)
|
|
296
266
|
this.flush()
|
|
297
267
|
this.syncSettingsFromPet()
|
|
298
268
|
return { ok: true, name: trimmed }
|
|
@@ -305,12 +275,13 @@ export class PetService extends Service {
|
|
|
305
275
|
* @param section - the resolved settings section.
|
|
306
276
|
*/
|
|
307
277
|
applySettingsSection(section: PetSettingsSection): void {
|
|
308
|
-
const next = { ...this.
|
|
278
|
+
const next = { ...this.ledger.snapshot.display }
|
|
309
279
|
next.visible = section.visible && (section.enabled ?? true)
|
|
310
280
|
next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)))
|
|
311
281
|
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)))
|
|
312
282
|
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)))
|
|
313
|
-
this.
|
|
283
|
+
this.ledger.setDisplay(next)
|
|
284
|
+
this.ledger.setName(section.name.trim())
|
|
314
285
|
this.flush()
|
|
315
286
|
}
|
|
316
287
|
|
|
@@ -318,12 +289,13 @@ export class PetService extends Service {
|
|
|
318
289
|
private syncSettingsFromPet(): void {
|
|
319
290
|
const settings = this.ctx.get('settings', false) as { update(ns: string, patch: object): Promise<void> } | undefined
|
|
320
291
|
if (settings === undefined) return
|
|
292
|
+
const snapshot = this.ledger.snapshot
|
|
321
293
|
void settings.update(PET_SETTINGS_NAMESPACE, {
|
|
322
|
-
visible:
|
|
323
|
-
size:
|
|
324
|
-
right:
|
|
325
|
-
bottom:
|
|
326
|
-
name:
|
|
294
|
+
visible: snapshot.display.visible,
|
|
295
|
+
size: snapshot.display.size,
|
|
296
|
+
right: snapshot.display.right,
|
|
297
|
+
bottom: snapshot.display.bottom,
|
|
298
|
+
name: snapshot.name,
|
|
327
299
|
}).catch(() => {
|
|
328
300
|
// A settings write failure must not break the pet's own persistence.
|
|
329
301
|
})
|
|
@@ -331,69 +303,36 @@ export class PetService extends Service {
|
|
|
331
303
|
|
|
332
304
|
/** Award the turn reward once per completed turn (idempotent per session + turn). */
|
|
333
305
|
private rewardTurn(sessionId: string, turn: number): void {
|
|
334
|
-
|
|
335
|
-
if (turn <= last) return
|
|
336
|
-
this.rewardedTurns.set(sessionId, turn)
|
|
337
|
-
this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) }
|
|
338
|
-
this.flush()
|
|
306
|
+
if (this.ledger.rewardTurn(sessionId, turn, Date.now())) this.flush()
|
|
339
307
|
}
|
|
340
308
|
|
|
341
|
-
/**
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
* still starts the time clock (anchor write), which is what lets the
|
|
345
|
-
* 30-minute time output ever accrue.
|
|
346
|
-
*/
|
|
347
|
-
private settleTreats(nowMs: number): void {
|
|
348
|
-
const settlement = settleTreatGrants(
|
|
349
|
-
this.persist.treats,
|
|
350
|
-
this.persist.affinity.turns,
|
|
351
|
-
nowMs,
|
|
352
|
-
this.treatConfig,
|
|
353
|
-
)
|
|
354
|
-
if (settlement.ledger !== this.persist.treats) {
|
|
355
|
-
this.persist = { ...this.persist, treats: settlement.ledger }
|
|
356
|
-
this.flush()
|
|
357
|
-
}
|
|
309
|
+
/** Preserve turn rewards for installations that only emit legacy activity. */
|
|
310
|
+
private rewardLegacyTurn(): void {
|
|
311
|
+
if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
|
|
358
312
|
}
|
|
359
313
|
|
|
360
314
|
private view(): PetStateView {
|
|
361
315
|
const snapshot = this.machine.render()
|
|
362
|
-
//
|
|
363
|
-
|
|
316
|
+
// Read-only: the ledger settles on economic events only, never on a read,
|
|
317
|
+
// so polling the state cannot trigger pet.json writes.
|
|
364
318
|
return {
|
|
365
319
|
animation: snapshot.animation,
|
|
366
320
|
...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
|
|
367
321
|
phase: snapshot.phase,
|
|
368
322
|
sessionActive: snapshot.sessionActive,
|
|
369
|
-
affinity: this.affinityView(
|
|
370
|
-
display: { ...this.
|
|
371
|
-
name: this.
|
|
323
|
+
affinity: this.ledger.affinityView(Date.now()),
|
|
324
|
+
display: { ...this.ledger.snapshot.display },
|
|
325
|
+
name: this.ledger.snapshot.name,
|
|
372
326
|
treats: {
|
|
373
|
-
stocked: this.
|
|
374
|
-
max: this.
|
|
327
|
+
stocked: this.ledger.snapshot.treats.treats,
|
|
328
|
+
max: this.ledger.treatMax,
|
|
375
329
|
},
|
|
376
330
|
}
|
|
377
331
|
}
|
|
378
332
|
|
|
379
|
-
private affinityView(affinity: AffinityState): PetStateView['affinity'] {
|
|
380
|
-
const nowMs = Date.now()
|
|
381
|
-
const rank = rankOf(affinity.points)
|
|
382
|
-
return {
|
|
383
|
-
points: affinity.points,
|
|
384
|
-
rank: rank.name,
|
|
385
|
-
rankEmoji: rank.emoji,
|
|
386
|
-
pets: affinity.pets,
|
|
387
|
-
feeds: affinity.feeds,
|
|
388
|
-
turns: affinity.turns,
|
|
389
|
-
petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
|
|
390
|
-
feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs,
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
333
|
private flush(): void {
|
|
395
334
|
try {
|
|
396
|
-
savePetPersist(this.
|
|
335
|
+
savePetPersist(this.ledger.snapshot, this.persistDir)
|
|
397
336
|
} catch {
|
|
398
337
|
// Persistence is best-effort; the in-memory ledger keeps working.
|
|
399
338
|
}
|
package/src/state.test.ts
CHANGED
|
@@ -11,8 +11,10 @@ describe('animationForPhase', () => {
|
|
|
11
11
|
it('maps each activity phase onto the animation contract', () => {
|
|
12
12
|
expect(animationForPhase('thinking')).toBe('running')
|
|
13
13
|
expect(animationForPhase('tool')).toBe('running-right')
|
|
14
|
+
expect(animationForPhase('review')).toBe('review')
|
|
14
15
|
expect(animationForPhase('waiting')).toBe('waiting')
|
|
15
16
|
expect(animationForPhase('done')).toBe('jumping')
|
|
17
|
+
expect(animationForPhase('failed')).toBe('failed')
|
|
16
18
|
expect(animationForPhase('idle')).toBe('idle')
|
|
17
19
|
})
|
|
18
20
|
})
|
|
@@ -27,7 +29,8 @@ describe('PetStateMachine', () => {
|
|
|
27
29
|
now += 2399
|
|
28
30
|
expect(machine.render().animation).toBe('jumping')
|
|
29
31
|
now += 2
|
|
30
|
-
expect(machine.render()
|
|
32
|
+
expect(machine.render()).toMatchObject({ animation: 'idle' })
|
|
33
|
+
expect(machine.render().bubble).toBeUndefined()
|
|
31
34
|
})
|
|
32
35
|
|
|
33
36
|
it('shows the phrase bubble when present, else the line', () => {
|