@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.
Files changed (79) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +106 -0
  3. package/assets/whale/pet.json +7 -0
  4. package/assets/whale/previews/failed.gif +0 -0
  5. package/assets/whale/previews/idle.gif +0 -0
  6. package/assets/whale/previews/jumping.gif +0 -0
  7. package/assets/whale/previews/review.gif +0 -0
  8. package/assets/whale/previews/running-left.gif +0 -0
  9. package/assets/whale/previews/running-right.gif +0 -0
  10. package/assets/whale/previews/running.gif +0 -0
  11. package/assets/whale/previews/waiting.gif +0 -0
  12. package/assets/whale/previews/waving.gif +0 -0
  13. package/assets/whale/spritesheet.webp +0 -0
  14. package/cordis.patch.yml +10 -0
  15. package/lib/client.js +1515 -0
  16. package/lib/index.js +642 -0
  17. package/lib/invariant.js +34 -0
  18. package/lib/types/affinity.d.ts +83 -0
  19. package/lib/types/affinity.d.ts.map +1 -0
  20. package/lib/types/client/PetDockEntry.d.ts +44 -0
  21. package/lib/types/client/PetDockEntry.d.ts.map +1 -0
  22. package/lib/types/client/PetSettingsCard.d.ts +67 -0
  23. package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
  24. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  26. package/lib/types/client/WhalePet.d.ts +49 -0
  27. package/lib/types/client/WhalePet.d.ts.map +1 -0
  28. package/lib/types/client/index.d.ts +46 -0
  29. package/lib/types/client/index.d.ts.map +1 -0
  30. package/lib/types/client/locales.d.ts +101 -0
  31. package/lib/types/client/locales.d.ts.map +1 -0
  32. package/lib/types/client/pet-store.d.ts +49 -0
  33. package/lib/types/client/pet-store.d.ts.map +1 -0
  34. package/lib/types/client/settings-form.d.ts +119 -0
  35. package/lib/types/client/settings-form.d.ts.map +1 -0
  36. package/lib/types/client/slots-augment.d.ts +19 -0
  37. package/lib/types/client/slots-augment.d.ts.map +1 -0
  38. package/lib/types/client/spritesheet.d.ts +69 -0
  39. package/lib/types/client/spritesheet.d.ts.map +1 -0
  40. package/lib/types/index.d.ts +44 -0
  41. package/lib/types/index.d.ts.map +1 -0
  42. package/lib/types/invariant.d.ts +10 -0
  43. package/lib/types/invariant.d.ts.map +1 -0
  44. package/lib/types/persist.d.ts +45 -0
  45. package/lib/types/persist.d.ts.map +1 -0
  46. package/lib/types/routes.d.ts +22 -0
  47. package/lib/types/routes.d.ts.map +1 -0
  48. package/lib/types/service.d.ts +161 -0
  49. package/lib/types/service.d.ts.map +1 -0
  50. package/lib/types/state.d.ts +80 -0
  51. package/lib/types/state.d.ts.map +1 -0
  52. package/lib/types/treats.d.ts +59 -0
  53. package/lib/types/treats.d.ts.map +1 -0
  54. package/package.json +100 -0
  55. package/src/affinity.test.ts +74 -0
  56. package/src/affinity.ts +144 -0
  57. package/src/client/PetDockEntry.tsx +95 -0
  58. package/src/client/PetSettingsCard.tsx +186 -0
  59. package/src/client/PluginSettingsCard.tsx +207 -0
  60. package/src/client/WhalePet.tsx +342 -0
  61. package/src/client/css-modules.d.ts +6 -0
  62. package/src/client/index.ts +261 -0
  63. package/src/client/locales.ts +108 -0
  64. package/src/client/pet-store.ts +80 -0
  65. package/src/client/pet.module.css +185 -0
  66. package/src/client/settings-card.module.css +277 -0
  67. package/src/client/settings-form.ts +294 -0
  68. package/src/client/slots-augment.ts +27 -0
  69. package/src/client/spritesheet.ts +138 -0
  70. package/src/index.ts +148 -0
  71. package/src/invariant.ts +40 -0
  72. package/src/persist.test.ts +96 -0
  73. package/src/persist.ts +128 -0
  74. package/src/routes.ts +171 -0
  75. package/src/service.ts +389 -0
  76. package/src/state.test.ts +65 -0
  77. package/src/state.ts +156 -0
  78. package/src/treats.test.ts +86 -0
  79. 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
+ })
@@ -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
+ }