@linxin666/dsh-pet 0.1.1 → 0.1.2
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.md +4 -1
- package/lib/client.js.map +1 -0
- package/lib/state-7ChK99mH.js +217 -0
- package/lib/state-CKh9f_dF.js +196 -0
- package/lib/state-DMgOYVFT.js +198 -0
- package/lib/state-IyVnKymD.js +198 -0
- package/lib/types/affinity.js +84 -0
- package/lib/types/client/PetDockEntry.js +34 -0
- package/lib/types/client/PetSettingsCard.js +55 -0
- package/lib/types/client/PluginSettingsCard.js +39 -0
- package/lib/types/client/WhalePet.js +239 -0
- package/lib/types/client/index.js +206 -0
- package/lib/types/client/locales.js +92 -0
- package/lib/types/client/pet-store.js +33 -0
- package/lib/types/client/settings-form.js +211 -0
- package/lib/types/client/slots-augment.js +1 -0
- package/lib/types/client/spritesheet.js +120 -0
- package/lib/types/index.js +84 -0
- package/lib/types/invariant.js +27 -0
- package/lib/types/persist.js +96 -0
- package/lib/types/routes.js +158 -0
- package/lib/types/service.js +255 -0
- package/lib/types/state.js +106 -0
- package/lib/types/treats.js +63 -0
- package/package.json +18 -13
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
//#region src/affinity.ts
|
|
2
|
+
const AFFINITY_MAX = 100;
|
|
3
|
+
/** Affinity ranks by points; the pet visibly grows with its rank. */
|
|
4
|
+
const AFFINITY_RANKS = [
|
|
5
|
+
{
|
|
6
|
+
min: 0,
|
|
7
|
+
name: "幼鲸",
|
|
8
|
+
emoji: "🐣"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
min: 25,
|
|
12
|
+
name: "伙伴",
|
|
13
|
+
emoji: "🐬"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
min: 50,
|
|
17
|
+
name: "挚友",
|
|
18
|
+
emoji: "🐳"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
min: 80,
|
|
22
|
+
name: "深海羁绊",
|
|
23
|
+
emoji: "💙"
|
|
24
|
+
}
|
|
25
|
+
];
|
|
26
|
+
const defaultAffinityConfig = {
|
|
27
|
+
turnReward: 1,
|
|
28
|
+
petReward: 1,
|
|
29
|
+
petCooldownMs: 1e4,
|
|
30
|
+
feedReward: 5,
|
|
31
|
+
feedCooldownMs: 3e4
|
|
32
|
+
};
|
|
33
|
+
function emptyAffinity() {
|
|
34
|
+
return {
|
|
35
|
+
points: 0,
|
|
36
|
+
lastPetAt: 0,
|
|
37
|
+
lastFeedAt: 0,
|
|
38
|
+
pets: 0,
|
|
39
|
+
feeds: 0,
|
|
40
|
+
turns: 0
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Rank for a point total. */
|
|
44
|
+
function rankOf(points) {
|
|
45
|
+
let rank = AFFINITY_RANKS[0];
|
|
46
|
+
for (const candidate of AFFINITY_RANKS) if (points >= candidate.min) rank = candidate;
|
|
47
|
+
return rank;
|
|
48
|
+
}
|
|
49
|
+
function clamp(points) {
|
|
50
|
+
return Math.min(100, Math.max(0, points));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Apply one interaction to a copy of the state (immutable style: returns a
|
|
54
|
+
* new object; the caller replaces the persisted state). Cooldowns only
|
|
55
|
+
* apply once the pet has been interacted with at least once (last*At === 0
|
|
56
|
+
* means "never", so the first pet/feed always lands).
|
|
57
|
+
*/
|
|
58
|
+
function applyInteraction(state, kind, nowMs, config = defaultAffinityConfig) {
|
|
59
|
+
const next = { ...state };
|
|
60
|
+
if (kind === "pet") {
|
|
61
|
+
if (state.lastPetAt !== 0 && nowMs - state.lastPetAt < config.petCooldownMs) return {
|
|
62
|
+
affinity: state,
|
|
63
|
+
delta: 0,
|
|
64
|
+
reaction: "摸过头啦,让鲸鱼娘歇口气~",
|
|
65
|
+
accepted: false
|
|
66
|
+
};
|
|
67
|
+
next.lastPetAt = nowMs;
|
|
68
|
+
next.pets += 1;
|
|
69
|
+
next.points = clamp(state.points + config.petReward);
|
|
70
|
+
return {
|
|
71
|
+
affinity: next,
|
|
72
|
+
delta: config.petReward,
|
|
73
|
+
reaction: "咕噜咕噜~被摸摸好舒服!",
|
|
74
|
+
accepted: true
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (kind === "feed") {
|
|
78
|
+
if (state.lastFeedAt !== 0 && nowMs - state.lastFeedAt < config.feedCooldownMs) return {
|
|
79
|
+
affinity: state,
|
|
80
|
+
delta: 0,
|
|
81
|
+
reaction: "吃饱啦,晚点再喂~",
|
|
82
|
+
accepted: false
|
|
83
|
+
};
|
|
84
|
+
next.lastFeedAt = nowMs;
|
|
85
|
+
next.feeds += 1;
|
|
86
|
+
next.points = clamp(state.points + config.feedReward);
|
|
87
|
+
return {
|
|
88
|
+
affinity: next,
|
|
89
|
+
delta: config.feedReward,
|
|
90
|
+
reaction: "呜哇!小鱼干好好吃!",
|
|
91
|
+
accepted: true
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
affinity: state,
|
|
96
|
+
delta: 0,
|
|
97
|
+
reaction: "",
|
|
98
|
+
accepted: false
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Reward one completed turn (called by the host on `done`). */
|
|
102
|
+
function applyTurnReward(state, config = defaultAffinityConfig) {
|
|
103
|
+
const next = { ...state };
|
|
104
|
+
next.turns += 1;
|
|
105
|
+
next.points = clamp(state.points + config.turnReward);
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/state.ts
|
|
110
|
+
const defaultPetStateConfig = { celebrateMs: 2400 };
|
|
111
|
+
/**
|
|
112
|
+
* Map one activity phase onto the animation contract.
|
|
113
|
+
* - thinking / tool → `running` (focused work), with `running-right` as the
|
|
114
|
+
* side-alternating variant the client may use for tool activity.
|
|
115
|
+
* - waiting → `waiting` (expectant pose, needs user input).
|
|
116
|
+
* - done → `jumping` (celebration), then back to `idle` after the window.
|
|
117
|
+
* - idle → `idle` (calm breathing loop).
|
|
118
|
+
* `failed` has no DSH phase source yet; the machine keeps the mapping table
|
|
119
|
+
* so a future error event can light it up.
|
|
120
|
+
*/
|
|
121
|
+
function animationForPhase(phase) {
|
|
122
|
+
switch (phase) {
|
|
123
|
+
case "thinking": return "running";
|
|
124
|
+
case "tool": return "running-right";
|
|
125
|
+
case "waiting": return "waiting";
|
|
126
|
+
case "done": return "jumping";
|
|
127
|
+
case "idle": return "idle";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** The spritesheet row index for one animation track. */
|
|
131
|
+
function rowOf(animation) {
|
|
132
|
+
return {
|
|
133
|
+
"idle": 0,
|
|
134
|
+
"running-right": 1,
|
|
135
|
+
"running-left": 2,
|
|
136
|
+
"waving": 3,
|
|
137
|
+
"jumping": 4,
|
|
138
|
+
"failed": 5,
|
|
139
|
+
"waiting": 6,
|
|
140
|
+
"running": 7,
|
|
141
|
+
"review": 8
|
|
142
|
+
}[animation];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* PetStateMachine — one instance per host process. Holds only the latest
|
|
146
|
+
* input snapshot and the celebration timing; no storage, no side effects.
|
|
147
|
+
*/
|
|
148
|
+
var PetStateMachine = class {
|
|
149
|
+
config;
|
|
150
|
+
now;
|
|
151
|
+
phase = "idle";
|
|
152
|
+
line;
|
|
153
|
+
phrase;
|
|
154
|
+
sessionActive = false;
|
|
155
|
+
doneAt;
|
|
156
|
+
constructor(config = defaultPetStateConfig, now = Date.now) {
|
|
157
|
+
this.config = config;
|
|
158
|
+
this.now = now;
|
|
159
|
+
}
|
|
160
|
+
/** Consume one `activity/status` session event. */
|
|
161
|
+
onActivityStatus(input) {
|
|
162
|
+
this.phase = input.phase;
|
|
163
|
+
this.line = input.line;
|
|
164
|
+
this.phrase = input.phrase;
|
|
165
|
+
if (input.phase === "done") this.doneAt = this.now();
|
|
166
|
+
}
|
|
167
|
+
/** A session became the active one (or a fresh session started). */
|
|
168
|
+
onSessionActive() {
|
|
169
|
+
this.sessionActive = true;
|
|
170
|
+
}
|
|
171
|
+
/** The active session was disposed (or none left). */
|
|
172
|
+
onSessionDisposed() {
|
|
173
|
+
this.sessionActive = false;
|
|
174
|
+
this.phase = "idle";
|
|
175
|
+
this.line = void 0;
|
|
176
|
+
this.phrase = void 0;
|
|
177
|
+
this.doneAt = void 0;
|
|
178
|
+
}
|
|
179
|
+
/** Render the current animation decision. */
|
|
180
|
+
render() {
|
|
181
|
+
const nowMs = this.now();
|
|
182
|
+
let animation = animationForPhase(this.phase);
|
|
183
|
+
if (this.phase === "done" && this.doneAt !== void 0) {
|
|
184
|
+
if (nowMs - this.doneAt < this.config.celebrateMs) animation = "jumping";
|
|
185
|
+
else animation = "idle";
|
|
186
|
+
}
|
|
187
|
+
const bubble = this.phrase ?? this.line;
|
|
188
|
+
return {
|
|
189
|
+
animation,
|
|
190
|
+
...bubble === void 0 ? {} : { bubble },
|
|
191
|
+
animationStartedAt: nowMs,
|
|
192
|
+
phase: this.phase,
|
|
193
|
+
sessionActive: this.sessionActive
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
//#endregion
|
|
198
|
+
export { AFFINITY_MAX as a, applyTurnReward as c, rankOf as d, rowOf as i, defaultAffinityConfig as l, animationForPhase as n, AFFINITY_RANKS as o, defaultPetStateConfig as r, applyInteraction as s, PetStateMachine as t, emptyAffinity as u };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
//#region src/affinity.ts
|
|
2
|
+
const AFFINITY_MAX = 100;
|
|
3
|
+
/** Affinity ranks by points; the pet visibly grows with its rank.
|
|
4
|
+
* Marker glyphs are plain ASCII (the repo bans all emoji characters);
|
|
5
|
+
* they read as a growing star trail alongside the rank name. */
|
|
6
|
+
const AFFINITY_RANKS = [
|
|
7
|
+
{
|
|
8
|
+
min: 0,
|
|
9
|
+
name: "幼鲸",
|
|
10
|
+
emoji: "*"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
min: 25,
|
|
14
|
+
name: "伙伴",
|
|
15
|
+
emoji: "**"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
min: 50,
|
|
19
|
+
name: "挚友",
|
|
20
|
+
emoji: "***"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
min: 80,
|
|
24
|
+
name: "深海羁绊",
|
|
25
|
+
emoji: "****"
|
|
26
|
+
}
|
|
27
|
+
];
|
|
28
|
+
const defaultAffinityConfig = {
|
|
29
|
+
turnReward: 1,
|
|
30
|
+
petReward: 1,
|
|
31
|
+
petCooldownMs: 1e4,
|
|
32
|
+
feedReward: 5,
|
|
33
|
+
feedCooldownMs: 3e4
|
|
34
|
+
};
|
|
35
|
+
function emptyAffinity() {
|
|
36
|
+
return {
|
|
37
|
+
points: 0,
|
|
38
|
+
lastPetAt: 0,
|
|
39
|
+
lastFeedAt: 0,
|
|
40
|
+
pets: 0,
|
|
41
|
+
feeds: 0,
|
|
42
|
+
turns: 0
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Rank for a point total. */
|
|
46
|
+
function rankOf(points) {
|
|
47
|
+
let rank = AFFINITY_RANKS[0];
|
|
48
|
+
for (const candidate of AFFINITY_RANKS) if (points >= candidate.min) rank = candidate;
|
|
49
|
+
return rank;
|
|
50
|
+
}
|
|
51
|
+
function clamp(points) {
|
|
52
|
+
return Math.min(100, Math.max(0, points));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Apply one interaction to a copy of the state (immutable style: returns a
|
|
56
|
+
* new object; the caller replaces the persisted state). Cooldowns only
|
|
57
|
+
* apply once the pet has been interacted with at least once (last*At === 0
|
|
58
|
+
* means "never", so the first pet/feed always lands).
|
|
59
|
+
*/
|
|
60
|
+
function applyInteraction(state, kind, nowMs, config = defaultAffinityConfig) {
|
|
61
|
+
const next = { ...state };
|
|
62
|
+
if (kind === "pet") {
|
|
63
|
+
if (state.lastPetAt !== 0 && nowMs - state.lastPetAt < config.petCooldownMs) return {
|
|
64
|
+
affinity: state,
|
|
65
|
+
delta: 0,
|
|
66
|
+
reaction: "摸过头啦,让鲸鱼娘歇口气~",
|
|
67
|
+
accepted: false
|
|
68
|
+
};
|
|
69
|
+
next.lastPetAt = nowMs;
|
|
70
|
+
next.pets += 1;
|
|
71
|
+
next.points = clamp(state.points + config.petReward);
|
|
72
|
+
return {
|
|
73
|
+
affinity: next,
|
|
74
|
+
delta: config.petReward,
|
|
75
|
+
reaction: "咕噜咕噜~被摸摸好舒服!",
|
|
76
|
+
accepted: true
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (kind === "feed") {
|
|
80
|
+
if (state.lastFeedAt !== 0 && nowMs - state.lastFeedAt < config.feedCooldownMs) return {
|
|
81
|
+
affinity: state,
|
|
82
|
+
delta: 0,
|
|
83
|
+
reaction: "吃饱啦,晚点再喂~",
|
|
84
|
+
accepted: false
|
|
85
|
+
};
|
|
86
|
+
next.lastFeedAt = nowMs;
|
|
87
|
+
next.feeds += 1;
|
|
88
|
+
next.points = clamp(state.points + config.feedReward);
|
|
89
|
+
return {
|
|
90
|
+
affinity: next,
|
|
91
|
+
delta: config.feedReward,
|
|
92
|
+
reaction: "呜哇!小鱼干好好吃!",
|
|
93
|
+
accepted: true
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
affinity: state,
|
|
98
|
+
delta: 0,
|
|
99
|
+
reaction: "",
|
|
100
|
+
accepted: false
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Reward one completed turn (called by the host on `done`). */
|
|
104
|
+
function applyTurnReward(state, config = defaultAffinityConfig) {
|
|
105
|
+
const next = { ...state };
|
|
106
|
+
next.turns += 1;
|
|
107
|
+
next.points = clamp(state.points + config.turnReward);
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/state.ts
|
|
112
|
+
const defaultPetStateConfig = { celebrateMs: 2400 };
|
|
113
|
+
/**
|
|
114
|
+
* Map one activity phase onto the animation contract.
|
|
115
|
+
* - thinking / tool → `running` (focused work), with `running-right` as the
|
|
116
|
+
* side-alternating variant the client may use for tool activity.
|
|
117
|
+
* - waiting → `waiting` (expectant pose, needs user input).
|
|
118
|
+
* - done → `jumping` (celebration), then back to `idle` after the window.
|
|
119
|
+
* - idle → `idle` (calm breathing loop).
|
|
120
|
+
* `failed` has no DSH phase source yet; the machine keeps the mapping table
|
|
121
|
+
* so a future error event can light it up.
|
|
122
|
+
*/
|
|
123
|
+
function animationForPhase(phase) {
|
|
124
|
+
switch (phase) {
|
|
125
|
+
case "thinking": return "running";
|
|
126
|
+
case "tool": return "running-right";
|
|
127
|
+
case "waiting": return "waiting";
|
|
128
|
+
case "done": return "jumping";
|
|
129
|
+
case "idle": return "idle";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** The spritesheet row index for one animation track. */
|
|
133
|
+
function rowOf(animation) {
|
|
134
|
+
return {
|
|
135
|
+
"idle": 0,
|
|
136
|
+
"running-right": 1,
|
|
137
|
+
"running-left": 2,
|
|
138
|
+
"waving": 3,
|
|
139
|
+
"jumping": 4,
|
|
140
|
+
"failed": 5,
|
|
141
|
+
"waiting": 6,
|
|
142
|
+
"running": 7,
|
|
143
|
+
"review": 8
|
|
144
|
+
}[animation];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* PetStateMachine — one instance per host process. Holds only the latest
|
|
148
|
+
* input snapshot and the celebration timing; no storage, no side effects.
|
|
149
|
+
*/
|
|
150
|
+
var PetStateMachine = class {
|
|
151
|
+
config;
|
|
152
|
+
now;
|
|
153
|
+
phase = "idle";
|
|
154
|
+
line;
|
|
155
|
+
phrase;
|
|
156
|
+
sessionActive = false;
|
|
157
|
+
doneAt;
|
|
158
|
+
constructor(config = defaultPetStateConfig, now = Date.now) {
|
|
159
|
+
this.config = config;
|
|
160
|
+
this.now = now;
|
|
161
|
+
}
|
|
162
|
+
/** Consume one `activity/status` session event. */
|
|
163
|
+
onActivityStatus(input) {
|
|
164
|
+
this.phase = input.phase;
|
|
165
|
+
this.line = input.line;
|
|
166
|
+
this.phrase = input.phrase;
|
|
167
|
+
if (input.phase === "done") this.doneAt = this.now();
|
|
168
|
+
}
|
|
169
|
+
/** A session became the active one (or a fresh session started). */
|
|
170
|
+
onSessionActive() {
|
|
171
|
+
this.sessionActive = true;
|
|
172
|
+
}
|
|
173
|
+
/** The active session was disposed (or none left). */
|
|
174
|
+
onSessionDisposed() {
|
|
175
|
+
this.sessionActive = false;
|
|
176
|
+
this.phase = "idle";
|
|
177
|
+
this.line = void 0;
|
|
178
|
+
this.phrase = void 0;
|
|
179
|
+
this.doneAt = void 0;
|
|
180
|
+
}
|
|
181
|
+
/** Render the current animation decision. */
|
|
182
|
+
render() {
|
|
183
|
+
const nowMs = this.now();
|
|
184
|
+
let animation = animationForPhase(this.phase);
|
|
185
|
+
if (this.phase === "done" && this.doneAt !== void 0) if (nowMs - this.doneAt < this.config.celebrateMs) animation = "jumping";
|
|
186
|
+
else animation = "idle";
|
|
187
|
+
const bubble = this.phrase ?? this.line;
|
|
188
|
+
return {
|
|
189
|
+
animation,
|
|
190
|
+
...bubble === void 0 ? {} : { bubble },
|
|
191
|
+
animationStartedAt: nowMs,
|
|
192
|
+
phase: this.phase,
|
|
193
|
+
sessionActive: this.sessionActive
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
//#endregion
|
|
198
|
+
export { AFFINITY_MAX as a, applyTurnReward as c, rankOf as d, rowOf as i, defaultAffinityConfig as l, animationForPhase as n, AFFINITY_RANKS as o, defaultPetStateConfig as r, applyInteraction as s, PetStateMachine as t, emptyAffinity as u };
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
export const AFFINITY_MAX = 100;
|
|
9
|
+
/** Affinity ranks by points; the pet visibly grows with its rank.
|
|
10
|
+
* Marker glyphs are plain ASCII (the repo bans all emoji characters);
|
|
11
|
+
* they read as a growing star trail alongside the rank name. */
|
|
12
|
+
export const AFFINITY_RANKS = [
|
|
13
|
+
{ min: 0, name: '幼鲸', emoji: '*' },
|
|
14
|
+
{ min: 25, name: '伙伴', emoji: '**' },
|
|
15
|
+
{ min: 50, name: '挚友', emoji: '***' },
|
|
16
|
+
{ min: 80, name: '深海羁绊', emoji: '****' },
|
|
17
|
+
];
|
|
18
|
+
export const defaultAffinityConfig = {
|
|
19
|
+
turnReward: 1,
|
|
20
|
+
petReward: 1,
|
|
21
|
+
petCooldownMs: 10_000,
|
|
22
|
+
feedReward: 5,
|
|
23
|
+
feedCooldownMs: 30_000,
|
|
24
|
+
};
|
|
25
|
+
export function emptyAffinity() {
|
|
26
|
+
return { points: 0, lastPetAt: 0, lastFeedAt: 0, pets: 0, feeds: 0, turns: 0 };
|
|
27
|
+
}
|
|
28
|
+
/** Rank for a point total. */
|
|
29
|
+
export function rankOf(points) {
|
|
30
|
+
let rank = AFFINITY_RANKS[0];
|
|
31
|
+
for (const candidate of AFFINITY_RANKS) {
|
|
32
|
+
if (points >= candidate.min)
|
|
33
|
+
rank = candidate;
|
|
34
|
+
}
|
|
35
|
+
return rank;
|
|
36
|
+
}
|
|
37
|
+
function clamp(points) {
|
|
38
|
+
return Math.min(AFFINITY_MAX, Math.max(0, points));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Apply one interaction to a copy of the state (immutable style: returns a
|
|
42
|
+
* new object; the caller replaces the persisted state). Cooldowns only
|
|
43
|
+
* apply once the pet has been interacted with at least once (last*At === 0
|
|
44
|
+
* means "never", so the first pet/feed always lands).
|
|
45
|
+
*/
|
|
46
|
+
export function applyInteraction(state, kind, nowMs, config = defaultAffinityConfig) {
|
|
47
|
+
const next = { ...state };
|
|
48
|
+
if (kind === 'pet') {
|
|
49
|
+
if (state.lastPetAt !== 0 && nowMs - state.lastPetAt < config.petCooldownMs) {
|
|
50
|
+
return { affinity: state, delta: 0, reaction: '摸过头啦,让鲸鱼娘歇口气~', accepted: false };
|
|
51
|
+
}
|
|
52
|
+
next.lastPetAt = nowMs;
|
|
53
|
+
next.pets += 1;
|
|
54
|
+
next.points = clamp(state.points + config.petReward);
|
|
55
|
+
return {
|
|
56
|
+
affinity: next,
|
|
57
|
+
delta: config.petReward,
|
|
58
|
+
reaction: '咕噜咕噜~被摸摸好舒服!',
|
|
59
|
+
accepted: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (kind === 'feed') {
|
|
63
|
+
if (state.lastFeedAt !== 0 && nowMs - state.lastFeedAt < config.feedCooldownMs) {
|
|
64
|
+
return { affinity: state, delta: 0, reaction: '吃饱啦,晚点再喂~', accepted: false };
|
|
65
|
+
}
|
|
66
|
+
next.lastFeedAt = nowMs;
|
|
67
|
+
next.feeds += 1;
|
|
68
|
+
next.points = clamp(state.points + config.feedReward);
|
|
69
|
+
return {
|
|
70
|
+
affinity: next,
|
|
71
|
+
delta: config.feedReward,
|
|
72
|
+
reaction: '呜哇!小鱼干好好吃!',
|
|
73
|
+
accepted: true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { affinity: state, delta: 0, reaction: '', accepted: false };
|
|
77
|
+
}
|
|
78
|
+
/** Reward one completed turn (called by the host on `done`). */
|
|
79
|
+
export function applyTurnReward(state, config = defaultAffinityConfig) {
|
|
80
|
+
const next = { ...state };
|
|
81
|
+
next.turns += 1;
|
|
82
|
+
next.points = clamp(state.points + config.turnReward);
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Dock anchor inside `conversation.input.selector.context`: the input
|
|
4
|
+
* selector row mounts in EVERY conversation phase (no-session cold start,
|
|
5
|
+
* the blank-session hero, and the active seat), so the floating pet stays on
|
|
6
|
+
* screen on the new-conversation screen too. While visible it mounts the
|
|
7
|
+
* floating WhalePet (portal); while hidden it renders the summon button.
|
|
8
|
+
* @module @linxin666/dsh-pet/client/PetDockEntry
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
11
|
+
import { WhalePet } from "./WhalePet.js";
|
|
12
|
+
import styles from './pet.module.css';
|
|
13
|
+
const DEFAULT_DISPLAY = { visible: true, size: 160, right: 24, bottom: 20 };
|
|
14
|
+
/**
|
|
15
|
+
* Dock entry: while the pet is visible, mount the floating WhalePet (it
|
|
16
|
+
* portals itself onto document.body); while hidden, render the summon
|
|
17
|
+
* button so the pet can always come back. The store is the plugin-owned
|
|
18
|
+
* single instance — the slot system provides none because the pet is
|
|
19
|
+
* host-global, not session-scoped.
|
|
20
|
+
*/
|
|
21
|
+
export function PetDockEntry(props) {
|
|
22
|
+
const { store, ensure } = props;
|
|
23
|
+
const ui = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
24
|
+
const snapshot = ui.snapshot;
|
|
25
|
+
const feedback = ui.feedback;
|
|
26
|
+
const visible = snapshot?.display.visible ?? true;
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
ensure();
|
|
29
|
+
}, [ensure]);
|
|
30
|
+
if (visible) {
|
|
31
|
+
return (_jsx("span", { "data-pet-dock": true, "data-testid": "pet-dock", children: _jsx(WhalePet, { snapshot: snapshot, display: snapshot?.display ?? DEFAULT_DISPLAY, feedback: feedback, onPet: props.pet, onFeed: props.feed, onHide: props.hide, onDragEnd: props.dragEnd, onRename: props.rename, onFeedbackDone: props.feedbackDone, t: props.t }) }));
|
|
32
|
+
}
|
|
33
|
+
return (_jsx("button", { type: "button", className: styles.summon, onClick: props.summon, "data-testid": "pet-summon", children: props.t('pet.summon', { name: snapshot?.name ?? '鲸鱼娘' }) }));
|
|
34
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { PluginSettingsCard, ValueField, BooleanField } from "./PluginSettingsCard.js";
|
|
3
|
+
import { CardForm, booleanField, numberField, textField } from "./settings-form.js";
|
|
4
|
+
/** Bridges the `pet` scope onto the card's staged form. */
|
|
5
|
+
export class PetSettingsCardController {
|
|
6
|
+
form;
|
|
7
|
+
store;
|
|
8
|
+
/** @param scope - the bound settings scope for the `pet` namespace. */
|
|
9
|
+
constructor(scope) {
|
|
10
|
+
this.form = new CardForm(scope, [
|
|
11
|
+
booleanField('enabled'),
|
|
12
|
+
booleanField('visible'),
|
|
13
|
+
numberField('size'),
|
|
14
|
+
numberField('right'),
|
|
15
|
+
numberField('bottom'),
|
|
16
|
+
textField('name'),
|
|
17
|
+
]);
|
|
18
|
+
this.store = this.form.bind(() => this.projection());
|
|
19
|
+
}
|
|
20
|
+
projection() {
|
|
21
|
+
return {
|
|
22
|
+
...this.form.shell(),
|
|
23
|
+
enabled: this.form.field('enabled'),
|
|
24
|
+
visible: this.form.field('visible'),
|
|
25
|
+
size: this.form.field('size'),
|
|
26
|
+
right: this.form.field('right'),
|
|
27
|
+
bottom: this.form.field('bottom'),
|
|
28
|
+
name: this.form.field('name'),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the face the card's slot registration injects.
|
|
33
|
+
* @returns the card's snapshot and its form actions.
|
|
34
|
+
*/
|
|
35
|
+
inject() {
|
|
36
|
+
return { hooks: { petSettingsCard: this.store }, ...this.form.actions() };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Render the pet settings card.
|
|
41
|
+
* @param props - locale copy, the card snapshot, and its form actions.
|
|
42
|
+
* @returns the card.
|
|
43
|
+
*/
|
|
44
|
+
export function PetSettingsCard(props) {
|
|
45
|
+
const { t } = props;
|
|
46
|
+
const state = props.usePetSettingsCard(snapshot => snapshot);
|
|
47
|
+
const disabled = !state.writable;
|
|
48
|
+
const fieldProps = {
|
|
49
|
+
overriddenLabel: t('settings.overridden'),
|
|
50
|
+
resetLabel: t('settings.reset'),
|
|
51
|
+
invalidLabel: t('settings.invalidNumber'),
|
|
52
|
+
disabled,
|
|
53
|
+
};
|
|
54
|
+
return (_jsxs(PluginSettingsCard, { t: t, titleKey: "settings.title", descriptionKey: "settings.description", state: state, onSave: props.save, onDiscard: props.discard, children: [_jsx(BooleanField, { id: "settings-pet-enabled", label: t('settings.enabled'), hint: t('settings.enabledHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.enabled, onEdit: (text) => { props.edit('enabled', text); }, onReset: () => { props.resetField('enabled'); } }), _jsx(BooleanField, { id: "settings-pet-visible", label: t('settings.visible'), hint: t('settings.visibleHint'), inheritLabel: t('settings.inherit'), onLabel: t('settings.on'), offLabel: t('settings.off'), ...fieldProps, ...state.visible, onEdit: (text) => { props.edit('visible', text); }, onReset: () => { props.resetField('visible'); } }), _jsx(ValueField, { id: "settings-pet-size", label: t('settings.size'), hint: t('settings.sizeHint'), numeric: true, ...fieldProps, ...state.size, onEdit: (text) => { props.edit('size', text); }, onReset: () => { props.resetField('size'); } }), _jsx(ValueField, { id: "settings-pet-right", label: t('settings.right'), hint: t('settings.rightHint'), numeric: true, ...fieldProps, ...state.right, onEdit: (text) => { props.edit('right', text); }, onReset: () => { props.resetField('right'); } }), _jsx(ValueField, { id: "settings-pet-bottom", label: t('settings.bottom'), hint: t('settings.bottomHint'), numeric: true, ...fieldProps, ...state.bottom, onEdit: (text) => { props.edit('bottom', text); }, onReset: () => { props.resetField('bottom'); } }), _jsx(ValueField, { id: "settings-pet-name", label: t('settings.name'), hint: t('settings.nameHint'), ...fieldProps, ...state.name, onEdit: (text) => { props.edit('name', text); }, onReset: () => { props.resetField('name'); } })] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Shared chrome for the plugin settings card: a disclosure header naming the
|
|
4
|
+
* plugin and what its settings govern, the controls inside, and the save that
|
|
5
|
+
* writes them. Renders nothing while the namespace is unavailable — a
|
|
6
|
+
* deployment that does not compose the owning plugin should show no trace of
|
|
7
|
+
* it. Mirrors the official ui-plugin-config PluginCard in a self-contained
|
|
8
|
+
* slice (this package must not depend on a sibling UI package).
|
|
9
|
+
*/
|
|
10
|
+
import { useState } from 'react';
|
|
11
|
+
import css from './settings-card.module.css';
|
|
12
|
+
/**
|
|
13
|
+
* Render one plugin settings card.
|
|
14
|
+
* @param props - the plugin's copy keys, its form state, and its controls.
|
|
15
|
+
* @returns the card, or nothing when the namespace is unavailable.
|
|
16
|
+
*/
|
|
17
|
+
export function PluginSettingsCard(props) {
|
|
18
|
+
const [open, setOpen] = useState(false);
|
|
19
|
+
const { state } = props;
|
|
20
|
+
if (!state.available)
|
|
21
|
+
return null;
|
|
22
|
+
const title = props.t(props.titleKey);
|
|
23
|
+
const blocked = !state.dirty || state.invalid || state.saving;
|
|
24
|
+
return (_jsxs("li", { className: css.card, children: [_jsxs("button", { type: "button", className: css.header, "aria-expanded": open, "aria-label": `${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`, onClick: () => { setOpen(!open); }, children: [_jsxs("span", { className: css.headText, children: [_jsx("span", { className: css.name, children: title }), _jsx("span", { className: css.description, children: props.t(props.descriptionKey) })] }), state.dirty ? _jsx("span", { className: css.pending, children: props.t('settings.unsaved') }) : null, _jsx("span", { className: open ? css.chevronOpen : css.chevron, children: "\u25BE" })] }), open
|
|
25
|
+
? (_jsxs("div", { className: css.body, children: [!state.writable ? _jsx("p", { className: css.readOnly, role: "status", children: props.t('settings.readOnly') }) : null, props.children, _jsxs("div", { className: css.footer, children: [state.failed ? _jsx("p", { className: css.failed, role: "status", children: props.t('settings.saveFailed') }) : null, _jsx("button", { type: "button", className: css.discard, disabled: !state.dirty || state.saving, onClick: props.onDiscard, children: props.t('settings.discard') }), _jsx("button", { type: "button", className: css.save, disabled: blocked, onClick: props.onSave, children: props.t(!state.saving ? 'settings.save' : 'settings.saving') })] })] }))
|
|
26
|
+
: null] }));
|
|
27
|
+
}
|
|
28
|
+
/** A staged value field. `numeric` only hints the keypad: which drafts a field accepts is decided by its spec. */
|
|
29
|
+
export function ValueField(props) {
|
|
30
|
+
return (_jsxs("div", { className: css.field, children: [_jsxs("div", { className: css.head, children: [_jsx("label", { className: css.label, htmlFor: props.id, children: props.label }), props.overridden
|
|
31
|
+
? (_jsxs("span", { className: css.badges, children: [_jsx("span", { className: css.badge, children: props.overriddenLabel }), _jsx("button", { type: "button", className: css.reset, disabled: props.disabled, onClick: props.onReset, children: props.resetLabel })] }))
|
|
32
|
+
: null] }), _jsx("input", { id: props.id, className: props.invalid ? css.inputInvalid : css.input, type: "text", ...props.numeric === true ? { inputMode: 'numeric' } : {}, ...props.invalid ? { 'aria-invalid': true } : {}, value: props.text, placeholder: props.placeholder ?? '', disabled: props.disabled, onChange: (event) => { props.onEdit(event.target.value); } }), _jsx("p", { className: props.invalid ? css.invalid : css.hint, children: props.invalid ? props.invalidLabel : props.hint })] }));
|
|
33
|
+
}
|
|
34
|
+
/** A staged boolean field: 继承 / 开 / 关. */
|
|
35
|
+
export function BooleanField(props) {
|
|
36
|
+
return (_jsxs("div", { className: css.field, children: [_jsxs("div", { className: css.head, children: [_jsx("label", { className: css.label, htmlFor: props.id, children: props.label }), props.overridden
|
|
37
|
+
? (_jsxs("span", { className: css.badges, children: [_jsx("span", { className: css.badge, children: props.overriddenLabel }), _jsx("button", { type: "button", className: css.reset, disabled: props.disabled, onClick: props.onReset, children: props.resetLabel })] }))
|
|
38
|
+
: null] }), _jsxs("select", { id: props.id, className: css.select, value: props.text, disabled: props.disabled, onChange: (event) => { props.onEdit(event.target.value); }, children: [_jsx("option", { value: "", children: props.inheritLabel }), _jsx("option", { value: "true", children: props.onLabel }), _jsx("option", { value: "false", children: props.offLabel })] }), _jsx("p", { className: css.hint, children: props.hint })] }));
|
|
39
|
+
}
|