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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { petViewport, petSurfaceWidth, placePetSurface } from '../pet-surfaces.js';
|
|
4
|
+
import { petLayout } from '../pet-view.js';
|
|
5
|
+
import { clampPosition } from '../ui-utils.js';
|
|
6
|
+
const overlap = (a, b) =>
|
|
7
|
+
Math.max(0, Math.min(a.left + a.width, b.x + b.width) - Math.max(a.left, b.x)) *
|
|
8
|
+
Math.max(0, Math.min(a.top + a.height, b.y + b.height) - Math.max(a.top, b.y));
|
|
9
|
+
|
|
10
|
+
test('visual viewport bounds include keyboard and zoom offsets, with a desktop fallback', () => {
|
|
11
|
+
assert.deepEqual(petViewport({ innerWidth: 1440, innerHeight: 900 }), {
|
|
12
|
+
width: 1440,
|
|
13
|
+
height: 900,
|
|
14
|
+
left: 0,
|
|
15
|
+
top: 0,
|
|
16
|
+
});
|
|
17
|
+
assert.deepEqual(
|
|
18
|
+
petViewport({
|
|
19
|
+
innerWidth: 800,
|
|
20
|
+
innerHeight: 900,
|
|
21
|
+
visualViewport: { width: 390, height: 310, offsetLeft: 20, offsetTop: 240 },
|
|
22
|
+
}),
|
|
23
|
+
{ width: 390, height: 310, left: 20, top: 240 },
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
test('card widths match content needs and shrink to the available viewport', () => {
|
|
27
|
+
const wide = { width: 1440, height: 900 };
|
|
28
|
+
assert.ok(petSurfaceWidth('schedule', wide) > petSurfaceWidth('income', wide));
|
|
29
|
+
assert.ok(petSurfaceWidth('menu', wide) < petSurfaceWidth('home', wide));
|
|
30
|
+
for (const name of ['home', 'income', 'earnings', 'water', 'appearance', 'schedule'])
|
|
31
|
+
assert.equal(petSurfaceWidth(name, { width: 320, height: 568 }), 296);
|
|
32
|
+
});
|
|
33
|
+
test('measured surfaces stay inside every viewport and preserve natural height when there is room', () => {
|
|
34
|
+
for (const viewport of [
|
|
35
|
+
{ width: 1440, height: 900 },
|
|
36
|
+
{ width: 768, height: 1024 },
|
|
37
|
+
{ width: 320, height: 568 },
|
|
38
|
+
{ width: 640, height: 320 },
|
|
39
|
+
{ width: 240, height: 140 },
|
|
40
|
+
{ width: 390, height: 280, left: 80, top: 220 },
|
|
41
|
+
])
|
|
42
|
+
for (const position of [null, { x: 0, y: 0 }, { x: 9999, y: 9999 }])
|
|
43
|
+
for (const size of [80, 180])
|
|
44
|
+
for (const height of [42, 230, 480, 1500])
|
|
45
|
+
for (const kind of ['card', 'menu']) {
|
|
46
|
+
const p = petLayout(position, viewport, size);
|
|
47
|
+
const anchor = { x: p.x, y: p.y, width: p.petWidth, height: p.petHeight };
|
|
48
|
+
const r = placePetSurface(anchor, viewport, {
|
|
49
|
+
width: petSurfaceWidth(kind === 'menu' ? 'menu' : 'schedule', viewport),
|
|
50
|
+
height,
|
|
51
|
+
kind,
|
|
52
|
+
point: { x: p.x, y: p.y },
|
|
53
|
+
});
|
|
54
|
+
assert.ok(r.left >= (viewport.left ?? 0) && r.top >= (viewport.top ?? 0));
|
|
55
|
+
assert.ok(r.left + r.width <= (viewport.left ?? 0) + viewport.width + 0.01);
|
|
56
|
+
assert.ok(r.top + r.height <= (viewport.top ?? 0) + viewport.height + 0.01);
|
|
57
|
+
assert.ok(r.height <= height);
|
|
58
|
+
}
|
|
59
|
+
const r = placePetSurface(
|
|
60
|
+
{ x: 1100, y: 500, width: 160, height: 176 },
|
|
61
|
+
{ width: 1440, height: 900 },
|
|
62
|
+
{ width: 320, height: 230 },
|
|
63
|
+
);
|
|
64
|
+
assert.equal(r.height, 230);
|
|
65
|
+
});
|
|
66
|
+
test('cards and menus avoid the character whenever a clear side is available', () => {
|
|
67
|
+
const viewport = { width: 1440, height: 900 };
|
|
68
|
+
for (const anchor of [
|
|
69
|
+
{ x: 1100, y: 650, width: 160, height: 176 },
|
|
70
|
+
{ x: 12, y: 12, width: 160, height: 176 },
|
|
71
|
+
])
|
|
72
|
+
for (const kind of ['card', 'menu']) {
|
|
73
|
+
const r = placePetSurface(anchor, viewport, {
|
|
74
|
+
width: 360,
|
|
75
|
+
height: 500,
|
|
76
|
+
kind,
|
|
77
|
+
point: { x: anchor.x + 80, y: anchor.y + 80 },
|
|
78
|
+
});
|
|
79
|
+
assert.equal(overlap(r, anchor), 0);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
test('content changes keep the header in place until bounds require movement', () => {
|
|
83
|
+
const anchor = { x: 1100, y: 600, width: 160, height: 176 },
|
|
84
|
+
viewport = { width: 1440, height: 1000 };
|
|
85
|
+
const first = placePetSurface(anchor, viewport, { width: 336, height: 200 });
|
|
86
|
+
const larger = placePetSurface(anchor, viewport, { width: 336, height: 240, previous: first });
|
|
87
|
+
assert.equal(larger.top, first.top);
|
|
88
|
+
assert.equal(larger.left, first.left);
|
|
89
|
+
assert.equal(larger.height, 240);
|
|
90
|
+
const tall = placePetSurface(anchor, viewport, { width: 336, height: 1400, previous: larger });
|
|
91
|
+
assert.ok(tall.top + tall.height <= 988);
|
|
92
|
+
});
|
|
93
|
+
test('menus open near a clicked card rather than jumping back to the pet', () => {
|
|
94
|
+
const r = placePetSurface(
|
|
95
|
+
{ x: 1100, y: 700, width: 148, height: 176 },
|
|
96
|
+
{ width: 1440, height: 1000 },
|
|
97
|
+
{ width: 224, height: 240, kind: 'menu', point: { x: 300, y: 100 } },
|
|
98
|
+
);
|
|
99
|
+
assert.equal(r.left, 308);
|
|
100
|
+
assert.equal(r.top, 108);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('water notices avoid the open card as well as the pet', () => {
|
|
104
|
+
const viewport = { width: 320, height: 568 };
|
|
105
|
+
const anchor = { x: 140, y: 414, width: 148, height: 118 };
|
|
106
|
+
const card = { left: 12, top: 12, width: 296, height: 390 };
|
|
107
|
+
const notice = placePetSurface(anchor, viewport, {
|
|
108
|
+
width: 116,
|
|
109
|
+
height: 34,
|
|
110
|
+
kind: 'menu',
|
|
111
|
+
obstacles: [card],
|
|
112
|
+
});
|
|
113
|
+
assert.equal(overlap(notice, anchor), 0);
|
|
114
|
+
assert.equal(
|
|
115
|
+
overlap(notice, { x: card.left, y: card.top, width: card.width, height: card.height }),
|
|
116
|
+
0,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('pet drag clamping and surface placement use the same offset coordinate space', () => {
|
|
121
|
+
const viewport = { width: 390, height: 310, left: 80, top: 240 };
|
|
122
|
+
const position = clampPosition({ x: -50, y: 9999 }, viewport, { width: 148, height: 176 });
|
|
123
|
+
assert.equal(position.x, 92);
|
|
124
|
+
assert.equal(position.y, 362);
|
|
125
|
+
const pet = petLayout(position, viewport, 132);
|
|
126
|
+
assert.equal(pet.x, 92);
|
|
127
|
+
assert.equal(pet.y, 362);
|
|
128
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { petLayout } from '../pet-view.js';
|
|
4
|
+
import { normalizeView } from '../ui-utils.js';
|
|
5
|
+
|
|
6
|
+
test('pet and popover stay on screen at edges, across sizes and narrow viewports', () => {
|
|
7
|
+
for (const viewport of [
|
|
8
|
+
{ width: 1440, height: 900 },
|
|
9
|
+
{ width: 390, height: 844 },
|
|
10
|
+
{ width: 320, height: 568 },
|
|
11
|
+
{ width: 280, height: 360 },
|
|
12
|
+
{ width: 320, height: 160 },
|
|
13
|
+
{ width: 800, height: 120 },
|
|
14
|
+
]) {
|
|
15
|
+
for (const size of [80, 132, 180])
|
|
16
|
+
for (const position of [null, { x: -500, y: -500 }, { x: 9999, y: 9999 }, { x: 120, y: 80 }])
|
|
17
|
+
for (const height of [120, 420, 900]) {
|
|
18
|
+
const r = petLayout(position, viewport, size, height);
|
|
19
|
+
assert.ok(r.x >= 0 && r.x + r.petWidth <= viewport.width);
|
|
20
|
+
assert.ok(r.y >= 0 && r.y + r.petHeight <= viewport.height);
|
|
21
|
+
assert.ok(r.panel.left >= 0 && r.panel.left + r.panel.width <= viewport.width);
|
|
22
|
+
assert.ok(
|
|
23
|
+
r.panel.top >= 0 &&
|
|
24
|
+
r.panel.top + Math.min(height, r.panel.maxHeight) <= viewport.height,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('popover flips to the available side and stacks on a phone', () => {
|
|
31
|
+
assert.equal(petLayout(null, { width: 1440, height: 900 }).panel.side, 'left');
|
|
32
|
+
assert.equal(petLayout({ x: 12, y: 200 }, { width: 1440, height: 900 }).panel.side, 'right');
|
|
33
|
+
assert.equal(petLayout(null, { width: 320, height: 568 }).panel.side, 'above');
|
|
34
|
+
assert.equal(petLayout({ x: 12, y: 12 }, { width: 320, height: 568 }).panel.side, 'below');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('pet preferences retain the original clock position and bound invalid size input', () => {
|
|
38
|
+
const old = { position: { x: 40, y: 50 }, masked: true, companion: true, opacity: 0.6 };
|
|
39
|
+
const next = normalizeView(old);
|
|
40
|
+
assert.deepEqual(next.position, old.position);
|
|
41
|
+
assert.equal(next.petPosition, null);
|
|
42
|
+
assert.equal(next.petSize, 132);
|
|
43
|
+
const saved = normalizeView({ ...next, petPosition: { x: 200, y: 300 }, petSize: 148 });
|
|
44
|
+
assert.deepEqual(normalizeView(JSON.parse(JSON.stringify(saved))), saved);
|
|
45
|
+
assert.equal(normalizeView({ petSize: 9000 }).petSize, 180);
|
|
46
|
+
assert.equal(normalizeView({ petSize: NaN }).petSize, 132);
|
|
47
|
+
assert.equal(normalizeView({ petPosition: { x: 'bad', y: 4 } }).petPosition, null);
|
|
48
|
+
});
|
package/tests/ui.test.mjs
CHANGED
|
@@ -1,25 +1,73 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
formatDuration,
|
|
6
|
+
clampPosition,
|
|
7
|
+
normalizeView,
|
|
8
|
+
clampOpacity,
|
|
9
|
+
decodeCommand,
|
|
10
|
+
changedSettings,
|
|
11
|
+
} from '../ui-utils.js';
|
|
5
12
|
test('duration rounds countdown upward and supports >24 hours', () => {
|
|
6
|
-
assert.equal(formatDuration(0), '00:00:00');
|
|
7
|
-
assert.equal(formatDuration(
|
|
8
|
-
assert.equal(formatDuration(
|
|
13
|
+
assert.equal(formatDuration(0), '00:00:00');
|
|
14
|
+
assert.equal(formatDuration(3661), '01:01:01');
|
|
15
|
+
assert.equal(formatDuration(0.1), '00:00:01');
|
|
16
|
+
assert.equal(formatDuration(90000), '25:00:00');
|
|
17
|
+
assert.equal(formatDuration(NaN), '--:--:--');
|
|
18
|
+
assert.equal(formatDuration(-1), '--:--:--');
|
|
9
19
|
});
|
|
10
20
|
test('position stays visible when viewport shrinks', () => {
|
|
11
|
-
assert.deepEqual(
|
|
12
|
-
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
clampPosition({ x: 9000, y: -20 }, { width: 800, height: 600 }, { width: 330, height: 400 }),
|
|
23
|
+
{ x: 458, y: 12 },
|
|
24
|
+
);
|
|
25
|
+
assert.deepEqual(clampPosition(null, { width: 200, height: 200 }, { width: 330, height: 400 }), {
|
|
26
|
+
x: 12,
|
|
27
|
+
y: 12,
|
|
28
|
+
});
|
|
13
29
|
});
|
|
14
|
-
test('view defaults: compact, salary hidden, 82% glass', () => {
|
|
15
|
-
assert.deepEqual(normalizeView(null), {
|
|
16
|
-
|
|
17
|
-
|
|
30
|
+
test('view defaults: compact, salary hidden, 82% glass and companion enabled', () => {
|
|
31
|
+
assert.deepEqual(normalizeView(null), {
|
|
32
|
+
petModel: 'whale-dress',
|
|
33
|
+
petSize: 132,
|
|
34
|
+
petPosition: null,
|
|
35
|
+
mini: true,
|
|
36
|
+
masked: true,
|
|
37
|
+
companion: true,
|
|
38
|
+
companionMotion: true,
|
|
39
|
+
opacity: 0.82,
|
|
40
|
+
position: null,
|
|
41
|
+
});
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
normalizeView({ mini: false, masked: false, opacity: 0.5, position: { x: 1, y: 2 } }),
|
|
44
|
+
{
|
|
45
|
+
petSize: 132,
|
|
46
|
+
petModel: 'whale-dress',
|
|
47
|
+
petPosition: null,
|
|
48
|
+
mini: false,
|
|
49
|
+
masked: false,
|
|
50
|
+
companion: true,
|
|
51
|
+
companionMotion: true,
|
|
52
|
+
opacity: 0.5,
|
|
53
|
+
position: { x: 1, y: 2 },
|
|
54
|
+
},
|
|
55
|
+
);
|
|
18
56
|
assert.equal(normalizeView({ mini: 'yes' }).mini, true);
|
|
19
57
|
assert.equal(normalizeView({ masked: 'false' }).masked, true);
|
|
20
58
|
});
|
|
21
59
|
test('malformed positions and opacity are repaired, not trusted', () => {
|
|
22
|
-
assert.deepEqual(normalizeView({ mini: 'yes', masked: true, position: { x: 'oops', y: 2 } }), {
|
|
60
|
+
assert.deepEqual(normalizeView({ mini: 'yes', masked: true, position: { x: 'oops', y: 2 } }), {
|
|
61
|
+
petModel: 'whale-dress',
|
|
62
|
+
petSize: 132,
|
|
63
|
+
petPosition: null,
|
|
64
|
+
mini: true,
|
|
65
|
+
masked: true,
|
|
66
|
+
companion: true,
|
|
67
|
+
companionMotion: true,
|
|
68
|
+
opacity: 0.82,
|
|
69
|
+
position: null,
|
|
70
|
+
});
|
|
23
71
|
assert.equal(normalizeView({ opacity: 0 }).opacity, 0.3);
|
|
24
72
|
assert.equal(normalizeView({ opacity: 9 }).opacity, 1);
|
|
25
73
|
assert.equal(normalizeView({ opacity: 'x' }).opacity, 0.82);
|
|
@@ -31,25 +79,52 @@ test('salary stays hidden until explicitly revealed, including on first paint',
|
|
|
31
79
|
assert.equal(normalizeView({ masked: false }).masked, false);
|
|
32
80
|
});
|
|
33
81
|
test('RPC decodes every failure layer and real command envelopes', () => {
|
|
34
|
-
const response = (data, kind = 'success') => ({
|
|
35
|
-
|
|
36
|
-
|
|
82
|
+
const response = (data, kind = 'success') => ({
|
|
83
|
+
ok: true,
|
|
84
|
+
value: { result: { kind, text: JSON.stringify(data) } },
|
|
85
|
+
});
|
|
86
|
+
assert.deepEqual(
|
|
87
|
+
decodeCommand(response({ ok: true, settings: { monthlySalary: 1000 }, configured: true }))
|
|
88
|
+
.settings,
|
|
89
|
+
{ monthlySalary: 1000 },
|
|
90
|
+
);
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => decodeCommand({ ok: false, error: { message: 'permission denied' } }),
|
|
93
|
+
/permission denied/,
|
|
94
|
+
);
|
|
37
95
|
assert.throws(() => decodeCommand({ ok: true }), /Command unavailable/);
|
|
38
|
-
assert.throws(
|
|
39
|
-
|
|
96
|
+
assert.throws(
|
|
97
|
+
() => decodeCommand(response({ ok: false, error: 'bad shift' }, 'error')),
|
|
98
|
+
/bad shift/,
|
|
99
|
+
);
|
|
100
|
+
assert.throws(
|
|
101
|
+
() => decodeCommand({ ok: true, value: { result: { kind: 'success', text: 'oops' } } }),
|
|
102
|
+
/Invalid/,
|
|
103
|
+
);
|
|
40
104
|
});
|
|
41
105
|
test('partial saves preserve unrelated edits from another caller', () => {
|
|
42
|
-
assert.deepEqual(
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
changedSettings(
|
|
108
|
+
{ salary: 1, days: [1, 2], start: '09:00' },
|
|
109
|
+
{ salary: 2, days: [1, 2], start: '09:00' },
|
|
110
|
+
),
|
|
111
|
+
{ salary: 2 },
|
|
112
|
+
);
|
|
43
113
|
});
|
|
44
114
|
test('Client uses the exact tested engine and a single surface', async () => {
|
|
45
115
|
const source = await readFile(new URL('../client.source.js', import.meta.url), 'utf8');
|
|
46
116
|
const built = await readFile(new URL('../client.js', import.meta.url), 'utf8');
|
|
47
|
-
const engine = (await readFile(new URL('../clock.js', import.meta.url), 'utf8')).replace(
|
|
117
|
+
const engine = (await readFile(new URL('../clock.js', import.meta.url), 'utf8')).replace(
|
|
118
|
+
/^export /gm,
|
|
119
|
+
'',
|
|
120
|
+
);
|
|
48
121
|
assert.ok(built.includes(engine));
|
|
49
122
|
assert.match(source, /prefers-reduced-motion:reduce/);
|
|
50
123
|
assert.match(source, /prefers-reduced-transparency:reduce/);
|
|
51
|
-
assert.match(source, /visibilitychange/);
|
|
52
|
-
assert.match(source, /
|
|
124
|
+
assert.match(source, /visibilitychange/);
|
|
125
|
+
assert.match(source, /removeEventListener\('storage'/);
|
|
126
|
+
assert.match(source, /ro\?\.disconnect/);
|
|
127
|
+
assert.match(source, /generation\.current/);
|
|
53
128
|
assert.doesNotMatch(source, /document\.body|window\.open|fetch\(/);
|
|
54
129
|
assert.match(source, /money\(safeState\.earned\)/);
|
|
55
130
|
assert.match(source, /owc-glass/);
|
|
@@ -64,7 +139,9 @@ test('compact strip stacks time above amount', async () => {
|
|
|
64
139
|
assert.match(css, /flex-direction:column/);
|
|
65
140
|
assert.match(source, /className: 'owc-pill-top'/);
|
|
66
141
|
assert.match(source, /className: 'owc-pill-bottom'/);
|
|
67
|
-
assert.ok(
|
|
142
|
+
assert.ok(
|
|
143
|
+
source.indexOf("className: 'owc-pill-time'") < source.indexOf("className: 'owc-pill-money'"),
|
|
144
|
+
);
|
|
68
145
|
});
|
|
69
146
|
test('compact strip keeps both drag surface and expand action; panel keeps collapse', async () => {
|
|
70
147
|
const source = await readFile(new URL('../client.source.js', import.meta.url), 'utf8');
|
package/tools/build.mjs
CHANGED
|
@@ -1,13 +1,58 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
2
3
|
const root = new URL('../', import.meta.url);
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
const moduleNames = [
|
|
5
|
+
'client.source.js',
|
|
6
|
+
'clock.js',
|
|
7
|
+
'ui-utils.js',
|
|
8
|
+
'companion.js',
|
|
9
|
+
'pet-view.js',
|
|
10
|
+
'pet-interactions.js',
|
|
11
|
+
'pet-surfaces.js',
|
|
12
|
+
'hydration.js',
|
|
13
|
+
'hydration-store.js',
|
|
14
|
+
];
|
|
15
|
+
const [entry, ...modules] = await Promise.all(
|
|
16
|
+
moduleNames.map((name) => readFile(new URL(name, root), 'utf8')),
|
|
17
|
+
);
|
|
18
|
+
const marker = '/* PURE_MODULES */';
|
|
19
|
+
if (entry.split(marker).length !== 2) {
|
|
20
|
+
throw new Error('Expected exactly one pure-module marker in client.source.js');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Embed the tested modules, removing only their internal module wiring.
|
|
24
|
+
// The browser and Node.js tests execute the same calculation and store code.
|
|
25
|
+
const pure = modules
|
|
26
|
+
.map((source) =>
|
|
27
|
+
source.replace(/^import .* from '\.\/hydration\.js';\n/gm, '').replace(/^export /gm, ''),
|
|
28
|
+
)
|
|
29
|
+
.join('\n');
|
|
30
|
+
const banner = '// Generated by tools/build.mjs; edit the source modules, not this file.\n';
|
|
31
|
+
const spriteMarker = '/* PET_SPRITES */';
|
|
32
|
+
if (entry.split(spriteMarker).length !== 2) throw new Error('Expected one sprite marker');
|
|
33
|
+
const sprites = Object.fromEntries(
|
|
34
|
+
await Promise.all(
|
|
35
|
+
['whale-dress', 'whale-ornate'].map(async (id) => {
|
|
36
|
+
const bytes = await readFile(new URL(`assets/characters/${id}.webp`, root));
|
|
37
|
+
if (bytes.toString('ascii', 8, 12) !== 'WEBP' || bytes.length > 200000)
|
|
38
|
+
throw new Error(`Invalid sprite: ${id}`);
|
|
39
|
+
return [id, 'data:image/webp;base64,' + bytes.toString('base64')];
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
const result =
|
|
44
|
+
banner +
|
|
45
|
+
entry
|
|
46
|
+
.replace(marker, pure)
|
|
47
|
+
.replace(spriteMarker, `const PET_SPRITES = ${JSON.stringify(sprites)};`);
|
|
48
|
+
const output = new URL('client.js', root);
|
|
49
|
+
|
|
50
|
+
if (process.argv.includes('--check')) {
|
|
51
|
+
if ((await readFile(output, 'utf8')) !== result) {
|
|
52
|
+
throw new Error('Client artifact is stale. Run npm run build and commit client.js.');
|
|
53
|
+
}
|
|
9
54
|
console.log('Client artifact matches tested source');
|
|
10
55
|
} else {
|
|
11
|
-
await writeFile(
|
|
56
|
+
await writeFile(output, result);
|
|
12
57
|
console.log('Built client.js from one calculation source');
|
|
13
58
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const root = new URL('../', import.meta.url);
|
|
7
|
+
const cwd = fileURLToPath(root);
|
|
8
|
+
const read = (path) => readFile(new URL(path, root), 'utf8');
|
|
9
|
+
const pkg = JSON.parse(await read('package.json'));
|
|
10
|
+
|
|
11
|
+
assert.equal(pkg.name, 'dsh-offwork');
|
|
12
|
+
assert.equal(pkg.type, 'module');
|
|
13
|
+
assert.equal(pkg.license, 'MIT');
|
|
14
|
+
assert.equal(pkg.repository.url, 'git+https://github.com/SnowBeatRain/dsh-offwork.git');
|
|
15
|
+
assert.ok(pkg.engines?.dsh, 'Declare the supported Harness versions');
|
|
16
|
+
assert.equal(pkg.dsh?.bundle?.patch, './cordis.patch.yml');
|
|
17
|
+
assert.equal(pkg.dsh?.client?.platform, 'web');
|
|
18
|
+
assert.match(await read('LICENSE'), /^MIT License/);
|
|
19
|
+
|
|
20
|
+
// This check covers this project's patch; Harness composition remains the
|
|
21
|
+
// authoritative check that the bundle can be installed in a real profile.
|
|
22
|
+
const patch = await read('cordis.patch.yml');
|
|
23
|
+
assert.match(patch, /^- insert:/m);
|
|
24
|
+
assert.match(patch, /^\s+- id: offwork-clock\s*$/m);
|
|
25
|
+
assert.match(patch, /^\s+name: ['"]?dsh-offwork['"]?\s*$/m);
|
|
26
|
+
|
|
27
|
+
for (const lifecycle of ['preinstall', 'install', 'postinstall', 'prepare']) {
|
|
28
|
+
assert.equal(pkg.scripts?.[lifecycle], undefined, `Unexpected install script: ${lifecycle}`);
|
|
29
|
+
}
|
|
30
|
+
for (const name of Object.keys(pkg.dependencies ?? {})) {
|
|
31
|
+
assert.ok(!name.startsWith('@deepseek-ai/'), `${name} belongs in peerDependencies`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sourceFiles = [];
|
|
35
|
+
for (const directory of ['', 'tests/', 'tools/']) {
|
|
36
|
+
for (const entry of await readdir(new URL(directory || '.', root), { withFileTypes: true })) {
|
|
37
|
+
if (entry.isFile() && /\.(?:js|mjs)$/.test(entry.name)) {
|
|
38
|
+
sourceFiles.push(directory + entry.name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const path of sourceFiles) {
|
|
43
|
+
execFileSync(process.execPath, ['--check', path], { cwd, stdio: 'pipe' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const language of ['zh', 'en']) {
|
|
47
|
+
const locale = JSON.parse(await read(`locale/${language}.json`));
|
|
48
|
+
assert.equal(typeof locale.meta?.title, 'string');
|
|
49
|
+
assert.ok(locale.meta.title.length > 0);
|
|
50
|
+
assert.equal(typeof locale.meta?.description, 'string');
|
|
51
|
+
assert.ok(locale.meta.description.length > 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Ignore lifecycle scripts here: this checker also runs inside prepublishOnly.
|
|
55
|
+
// Inspect npm's actual package file list, rather than relying on .gitignore.
|
|
56
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
57
|
+
const [packed] = JSON.parse(
|
|
58
|
+
execFileSync(npm, ['pack', '--dry-run', '--json', '--ignore-scripts'], {
|
|
59
|
+
cwd,
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const files = new Set(packed.files.map((file) => file.path));
|
|
65
|
+
assert.equal(packed.name, pkg.name);
|
|
66
|
+
assert.equal(packed.version, pkg.version);
|
|
67
|
+
for (const path of [
|
|
68
|
+
'package.json',
|
|
69
|
+
'index.js',
|
|
70
|
+
'client.js',
|
|
71
|
+
'companion.js',
|
|
72
|
+
'pet-view.js',
|
|
73
|
+
'pet-interactions.js',
|
|
74
|
+
'pet-surfaces.js',
|
|
75
|
+
'hydration.js',
|
|
76
|
+
'hydration-store.js',
|
|
77
|
+
'cordis.patch.yml',
|
|
78
|
+
'icon.svg',
|
|
79
|
+
'assets/characters/whale-dress.webp',
|
|
80
|
+
'assets/characters/whale-ornate.webp',
|
|
81
|
+
'assets/characters/NOTICE.md',
|
|
82
|
+
'locale/zh.json',
|
|
83
|
+
'locale/en.json',
|
|
84
|
+
'README.md',
|
|
85
|
+
'LICENSE',
|
|
86
|
+
]) {
|
|
87
|
+
assert.ok(files.has(path), `Missing package file: ${path}`);
|
|
88
|
+
}
|
|
89
|
+
for (const target of Object.values(pkg.exports)) {
|
|
90
|
+
if (typeof target === 'string' && !target.includes('*')) {
|
|
91
|
+
assert.ok(files.has(target.replace(/^\.\//, '')), `Missing export: ${target}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const path of files) {
|
|
95
|
+
assert.ok(
|
|
96
|
+
!/(^|\/)(?:data\.json|\.data-[^/]*|\.env(?:\.[^/]*)?|\.npmrc|\.git(?:\/|$)|node_modules(?:\/|$))/.test(
|
|
97
|
+
path,
|
|
98
|
+
),
|
|
99
|
+
`Local settings or credentials included in package: ${path}`,
|
|
100
|
+
);
|
|
101
|
+
assert.ok(!path.endsWith('.tgz'), `Nested package archive: ${path}`);
|
|
102
|
+
}
|
|
103
|
+
console.log(
|
|
104
|
+
`Package checks passed: ${sourceFiles.length} JavaScript files, ${files.size} packed files.`,
|
|
105
|
+
);
|
package/ui-utils.js
CHANGED
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
export function formatDuration(seconds) {
|
|
3
3
|
if (!Number.isFinite(seconds) || seconds < 0) return '--:--:--';
|
|
4
4
|
const n = Math.ceil(seconds);
|
|
5
|
-
return [Math.floor(n / 3600), Math.floor(n % 3600 / 60), n % 60]
|
|
6
|
-
.map(part => String(part).padStart(2, '0'))
|
|
5
|
+
return [Math.floor(n / 3600), Math.floor((n % 3600) / 60), n % 60]
|
|
6
|
+
.map((part) => String(part).padStart(2, '0'))
|
|
7
|
+
.join(':');
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export function clampPosition(position, viewport, size) {
|
|
10
|
-
const width = Math.max(0, viewport.width),
|
|
11
|
+
const width = Math.max(0, viewport.width),
|
|
12
|
+
height = Math.max(0, viewport.height);
|
|
11
13
|
const edge = Math.min(12, width / 2, height / 2);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
+
const left = viewport.left ?? 0,
|
|
15
|
+
top = viewport.top ?? 0;
|
|
16
|
+
const maxX = Math.max(left + edge, left + width - size.width - edge);
|
|
17
|
+
const maxY = Math.max(top + edge, top + height - size.height - edge);
|
|
14
18
|
return {
|
|
15
|
-
x: Math.max(edge, Math.min(Number.isFinite(position?.x) ? position.x : maxX, maxX)),
|
|
16
|
-
y: Math.max(edge, Math.min(Number.isFinite(position?.y) ? position.y : 88, maxY)),
|
|
19
|
+
x: Math.max(left + edge, Math.min(Number.isFinite(position?.x) ? position.x : maxX, maxX)),
|
|
20
|
+
y: Math.max(top + edge, Math.min(Number.isFinite(position?.y) ? position.y : top + 88, maxY)),
|
|
17
21
|
};
|
|
18
22
|
}
|
|
19
23
|
|
|
@@ -27,9 +31,21 @@ export function normalizeView(value) {
|
|
|
27
31
|
return {
|
|
28
32
|
mini: value?.mini !== false,
|
|
29
33
|
masked: value?.masked !== false,
|
|
34
|
+
companion: value?.companion !== false,
|
|
35
|
+
companionMotion: value?.companionMotion !== false,
|
|
36
|
+
petModel: ['whale-dress', 'whale-ornate', 'xiaoxie-original'].includes(value?.petModel)
|
|
37
|
+
? value.petModel
|
|
38
|
+
: 'whale-dress',
|
|
39
|
+
petSize: Math.min(180, Math.max(80, Number.isFinite(value?.petSize) ? value.petSize : 132)),
|
|
40
|
+
petPosition:
|
|
41
|
+
Number.isFinite(value?.petPosition?.x) && Number.isFinite(value?.petPosition?.y)
|
|
42
|
+
? { x: value.petPosition.x, y: value.petPosition.y }
|
|
43
|
+
: null,
|
|
30
44
|
opacity: clampOpacity(value?.opacity),
|
|
31
|
-
position:
|
|
32
|
-
|
|
45
|
+
position:
|
|
46
|
+
Number.isFinite(value?.position?.x) && Number.isFinite(value?.position?.y)
|
|
47
|
+
? { x: value.position.x, y: value.position.y }
|
|
48
|
+
: null,
|
|
33
49
|
};
|
|
34
50
|
}
|
|
35
51
|
|
|
@@ -38,14 +54,21 @@ export function decodeCommand(rpc) {
|
|
|
38
54
|
const result = rpc.value?.result;
|
|
39
55
|
if (!result || typeof result.text !== 'string') throw new Error('Command unavailable');
|
|
40
56
|
let data;
|
|
41
|
-
try {
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
try {
|
|
58
|
+
data = JSON.parse(result.text);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error('Invalid command response');
|
|
61
|
+
}
|
|
62
|
+
if (result.kind !== 'success' || data?.ok !== true)
|
|
63
|
+
throw new Error(data?.error || 'Request failed');
|
|
44
64
|
if (!data.settings || typeof data.settings !== 'object') throw new Error('Missing settings');
|
|
45
65
|
return data;
|
|
46
66
|
}
|
|
47
67
|
|
|
48
68
|
export function changedSettings(original, draft) {
|
|
49
|
-
return Object.fromEntries(
|
|
50
|
-
.
|
|
69
|
+
return Object.fromEntries(
|
|
70
|
+
Object.entries(draft).filter(
|
|
71
|
+
([key, value]) => JSON.stringify(value) !== JSON.stringify(original[key]),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
51
74
|
}
|