dsh-offwork 1.0.0 → 1.1.0
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/CHANGELOG.md +27 -0
- package/README.md +77 -43
- package/assets/characters/NOTICE.md +7 -0
- package/assets/characters/whale-dress.webp +0 -0
- package/assets/characters/whale-ornate.webp +0 -0
- package/client.js +4410 -327
- package/client.source.js +3093 -278
- package/clock.js +81 -34
- package/companion.js +185 -0
- package/hydration-store.js +101 -0
- package/hydration.js +303 -0
- package/index.js +142 -66
- package/locale/en.json +6 -1
- package/locale/zh.json +6 -1
- package/package.json +27 -6
- package/pet-interactions.js +138 -0
- package/pet-surfaces.js +194 -0
- package/pet-view.js +272 -0
- package/tests/clock.test.mjs +76 -33
- package/tests/companion.test.mjs +86 -0
- package/tests/host.test.mjs +76 -27
- package/tests/hydration-extras.test.mjs +226 -0
- package/tests/hydration-store.test.mjs +287 -0
- package/tests/hydration.test.mjs +211 -0
- package/tests/pet-character.test.mjs +56 -0
- package/tests/pet-interactions.test.mjs +88 -0
- package/tests/pet-surfaces.test.mjs +128 -0
- package/tests/pet-view.test.mjs +48 -0
- package/tests/ui.test.mjs +98 -21
- package/tools/build.mjs +52 -7
- package/tools/check-package.mjs +105 -0
- package/ui-utils.js +37 -14
package/hydration.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Pure hydration rules. Time is measured in paid-work milliseconds from calculate().
|
|
2
|
+
export const HYDRATION_SCHEMA = 2;
|
|
3
|
+
export const HYDRATION_ACTION_LIMIT = 64;
|
|
4
|
+
export const HYDRATION_LEASE_MS = 8000;
|
|
5
|
+
|
|
6
|
+
export function defaultHydration() {
|
|
7
|
+
return { schema: HYDRATION_SCHEMA, revision: 0, enabled: false, interval: 60, shift: null };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function validateHydration(value) {
|
|
11
|
+
const integer = (number, max = Number.MAX_SAFE_INTEGER) =>
|
|
12
|
+
Number.isSafeInteger(number) && number >= 0 && number <= max;
|
|
13
|
+
const text = (string) => typeof string === 'string' && string.length > 0 && string.length <= 1024;
|
|
14
|
+
const keys = (object, allowed) =>
|
|
15
|
+
object &&
|
|
16
|
+
typeof object === 'object' &&
|
|
17
|
+
!Array.isArray(object) &&
|
|
18
|
+
Object.keys(object).every((key) => allowed.includes(key));
|
|
19
|
+
if (
|
|
20
|
+
!keys(value, ['schema', 'revision', 'enabled', 'interval', 'shift']) ||
|
|
21
|
+
![1, HYDRATION_SCHEMA].includes(value.schema) ||
|
|
22
|
+
!integer(value.revision) ||
|
|
23
|
+
typeof value.enabled !== 'boolean' ||
|
|
24
|
+
!integer(value.interval, 240) ||
|
|
25
|
+
value.interval < 5
|
|
26
|
+
) {
|
|
27
|
+
throw new Error('Invalid hydration data');
|
|
28
|
+
}
|
|
29
|
+
const shift = value.shift;
|
|
30
|
+
if (shift !== null) {
|
|
31
|
+
if (
|
|
32
|
+
!keys(shift, [
|
|
33
|
+
'id',
|
|
34
|
+
'schedule',
|
|
35
|
+
'config',
|
|
36
|
+
'count',
|
|
37
|
+
'paused',
|
|
38
|
+
'due',
|
|
39
|
+
'sequence',
|
|
40
|
+
'claim',
|
|
41
|
+
...(value.schema === 2 ? ['quietUntil', 'recentActions'] : []),
|
|
42
|
+
]) ||
|
|
43
|
+
!text(shift.id) ||
|
|
44
|
+
!text(shift.schedule) ||
|
|
45
|
+
!text(shift.config) ||
|
|
46
|
+
!integer(shift.count, 1000000) ||
|
|
47
|
+
typeof shift.paused !== 'boolean' ||
|
|
48
|
+
!integer(shift.sequence) ||
|
|
49
|
+
!integer(shift.due, 172800000)
|
|
50
|
+
) {
|
|
51
|
+
throw new Error('Invalid hydration shift');
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
value.schema === 2 &&
|
|
55
|
+
(!integer(shift.quietUntil) ||
|
|
56
|
+
!Array.isArray(shift.recentActions) ||
|
|
57
|
+
shift.recentActions.length > HYDRATION_ACTION_LIMIT ||
|
|
58
|
+
!shift.recentActions.every((id) => text(id) && id.length <= 128) ||
|
|
59
|
+
new Set(shift.recentActions).size !== shift.recentActions.length)
|
|
60
|
+
)
|
|
61
|
+
throw new Error('Invalid hydration actions');
|
|
62
|
+
if (
|
|
63
|
+
shift.claim !== null &&
|
|
64
|
+
(!keys(shift.claim, ['owner', 'until', 'sequence']) ||
|
|
65
|
+
!text(shift.claim.owner) ||
|
|
66
|
+
!integer(shift.claim.until) ||
|
|
67
|
+
!integer(shift.claim.sequence))
|
|
68
|
+
) {
|
|
69
|
+
throw new Error('Invalid hydration claim');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const result = structuredClone(value);
|
|
73
|
+
if (result.schema === 1) {
|
|
74
|
+
result.schema = HYDRATION_SCHEMA;
|
|
75
|
+
if (result.shift) {
|
|
76
|
+
result.shift.quietUntil = 0;
|
|
77
|
+
result.shift.recentActions = [];
|
|
78
|
+
result.shift.claim = null;
|
|
79
|
+
result.shift.sequence++;
|
|
80
|
+
if (!Number.isSafeInteger(result.shift.sequence))
|
|
81
|
+
throw new Error('Invalid hydration sequence');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hydrationContext(settings, clock) {
|
|
88
|
+
if (!settings || !clock || !Number.isFinite(clock.elapsed) || !Number.isFinite(clock.total))
|
|
89
|
+
return null;
|
|
90
|
+
return {
|
|
91
|
+
id: JSON.stringify([settings.timezone, clock.shiftDate]),
|
|
92
|
+
config: JSON.stringify([
|
|
93
|
+
settings.timezone,
|
|
94
|
+
settings.start,
|
|
95
|
+
settings.end,
|
|
96
|
+
settings.breakStart,
|
|
97
|
+
settings.breakEnd,
|
|
98
|
+
[...settings.weekdays].sort(),
|
|
99
|
+
]),
|
|
100
|
+
schedule: JSON.stringify([
|
|
101
|
+
settings.start,
|
|
102
|
+
settings.end,
|
|
103
|
+
settings.breakStart,
|
|
104
|
+
settings.breakEnd,
|
|
105
|
+
[...settings.weekdays].sort(),
|
|
106
|
+
clock.shiftStart,
|
|
107
|
+
clock.shiftEnd,
|
|
108
|
+
]),
|
|
109
|
+
status: clock.status,
|
|
110
|
+
end: clock.shiftEnd,
|
|
111
|
+
elapsed: Math.max(0, Math.floor(clock.elapsed * 1000)),
|
|
112
|
+
total: Math.max(0, Math.floor(clock.total * 1000)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function reconcileHydration(state, context, manual = false) {
|
|
117
|
+
const next = structuredClone(state);
|
|
118
|
+
if (!context) return next;
|
|
119
|
+
if (!next.enabled && !manual) {
|
|
120
|
+
if (next.shift) next.shift.claim = null;
|
|
121
|
+
return next;
|
|
122
|
+
}
|
|
123
|
+
if (next.shift?.id !== context.id) {
|
|
124
|
+
// Do not discard the last shift's count on a day off. New working shifts
|
|
125
|
+
// are initialized once the user opens the page, never backfilled.
|
|
126
|
+
if (!['before', 'working', 'lunch'].includes(context.status)) return next;
|
|
127
|
+
next.shift = {
|
|
128
|
+
id: context.id,
|
|
129
|
+
schedule: context.schedule,
|
|
130
|
+
config: context.config,
|
|
131
|
+
count: 0,
|
|
132
|
+
paused: false,
|
|
133
|
+
due: context.elapsed + next.interval * 60000,
|
|
134
|
+
sequence: 0,
|
|
135
|
+
claim: null,
|
|
136
|
+
quietUntil: 0,
|
|
137
|
+
recentActions: [],
|
|
138
|
+
};
|
|
139
|
+
} else if (next.shift.schedule !== context.schedule) {
|
|
140
|
+
next.shift.schedule = context.schedule;
|
|
141
|
+
next.shift.config = context.config;
|
|
142
|
+
next.shift.due = context.elapsed + next.interval * 60000;
|
|
143
|
+
next.shift.sequence++;
|
|
144
|
+
next.shift.claim = null;
|
|
145
|
+
next.shift.quietUntil = Math.min(next.shift.quietUntil, context.end);
|
|
146
|
+
}
|
|
147
|
+
if (next.shift && (!next.enabled || context.status !== 'working')) next.shift.claim = null;
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function hydrationView(state, context, now = 0) {
|
|
152
|
+
const same =
|
|
153
|
+
context && state.shift?.id === context.id && state.shift.schedule === context.schedule;
|
|
154
|
+
const shift = same ? state.shift : null;
|
|
155
|
+
const count = shift?.count ?? 0;
|
|
156
|
+
const remaining = shift ? Math.max(0, shift.due - context.elapsed) : state.interval * 60000;
|
|
157
|
+
const eventId = shift
|
|
158
|
+
? JSON.stringify([shift.id, shift.schedule, shift.sequence, shift.due])
|
|
159
|
+
: null;
|
|
160
|
+
const quietRemaining =
|
|
161
|
+
shift && ['working', 'lunch'].includes(context.status)
|
|
162
|
+
? Math.max(0, Math.min(shift.quietUntil, context.end) - now)
|
|
163
|
+
: 0;
|
|
164
|
+
let phase = 'waiting';
|
|
165
|
+
if (!state.enabled) phase = 'off';
|
|
166
|
+
else if (shift?.paused) phase = 'paused';
|
|
167
|
+
else if (quietRemaining > 0) phase = 'quiet';
|
|
168
|
+
else if (context?.status === 'lunch') phase = 'break';
|
|
169
|
+
else if (shift && context.status === 'working') {
|
|
170
|
+
phase = remaining === 0 ? 'due' : shift.due >= context.total ? 'finished' : 'countdown';
|
|
171
|
+
}
|
|
172
|
+
return { phase, count, remaining, eventId, quietRemaining };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function transitionHydration(state, context, action, now, owner) {
|
|
176
|
+
if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid reminder time');
|
|
177
|
+
// Reconfiguration requires an explicit compare-and-set grant obtained before
|
|
178
|
+
// reading/saving Host settings. A stale tab may release its lease or disable
|
|
179
|
+
// reminders, but cannot silently restore its previous schedule.
|
|
180
|
+
if (
|
|
181
|
+
context &&
|
|
182
|
+
state.shift &&
|
|
183
|
+
(state.shift.config !== context.config ||
|
|
184
|
+
(state.shift.id === context.id && state.shift.schedule !== context.schedule)) &&
|
|
185
|
+
action.baseSchedule !== state.shift.schedule
|
|
186
|
+
)
|
|
187
|
+
context = null;
|
|
188
|
+
const manual =
|
|
189
|
+
action.type === 'manual-drink' &&
|
|
190
|
+
action.shiftId === context?.id &&
|
|
191
|
+
['working', 'lunch'].includes(context?.status);
|
|
192
|
+
let next = reconcileHydration(state, context, manual);
|
|
193
|
+
const restart = () => {
|
|
194
|
+
if (!next.shift || !context || next.shift.id !== context.id) return;
|
|
195
|
+
next.shift.due = context.elapsed + next.interval * 60000;
|
|
196
|
+
next.shift.sequence++;
|
|
197
|
+
next.shift.claim = null;
|
|
198
|
+
};
|
|
199
|
+
if (action.type === 'reset') return defaultHydration();
|
|
200
|
+
if (action.type === 'preferences') {
|
|
201
|
+
const enabled = Object.hasOwn(action, 'enabled') ? action.enabled : next.enabled;
|
|
202
|
+
const interval = Object.hasOwn(action, 'interval') ? action.interval : next.interval;
|
|
203
|
+
if (
|
|
204
|
+
typeof enabled !== 'boolean' ||
|
|
205
|
+
!Number.isInteger(interval) ||
|
|
206
|
+
interval < 5 ||
|
|
207
|
+
interval > 240
|
|
208
|
+
)
|
|
209
|
+
throw new Error('Invalid reminder preferences');
|
|
210
|
+
const changed = next.enabled !== enabled || next.interval !== interval;
|
|
211
|
+
next.enabled = enabled;
|
|
212
|
+
next.interval = interval;
|
|
213
|
+
next = reconcileHydration(next, context);
|
|
214
|
+
if (!next.enabled && next.shift) {
|
|
215
|
+
next.shift.claim = null;
|
|
216
|
+
next.shift.quietUntil = 0;
|
|
217
|
+
}
|
|
218
|
+
if (changed) restart();
|
|
219
|
+
return next;
|
|
220
|
+
}
|
|
221
|
+
const shift = next.shift;
|
|
222
|
+
if (action.type === 'release') {
|
|
223
|
+
if (shift?.claim?.owner === owner) shift.claim = null;
|
|
224
|
+
return next;
|
|
225
|
+
}
|
|
226
|
+
if (!shift || !context || shift.id !== context.id) return next;
|
|
227
|
+
if (shift.quietUntil && (now >= shift.quietUntil || now >= context.end)) shift.quietUntil = 0;
|
|
228
|
+
const view = hydrationView(next, context, now);
|
|
229
|
+
const validClaim =
|
|
230
|
+
shift.claim &&
|
|
231
|
+
shift.claim.owner === owner &&
|
|
232
|
+
shift.claim.sequence === shift.sequence &&
|
|
233
|
+
shift.claim.until > now &&
|
|
234
|
+
shift.claim.until <= now + HYDRATION_LEASE_MS;
|
|
235
|
+
if (action.type === 'manual-drink' && manual) {
|
|
236
|
+
if (
|
|
237
|
+
typeof action.actionId !== 'string' ||
|
|
238
|
+
!action.actionId.length ||
|
|
239
|
+
action.actionId.length > 128
|
|
240
|
+
)
|
|
241
|
+
throw new Error('Invalid hydration action id');
|
|
242
|
+
if (
|
|
243
|
+
shift.recentActions.includes(action.actionId) ||
|
|
244
|
+
(action.eventId !== undefined && action.eventId !== view.eventId)
|
|
245
|
+
)
|
|
246
|
+
return next;
|
|
247
|
+
shift.recentActions = [
|
|
248
|
+
...shift.recentActions.slice(-(HYDRATION_ACTION_LIMIT - 1)),
|
|
249
|
+
action.actionId,
|
|
250
|
+
];
|
|
251
|
+
shift.count = Math.min(1000000, shift.count + 1);
|
|
252
|
+
if (next.enabled && !shift.paused) restart();
|
|
253
|
+
else {
|
|
254
|
+
shift.sequence++;
|
|
255
|
+
shift.claim = null;
|
|
256
|
+
}
|
|
257
|
+
} else if (
|
|
258
|
+
action.type === 'quiet' &&
|
|
259
|
+
next.enabled &&
|
|
260
|
+
!shift.paused &&
|
|
261
|
+
['working', 'lunch'].includes(context.status)
|
|
262
|
+
) {
|
|
263
|
+
if (![30, 60].includes(action.minutes)) throw new Error('Invalid quiet duration');
|
|
264
|
+
shift.quietUntil = Math.min(now + action.minutes * 60000, context.end);
|
|
265
|
+
shift.claim = null;
|
|
266
|
+
shift.sequence++;
|
|
267
|
+
} else if (action.type === 'end-quiet' && shift.quietUntil) {
|
|
268
|
+
shift.quietUntil = 0;
|
|
269
|
+
shift.claim = null;
|
|
270
|
+
shift.sequence++;
|
|
271
|
+
} else if (action.type === 'claim' && view.phase === 'due') {
|
|
272
|
+
const claim = shift.claim;
|
|
273
|
+
if (
|
|
274
|
+
!claim ||
|
|
275
|
+
claim.owner === owner ||
|
|
276
|
+
claim.until <= now ||
|
|
277
|
+
claim.until > now + HYDRATION_LEASE_MS
|
|
278
|
+
) {
|
|
279
|
+
shift.claim = { owner, until: now + HYDRATION_LEASE_MS, sequence: shift.sequence };
|
|
280
|
+
}
|
|
281
|
+
} else if (action.type === 'clear-count') {
|
|
282
|
+
shift.count = 0;
|
|
283
|
+
} else if (action.type === 'pause' && next.enabled && !shift.paused) {
|
|
284
|
+
shift.paused = true;
|
|
285
|
+
shift.quietUntil = 0;
|
|
286
|
+
shift.claim = null;
|
|
287
|
+
shift.sequence++;
|
|
288
|
+
} else if (action.type === 'resume' && next.enabled && shift.paused) {
|
|
289
|
+
shift.paused = false;
|
|
290
|
+
restart();
|
|
291
|
+
} else if (
|
|
292
|
+
['drink', 'snooze'].includes(action.type) &&
|
|
293
|
+
view.phase === 'due' &&
|
|
294
|
+
validClaim &&
|
|
295
|
+
action.eventId === view.eventId
|
|
296
|
+
) {
|
|
297
|
+
if (action.type === 'drink') shift.count = Math.min(1000000, shift.count + 1);
|
|
298
|
+
shift.due = context.elapsed + (action.type === 'snooze' ? 10 : next.interval) * 60000;
|
|
299
|
+
shift.sequence++;
|
|
300
|
+
shift.claim = null;
|
|
301
|
+
}
|
|
302
|
+
return next;
|
|
303
|
+
}
|
package/index.js
CHANGED
|
@@ -20,8 +20,11 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
20
20
|
|
|
21
21
|
const dataFile = new URL('./data.json', import.meta.url);
|
|
22
22
|
const keys = Object.keys(DEFAULT_SETTINGS);
|
|
23
|
-
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
-
const fail = error => ({
|
|
23
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
const fail = (error) => ({
|
|
25
|
+
ok: false,
|
|
26
|
+
error: error instanceof Error ? error.message : String(error),
|
|
27
|
+
});
|
|
25
28
|
|
|
26
29
|
function validatePatch(value) {
|
|
27
30
|
if (!isObject(value)) throw new Error('settings 必须是 JSON 对象。');
|
|
@@ -38,16 +41,28 @@ export function validateSettings(value) {
|
|
|
38
41
|
if (value.payMode !== 'monthly' && value.payMode !== 'daily') {
|
|
39
42
|
throw new Error('payMode 必须是 monthly 或 daily。');
|
|
40
43
|
}
|
|
41
|
-
if (
|
|
44
|
+
if (
|
|
45
|
+
typeof value.dailySalary !== 'number' ||
|
|
46
|
+
!Number.isFinite(value.dailySalary) ||
|
|
47
|
+
value.dailySalary < 0
|
|
48
|
+
) {
|
|
42
49
|
throw new Error('dailySalary 必须是大于等于 0 的有限数字。');
|
|
43
50
|
}
|
|
44
|
-
if (
|
|
51
|
+
if (
|
|
52
|
+
typeof value.monthlySalary !== 'number' ||
|
|
53
|
+
!Number.isFinite(value.monthlySalary) ||
|
|
54
|
+
value.monthlySalary < 0
|
|
55
|
+
) {
|
|
45
56
|
throw new Error('monthlySalary 必须是大于等于 0 的有限数字。');
|
|
46
57
|
}
|
|
47
|
-
if (
|
|
58
|
+
if (
|
|
59
|
+
typeof value.workdaysPerMonth !== 'number' ||
|
|
60
|
+
!Number.isFinite(value.workdaysPerMonth) ||
|
|
61
|
+
value.workdaysPerMonth <= 0
|
|
62
|
+
) {
|
|
48
63
|
throw new Error('workdaysPerMonth 必须是大于 0 的有限数字。');
|
|
49
64
|
}
|
|
50
|
-
const minutes = key => {
|
|
65
|
+
const minutes = (key) => {
|
|
51
66
|
if (typeof value[key] !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value[key])) {
|
|
52
67
|
throw new Error(`${key} 必须是 24 小时制 HH:mm。`);
|
|
53
68
|
}
|
|
@@ -59,7 +74,7 @@ export function validateSettings(value) {
|
|
|
59
74
|
const breakStart = minutes('breakStart');
|
|
60
75
|
const breakEnd = minutes('breakEnd');
|
|
61
76
|
if (end === start) throw new Error('start 和 end 不能相同。');
|
|
62
|
-
const relative = minute => (minute - start + 1440) % 1440;
|
|
77
|
+
const relative = (minute) => (minute - start + 1440) % 1440;
|
|
63
78
|
const duration = relative(end);
|
|
64
79
|
if (breakStart !== breakEnd) {
|
|
65
80
|
const from = relative(breakStart);
|
|
@@ -69,16 +84,26 @@ export function validateSettings(value) {
|
|
|
69
84
|
}
|
|
70
85
|
if (to - from >= duration) throw new Error('午休不能占满整个班次。');
|
|
71
86
|
}
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
if (
|
|
88
|
+
!Array.isArray(value.weekdays) ||
|
|
89
|
+
value.weekdays.length === 0 ||
|
|
90
|
+
value.weekdays.some((day) => !Number.isInteger(day) || day < 0 || day > 6) ||
|
|
91
|
+
new Set(value.weekdays).size !== value.weekdays.length
|
|
92
|
+
) {
|
|
75
93
|
throw new Error('weekdays 必须是非空、不重复的 0–6 整数数组(0 为周日)。');
|
|
76
94
|
}
|
|
77
|
-
if (
|
|
95
|
+
if (
|
|
96
|
+
typeof value.timezone !== 'string' ||
|
|
97
|
+
value.timezone.length === 0 ||
|
|
98
|
+
value.timezone.trim() !== value.timezone
|
|
99
|
+
) {
|
|
100
|
+
throw new Error('timezone 必须是有效时区名称。');
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
new Intl.DateTimeFormat('en', { timeZone: value.timezone });
|
|
104
|
+
} catch {
|
|
78
105
|
throw new Error('timezone 必须是有效时区名称。');
|
|
79
106
|
}
|
|
80
|
-
try { new Intl.DateTimeFormat('en', { timeZone: value.timezone }); }
|
|
81
|
-
catch { throw new Error('timezone 必须是有效时区名称。'); }
|
|
82
107
|
return { ...value, weekdays: [...value.weekdays] };
|
|
83
108
|
}
|
|
84
109
|
|
|
@@ -89,18 +114,26 @@ export function createOperations(dataFileArg = dataFile) {
|
|
|
89
114
|
let queue = Promise.resolve();
|
|
90
115
|
async function read() {
|
|
91
116
|
let text;
|
|
92
|
-
try {
|
|
93
|
-
|
|
94
|
-
|
|
117
|
+
try {
|
|
118
|
+
text = await readFile(target, 'utf8');
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error.code === 'ENOENT')
|
|
121
|
+
return { settings: validateSettings(DEFAULT_SETTINGS), configured: false };
|
|
95
122
|
throw new Error('无法读取本机配置。');
|
|
96
123
|
}
|
|
97
124
|
try {
|
|
98
125
|
const stored = JSON.parse(text);
|
|
99
126
|
validatePatch(stored);
|
|
100
127
|
// Only the newly introduced fields are optional in legacy files.
|
|
101
|
-
const settings = validateSettings({
|
|
128
|
+
const settings = validateSettings({
|
|
129
|
+
payMode: DEFAULT_SETTINGS.payMode,
|
|
130
|
+
dailySalary: DEFAULT_SETTINGS.dailySalary,
|
|
131
|
+
...stored,
|
|
132
|
+
});
|
|
102
133
|
return { settings, configured: true };
|
|
103
|
-
} catch {
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error('本机配置损坏或无效;请检查插件目录 data.json。');
|
|
136
|
+
}
|
|
104
137
|
}
|
|
105
138
|
async function write(settings) {
|
|
106
139
|
const temp = join(dirname(target), `.data-${randomUUID()}.tmp`);
|
|
@@ -125,74 +158,117 @@ export function createOperations(dataFileArg = dataFile) {
|
|
|
125
158
|
const settings = validateSettings({ ...current.settings, ...patch });
|
|
126
159
|
await write(settings);
|
|
127
160
|
return { ok: true, settings, configured: true };
|
|
128
|
-
} catch (error) {
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return fail(error);
|
|
163
|
+
}
|
|
129
164
|
});
|
|
130
|
-
queue = operation.then(
|
|
165
|
+
queue = operation.then(
|
|
166
|
+
() => {},
|
|
167
|
+
() => {},
|
|
168
|
+
);
|
|
131
169
|
return operation;
|
|
132
170
|
};
|
|
133
171
|
}
|
|
134
172
|
|
|
135
173
|
const settingsSchema = {
|
|
136
|
-
type: 'object',
|
|
174
|
+
type: 'object',
|
|
175
|
+
additionalProperties: false,
|
|
137
176
|
description: '要合并保存的配置字段。时间使用 HH:mm。',
|
|
138
177
|
properties: {
|
|
139
|
-
payMode: {
|
|
178
|
+
payMode: {
|
|
179
|
+
type: 'string',
|
|
180
|
+
enum: ['monthly', 'daily'],
|
|
181
|
+
description: '计薪方式,默认 monthly(月薪);daily 为日薪。',
|
|
182
|
+
},
|
|
140
183
|
dailySalary: { type: 'number', description: '日薪,有限数字且 >= 0,默认 400。' },
|
|
141
184
|
monthlySalary: { type: 'number', description: '月薪,有限数字且 >= 0。' },
|
|
142
185
|
workdaysPerMonth: { type: 'number', description: '每月工作日数,有限数字且 > 0。' },
|
|
143
186
|
start: { type: 'string', description: '上班时间 HH:mm。' },
|
|
144
187
|
end: { type: 'string', description: '下班时间 HH:mm;早于上班表示次日,不能与上班相同。' },
|
|
145
188
|
breakStart: { type: 'string', description: '休息开始 HH:mm。' },
|
|
146
|
-
breakEnd: {
|
|
147
|
-
|
|
189
|
+
breakEnd: {
|
|
190
|
+
type: 'string',
|
|
191
|
+
description: '休息结束 HH:mm;与开始相同即关闭,否则按班次顺序在班次内,且不能占满班次。',
|
|
192
|
+
},
|
|
193
|
+
weekdays: {
|
|
194
|
+
type: 'array',
|
|
195
|
+
items: { type: 'integer', enum: [0, 1, 2, 3, 4, 5, 6] },
|
|
196
|
+
description: '非空、不重复;0 为周日,1 为周一。',
|
|
197
|
+
},
|
|
148
198
|
timezone: { type: 'string', description: '有效时区,例如 Asia/Shanghai。' },
|
|
149
199
|
},
|
|
150
200
|
};
|
|
151
201
|
|
|
152
202
|
export function apply(ctx) {
|
|
153
203
|
const operate = createOperations();
|
|
154
|
-
ctx.effect(() =>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
name: 'offwork_clock',
|
|
174
|
-
description: '读取或更新本机下班时钟的工资与班次配置。get 返回配置及 configured(是否已有保存文件);set 合并保存。工资仅存本机,但调用及结果会进入会话记录。',
|
|
175
|
-
parameters: {
|
|
176
|
-
type: 'object', additionalProperties: false, required: ['action'],
|
|
177
|
-
properties: {
|
|
178
|
-
action: { type: 'string', enum: ['get', 'set'], description: 'get 读取;set 更新。' },
|
|
179
|
-
settings: settingsSchema,
|
|
204
|
+
ctx.effect(() =>
|
|
205
|
+
ctx.commands.register({
|
|
206
|
+
name: 'offwork-clock',
|
|
207
|
+
description: '读取或保存本机下班时钟配置:get 或 set JSON。',
|
|
208
|
+
input: { hint: 'get | set {"monthlySalary":10000}' },
|
|
209
|
+
recordInput: false,
|
|
210
|
+
async handler({ rawInput }) {
|
|
211
|
+
let result;
|
|
212
|
+
try {
|
|
213
|
+
const match = /^(get|set)(?:\s+([\s\S]*))?$/.exec(rawInput.trim());
|
|
214
|
+
if (!match) throw new Error('用法:/offwork-clock get 或 /offwork-clock set JSON');
|
|
215
|
+
const [, action, json] = match;
|
|
216
|
+
if (action === 'get' && json !== undefined) throw new Error('get 不接受参数。');
|
|
217
|
+
if (action === 'set' && !json?.trim()) throw new Error('set 需要 JSON 配置对象。');
|
|
218
|
+
result = await operate(action, action === 'set' ? JSON.parse(json) : undefined);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
result = fail(error);
|
|
221
|
+
}
|
|
222
|
+
return { kind: result.ok ? 'success' : 'error', text: JSON.stringify(result) };
|
|
180
223
|
},
|
|
181
|
-
},
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
ctx.effect(() =>
|
|
227
|
+
ctx.tools.register({
|
|
228
|
+
name: 'offwork_clock',
|
|
229
|
+
description:
|
|
230
|
+
'读取或更新下班时钟的工资与班次配置。get 返回配置及 configured(是否已有保存文件);set 合并保存。配置保存在运行 Harness 的机器上,调用及结果会进入会话记录。',
|
|
231
|
+
parameters: {
|
|
232
|
+
type: 'object',
|
|
233
|
+
additionalProperties: false,
|
|
234
|
+
required: ['action'],
|
|
235
|
+
properties: {
|
|
236
|
+
action: { type: 'string', enum: ['get', 'set'], description: 'get 读取;set 更新。' },
|
|
237
|
+
settings: settingsSchema,
|
|
238
|
+
},
|
|
188
239
|
},
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
240
|
+
output: {
|
|
241
|
+
schema: {
|
|
242
|
+
oneOf: [
|
|
243
|
+
{
|
|
244
|
+
type: 'object',
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
required: ['ok', 'settings', 'configured'],
|
|
247
|
+
properties: {
|
|
248
|
+
ok: { type: 'boolean', const: true },
|
|
249
|
+
settings: { ...settingsSchema, required: keys },
|
|
250
|
+
configured: { type: 'boolean' },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
type: 'object',
|
|
255
|
+
additionalProperties: false,
|
|
256
|
+
required: ['ok', 'error'],
|
|
257
|
+
properties: { ok: { type: 'boolean', const: false }, error: { type: 'string' } },
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
262
|
+
},
|
|
263
|
+
async execute(args) {
|
|
264
|
+
if (
|
|
265
|
+
!isObject(args) ||
|
|
266
|
+
Object.keys(args).some((key) => key !== 'action' && key !== 'settings')
|
|
267
|
+
) {
|
|
268
|
+
return fail('参数必须是仅含 action 和可选 settings 的对象。');
|
|
269
|
+
}
|
|
270
|
+
return operate(args.action, args.settings);
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
198
274
|
}
|
package/locale/en.json
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Offwork Clock",
|
|
4
|
+
"description": "A Harness character companion with a clock-out countdown, estimated earnings, hydration reminders, quiet time and independent feature cards."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-offwork",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Harness 页面陪伴角色、下班倒计时、实时收入与喝水提醒。Character companion, clock-out countdown, earnings and hydration reminders for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "now",
|
|
8
|
-
"repository": {
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/SnowBeatRain/dsh-offwork.git"
|
|
11
|
+
},
|
|
9
12
|
"homepage": "https://github.com/SnowBeatRain/dsh-offwork#readme",
|
|
10
|
-
"bugs": {
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/SnowBeatRain/dsh-offwork/issues"
|
|
15
|
+
},
|
|
11
16
|
"keywords": [
|
|
12
17
|
"deepseek-harness",
|
|
13
18
|
"dsh-plugin",
|
|
@@ -33,18 +38,30 @@
|
|
|
33
38
|
"client.source.js",
|
|
34
39
|
"clock.js",
|
|
35
40
|
"ui-utils.js",
|
|
41
|
+
"companion.js",
|
|
42
|
+
"pet-view.js",
|
|
43
|
+
"pet-interactions.js",
|
|
44
|
+
"pet-surfaces.js",
|
|
45
|
+
"hydration.js",
|
|
46
|
+
"hydration-store.js",
|
|
36
47
|
"cordis.patch.yml",
|
|
37
48
|
"locale/*.json",
|
|
38
49
|
"icon.svg",
|
|
50
|
+
"assets/characters/*",
|
|
39
51
|
"LICENSE",
|
|
40
52
|
"README.md",
|
|
53
|
+
"CHANGELOG.md",
|
|
41
54
|
"tests/*.mjs",
|
|
42
|
-
"tools
|
|
55
|
+
"tools/*.mjs"
|
|
43
56
|
],
|
|
44
57
|
"scripts": {
|
|
45
58
|
"build": "node tools/build.mjs",
|
|
59
|
+
"format": "prettier --write . && npm run build",
|
|
60
|
+
"format:check": "prettier --check .",
|
|
46
61
|
"test": "node tools/build.mjs --check && node --test tests/*.mjs",
|
|
47
|
-
"
|
|
62
|
+
"check:package": "node tools/check-package.mjs",
|
|
63
|
+
"check": "npm run format:check && npm test && npm run check:package",
|
|
64
|
+
"prepublishOnly": "npm run build && npm run check"
|
|
48
65
|
},
|
|
49
66
|
"dsh": {
|
|
50
67
|
"bundle": {
|
|
@@ -58,5 +75,9 @@
|
|
|
58
75
|
"@deepseek-ai/dsh-api-remotes"
|
|
59
76
|
]
|
|
60
77
|
}
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"fake-indexeddb": "6.2.7",
|
|
81
|
+
"prettier": "3.9.10"
|
|
61
82
|
}
|
|
62
83
|
}
|