@linxin666/dsh-pet 0.1.1
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/LICENSE +29 -0
- package/README.md +106 -0
- package/assets/whale/pet.json +7 -0
- package/assets/whale/previews/failed.gif +0 -0
- package/assets/whale/previews/idle.gif +0 -0
- package/assets/whale/previews/jumping.gif +0 -0
- package/assets/whale/previews/review.gif +0 -0
- package/assets/whale/previews/running-left.gif +0 -0
- package/assets/whale/previews/running-right.gif +0 -0
- package/assets/whale/previews/running.gif +0 -0
- package/assets/whale/previews/waiting.gif +0 -0
- package/assets/whale/previews/waving.gif +0 -0
- package/assets/whale/spritesheet.webp +0 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1515 -0
- package/lib/index.js +642 -0
- package/lib/invariant.js +34 -0
- package/lib/types/affinity.d.ts +83 -0
- package/lib/types/affinity.d.ts.map +1 -0
- package/lib/types/client/PetDockEntry.d.ts +44 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -0
- package/lib/types/client/PetSettingsCard.d.ts +67 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/WhalePet.d.ts +49 -0
- package/lib/types/client/WhalePet.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +46 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +101 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pet-store.d.ts +49 -0
- package/lib/types/client/pet-store.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/slots-augment.d.ts +19 -0
- package/lib/types/client/slots-augment.d.ts.map +1 -0
- package/lib/types/client/spritesheet.d.ts +69 -0
- package/lib/types/client/spritesheet.d.ts.map +1 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +10 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/persist.d.ts +45 -0
- package/lib/types/persist.d.ts.map +1 -0
- package/lib/types/routes.d.ts +22 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/service.d.ts +161 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/state.d.ts +80 -0
- package/lib/types/state.d.ts.map +1 -0
- package/lib/types/treats.d.ts +59 -0
- package/lib/types/treats.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/affinity.test.ts +74 -0
- package/src/affinity.ts +144 -0
- package/src/client/PetDockEntry.tsx +95 -0
- package/src/client/PetSettingsCard.tsx +186 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/WhalePet.tsx +342 -0
- package/src/client/css-modules.d.ts +6 -0
- package/src/client/index.ts +261 -0
- package/src/client/locales.ts +108 -0
- package/src/client/pet-store.ts +80 -0
- package/src/client/pet.module.css +185 -0
- package/src/client/settings-card.module.css +277 -0
- package/src/client/settings-form.ts +294 -0
- package/src/client/slots-augment.ts +27 -0
- package/src/client/spritesheet.ts +138 -0
- package/src/index.ts +148 -0
- package/src/invariant.ts +40 -0
- package/src/persist.test.ts +96 -0
- package/src/persist.ts +128 -0
- package/src/routes.ts +171 -0
- package/src/service.ts +389 -0
- package/src/state.test.ts +65 -0
- package/src/state.ts +156 -0
- package/src/treats.test.ts +86 -0
- package/src/treats.ts +101 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Treat (小鱼干) economy — pure, clock-injected. The pet's food comes from
|
|
3
|
+
* two sources, both tied to companionship:
|
|
4
|
+
* - work output: every N completed turns grant one treat;
|
|
5
|
+
* - time output: every T minutes of wall-clock time grant one treat.
|
|
6
|
+
* Feeding consumes one treat. Settlement is lazy: it runs whenever the host
|
|
7
|
+
* serves a state snapshot or an interaction, so there is no timer and no
|
|
8
|
+
* drift — elapsed periods are computed from the persisted last-grant marks.
|
|
9
|
+
* @module @linxin666/dsh-pet/treats
|
|
10
|
+
*/
|
|
11
|
+
/** Treat economy tuning. */
|
|
12
|
+
export interface TreatConfig {
|
|
13
|
+
/** Completed turns per work-output treat. */
|
|
14
|
+
turnsPerTreat: number;
|
|
15
|
+
/** Wall-clock ms per time-output treat. */
|
|
16
|
+
timeTreatMs: number;
|
|
17
|
+
/** Hard cap on stocked treats. */
|
|
18
|
+
maxTreats: number;
|
|
19
|
+
}
|
|
20
|
+
export declare const defaultTreatConfig: TreatConfig;
|
|
21
|
+
/** Treat ledger as persisted inside PetPersist. */
|
|
22
|
+
export interface TreatLedger {
|
|
23
|
+
/** Current stocked treats (0..maxTreats). */
|
|
24
|
+
treats: number;
|
|
25
|
+
/** Time-output anchor: epoch ms the wall-clock treat clock last advanced (0 = never started). */
|
|
26
|
+
lastTreatGrantAt: number;
|
|
27
|
+
/** Work-output anchor: affinity turns counter at the last work-out settle. */
|
|
28
|
+
turnsAtLastTreatGrant: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function emptyTreatLedger(): TreatLedger;
|
|
31
|
+
/** Outcome of one settlement pass. */
|
|
32
|
+
export interface TreatSettlement {
|
|
33
|
+
/** Mutated ledger (caller persists it). */
|
|
34
|
+
ledger: TreatLedger;
|
|
35
|
+
/** Treats gained in this pass (work + time). */
|
|
36
|
+
gained: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Settle treat grants from both sources against one ledger snapshot.
|
|
40
|
+
* Work output counts whole periods since the last work settlement
|
|
41
|
+
* (turnsDelta / turnsPerTreat) and advances only the work anchor;
|
|
42
|
+
* time output counts whole periods since the time anchor
|
|
43
|
+
* (`lastTreatGrantAt`) and advances only the time anchor. The two sources
|
|
44
|
+
* are independent so a continuously working user still earns time treats.
|
|
45
|
+
* 0 time history never backfills — the clock starts at the first settlement.
|
|
46
|
+
* Both sources are clamped by the stock cap.
|
|
47
|
+
*/
|
|
48
|
+
export declare function settleTreatGrants(ledger: TreatLedger, turns: number, nowMs: number, config?: TreatConfig): TreatSettlement;
|
|
49
|
+
/**
|
|
50
|
+
* Consume one treat for a feed. Returns the outcome; a feed with no stocked
|
|
51
|
+
* treats is refused.
|
|
52
|
+
*/
|
|
53
|
+
export declare function consumeTreat(ledger: TreatLedger): {
|
|
54
|
+
ok: true;
|
|
55
|
+
ledger: TreatLedger;
|
|
56
|
+
} | {
|
|
57
|
+
ok: false;
|
|
58
|
+
};
|
|
59
|
+
//# sourceMappingURL=treats.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"treats.d.ts","sourceRoot":"","sources":["../../src/treats.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,4BAA4B;AAC5B,MAAM,WAAW,WAAW;IAC1B,6CAA6C;IAC7C,aAAa,EAAE,MAAM,CAAA;IACrB,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAA;IACnB,kCAAkC;IAClC,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,eAAO,MAAM,kBAAkB,EAAE,WAIhC,CAAA;AAED,mDAAmD;AACnD,MAAM,WAAW,WAAW;IAC1B,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,gBAAgB,EAAE,MAAM,CAAA;IACxB,8EAA8E;IAC9E,qBAAqB,EAAE,MAAM,CAAA;CAC9B;AAED,wBAAgB,gBAAgB,IAAI,WAAW,CAE9C;AAED,sCAAsC;AACtC,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,MAAM,EAAE,WAAW,CAAA;IACnB,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAA;CACf;AAMD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,WAAgC,GACvC,eAAe,CAqBjB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,WAAW,GAClB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAA;CAAE,CAGnD"}
|
package/package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@linxin666/dsh-pet",
|
|
3
|
+
"description": "鲸鱼娘宠物插件 for the dsh web GUI: a soft healing whale-girl companion that reacts to model activity (idle/waiting/thinking/tool/done), with petting/feeding interactions and an affinity score",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
8
|
+
},
|
|
9
|
+
"main": "lib/index.js",
|
|
10
|
+
"types": "lib/types/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./lib/types/index.d.ts",
|
|
14
|
+
"default": "./lib/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./invariant": {
|
|
17
|
+
"types": "./lib/types/invariant.d.ts",
|
|
18
|
+
"default": "./lib/invariant.js"
|
|
19
|
+
},
|
|
20
|
+
"./client": {
|
|
21
|
+
"types": "./lib/types/client/index.d.ts",
|
|
22
|
+
"default": "./lib/client.js"
|
|
23
|
+
},
|
|
24
|
+
"./src/*": "./src/*",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/index.js",
|
|
29
|
+
"lib/invariant.js",
|
|
30
|
+
"lib/client.js",
|
|
31
|
+
"lib/types/**/*.d.ts",
|
|
32
|
+
"lib/types/**/*.d.ts.map",
|
|
33
|
+
"src",
|
|
34
|
+
"assets",
|
|
35
|
+
"cordis.patch.yml"
|
|
36
|
+
],
|
|
37
|
+
"license": "BSD-3-Clause",
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
},
|
|
42
|
+
"client": {
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
45
|
+
"@deepseek-ai/dsh-client-connection",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
49
|
+
],
|
|
50
|
+
"platform": "web"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"clsx": "^2.1.1",
|
|
55
|
+
"schemastery": "^3.18.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
|
|
59
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
60
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
|
62
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
63
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
65
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
66
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
67
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
|
68
|
+
"react": "^18.2.0",
|
|
69
|
+
"react-dom": "^18.2.0"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
73
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
74
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
78
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
79
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
80
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
81
|
+
"@testing-library/dom": "^10.4.1",
|
|
82
|
+
"@testing-library/react": "^16.3.2",
|
|
83
|
+
"@types/node": "^22.20.0",
|
|
84
|
+
"@types/react": "~18.3.1",
|
|
85
|
+
"@types/react-dom": "~18.3.0",
|
|
86
|
+
"jsdom": "29.1.1",
|
|
87
|
+
"lightningcss": "^1.32.0",
|
|
88
|
+
"react": "^18.2.0",
|
|
89
|
+
"react-dom": "^18.2.0",
|
|
90
|
+
"tsdown": "^0.22.2",
|
|
91
|
+
"typescript": "^6.0.3",
|
|
92
|
+
"vite-tsconfig-paths": "^6.1.1",
|
|
93
|
+
"vitest": "^4.1.8"
|
|
94
|
+
},
|
|
95
|
+
"scripts": {
|
|
96
|
+
"build": "tsc -b && tsdown",
|
|
97
|
+
"test": "vitest run",
|
|
98
|
+
"typecheck": "tsc -b --pretty false && tsc -p tsconfig.test.json --pretty false"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
AFFINITY_MAX,
|
|
4
|
+
AFFINITY_RANKS,
|
|
5
|
+
applyInteraction,
|
|
6
|
+
applyTurnReward,
|
|
7
|
+
defaultAffinityConfig,
|
|
8
|
+
emptyAffinity,
|
|
9
|
+
rankOf,
|
|
10
|
+
} from './affinity.ts'
|
|
11
|
+
|
|
12
|
+
describe('applyInteraction', () => {
|
|
13
|
+
it('accepts the first pet and grants the pet reward', () => {
|
|
14
|
+
const now = 1_000_000
|
|
15
|
+
const outcome = applyInteraction(emptyAffinity(), 'pet', now)
|
|
16
|
+
expect(outcome.accepted).toBe(true)
|
|
17
|
+
expect(outcome.delta).toBe(defaultAffinityConfig.petReward)
|
|
18
|
+
expect(outcome.affinity.points).toBe(defaultAffinityConfig.petReward)
|
|
19
|
+
expect(outcome.affinity.pets).toBe(1)
|
|
20
|
+
expect(outcome.affinity.lastPetAt).toBe(now)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('rejects a pet inside the cooldown without mutating state', () => {
|
|
24
|
+
const now = 1_000_000
|
|
25
|
+
const first = applyInteraction(emptyAffinity(), 'pet', now)
|
|
26
|
+
const second = applyInteraction(first.affinity, 'pet', now + defaultAffinityConfig.petCooldownMs - 1)
|
|
27
|
+
expect(second.accepted).toBe(false)
|
|
28
|
+
expect(second.delta).toBe(0)
|
|
29
|
+
expect(second.affinity).toBe(first.affinity) // same reference: no mutation
|
|
30
|
+
expect(second.affinity.pets).toBe(1)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('accepts a pet again after the cooldown elapsed', () => {
|
|
34
|
+
const now = 1_000_000
|
|
35
|
+
const first = applyInteraction(emptyAffinity(), 'pet', now)
|
|
36
|
+
const second = applyInteraction(first.affinity, 'pet', now + defaultAffinityConfig.petCooldownMs)
|
|
37
|
+
expect(second.accepted).toBe(true)
|
|
38
|
+
expect(second.affinity.pets).toBe(2)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('rejects a feed inside the cooldown without spending anything', () => {
|
|
42
|
+
const now = 1_000_000
|
|
43
|
+
const first = applyInteraction(emptyAffinity(), 'feed', now)
|
|
44
|
+
const second = applyInteraction(first.affinity, 'feed', now + defaultAffinityConfig.feedCooldownMs - 1)
|
|
45
|
+
expect(second.accepted).toBe(false)
|
|
46
|
+
expect(second.delta).toBe(0)
|
|
47
|
+
expect(second.affinity).toBe(first.affinity)
|
|
48
|
+
expect(second.affinity.feeds).toBe(1)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('clamps points at AFFINITY_MAX', () => {
|
|
52
|
+
const state = { ...emptyAffinity(), points: AFFINITY_MAX - 1 }
|
|
53
|
+
const outcome = applyInteraction(state, 'pet', 1_000_000)
|
|
54
|
+
expect(outcome.affinity.points).toBe(AFFINITY_MAX)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('applyTurnReward', () => {
|
|
59
|
+
it('increments turns and points', () => {
|
|
60
|
+
const next = applyTurnReward(emptyAffinity())
|
|
61
|
+
expect(next.turns).toBe(1)
|
|
62
|
+
expect(next.points).toBe(defaultAffinityConfig.turnReward)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('rankOf', () => {
|
|
67
|
+
it('maps point totals onto the rank ladder', () => {
|
|
68
|
+
for (const rank of AFFINITY_RANKS) {
|
|
69
|
+
expect(rankOf(rank.min).name).toBe(rank.name)
|
|
70
|
+
}
|
|
71
|
+
expect(rankOf(AFFINITY_MAX).name).toBe(AFFINITY_RANKS[AFFINITY_RANKS.length - 1]!.name)
|
|
72
|
+
expect(rankOf(-1).name).toBe(AFFINITY_RANKS[0]!.name)
|
|
73
|
+
})
|
|
74
|
+
})
|
package/src/affinity.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Affinity score — pure, clock-injected. The pet grows closer the more you
|
|
3
|
+
* work together and care for it: every completed turn earns a small reward,
|
|
4
|
+
* petting earns a tiny one (cooldown-gated), feeding earns the most.
|
|
5
|
+
* Persistence lives in the service; this module only computes transitions.
|
|
6
|
+
* @module @linxin666/dsh-pet/affinity
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One interaction the user can perform on the pet. */
|
|
10
|
+
export type PetInteraction = 'pet' | 'feed'
|
|
11
|
+
|
|
12
|
+
/** Affinity state as persisted. */
|
|
13
|
+
export interface AffinityState {
|
|
14
|
+
/** Total affinity points, capped at AFFINITY_MAX. */
|
|
15
|
+
points: number
|
|
16
|
+
/** Epoch ms of the last pet interaction. */
|
|
17
|
+
lastPetAt: number
|
|
18
|
+
/** Epoch ms of the last feed. */
|
|
19
|
+
lastFeedAt: number
|
|
20
|
+
/** Total pet count (lifetime). */
|
|
21
|
+
pets: number
|
|
22
|
+
/** Total feed count (lifetime). */
|
|
23
|
+
feeds: number
|
|
24
|
+
/** Total completed turns witnessed (lifetime). */
|
|
25
|
+
turns: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const AFFINITY_MAX = 100
|
|
29
|
+
|
|
30
|
+
/** Affinity ranks by points; the pet visibly grows with its rank.
|
|
31
|
+
* Marker glyphs are plain ASCII (the repo bans all emoji characters);
|
|
32
|
+
* they read as a growing star trail alongside the rank name. */
|
|
33
|
+
export const AFFINITY_RANKS = [
|
|
34
|
+
{ min: 0, name: '幼鲸', emoji: '*' },
|
|
35
|
+
{ min: 25, name: '伙伴', emoji: '**' },
|
|
36
|
+
{ min: 50, name: '挚友', emoji: '***' },
|
|
37
|
+
{ min: 80, name: '深海羁绊', emoji: '****' },
|
|
38
|
+
] as const
|
|
39
|
+
|
|
40
|
+
/** Interaction tuning (all in points / ms). */
|
|
41
|
+
export interface AffinityConfig {
|
|
42
|
+
/** Points per completed turn. */
|
|
43
|
+
turnReward: number
|
|
44
|
+
/** Points per pet; applied only outside the pet cooldown. */
|
|
45
|
+
petReward: number
|
|
46
|
+
/** Cooldown between pets, ms. */
|
|
47
|
+
petCooldownMs: number
|
|
48
|
+
/** Points per feed. */
|
|
49
|
+
feedReward: number
|
|
50
|
+
/** Cooldown between feeds, ms. */
|
|
51
|
+
feedCooldownMs: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const defaultAffinityConfig: AffinityConfig = {
|
|
55
|
+
turnReward: 1,
|
|
56
|
+
petReward: 1,
|
|
57
|
+
petCooldownMs: 10_000,
|
|
58
|
+
feedReward: 5,
|
|
59
|
+
feedCooldownMs: 30_000,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function emptyAffinity(): AffinityState {
|
|
63
|
+
return { points: 0, lastPetAt: 0, lastFeedAt: 0, pets: 0, feeds: 0, turns: 0 }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Outcome of one interaction. */
|
|
67
|
+
export interface InteractionOutcome {
|
|
68
|
+
/** Mutated affinity state (caller persists it). */
|
|
69
|
+
affinity: AffinityState
|
|
70
|
+
/** Points actually gained (0 when inside the cooldown). */
|
|
71
|
+
delta: number
|
|
72
|
+
/** Human-readable reaction copy the UI shows as a bubble. */
|
|
73
|
+
reaction: string
|
|
74
|
+
/** True when the interaction was accepted (outside cooldown). */
|
|
75
|
+
accepted: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Rank for a point total. */
|
|
79
|
+
export function rankOf(points: number): (typeof AFFINITY_RANKS)[number] {
|
|
80
|
+
let rank: (typeof AFFINITY_RANKS)[number] = AFFINITY_RANKS[0]!
|
|
81
|
+
for (const candidate of AFFINITY_RANKS) {
|
|
82
|
+
if (points >= candidate.min) rank = candidate
|
|
83
|
+
}
|
|
84
|
+
return rank
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clamp(points: number): number {
|
|
88
|
+
return Math.min(AFFINITY_MAX, Math.max(0, points))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Apply one interaction to a copy of the state (immutable style: returns a
|
|
93
|
+
* new object; the caller replaces the persisted state). Cooldowns only
|
|
94
|
+
* apply once the pet has been interacted with at least once (last*At === 0
|
|
95
|
+
* means "never", so the first pet/feed always lands).
|
|
96
|
+
*/
|
|
97
|
+
export function applyInteraction(
|
|
98
|
+
state: AffinityState,
|
|
99
|
+
kind: PetInteraction,
|
|
100
|
+
nowMs: number,
|
|
101
|
+
config: AffinityConfig = defaultAffinityConfig,
|
|
102
|
+
): InteractionOutcome {
|
|
103
|
+
const next = { ...state }
|
|
104
|
+
if (kind === 'pet') {
|
|
105
|
+
if (state.lastPetAt !== 0 && nowMs - state.lastPetAt < config.petCooldownMs) {
|
|
106
|
+
return { affinity: state, delta: 0, reaction: '摸过头啦,让鲸鱼娘歇口气~', accepted: false }
|
|
107
|
+
}
|
|
108
|
+
next.lastPetAt = nowMs
|
|
109
|
+
next.pets += 1
|
|
110
|
+
next.points = clamp(state.points + config.petReward)
|
|
111
|
+
return {
|
|
112
|
+
affinity: next,
|
|
113
|
+
delta: config.petReward,
|
|
114
|
+
reaction: '咕噜咕噜~被摸摸好舒服!',
|
|
115
|
+
accepted: true,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (kind === 'feed') {
|
|
119
|
+
if (state.lastFeedAt !== 0 && nowMs - state.lastFeedAt < config.feedCooldownMs) {
|
|
120
|
+
return { affinity: state, delta: 0, reaction: '吃饱啦,晚点再喂~', accepted: false }
|
|
121
|
+
}
|
|
122
|
+
next.lastFeedAt = nowMs
|
|
123
|
+
next.feeds += 1
|
|
124
|
+
next.points = clamp(state.points + config.feedReward)
|
|
125
|
+
return {
|
|
126
|
+
affinity: next,
|
|
127
|
+
delta: config.feedReward,
|
|
128
|
+
reaction: '呜哇!小鱼干好好吃!',
|
|
129
|
+
accepted: true,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { affinity: state, delta: 0, reaction: '', accepted: false }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Reward one completed turn (called by the host on `done`). */
|
|
136
|
+
export function applyTurnReward(
|
|
137
|
+
state: AffinityState,
|
|
138
|
+
config: AffinityConfig = defaultAffinityConfig,
|
|
139
|
+
): AffinityState {
|
|
140
|
+
const next = { ...state }
|
|
141
|
+
next.turns += 1
|
|
142
|
+
next.points = clamp(state.points + config.turnReward)
|
|
143
|
+
return next
|
|
144
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dock anchor inside `conversation.input.selector.context`: the input
|
|
3
|
+
* selector row mounts in EVERY conversation phase (no-session cold start,
|
|
4
|
+
* the blank-session hero, and the active seat), so the floating pet stays on
|
|
5
|
+
* screen on the new-conversation screen too. While visible it mounts the
|
|
6
|
+
* floating WhalePet (portal); while hidden it renders the summon button.
|
|
7
|
+
* @module @linxin666/dsh-pet/client/PetDockEntry
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useSyncExternalStore, type ReactElement } from 'react'
|
|
11
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
13
|
+
import type { PetDisplayConfig } from '../persist.ts'
|
|
14
|
+
import type { PetStoreInstance } from './pet-store.ts'
|
|
15
|
+
import { WhalePet } from './WhalePet.tsx'
|
|
16
|
+
import { NS } from './locales.ts'
|
|
17
|
+
import styles from './pet.module.css'
|
|
18
|
+
|
|
19
|
+
/** Injected actions handed to the dock entry component. */
|
|
20
|
+
export interface PetInjected {
|
|
21
|
+
/** The app-wide pet store instance (snapshot + feedback). */
|
|
22
|
+
store: PetStoreInstance
|
|
23
|
+
/** Ensure the first snapshot is fetched (called on mount). */
|
|
24
|
+
ensure: () => void
|
|
25
|
+
/** Pet the whale girl (click). */
|
|
26
|
+
pet: () => void
|
|
27
|
+
/** Feed the whale girl. */
|
|
28
|
+
feed: () => void
|
|
29
|
+
/** Hide the whale girl. */
|
|
30
|
+
hide: () => void
|
|
31
|
+
/** Summon the hidden whale girl back. */
|
|
32
|
+
summon: () => void
|
|
33
|
+
/** Persist a drag position. */
|
|
34
|
+
dragEnd: (right: number, bottom: number) => void
|
|
35
|
+
/** Rename the pet (persisted by the host). */
|
|
36
|
+
rename: (name: string) => void
|
|
37
|
+
/** Clear the reaction bubble. */
|
|
38
|
+
feedbackDone: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Composed props of the dock entry (runtime + locale + injected). */
|
|
42
|
+
export type PetDockEntryProps =
|
|
43
|
+
PropsRuntime<'conversation.input.selector.context'>
|
|
44
|
+
& PetInjected
|
|
45
|
+
& PropsLocale<typeof NS>
|
|
46
|
+
|
|
47
|
+
const DEFAULT_DISPLAY: PetDisplayConfig = { visible: true, size: 160, right: 24, bottom: 20 }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Dock entry: while the pet is visible, mount the floating WhalePet (it
|
|
51
|
+
* portals itself onto document.body); while hidden, render the summon
|
|
52
|
+
* button so the pet can always come back. The store is the plugin-owned
|
|
53
|
+
* single instance — the slot system provides none because the pet is
|
|
54
|
+
* host-global, not session-scoped.
|
|
55
|
+
*/
|
|
56
|
+
export function PetDockEntry(props: PetDockEntryProps): ReactElement {
|
|
57
|
+
const { store, ensure } = props
|
|
58
|
+
const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
|
59
|
+
const snapshot = ui.snapshot
|
|
60
|
+
const feedback = ui.feedback
|
|
61
|
+
const visible = snapshot?.display.visible ?? true
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
ensure()
|
|
65
|
+
}, [ensure])
|
|
66
|
+
|
|
67
|
+
if (visible) {
|
|
68
|
+
return (
|
|
69
|
+
<span data-pet-dock data-testid="pet-dock">
|
|
70
|
+
<WhalePet
|
|
71
|
+
snapshot={snapshot}
|
|
72
|
+
display={snapshot?.display ?? DEFAULT_DISPLAY}
|
|
73
|
+
feedback={feedback}
|
|
74
|
+
onPet={props.pet}
|
|
75
|
+
onFeed={props.feed}
|
|
76
|
+
onHide={props.hide}
|
|
77
|
+
onDragEnd={props.dragEnd}
|
|
78
|
+
onRename={props.rename}
|
|
79
|
+
onFeedbackDone={props.feedbackDone}
|
|
80
|
+
t={props.t}
|
|
81
|
+
/>
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className={styles.summon}
|
|
89
|
+
onClick={props.summon}
|
|
90
|
+
data-testid="pet-summon"
|
|
91
|
+
>
|
|
92
|
+
{props.t('pet.summon', { name: snapshot?.name ?? '鲸鱼娘' })}
|
|
93
|
+
</button>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pet settings card: display layout and name, bound to the `pet` settings
|
|
3
|
+
* namespace the host plugin registers. Registered into the
|
|
4
|
+
* `settings.plugin.item` slot the plugin-configuration section renders.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
8
|
+
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
9
|
+
import { PluginSettingsCard, ValueField, BooleanField } from './PluginSettingsCard.tsx'
|
|
10
|
+
import { CardForm, booleanField, numberField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
|
|
11
|
+
|
|
12
|
+
/** The pet's settings fields this card edits (the namespace's full schema). */
|
|
13
|
+
export interface PetSettings {
|
|
14
|
+
/** Master switch for the plugin. */
|
|
15
|
+
enabled?: boolean
|
|
16
|
+
/** Master switch. */
|
|
17
|
+
visible?: boolean
|
|
18
|
+
/** Scale of the rendered pet in px (sprite cell height). */
|
|
19
|
+
size?: number
|
|
20
|
+
/** Horizontal inset from the viewport right edge, px. */
|
|
21
|
+
right?: number
|
|
22
|
+
/** Vertical inset from the viewport bottom edge, px. */
|
|
23
|
+
bottom?: number
|
|
24
|
+
/** User-customizable pet display name. */
|
|
25
|
+
name?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the pet settings card renders. */
|
|
29
|
+
export interface PetSettingsCardState extends CardShell {
|
|
30
|
+
/** Plugin master switch. */
|
|
31
|
+
enabled: CardFieldState
|
|
32
|
+
/** Master switch. */
|
|
33
|
+
visible: CardFieldState
|
|
34
|
+
/** Pet scale. */
|
|
35
|
+
size: CardFieldState
|
|
36
|
+
/** Right inset. */
|
|
37
|
+
right: CardFieldState
|
|
38
|
+
/** Bottom inset. */
|
|
39
|
+
bottom: CardFieldState
|
|
40
|
+
/** Pet name. */
|
|
41
|
+
name: CardFieldState
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The registration-side face the card's slot entry injects. */
|
|
45
|
+
export interface PetSettingsCardFace extends CardActions {
|
|
46
|
+
hooks: {
|
|
47
|
+
/** Card snapshot bound by the renderer as usePetSettingsCard. */
|
|
48
|
+
petSettingsCard: SnapshotStore<PetSettingsCardState>
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Bridges the `pet` scope onto the card's staged form. */
|
|
53
|
+
export class PetSettingsCardController {
|
|
54
|
+
private readonly form: CardForm<PetSettings>
|
|
55
|
+
private readonly store: SnapshotStore<PetSettingsCardState>
|
|
56
|
+
|
|
57
|
+
/** @param scope - the bound settings scope for the `pet` namespace. */
|
|
58
|
+
constructor(scope: SettingsScope<PetSettings>) {
|
|
59
|
+
this.form = new CardForm(scope, [
|
|
60
|
+
booleanField('enabled'),
|
|
61
|
+
booleanField('visible'),
|
|
62
|
+
numberField('size'),
|
|
63
|
+
numberField('right'),
|
|
64
|
+
numberField('bottom'),
|
|
65
|
+
textField('name'),
|
|
66
|
+
])
|
|
67
|
+
this.store = this.form.bind(() => this.projection())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private projection(): PetSettingsCardState {
|
|
71
|
+
return {
|
|
72
|
+
...this.form.shell(),
|
|
73
|
+
enabled: this.form.field('enabled'),
|
|
74
|
+
visible: this.form.field('visible'),
|
|
75
|
+
size: this.form.field('size'),
|
|
76
|
+
right: this.form.field('right'),
|
|
77
|
+
bottom: this.form.field('bottom'),
|
|
78
|
+
name: this.form.field('name'),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the face the card's slot registration injects.
|
|
84
|
+
* @returns the card's snapshot and its form actions.
|
|
85
|
+
*/
|
|
86
|
+
inject(): PetSettingsCardFace {
|
|
87
|
+
return { hooks: { petSettingsCard: this.store }, ...this.form.actions() }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Props the renderer binds for the pet settings card. */
|
|
92
|
+
export type PetSettingsCardProps =
|
|
93
|
+
PropsRuntime<'web-ui.plugin.item'>
|
|
94
|
+
& PropsLocale<'pet'>
|
|
95
|
+
& InjectFace<PetSettingsCardFace>
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Render the pet settings card.
|
|
99
|
+
* @param props - locale copy, the card snapshot, and its form actions.
|
|
100
|
+
* @returns the card.
|
|
101
|
+
*/
|
|
102
|
+
export function PetSettingsCard(props: PetSettingsCardProps) {
|
|
103
|
+
const { t } = props
|
|
104
|
+
const state = props.usePetSettingsCard(snapshot => snapshot)
|
|
105
|
+
const disabled = !state.writable
|
|
106
|
+
const fieldProps = {
|
|
107
|
+
overriddenLabel: t('settings.overridden'),
|
|
108
|
+
resetLabel: t('settings.reset'),
|
|
109
|
+
invalidLabel: t('settings.invalidNumber'),
|
|
110
|
+
disabled,
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
<PluginSettingsCard
|
|
114
|
+
t={t}
|
|
115
|
+
titleKey="settings.title"
|
|
116
|
+
descriptionKey="settings.description"
|
|
117
|
+
state={state}
|
|
118
|
+
onSave={props.save}
|
|
119
|
+
onDiscard={props.discard}
|
|
120
|
+
>
|
|
121
|
+
<BooleanField
|
|
122
|
+
id="settings-pet-enabled"
|
|
123
|
+
label={t('settings.enabled')}
|
|
124
|
+
hint={t('settings.enabledHint')}
|
|
125
|
+
inheritLabel={t('settings.inherit')}
|
|
126
|
+
onLabel={t('settings.on')}
|
|
127
|
+
offLabel={t('settings.off')}
|
|
128
|
+
{...fieldProps}
|
|
129
|
+
{...state.enabled}
|
|
130
|
+
onEdit={(text) => { props.edit('enabled', text) }}
|
|
131
|
+
onReset={() => { props.resetField('enabled') }}
|
|
132
|
+
/>
|
|
133
|
+
<BooleanField
|
|
134
|
+
id="settings-pet-visible"
|
|
135
|
+
label={t('settings.visible')}
|
|
136
|
+
hint={t('settings.visibleHint')}
|
|
137
|
+
inheritLabel={t('settings.inherit')}
|
|
138
|
+
onLabel={t('settings.on')}
|
|
139
|
+
offLabel={t('settings.off')}
|
|
140
|
+
{...fieldProps}
|
|
141
|
+
{...state.visible}
|
|
142
|
+
onEdit={(text) => { props.edit('visible', text) }}
|
|
143
|
+
onReset={() => { props.resetField('visible') }}
|
|
144
|
+
/>
|
|
145
|
+
<ValueField
|
|
146
|
+
id="settings-pet-size"
|
|
147
|
+
label={t('settings.size')}
|
|
148
|
+
hint={t('settings.sizeHint')}
|
|
149
|
+
numeric
|
|
150
|
+
{...fieldProps}
|
|
151
|
+
{...state.size}
|
|
152
|
+
onEdit={(text) => { props.edit('size', text) }}
|
|
153
|
+
onReset={() => { props.resetField('size') }}
|
|
154
|
+
/>
|
|
155
|
+
<ValueField
|
|
156
|
+
id="settings-pet-right"
|
|
157
|
+
label={t('settings.right')}
|
|
158
|
+
hint={t('settings.rightHint')}
|
|
159
|
+
numeric
|
|
160
|
+
{...fieldProps}
|
|
161
|
+
{...state.right}
|
|
162
|
+
onEdit={(text) => { props.edit('right', text) }}
|
|
163
|
+
onReset={() => { props.resetField('right') }}
|
|
164
|
+
/>
|
|
165
|
+
<ValueField
|
|
166
|
+
id="settings-pet-bottom"
|
|
167
|
+
label={t('settings.bottom')}
|
|
168
|
+
hint={t('settings.bottomHint')}
|
|
169
|
+
numeric
|
|
170
|
+
{...fieldProps}
|
|
171
|
+
{...state.bottom}
|
|
172
|
+
onEdit={(text) => { props.edit('bottom', text) }}
|
|
173
|
+
onReset={() => { props.resetField('bottom') }}
|
|
174
|
+
/>
|
|
175
|
+
<ValueField
|
|
176
|
+
id="settings-pet-name"
|
|
177
|
+
label={t('settings.name')}
|
|
178
|
+
hint={t('settings.nameHint')}
|
|
179
|
+
{...fieldProps}
|
|
180
|
+
{...state.name}
|
|
181
|
+
onEdit={(text) => { props.edit('name', text) }}
|
|
182
|
+
onReset={() => { props.resetField('name') }}
|
|
183
|
+
/>
|
|
184
|
+
</PluginSettingsCard>
|
|
185
|
+
)
|
|
186
|
+
}
|