dsh-offwork 1.1.0 → 1.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/CHANGELOG.md +21 -0
- package/README.md +50 -32
- package/assets/animations/NOTICE.md +15 -0
- package/assets/animations/alpha-probe.webm +0 -0
- package/assets/animations/ornate-drink.webm +0 -0
- package/assets/animations/ornate-greet.webm +0 -0
- package/assets/animations/ornate-idle.webm +0 -0
- package/assets/animations/whale-drink.webm +0 -0
- package/assets/animations/whale-greet.webm +0 -0
- package/assets/characters/NOTICE.md +4 -0
- package/assets/characters/whale-actions.webp +0 -0
- package/assets/characters/whale-ornate-actions.webp +0 -0
- package/client.js +2970 -770
- package/client.source.js +1668 -444
- package/companion.js +6 -9
- package/countdown.js +83 -0
- package/hydration-store.js +55 -16
- package/hydration.js +231 -250
- package/index.js +2 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/media-manifest.js +29 -0
- package/media-server.js +123 -0
- package/package.json +9 -2
- package/pet-interactions.js +78 -7
- package/pet-motion.js +111 -0
- package/pet-reminder.js +20 -0
- package/pet-surfaces.js +130 -6
- package/pet-video.js +380 -0
- package/pet-view.js +129 -13
- package/tests/classic-overview.test.mjs +77 -0
- package/tests/companion.test.mjs +12 -12
- package/tests/countdown.test.mjs +115 -0
- package/tests/host.test.mjs +6 -0
- package/tests/hydration-extras.test.mjs +168 -204
- package/tests/hydration-store.test.mjs +199 -213
- package/tests/hydration.test.mjs +116 -186
- package/tests/media-server.test.mjs +179 -0
- package/tests/pet-character.test.mjs +8 -2
- package/tests/pet-motion.test.mjs +139 -0
- package/tests/pet-reminder.test.mjs +71 -0
- package/tests/pet-video.test.mjs +503 -0
- package/tools/build.mjs +62 -13
- package/tools/check-package.mjs +19 -0
- package/ui-utils.js +51 -1
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createPetVideoMotion, petVideoAlphaSupported } from '../pet-video.js';
|
|
4
|
+
|
|
5
|
+
const flush = async () => {
|
|
6
|
+
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
7
|
+
};
|
|
8
|
+
function fixture({ alpha = true, rejectPlay = false, idle = false, options = {} } = {}) {
|
|
9
|
+
const media = Object.assign(new EventTarget(), { matches: false });
|
|
10
|
+
const document = Object.assign(new EventTarget(), { hidden: false });
|
|
11
|
+
const videos = [],
|
|
12
|
+
timers = new Map(),
|
|
13
|
+
calls = [],
|
|
14
|
+
states = [];
|
|
15
|
+
let nextTimer = 0;
|
|
16
|
+
document.createElement = (tag) => {
|
|
17
|
+
if (tag === 'canvas')
|
|
18
|
+
return {
|
|
19
|
+
getContext: () => ({
|
|
20
|
+
drawImage() {},
|
|
21
|
+
getImageData: (x) => ({ data: [0, 0, 0, x === 1 || !alpha ? 255 : 0] }),
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
const video = {
|
|
25
|
+
style: {},
|
|
26
|
+
paused: false,
|
|
27
|
+
duration: 5,
|
|
28
|
+
removed: false,
|
|
29
|
+
canPlayType: () => 'probably',
|
|
30
|
+
requestVideoFrameCallback(callback) {
|
|
31
|
+
queueMicrotask(callback);
|
|
32
|
+
return 1;
|
|
33
|
+
},
|
|
34
|
+
cancelVideoFrameCallback() {},
|
|
35
|
+
setAttribute() {},
|
|
36
|
+
removeAttribute(name) {
|
|
37
|
+
delete this[name];
|
|
38
|
+
},
|
|
39
|
+
pause() {
|
|
40
|
+
this.paused = true;
|
|
41
|
+
},
|
|
42
|
+
load() {
|
|
43
|
+
if (this.src === 'probe') queueMicrotask(() => this.onloadeddata?.());
|
|
44
|
+
},
|
|
45
|
+
play() {
|
|
46
|
+
return rejectPlay ? Promise.reject(new Error('blocked')) : Promise.resolve();
|
|
47
|
+
},
|
|
48
|
+
remove() {
|
|
49
|
+
this.removed = true;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
videos.push(video);
|
|
53
|
+
return video;
|
|
54
|
+
};
|
|
55
|
+
const host = {
|
|
56
|
+
document,
|
|
57
|
+
matchMedia: () => media,
|
|
58
|
+
setTimeout(fn, ms) {
|
|
59
|
+
const id = ++nextTimer;
|
|
60
|
+
timers.set(id, { fn, ms });
|
|
61
|
+
return id;
|
|
62
|
+
},
|
|
63
|
+
clearTimeout(id) {
|
|
64
|
+
timers.delete(id);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const element = { dataset: {}, querySelector: () => ({ appendChild() {} }) };
|
|
68
|
+
const base = {
|
|
69
|
+
play(action) {
|
|
70
|
+
calls.push(action);
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
configure() {},
|
|
74
|
+
release() {
|
|
75
|
+
delete element.dataset.action;
|
|
76
|
+
},
|
|
77
|
+
drag() {
|
|
78
|
+
element.dataset.action = 'drag';
|
|
79
|
+
},
|
|
80
|
+
destroy() {
|
|
81
|
+
calls.push('destroy');
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const motion = createPetVideoMotion(
|
|
85
|
+
element,
|
|
86
|
+
host,
|
|
87
|
+
base,
|
|
88
|
+
{ greet: 'greet-data', celebrate: 'drink-data', ...(idle ? { idle: 'idle-data' } : {}) },
|
|
89
|
+
'probe',
|
|
90
|
+
{ ...options, onStateChange: (state) => states.push(state) },
|
|
91
|
+
);
|
|
92
|
+
motion.configure({ enabled: true, resting: false });
|
|
93
|
+
return { host, document, media, element, base, motion, videos, timers, calls, states };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test('alpha support checks decoded pixels rather than trusting WebM codec support', async () => {
|
|
97
|
+
for (const alpha of [true, false]) {
|
|
98
|
+
const f = fixture({ alpha });
|
|
99
|
+
assert.equal(await petVideoAlphaSupported(f.host, 'probe'), alpha);
|
|
100
|
+
assert.equal(f.videos.length, 1);
|
|
101
|
+
assert.equal(f.videos[0].src, undefined);
|
|
102
|
+
assert.equal(f.timers.size, 0);
|
|
103
|
+
f.motion.destroy();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('clips reveal only after playing; accepted drinking interrupts greeting and stale events cannot stop it', async () => {
|
|
108
|
+
const f = fixture();
|
|
109
|
+
f.motion.play('greet');
|
|
110
|
+
await flush();
|
|
111
|
+
const greet = f.videos.at(-1),
|
|
112
|
+
staleEnd = greet.onended;
|
|
113
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
114
|
+
greet.onplaying();
|
|
115
|
+
assert.equal(f.element.dataset.videoPlaying, 'true');
|
|
116
|
+
assert.equal(greet.muted, true);
|
|
117
|
+
assert.equal(greet.playsInline, true);
|
|
118
|
+
assert.equal(f.motion.play('greet'), false);
|
|
119
|
+
f.motion.play('celebrate');
|
|
120
|
+
await flush();
|
|
121
|
+
const drink = f.videos.at(-1);
|
|
122
|
+
assert.equal(greet.removed, false);
|
|
123
|
+
assert.equal(greet.paused, false);
|
|
124
|
+
assert.equal(f.element.dataset.action, 'greet');
|
|
125
|
+
assert.equal(f.element.dataset.videoLoading, 'celebrate');
|
|
126
|
+
assert.equal(drink.style.opacity, '0');
|
|
127
|
+
drink.onplaying();
|
|
128
|
+
assert.equal(greet.paused, true);
|
|
129
|
+
assert.equal(greet.style.opacity, '0');
|
|
130
|
+
await fireTimer(f, 150);
|
|
131
|
+
assert.equal(greet.removed, true);
|
|
132
|
+
staleEnd();
|
|
133
|
+
assert.equal(f.element.dataset.action, 'celebrate');
|
|
134
|
+
assert.equal(f.motion.play('greet'), false);
|
|
135
|
+
assert.equal(f.motion.play('look'), false);
|
|
136
|
+
drink.onended();
|
|
137
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
138
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
139
|
+
await fireTimer(f, 150);
|
|
140
|
+
assert.equal(drink.removed, true);
|
|
141
|
+
f.motion.destroy();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('motion changes and disposal cancel pending probes without late playback', async () => {
|
|
145
|
+
const f = fixture();
|
|
146
|
+
f.motion.play('greet');
|
|
147
|
+
f.motion.configure({ enabled: false, resting: false });
|
|
148
|
+
await flush();
|
|
149
|
+
assert.equal(f.videos.length, 1);
|
|
150
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
151
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
152
|
+
f.motion.play('greet');
|
|
153
|
+
await flush();
|
|
154
|
+
const oldPlaying = f.videos.at(-1).onplaying;
|
|
155
|
+
f.motion.destroy();
|
|
156
|
+
oldPlaying();
|
|
157
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
158
|
+
assert.equal(f.timers.size, 0);
|
|
159
|
+
assert.equal(f.motion.play('greet'), false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('background, reduced motion, quiet time and dragging stop media without replay', async () => {
|
|
163
|
+
const f = fixture();
|
|
164
|
+
await flush();
|
|
165
|
+
for (const kind of ['hidden', 'reduce', 'quiet', 'drag']) {
|
|
166
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
167
|
+
f.motion.play('celebrate');
|
|
168
|
+
await flush();
|
|
169
|
+
const video = f.videos.at(-1);
|
|
170
|
+
video.onplaying();
|
|
171
|
+
if (kind === 'hidden') {
|
|
172
|
+
f.document.hidden = true;
|
|
173
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
174
|
+
}
|
|
175
|
+
if (kind === 'reduce') {
|
|
176
|
+
f.media.matches = true;
|
|
177
|
+
f.media.dispatchEvent(new Event('change'));
|
|
178
|
+
}
|
|
179
|
+
if (kind === 'quiet') f.motion.configure({ enabled: true, resting: true });
|
|
180
|
+
if (kind === 'drag') f.motion.drag(10);
|
|
181
|
+
assert.equal(video.removed, true);
|
|
182
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
183
|
+
const count = f.videos.length;
|
|
184
|
+
f.document.hidden = false;
|
|
185
|
+
f.media.matches = false;
|
|
186
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
187
|
+
f.media.dispatchEvent(new Event('change'));
|
|
188
|
+
f.motion.release(true);
|
|
189
|
+
await flush();
|
|
190
|
+
assert.equal(f.videos.length, count);
|
|
191
|
+
}
|
|
192
|
+
f.motion.destroy();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('unsupported alpha, rejected playback and a stalled clip fall back without repeatedly loading bad media', async () => {
|
|
196
|
+
for (const options of [{ alpha: false }, { rejectPlay: true }, {}]) {
|
|
197
|
+
const f = fixture(options);
|
|
198
|
+
f.motion.play('greet');
|
|
199
|
+
await flush();
|
|
200
|
+
if (!options.alpha && options.alpha !== false && !options.rejectPlay) {
|
|
201
|
+
const watchdog = [...f.timers.values()].find((t) => t.ms === 9000);
|
|
202
|
+
watchdog.fn();
|
|
203
|
+
await flush();
|
|
204
|
+
}
|
|
205
|
+
assert.deepEqual(f.calls, ['greet']);
|
|
206
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
207
|
+
const count = f.videos.length;
|
|
208
|
+
f.motion.play('greet');
|
|
209
|
+
await flush();
|
|
210
|
+
assert.equal(f.videos.length, count);
|
|
211
|
+
assert.deepEqual(f.calls, ['greet', 'greet']);
|
|
212
|
+
f.motion.destroy();
|
|
213
|
+
assert.equal(f.timers.size, 0);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
async function fireTimer(f, milliseconds) {
|
|
218
|
+
const found = [...f.timers].find(([, timer]) => timer.ms === milliseconds);
|
|
219
|
+
assert.ok(found, `Expected ${milliseconds}ms timer`);
|
|
220
|
+
f.timers.delete(found[0]);
|
|
221
|
+
found[1].fn();
|
|
222
|
+
await flush();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test('idle loops are interrupted by actions and resume once after action completion', async () => {
|
|
226
|
+
const f = fixture({ idle: true });
|
|
227
|
+
await flush();
|
|
228
|
+
const idle = f.videos.at(-1);
|
|
229
|
+
idle.onplaying();
|
|
230
|
+
assert.equal(idle.loop, true);
|
|
231
|
+
assert.equal(f.timers.size, 0);
|
|
232
|
+
assert.equal(f.motion.play('look'), false);
|
|
233
|
+
f.motion.play('greet');
|
|
234
|
+
await flush();
|
|
235
|
+
const greet = f.videos.at(-1);
|
|
236
|
+
greet.onplaying();
|
|
237
|
+
assert.equal(idle.paused, true);
|
|
238
|
+
await fireTimer(f, 150);
|
|
239
|
+
assert.equal(idle.removed, true);
|
|
240
|
+
assert.equal(greet.loop, false);
|
|
241
|
+
const oldEnd = greet.onended;
|
|
242
|
+
f.motion.play('celebrate');
|
|
243
|
+
await flush();
|
|
244
|
+
const drink = f.videos.at(-1);
|
|
245
|
+
drink.duration = 7.5;
|
|
246
|
+
drink.onplaying();
|
|
247
|
+
assert.ok([...f.timers.values()].some((t) => t.ms === 9500));
|
|
248
|
+
oldEnd();
|
|
249
|
+
assert.equal(f.element.dataset.action, 'celebrate');
|
|
250
|
+
drink.onended();
|
|
251
|
+
await fireTimer(f, 0);
|
|
252
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
253
|
+
assert.equal(f.videos.at(-1).loop, true);
|
|
254
|
+
f.motion.destroy();
|
|
255
|
+
assert.equal(f.timers.size, 0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('returning from hidden or reduced-motion state starts idle rather than replaying an action', async () => {
|
|
259
|
+
const f = fixture({ idle: true });
|
|
260
|
+
await flush();
|
|
261
|
+
f.motion.play('greet');
|
|
262
|
+
await flush();
|
|
263
|
+
f.document.hidden = true;
|
|
264
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
265
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
266
|
+
f.document.hidden = false;
|
|
267
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
268
|
+
await fireTimer(f, 0);
|
|
269
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
270
|
+
f.media.matches = true;
|
|
271
|
+
f.media.dispatchEvent(new Event('change'));
|
|
272
|
+
const count = f.videos.length;
|
|
273
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
274
|
+
await flush();
|
|
275
|
+
assert.equal(f.videos.length, count);
|
|
276
|
+
f.media.matches = false;
|
|
277
|
+
f.media.dispatchEvent(new Event('change'));
|
|
278
|
+
await fireTimer(f, 0);
|
|
279
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
280
|
+
f.motion.destroy();
|
|
281
|
+
assert.equal(f.timers.size, 0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('quiet state and a held drag suppress idle until they finish', async () => {
|
|
285
|
+
const f = fixture({ idle: true });
|
|
286
|
+
await flush();
|
|
287
|
+
f.motion.configure({ enabled: true, resting: true });
|
|
288
|
+
const count = f.videos.length;
|
|
289
|
+
await flush();
|
|
290
|
+
assert.equal(f.videos.length, count);
|
|
291
|
+
f.motion.play('greet');
|
|
292
|
+
await flush();
|
|
293
|
+
f.videos.at(-1).onended();
|
|
294
|
+
await flush();
|
|
295
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
296
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
297
|
+
await flush();
|
|
298
|
+
f.motion.drag(1);
|
|
299
|
+
const before = f.videos.length;
|
|
300
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
301
|
+
await flush();
|
|
302
|
+
assert.equal(f.videos.length, before);
|
|
303
|
+
assert.equal(f.element.dataset.action, 'drag');
|
|
304
|
+
f.motion.release(false);
|
|
305
|
+
await fireTimer(f, 650);
|
|
306
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
307
|
+
f.motion.destroy();
|
|
308
|
+
assert.equal(f.timers.size, 0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('a stalled idle clip fails closed without disabling usable action clips', async () => {
|
|
312
|
+
const f = fixture({ idle: true });
|
|
313
|
+
await flush();
|
|
314
|
+
const idle = f.videos.at(-1);
|
|
315
|
+
idle.onplaying();
|
|
316
|
+
idle.onwaiting();
|
|
317
|
+
await fireTimer(f, 4000);
|
|
318
|
+
assert.equal(idle.removed, true);
|
|
319
|
+
const count = f.videos.length;
|
|
320
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
321
|
+
await flush();
|
|
322
|
+
assert.equal(f.videos.length, count);
|
|
323
|
+
f.motion.play('greet');
|
|
324
|
+
await flush();
|
|
325
|
+
const greet = f.videos.at(-1);
|
|
326
|
+
greet.onplaying();
|
|
327
|
+
greet.onended();
|
|
328
|
+
await flush();
|
|
329
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
330
|
+
f.motion.destroy();
|
|
331
|
+
assert.equal(f.timers.size, 0);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('failed action fallback suspends idle during CSS feedback and then returns to idle', async () => {
|
|
335
|
+
const f = fixture({ idle: true });
|
|
336
|
+
await flush();
|
|
337
|
+
f.motion.play('greet');
|
|
338
|
+
await flush();
|
|
339
|
+
f.videos.at(-1).onerror();
|
|
340
|
+
assert.deepEqual(f.calls, ['greet']);
|
|
341
|
+
await fireTimer(f, 1400);
|
|
342
|
+
const idle = f.videos.at(-1);
|
|
343
|
+
idle.onplaying();
|
|
344
|
+
const count = f.videos.length;
|
|
345
|
+
f.motion.play('greet');
|
|
346
|
+
await flush();
|
|
347
|
+
assert.equal(idle.removed, true);
|
|
348
|
+
assert.equal(f.videos.length, count);
|
|
349
|
+
assert.deepEqual(f.calls, ['greet', 'greet']);
|
|
350
|
+
await fireTimer(f, 1400);
|
|
351
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
352
|
+
f.motion.destroy();
|
|
353
|
+
assert.equal(f.timers.size, 0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('preview waits for explicit play, switches actions freely and never resumes idle automatically', async () => {
|
|
357
|
+
const f = fixture({
|
|
358
|
+
idle: true,
|
|
359
|
+
options: { autoIdle: false, fallback: false, interruptible: true },
|
|
360
|
+
});
|
|
361
|
+
await flush();
|
|
362
|
+
assert.equal(f.videos.length, 0);
|
|
363
|
+
f.motion.play('greet');
|
|
364
|
+
await flush();
|
|
365
|
+
const greet = f.videos.at(-1);
|
|
366
|
+
greet.onplaying();
|
|
367
|
+
const announcements = f.states.length;
|
|
368
|
+
greet.onplaying();
|
|
369
|
+
assert.equal(f.states.length, announcements);
|
|
370
|
+
greet.onended();
|
|
371
|
+
await flush();
|
|
372
|
+
assert.equal(f.states.at(-1).phase, 'idle');
|
|
373
|
+
assert.equal(f.videos.length, 2);
|
|
374
|
+
await fireTimer(f, 150);
|
|
375
|
+
f.motion.play('idle');
|
|
376
|
+
await flush();
|
|
377
|
+
f.videos.at(-1).onplaying();
|
|
378
|
+
f.motion.play('celebrate');
|
|
379
|
+
await flush();
|
|
380
|
+
f.videos.at(-1).onplaying();
|
|
381
|
+
assert.equal(f.motion.play('greet'), true);
|
|
382
|
+
await flush();
|
|
383
|
+
f.videos.at(-1).onplaying();
|
|
384
|
+
assert.equal(f.motion.play('idle'), true);
|
|
385
|
+
await flush();
|
|
386
|
+
f.videos.at(-1).onplaying();
|
|
387
|
+
assert.ok(f.videos.filter((v) => v.src && !v.removed).length <= 2);
|
|
388
|
+
f.motion.stop();
|
|
389
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
390
|
+
assert.equal(f.states.at(-1).phase, 'idle');
|
|
391
|
+
assert.deepEqual(f.calls, []);
|
|
392
|
+
f.motion.destroy();
|
|
393
|
+
assert.equal(f.timers.size, 0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('preview cancellation ignores late probe and video callbacks', async () => {
|
|
397
|
+
const f = fixture({ options: { autoIdle: false, fallback: false, interruptible: true } });
|
|
398
|
+
f.motion.play('greet');
|
|
399
|
+
f.motion.stop();
|
|
400
|
+
await flush();
|
|
401
|
+
assert.equal(f.videos.length, 1);
|
|
402
|
+
assert.deepEqual(
|
|
403
|
+
f.states.map((s) => s.phase),
|
|
404
|
+
['loading', 'idle'],
|
|
405
|
+
);
|
|
406
|
+
f.motion.play('greet');
|
|
407
|
+
await flush();
|
|
408
|
+
const late = f.videos.at(-1).onplaying;
|
|
409
|
+
f.motion.stop();
|
|
410
|
+
late();
|
|
411
|
+
assert.equal(f.states.at(-1).phase, 'idle');
|
|
412
|
+
const count = f.states.length;
|
|
413
|
+
f.motion.destroy();
|
|
414
|
+
late();
|
|
415
|
+
assert.equal(f.states.length, count);
|
|
416
|
+
assert.equal(f.timers.size, 0);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('preview reports media failure without pretending to play and supports explicit retry', async () => {
|
|
420
|
+
const f = fixture({
|
|
421
|
+
rejectPlay: true,
|
|
422
|
+
options: { autoIdle: false, fallback: false, interruptible: true },
|
|
423
|
+
});
|
|
424
|
+
f.motion.play('greet');
|
|
425
|
+
await flush();
|
|
426
|
+
assert.equal(f.states.at(-1).phase, 'unavailable');
|
|
427
|
+
assert.deepEqual(f.calls, []);
|
|
428
|
+
const create = f.document.createElement;
|
|
429
|
+
f.document.createElement = (tag) => {
|
|
430
|
+
const node = create(tag);
|
|
431
|
+
if (tag === 'video') node.play = () => Promise.resolve();
|
|
432
|
+
return node;
|
|
433
|
+
};
|
|
434
|
+
assert.equal(f.motion.retry('greet'), true);
|
|
435
|
+
await flush();
|
|
436
|
+
f.videos.at(-1).onplaying();
|
|
437
|
+
assert.equal(f.states.at(-1).phase, 'playing');
|
|
438
|
+
f.motion.destroy();
|
|
439
|
+
assert.equal(f.timers.size, 0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('preview distinguishes unsupported transparency from retryable resource failures', async () => {
|
|
443
|
+
const f = fixture({
|
|
444
|
+
alpha: false,
|
|
445
|
+
options: { autoIdle: false, fallback: false, interruptible: true },
|
|
446
|
+
});
|
|
447
|
+
f.motion.play('greet');
|
|
448
|
+
await flush();
|
|
449
|
+
assert.equal(f.states.at(-1).phase, 'unsupported');
|
|
450
|
+
assert.deepEqual(f.calls, []);
|
|
451
|
+
const count = f.videos.length;
|
|
452
|
+
f.motion.retry('greet');
|
|
453
|
+
await flush();
|
|
454
|
+
assert.equal(f.videos.length, count);
|
|
455
|
+
assert.equal(f.states.at(-1).phase, 'unsupported');
|
|
456
|
+
f.motion.destroy();
|
|
457
|
+
assert.equal(f.timers.size, 0);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('alpha detection waits for a readable frame rather than treating an empty canvas as unsupported', async () => {
|
|
461
|
+
const f = fixture();
|
|
462
|
+
const create = f.document.createElement;
|
|
463
|
+
let reads = 0;
|
|
464
|
+
f.document.createElement = (tag) => {
|
|
465
|
+
const node = create(tag);
|
|
466
|
+
if (tag === 'canvas')
|
|
467
|
+
node.getContext = () => ({
|
|
468
|
+
drawImage() {},
|
|
469
|
+
getImageData(x) {
|
|
470
|
+
reads++;
|
|
471
|
+
return { data: [0, 0, 0, reads <= 2 ? 0 : x === 1 ? 255 : 0] };
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
return node;
|
|
475
|
+
};
|
|
476
|
+
assert.equal(await petVideoAlphaSupported(f.host, 'probe'), true);
|
|
477
|
+
assert.ok(reads >= 4);
|
|
478
|
+
f.motion.destroy();
|
|
479
|
+
assert.equal(f.timers.size, 0);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('a probe timeout remains retryable instead of poisoning the browser capability cache', async () => {
|
|
483
|
+
const f = fixture();
|
|
484
|
+
const create = f.document.createElement;
|
|
485
|
+
f.document.createElement = (tag) => {
|
|
486
|
+
const node = create(tag);
|
|
487
|
+
if (tag === 'video') node.load = () => {};
|
|
488
|
+
if (tag === 'canvas')
|
|
489
|
+
node.getContext = () => ({
|
|
490
|
+
drawImage() {
|
|
491
|
+
throw new Error('no decoded frame');
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
return node;
|
|
495
|
+
};
|
|
496
|
+
const first = petVideoAlphaSupported(f.host, 'probe');
|
|
497
|
+
await fireTimer(f, 5000);
|
|
498
|
+
assert.equal(await first, null);
|
|
499
|
+
f.document.createElement = create;
|
|
500
|
+
assert.equal(await petVideoAlphaSupported(f.host, 'probe'), true);
|
|
501
|
+
f.motion.destroy();
|
|
502
|
+
assert.equal(f.timers.size, 0);
|
|
503
|
+
});
|
package/tools/build.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
|
|
3
4
|
const root = new URL('../', import.meta.url);
|
|
4
5
|
const moduleNames = [
|
|
@@ -7,10 +8,14 @@ const moduleNames = [
|
|
|
7
8
|
'ui-utils.js',
|
|
8
9
|
'companion.js',
|
|
9
10
|
'pet-view.js',
|
|
11
|
+
'pet-motion.js',
|
|
12
|
+
'pet-video.js',
|
|
13
|
+
'pet-reminder.js',
|
|
10
14
|
'pet-interactions.js',
|
|
11
15
|
'pet-surfaces.js',
|
|
12
16
|
'hydration.js',
|
|
13
17
|
'hydration-store.js',
|
|
18
|
+
'countdown.js',
|
|
14
19
|
];
|
|
15
20
|
const [entry, ...modules] = await Promise.all(
|
|
16
21
|
moduleNames.map((name) => readFile(new URL(name, root), 'utf8')),
|
|
@@ -24,7 +29,9 @@ if (entry.split(marker).length !== 2) {
|
|
|
24
29
|
// The browser and Node.js tests execute the same calculation and store code.
|
|
25
30
|
const pure = modules
|
|
26
31
|
.map((source) =>
|
|
27
|
-
source
|
|
32
|
+
source
|
|
33
|
+
.replace(/^import\s+\{[^}]+\}\s+from '\.\/(?:hydration|clock)\.js';\n/gm, '')
|
|
34
|
+
.replace(/^export /gm, ''),
|
|
28
35
|
)
|
|
29
36
|
.join('\n');
|
|
30
37
|
const banner = '// Generated by tools/build.mjs; edit the source modules, not this file.\n';
|
|
@@ -32,7 +39,7 @@ const spriteMarker = '/* PET_SPRITES */';
|
|
|
32
39
|
if (entry.split(spriteMarker).length !== 2) throw new Error('Expected one sprite marker');
|
|
33
40
|
const sprites = Object.fromEntries(
|
|
34
41
|
await Promise.all(
|
|
35
|
-
['whale-dress', 'whale-ornate'].map(async (id) => {
|
|
42
|
+
['whale-dress', 'whale-ornate', 'whale-actions', 'whale-ornate-actions'].map(async (id) => {
|
|
36
43
|
const bytes = await readFile(new URL(`assets/characters/${id}.webp`, root));
|
|
37
44
|
if (bytes.toString('ascii', 8, 12) !== 'WEBP' || bytes.length > 200000)
|
|
38
45
|
throw new Error(`Invalid sprite: ${id}`);
|
|
@@ -40,19 +47,61 @@ const sprites = Object.fromEntries(
|
|
|
40
47
|
}),
|
|
41
48
|
),
|
|
42
49
|
);
|
|
50
|
+
const clipMarker = '/* PET_CLIPS */';
|
|
51
|
+
if (entry.split(clipMarker).length !== 2) throw new Error('Expected one clip marker');
|
|
52
|
+
const mediaRoute = '/offwork-clock/media';
|
|
53
|
+
const mediaAssets = {};
|
|
54
|
+
async function clip(name, maximum, inline = false) {
|
|
55
|
+
const bytes = await readFile(new URL(`assets/animations/${name}.webm`, root));
|
|
56
|
+
if (bytes.length < 4 || bytes.readUInt32BE(0) !== 0x1a45dfa3 || bytes.length > maximum)
|
|
57
|
+
throw new Error(`Invalid or oversized WebM: ${name}`);
|
|
58
|
+
if (inline) return 'data:video/webm;base64,' + bytes.toString('base64');
|
|
59
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
60
|
+
const key = `${sha256}/${name}.webm`;
|
|
61
|
+
mediaAssets[key] = { file: `assets/animations/${name}.webm`, size: bytes.length, sha256 };
|
|
62
|
+
return `${mediaRoute.slice(1)}/${key}`;
|
|
63
|
+
}
|
|
64
|
+
const [probe, greet, celebrate, ornateIdle, ornateGreet, ornateDrink] = await Promise.all([
|
|
65
|
+
clip('alpha-probe', 10000, true),
|
|
66
|
+
clip('whale-greet', 2000000),
|
|
67
|
+
clip('whale-drink', 2000000),
|
|
68
|
+
clip('ornate-idle', 2000000),
|
|
69
|
+
clip('ornate-greet', 2000000),
|
|
70
|
+
clip('ornate-drink', 2000000),
|
|
71
|
+
]);
|
|
72
|
+
const clips = {
|
|
73
|
+
probe,
|
|
74
|
+
models: {
|
|
75
|
+
'whale-actions': { greet, celebrate },
|
|
76
|
+
'whale-ornate-actions': { idle: ornateIdle, greet: ornateGreet, celebrate: ornateDrink },
|
|
77
|
+
},
|
|
78
|
+
};
|
|
43
79
|
const result =
|
|
44
80
|
banner +
|
|
45
81
|
entry
|
|
46
82
|
.replace(marker, pure)
|
|
47
|
-
.replace(spriteMarker, `const PET_SPRITES = ${JSON.stringify(sprites)};`)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
.replace(spriteMarker, () => `const PET_SPRITES = ${JSON.stringify(sprites)};`)
|
|
84
|
+
.replace(
|
|
85
|
+
clipMarker,
|
|
86
|
+
() =>
|
|
87
|
+
`const PET_CLIPS = ${JSON.stringify(clips)};\nPET_CLIPS.models = petClipUrls(PET_CLIPS.models, document.baseURI);`,
|
|
88
|
+
);
|
|
89
|
+
const manifest =
|
|
90
|
+
banner +
|
|
91
|
+
`export const MEDIA_ROUTE = ${JSON.stringify(mediaRoute)};\nexport const MEDIA_ASSETS = Object.freeze(${JSON.stringify(Object.fromEntries(Object.entries(mediaAssets).sort()), null, 2)});\n`;
|
|
92
|
+
const outputs = [
|
|
93
|
+
['client.js', result],
|
|
94
|
+
['media-manifest.js', manifest],
|
|
95
|
+
];
|
|
96
|
+
for (const [name, contents] of outputs) {
|
|
97
|
+
const output = new URL(name, root);
|
|
98
|
+
if (process.argv.includes('--check')) {
|
|
99
|
+
if ((await readFile(output, 'utf8')) !== contents)
|
|
100
|
+
throw new Error(`Generated artifact is stale: ${name}. Run npm run build.`);
|
|
101
|
+
} else await writeFile(output, contents);
|
|
58
102
|
}
|
|
103
|
+
console.log(
|
|
104
|
+
process.argv.includes('--check')
|
|
105
|
+
? 'Client and media manifest match tested source'
|
|
106
|
+
: 'Built client and content-addressed media manifest',
|
|
107
|
+
);
|
package/tools/check-package.mjs
CHANGED
|
@@ -16,6 +16,10 @@ assert.ok(pkg.engines?.dsh, 'Declare the supported Harness versions');
|
|
|
16
16
|
assert.equal(pkg.dsh?.bundle?.patch, './cordis.patch.yml');
|
|
17
17
|
assert.equal(pkg.dsh?.client?.platform, 'web');
|
|
18
18
|
assert.match(await read('LICENSE'), /^MIT License/);
|
|
19
|
+
assert.ok(
|
|
20
|
+
Buffer.byteLength(await read('client.js')) < 2000000,
|
|
21
|
+
'Keep the initial client below 2MB; character movies must remain on-demand resources',
|
|
22
|
+
);
|
|
19
23
|
|
|
20
24
|
// This check covers this project's patch; Harness composition remains the
|
|
21
25
|
// authoritative check that the bundle can be installed in a real profile.
|
|
@@ -67,17 +71,32 @@ assert.equal(packed.version, pkg.version);
|
|
|
67
71
|
for (const path of [
|
|
68
72
|
'package.json',
|
|
69
73
|
'index.js',
|
|
74
|
+
'media-server.js',
|
|
75
|
+
'media-manifest.js',
|
|
70
76
|
'client.js',
|
|
71
77
|
'companion.js',
|
|
72
78
|
'pet-view.js',
|
|
79
|
+
'pet-motion.js',
|
|
80
|
+
'pet-video.js',
|
|
81
|
+
'pet-reminder.js',
|
|
73
82
|
'pet-interactions.js',
|
|
74
83
|
'pet-surfaces.js',
|
|
75
84
|
'hydration.js',
|
|
76
85
|
'hydration-store.js',
|
|
86
|
+
'countdown.js',
|
|
77
87
|
'cordis.patch.yml',
|
|
78
88
|
'icon.svg',
|
|
79
89
|
'assets/characters/whale-dress.webp',
|
|
80
90
|
'assets/characters/whale-ornate.webp',
|
|
91
|
+
'assets/characters/whale-actions.webp',
|
|
92
|
+
'assets/characters/whale-ornate-actions.webp',
|
|
93
|
+
'assets/animations/ornate-idle.webm',
|
|
94
|
+
'assets/animations/ornate-greet.webm',
|
|
95
|
+
'assets/animations/ornate-drink.webm',
|
|
96
|
+
'assets/animations/whale-greet.webm',
|
|
97
|
+
'assets/animations/whale-drink.webm',
|
|
98
|
+
'assets/animations/alpha-probe.webm',
|
|
99
|
+
'assets/animations/NOTICE.md',
|
|
81
100
|
'assets/characters/NOTICE.md',
|
|
82
101
|
'locale/zh.json',
|
|
83
102
|
'locale/en.json',
|