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.
@@ -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 { formatDuration, clampPosition, normalizeView, clampOpacity, decodeCommand, changedSettings } from '../ui-utils.js';
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'); assert.equal(formatDuration(3661), '01:01:01');
7
- assert.equal(formatDuration(.1), '00:00:01'); assert.equal(formatDuration(90000), '25:00:00');
8
- assert.equal(formatDuration(NaN), '--:--:--'); assert.equal(formatDuration(-1), '--:--:--');
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(clampPosition({ x: 9000, y: -20 }, { width: 800, height: 600 }, { width: 330, height: 400 }), { x: 458, y: 12 });
12
- assert.deepEqual(clampPosition(null, { width: 200, height: 200 }, { width: 330, height: 400 }), { x: 12, y: 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), { mini: true, masked: true, opacity: 0.82, position: null });
16
- assert.deepEqual(normalizeView({ mini: false, masked: false, opacity: 0.5, position: { x: 1, y: 2 } }),
17
- { mini: false, masked: false, opacity: 0.5, position: { x: 1, y: 2 } });
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 } }), { mini: true, masked: true, opacity: 0.82, position: null });
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') => ({ ok: true, value: { result: { kind, text: JSON.stringify(data) } } });
35
- assert.deepEqual(decodeCommand(response({ ok: true, settings: { monthlySalary: 1000 }, configured: true })).settings, { monthlySalary: 1000 });
36
- assert.throws(() => decodeCommand({ ok: false, error: { message: 'permission denied' } }), /permission denied/);
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(() => decodeCommand(response({ ok: false, error: 'bad shift' }, 'error')), /bad shift/);
39
- assert.throws(() => decodeCommand({ ok: true, value: { result: { kind: 'success', text: 'oops' } } }), /Invalid/);
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(changedSettings({ salary: 1, days: [1, 2], start: '09:00' }, { salary: 2, days: [1, 2], start: '09:00' }), { salary: 2 });
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(/^export /gm, '');
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/); assert.match(source, /removeEventListener\('storage'/);
52
- assert.match(source, /ro\?\.disconnect/); assert.match(source, /generation\.current/);
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(source.indexOf("className: 'owc-pill-time'") < source.indexOf("className: 'owc-pill-money'"));
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 [entry, clock, utils] = await Promise.all(['client.source.js','clock.js','ui-utils.js'].map(name=>readFile(new URL(name,root),'utf8')));
4
- if (entry.split('/* PURE_MODULES */').length !== 2) throw new Error('Expected exactly one pure-module marker');
5
- const pure = [clock,utils].map(text=>text.replace(/^export /gm,'')).join('\n');
6
- const result = '// Generated by tools/build.mjs; edit client.source.js, clock.js or ui-utils.js.\n'+entry.replace('/* PURE_MODULES */',pure);
7
- if(process.argv.includes('--check')) {
8
- if(await readFile(new URL('client.js',root),'utf8')!==result) throw new Error('Client artifact is stale');
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(new URL('client.js',root),result);
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')).join(':');
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), height = Math.max(0, viewport.height);
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 maxX = Math.max(edge, width - size.width - edge);
13
- const maxY = Math.max(edge, height - size.height - edge);
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: Number.isFinite(value?.position?.x) && Number.isFinite(value?.position?.y)
32
- ? { x: value.position.x, y: value.position.y } : null,
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 { data = JSON.parse(result.text); }
42
- catch { throw new Error('Invalid command response'); }
43
- if (result.kind !== 'success' || data?.ok !== true) throw new Error(data?.error || 'Request failed');
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(Object.entries(draft)
50
- .filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(original[key])));
69
+ return Object.fromEntries(
70
+ Object.entries(draft).filter(
71
+ ([key, value]) => JSON.stringify(value) !== JSON.stringify(original[key]),
72
+ ),
73
+ );
51
74
  }