dsh-plugin-vrmpet3d 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.js CHANGED
@@ -71,16 +71,32 @@
71
71
  * The `{duration}` token and its machinery stay — a line the user writes may still carry it (see
72
72
  * {@link STATUS_DURATION_TOKEN}) — but the shipped line is DSH's token-free 「深度求索中 ···」, which is also what the
73
73
  * app shows while the latest turn is not open.
74
+ *
75
+ * ONE TABLE PER LANGUAGE, keyed state → language, the same shape `SPEECH_LINES` uses for its pools and for the
76
+ * same reason: a status line is TEXT THE USER READS, so an interface in English that still said 「任务达成」 would
77
+ * be a half-translated one. The two halves are written rather than translated word for word, and the Chinese is
78
+ * the operator's own wording, unchanged since 2026-10-13.
79
+ *
80
+ * An EDIT wins over both: `statusEntryFor` only reaches for these while the user's own box is empty, so a line
81
+ * typed by hand is one line in one language and stays exactly that, in either interface language. That is the
82
+ * whole reason this is a per-language DEFAULT rather than a second field in the settings.
74
83
  */
75
84
  var DEFAULT_STATUS_TEXT = {
76
- resting: '',
77
- working: '深度求索中 ···',
78
- waiting: '我有问题要问你',
79
- done: '任务达成',
80
- error: '遇到问题了...',
81
- offline: '',
85
+ resting: { zh: '', en: '' },
86
+ working: { zh: '深度求索中 ···', en: 'Working ···' },
87
+ waiting: { zh: '我有问题要问你', en: 'I have a question for you' },
88
+ done: { zh: '任务达成', en: 'Task complete' },
89
+ error: { zh: '遇到问题了...', en: 'Something went wrong...' },
90
+ offline: { zh: '', en: '' },
82
91
  };
83
92
 
93
+ /** One state's shipped line in one language; `''` for a state that says nothing by default. */
94
+ function defaultStatusText(state, language) {
95
+ var entry = DEFAULT_STATUS_TEXT[state];
96
+ if (!entry || typeof entry !== 'object') return '';
97
+ return typeof entry[language === 'en' ? 'en' : 'zh'] === 'string' ? entry[language === 'en' ? 'en' : 'zh'] : '';
98
+ }
99
+
84
100
  /**
85
101
  * The token a status line may carry where the live elapsed time goes, and the units it is written with.
86
102
  *
@@ -96,6 +112,155 @@
96
112
  en: { hour: 'h ', minute: 'm ', second: 's' },
97
113
  };
98
114
 
115
+ /**
116
+ * The three answers to 「界面语言」, and the ONE rule that turns them into the language a surface draws in.
117
+ *
118
+ * `auto` is the shipped answer and means "whatever the surface is already running in": DSH's own locale inside
119
+ * the app, the browser's on a host-served page, and on a surface that can see neither (a suite, a headless
120
+ * host) English — the plugin's own fallback rather than a guess about the operator.
121
+ *
122
+ * The other two are the user overruling that, and they exist because the two questions are NOT the same one:
123
+ * DSH can be running in English while its owner wants the pet in Chinese, and the first-run guide is read once,
124
+ * before any of that has settled. Operator, 2026-10-15: 「把全部界面做中英双语自动识别,并在通用设置和初始安装
125
+ * 引导页的第一页欢迎页面中加上语言选择」.
126
+ *
127
+ * `auto` is STORED as `auto`, never resolved on write. A profile that stored the resolved 'zh' would freeze the
128
+ * language on the day it was created, and the user who later switched DSH to English would keep a Chinese pet
129
+ * with no way to tell that from a deliberate choice — the same mistake `DEFAULTS.petSurface` documents for
130
+ * 显示位置, and it is why this is a preference the CLEANER knows about rather than a value a page writes.
131
+ */
132
+ var LANGUAGE_SETTINGS = ['auto', 'zh', 'en'];
133
+
134
+ /** `'auto'` for anything that is not one of the three — an unknown stored value is a reset, not an error. */
135
+ function cleanLanguage(value) {
136
+ return LANGUAGE_SETTINGS.indexOf(value) === -1 ? 'auto' : value;
137
+ }
138
+
139
+ /**
140
+ * Which of the two languages this surface draws in, given the user's setting and what the environment says.
141
+ *
142
+ * `hints` is a LIST, most authoritative first — DSH's locale, then the document's own `lang`, then
143
+ * `navigator.language` — and the FIRST hint that says anything decides. That order is the rule and not a detail:
144
+ * DSH is the application the pet lives inside, so its language outranks the browser's, and a page DSH has
145
+ * already marked `lang="zh"` outranks a browser whose own preference is en-US.
146
+ *
147
+ * The first hint decides the ZH-or-not question instead of the chain continuing past it, because this plugin has
148
+ * exactly two languages. A hint of `fr` is not a reason to go looking for a Chinese one further down the chain;
149
+ * it is a reason to fall back to English, which is also what `auto` with nothing to go on means.
150
+ */
151
+ function resolveLanguage(preference, hints) {
152
+ var wanted = cleanLanguage(preference);
153
+ if (wanted !== 'auto') return wanted;
154
+ var list = Array.isArray(hints) ? hints : [hints];
155
+ for (var i = 0; i < list.length; i += 1) {
156
+ var hint = list[i];
157
+ if (typeof hint !== 'string' || !hint) continue;
158
+ return /^(zh|cn|chinese)/i.test(hint.trim()) ? 'zh' : 'en';
159
+ }
160
+ return 'en';
161
+ }
162
+
163
+ /**
164
+ * The environment's own two hints, in the order they outrank each other — what a surface that has no DSH locale
165
+ * to ask uses. `scope` exists so a suite can hand in a fake window instead of moving the real one.
166
+ *
167
+ * The BROWSER comes first, and the document's own `lang` second, which is the opposite of the order the app's
168
+ * widget uses — deliberately. Every page this plugin serves ships `<html lang="zh">` (the plugin was written in
169
+ * Chinese), so a page that read its own markup first could never be auto-detected as English: the hint would be
170
+ * its own default rather than anything about the user. `navigator.language` is the browser's answer and in the
171
+ * floating window it is Electron's, i.e. the machine's.
172
+ */
173
+ function browserLanguageHints(scope) {
174
+ var win = scope || (typeof globalThis !== 'undefined' ? globalThis : null);
175
+ var doc = win && win.document;
176
+ var nav = win && win.navigator;
177
+ return [
178
+ nav ? (nav.language || (nav.languages && nav.languages[0])) : null,
179
+ doc && doc.documentElement ? doc.documentElement.lang : null,
180
+ ];
181
+ }
182
+
183
+ /**
184
+ * One surface's own wording, in the two languages, chosen by {@link resolveLanguage}.
185
+ *
186
+ * Every surface keeps its OWN table — `lib/menu.js`'s `LABELS`, `lib/model-picker.js`'s `TEXT`, the settings
187
+ * pages' `TEXT` — rather than one global dictionary, because that is already this repository's shape (a surface
188
+ * owns the words it draws) and because a single table for six surfaces would make every one of them edit the
189
+ * same file. What IS shared is the SHAPE: `{ zh: {...}, en: {...} }`, this accessor, and {@link applyI18n}.
190
+ *
191
+ * A language half that is missing or empty falls back to the other one rather than to blank: a half-translated
192
+ * surface reads as Chinese in one spot, which is a bug somebody reports, where a blank label is a page that
193
+ * looks broken. `textOf` below is what makes a GAP visible, and a suite is what keeps one from shipping.
194
+ */
195
+ function textFor(table, language) {
196
+ if (!table || typeof table !== 'object') return {};
197
+ var wanted = language === 'en' ? table.en : table.zh;
198
+ var other = language === 'en' ? table.zh : table.en;
199
+ if (wanted && typeof wanted === 'object') return wanted;
200
+ return other && typeof other === 'object' ? other : {};
201
+ }
202
+
203
+ /** One entry out of a surface's table. A missing key returns the KEY, so a gap is visible on screen. */
204
+ function textOf(table, language, key) {
205
+ var value = textFor(table, language)[key];
206
+ return typeof value === 'string' ? value : key;
207
+ }
208
+
209
+ /**
210
+ * Fill a document's marked-up text from one surface's table.
211
+ *
212
+ * The markup carries the CHINESE text as well as the key, and that is on purpose: this plugin's pages all ship a
213
+ * bootstrap watchdog for the case where a script never ran, so a page whose script DID not run should still say
214
+ * something in one language rather than show nothing. `verify-desktop.mjs` asserts the two agree, which is what
215
+ * stops the markup and the table from drifting apart.
216
+ *
217
+ * Four attributes, one per place text lives: `data-i18n` writes `textContent`, `data-i18n-html` writes
218
+ * `innerHTML` and is for OUR OWN sentence with a `<strong>` in it (never for anything a user or a model wrote),
219
+ * and `data-i18n-placeholder` / `data-i18n-title` / `data-i18n-aria` write those three attributes.
220
+ *
221
+ * A key the table does not have leaves the markup ALONE rather than printing the key: the visible text stays the
222
+ * Chinese fallback, and the missing entry is the suite's business, not the user's.
223
+ *
224
+ * @returns {number} how many places were rewritten, so a suite can tell "applied nothing" from "no markup".
225
+ */
226
+ function applyI18n(root, table, language) {
227
+ var doc = root && typeof root.querySelectorAll === 'function'
228
+ ? root
229
+ : (typeof globalThis !== 'undefined' ? globalThis.document : null);
230
+ if (!doc || typeof doc.querySelectorAll !== 'function') return 0;
231
+ var words = textFor(table, language);
232
+ var selector = '[data-i18n],[data-i18n-html],[data-i18n-placeholder],[data-i18n-title],[data-i18n-aria]';
233
+ var nodes;
234
+ try {
235
+ nodes = doc.querySelectorAll(selector);
236
+ } catch (error) {
237
+ return 0;
238
+ }
239
+ var applied = 0;
240
+ var write = function (node, attribute, key, html) {
241
+ var value = words[key];
242
+ if (typeof value !== 'string') return;
243
+ if (attribute === 'data-i18n') node.textContent = value;
244
+ else if (html) node.innerHTML = value;
245
+ else node.setAttribute(attribute, value);
246
+ applied += 1;
247
+ };
248
+ for (var i = 0; i < nodes.length; i += 1) {
249
+ var node = nodes[i];
250
+ var key = node.getAttribute('data-i18n');
251
+ if (key) write(node, 'data-i18n', key, false);
252
+ key = node.getAttribute('data-i18n-html');
253
+ if (key) write(node, 'data-i18n-html', key, true);
254
+ key = node.getAttribute('data-i18n-placeholder');
255
+ if (key) write(node, 'placeholder', key, false);
256
+ key = node.getAttribute('data-i18n-title');
257
+ if (key) write(node, 'title', key, false);
258
+ key = node.getAttribute('data-i18n-aria');
259
+ if (key) write(node, 'aria-label', key, false);
260
+ }
261
+ return applied;
262
+ }
263
+
99
264
  /**
100
265
  * The states whose prompt carries DSH's whale mark.
101
266
  *
@@ -1014,9 +1179,8 @@
1014
1179
  }
1015
1180
 
1016
1181
  /**
1017
- * Which full-deflection distance to use: the SETTING when it is positive, otherwise the caller's own
1018
- * frame. Keeping that decision here rather than in each surface is what keeps "0 = 自动" meaning the
1019
- * same thing in the floating window (the screen's width) and in the app overlay (its window's width).
1182
+ * A positive setting overrides automatic distance. Both axes and both surfaces
1183
+ * pass the screen width here; automatic distance is half of that width.
1020
1184
  */
1021
1185
  function resolveGazeSpan(span, fallback) {
1022
1186
  var wanted = Number(span);
@@ -1024,7 +1188,7 @@
1024
1188
  return clamp(wanted, GAZE_SPAN_RANGE.min, GAZE_SPAN_RANGE.max);
1025
1189
  }
1026
1190
  var base = Number(fallback);
1027
- return isFiniteNumber(base) && base > 0 ? base : GAZE_SPAN.x;
1191
+ return isFiniteNumber(base) && base > 0 ? base / 2 : GAZE_SPAN.x;
1028
1192
  }
1029
1193
 
1030
1194
  /**
@@ -1046,7 +1210,9 @@
1046
1210
  * Pure numbers, no DOM, so the logic suite covers the clamping and the signs.
1047
1211
  *
1048
1212
  * @param {{pointerX:number, pointerY:number, centreX:number, centreY:number,
1049
- * spanX?:number, spanY?:number, curve?:number}} input
1213
+ * spanX?:number, spanY?:number, curve?:number, curveY?:number,
1214
+ * curveRadial?:number, curveSpatial?:number, distance?:number,
1215
+ * mode?:'axes'|'radial'|'spatial'}} input
1050
1216
  * @returns {{x:number, y:number}|null} `null` when the input is unusable; `y` is
1051
1217
  * positive BELOW the pet, i.e. in screen coordinates
1052
1218
  */
@@ -1065,12 +1231,39 @@
1065
1231
  : GAZE_SPAN.y;
1066
1232
  // The settings' curve, unless the caller passes its own (the suite does, to pin a shape).
1067
1233
  var edgeSlope = isFiniteNumber(Number(input.curve)) ? Number(input.curve) : DEFAULTS.gazeCurve;
1234
+ var slopeY = isFiniteNumber(Number(input.curveY)) ? Number(input.curveY) : edgeSlope;
1235
+ if (input.mode === 'spatial') {
1236
+ var spatialX = (pointerX - centreX) / spanX;
1237
+ var spatialY = (pointerY - centreY) / spanY;
1238
+ if (!isFiniteNumber(spatialX) || !isFiniteNumber(spatialY)) return null;
1239
+ var depth = isFiniteNumber(Number(input.distance)) && Number(input.distance) > 0
1240
+ ? clamp(Number(input.distance), 0.1, 5) : DEFAULTS.gazeDistance;
1241
+ // A virtual target plane in span units: sideways distance reduces pitch
1242
+ // at a fixed pointer height. Curves shape angles, rather than pixel offsets.
1243
+ var yawAngle = Math.atan2(spatialX, depth) / (Math.PI / 2);
1244
+ var pitchAngle = Math.atan2(spatialY, Math.hypot(spatialX, depth)) / (Math.PI / 2);
1245
+ var spatialSlope = isFiniteNumber(Number(input.curveSpatial)) ? Number(input.curveSpatial) : DEFAULTS.gazeCurveSpatial;
1246
+ return {
1247
+ x: (yawAngle < 0 ? -1 : 1) * gazeCurve(Math.abs(yawAngle), spatialSlope),
1248
+ y: (pitchAngle < 0 ? -1 : 1) * gazeCurve(Math.abs(pitchAngle), spatialSlope),
1249
+ };
1250
+ }
1251
+ if (input.mode === 'radial') {
1252
+ var dx = (pointerX - centreX) / spanX;
1253
+ var dy = (pointerY - centreY) / spanY;
1254
+ var distance = Math.hypot(dx, dy);
1255
+ if (!isFiniteNumber(distance)) return null;
1256
+ if (distance === 0) return { x: 0, y: 0 };
1257
+ var radialSlope = isFiniteNumber(Number(input.curveRadial)) ? Number(input.curveRadial) : edgeSlope;
1258
+ var gain = gazeCurve(Math.min(1, distance), radialSlope) / distance;
1259
+ return { x: dx * gain, y: dy * gain };
1260
+ }
1068
1261
  var rawX = clamp((pointerX - centreX) / spanX, -1, 1);
1069
1262
  var rawY = clamp((pointerY - centreY) / spanY, -1, 1);
1070
1263
  // Mirrored through `gazeCurve` so the sign survives and the response is the curve on both sides.
1071
1264
  return {
1072
1265
  x: rawX < 0 ? -gazeCurve(-rawX, edgeSlope) : gazeCurve(rawX, edgeSlope),
1073
- y: rawY < 0 ? -gazeCurve(-rawY, edgeSlope) : gazeCurve(rawY, edgeSlope)
1266
+ y: rawY < 0 ? -gazeCurve(-rawY, slopeY) : gazeCurve(rawY, slopeY)
1074
1267
  };
1075
1268
  }
1076
1269
 
@@ -1080,6 +1273,12 @@
1080
1273
  /** Default pose and behaviour knobs. */
1081
1274
  var DEFAULTS = {
1082
1275
  scope: 'global',
1276
+ /**
1277
+ * 界面语言 — `auto` follows the surface's own environment ({@link resolveLanguage}), and 中文 / English overrule
1278
+ * it. It sits first in the table rather than beside the appearance rows because it is the one preference that
1279
+ * decides how every OTHER row is spelled.
1280
+ */
1281
+ language: 'auto',
1083
1282
  size: 100,
1084
1283
  /**
1085
1284
  * WHERE the pet is shown, as the cleaner's DEFAULT rather than a string repeated beside it.
@@ -1177,12 +1376,21 @@
1177
1376
  * operator's own (2026-10-13: 「视线:曲率设置默认为0.1,提供0.05-1的选择(原先是0.1-1),静止停看默认为2秒」).
1178
1377
  * `gazeCurve` is the curve's slope at the frame's edge — 1 is a straight line, smaller is a
1179
1378
  * steeper start and a flatter tail — and `gazeSpanX`/`gazeSpanY` are the full-deflection
1180
- * distances in CSS pixels, where **0 means AUTO**: the caller's own frame (the screen's width in
1181
- * the floating window, the window's width in the app overlay) and, vertically, the fallback below.
1379
+ * distances in CSS pixels, where **0 means AUTO**: half the screen width on both axes.
1380
+ * `gazeCurve` / `gazeCurveY` remain readable for older delivered clients; the current
1381
+ * settings expose only spatial (default) and radial modes.
1182
1382
  */
1183
1383
  gazeCurve: 0.1,
1384
+ gazeCurveMode: 'spatial',
1385
+ gazeCurveY: 0.1,
1386
+ gazeCurveRadial: 0.1,
1387
+ gazeCurveSpatial: 1,
1388
+ gazeDistance: 0.1,
1184
1389
  gazeSpanX: 0,
1185
1390
  gazeSpanY: 0,
1391
+ // Actual head pitch in degrees, after the renderer's gaze weight.
1392
+ gazePitchUp: 60,
1393
+ gazePitchDown: 60,
1186
1394
  /**
1187
1395
  * How long the cursor may sit still before the pet stops following it, in seconds.
1188
1396
  *
@@ -1294,6 +1502,7 @@
1294
1502
  * not silently pin drag and gravity to the numbers the panel happened to be showing.
1295
1503
  */
1296
1504
  springs: {},
1505
+ toon: {},
1297
1506
  /**
1298
1507
  * Per-model size multiplier for BOTH texts: `{ [modelId]: number }`.
1299
1508
  *
@@ -1925,6 +2134,13 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
1925
2134
  var out = {};
1926
2135
  var source = raw && typeof raw === 'object' ? raw : {};
1927
2136
  out.scope = source.scope === 'current' ? 'current' : 'global';
2137
+ /**
2138
+ * 界面语言 — cleaned like every other enumerated preference: an unknown value (an older profile, a typo, a page
2139
+ * that sent the wrong thing) is `auto`, which resolves to the surface's own environment anyway. It is NOT
2140
+ * counted as authorship by `authoredWeight` over in `lib/settings.js`: a language is a way of looking at the
2141
+ * pet, not something the user wrote into it.
2142
+ */
2143
+ out.language = cleanLanguage(source.language);
1928
2144
  // An unknown surface falls back to the shipped default, not to a string repeated here.
1929
2145
  out.petSurface = PET_SURFACES.indexOf(source.petSurface) === -1 ? DEFAULTS.petSurface : source.petSurface;
1930
2146
  out.size = Number.isFinite(Number(source.size)) ? clamp(Number(source.size), SIZE_RANGE.min, SIZE_RANGE.max) : DEFAULTS.size;
@@ -2021,12 +2237,25 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2021
2237
  out.gazeCurve = Number.isFinite(Number(source.gazeCurve))
2022
2238
  ? clamp(Number(source.gazeCurve), GAZE_CURVE_RANGE.min, GAZE_CURVE_RANGE.max)
2023
2239
  : DEFAULTS.gazeCurve;
2240
+ out.gazeCurveMode = source.gazeCurveMode === 'radial' ? 'radial' : 'spatial';
2241
+ ['gazeCurveY', 'gazeCurveRadial'].forEach(function (key) {
2242
+ out[key] = typeof source[key] === 'number' && Number.isFinite(source[key])
2243
+ ? clamp(source[key], GAZE_CURVE_RANGE.min, GAZE_CURVE_RANGE.max) : DEFAULTS[key];
2244
+ });
2245
+ out.gazeCurveSpatial = typeof source.gazeCurveSpatial === 'number' && Number.isFinite(source.gazeCurveSpatial)
2246
+ ? clamp(source.gazeCurveSpatial, GAZE_CURVE_RANGE.min, GAZE_CURVE_RANGE.max) : DEFAULTS.gazeCurveSpatial;
2247
+ out.gazeDistance = typeof source.gazeDistance === 'number' && Number.isFinite(source.gazeDistance)
2248
+ ? clamp(source.gazeDistance, 0.1, 5) : DEFAULTS.gazeDistance;
2024
2249
  out.gazeSpanX = Number.isFinite(Number(source.gazeSpanX))
2025
2250
  ? clamp(Number(source.gazeSpanX), GAZE_SPAN_RANGE.min, GAZE_SPAN_RANGE.max)
2026
2251
  : DEFAULTS.gazeSpanX;
2027
2252
  out.gazeSpanY = Number.isFinite(Number(source.gazeSpanY))
2028
2253
  ? clamp(Number(source.gazeSpanY), GAZE_SPAN_RANGE.min, GAZE_SPAN_RANGE.max)
2029
2254
  : DEFAULTS.gazeSpanY;
2255
+ ['gazePitchUp', 'gazePitchDown'].forEach(function (key) {
2256
+ out[key] = typeof source[key] === 'number' && Number.isFinite(source[key])
2257
+ ? clamp(source[key], 0, 60) : DEFAULTS[key];
2258
+ });
2030
2259
  // 0 is a legitimate value here — it turns the idle rule off — so this cannot fall back to the default the
2031
2260
  // way an unparseable value does. A negative number is unparseable for this purpose and becomes 0.
2032
2261
  out.gazeIdleSeconds = Number.isFinite(Number(source.gazeIdleSeconds))
@@ -2081,6 +2310,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2081
2310
  ? source.expressions.emotion.parts
2082
2311
  : null);
2083
2312
  out.springs = cleanSprings(source.springs);
2313
+ out.toon = cleanToon(source.toon);
2084
2314
  return out;
2085
2315
  }
2086
2316
 
@@ -2091,6 +2321,25 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2091
2321
  * directory's manifest says `loop: false`) has to be OVERRIDABLE back to looping, and "no entry" cannot
2092
2322
  * express that — absence means "ask the catalog".
2093
2323
  */
2324
+ function cleanToon(raw) {
2325
+ var out = {};
2326
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
2327
+ Object.keys(raw).slice(0, 256).forEach(function (id) {
2328
+ if (id === '__proto__' || id === 'constructor' || id === 'prototype') return;
2329
+ var entry = raw[id];
2330
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return;
2331
+ var next = {};
2332
+ if (typeof entry.enabled === 'boolean') next.enabled = entry.enabled;
2333
+ var ranges = { shadingToonyFactor: [0, 1], shadingShiftFactor: [-1, 1], shadeStrength: [0, 1], outlineScale: [0, 3] };
2334
+ Object.keys(ranges).forEach(function (key) {
2335
+ var value = entry[key];
2336
+ if (typeof value === 'number' && Number.isFinite(value)) next[key] = clamp(value, ranges[key][0], ranges[key][1]);
2337
+ });
2338
+ if (Object.keys(next).length) out[id] = next;
2339
+ });
2340
+ return out;
2341
+ }
2342
+
2094
2343
  function cleanMotionLoop(raw) {
2095
2344
  var out = {};
2096
2345
  var source = raw && typeof raw === 'object' ? raw : {};
@@ -2480,8 +2729,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2480
2729
  // ---------------------------------------------------------------------------
2481
2730
 
2482
2731
  /** One state's status line, exactly as the renderer draws it: text, then the tag. */
2483
- function statusLineFor(preferences, state) {
2484
- var entry = statusEntryFor(preferences, state);
2732
+ function statusLineFor(preferences, state, language) {
2733
+ var entry = statusEntryFor(preferences, state, language);
2485
2734
  if (!entry) return '';
2486
2735
  var tag = typeof entry.tag === 'string' ? entry.tag : '';
2487
2736
  return (entry.text + tag).slice(0, STATUS_LIMITS.textLength + STATUS_LIMITS.tagLength);
@@ -2493,11 +2742,15 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2493
2742
  * Always an OBJECT, never null: every state has something to say or an explicit reason to say nothing,
2494
2743
  * and a caller that had to distinguish "unconfigured" from "configured empty" would get it wrong in the
2495
2744
  * one case that matters (a user who cleared a line).
2745
+ *
2746
+ * `language` picks WHICH shipped default fills an empty box — see {@link DEFAULT_STATUS_TEXT}. It is the third
2747
+ * parameter and optional, so a caller that does not know the surface's language keeps the Chinese default the
2748
+ * suites and the older call sites already pin.
2496
2749
  */
2497
- function statusEntryFor(preferences, state) {
2750
+ function statusEntryFor(preferences, state, language) {
2498
2751
  var map = preferences && preferences.status && typeof preferences.status === 'object' ? preferences.status : {};
2499
2752
  var stored = map[state] && typeof map[state] === 'object' ? map[state] : {};
2500
- var fallback = Object.prototype.hasOwnProperty.call(DEFAULT_STATUS_TEXT, state) ? DEFAULT_STATUS_TEXT[state] : '';
2753
+ var fallback = defaultStatusText(state, language);
2501
2754
  var text = typeof stored.text === 'string' ? stored.text : fallback;
2502
2755
  var fallbackAnimation = Object.prototype.hasOwnProperty.call(DEFAULT_STATUS_ANIMATION, state)
2503
2756
  ? DEFAULT_STATUS_ANIMATION[state]
@@ -3370,6 +3623,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3370
3623
  * @returns {string|null} the line to show, or null for silence
3371
3624
  */
3372
3625
  SpeechMachine.prototype.observe = function (preferences, state, pendingKind, language, random, motion) {
3626
+ if (state === 'dancing') {
3627
+ this.current = null;
3628
+ this.wantsAi = null;
3629
+ this.lastEvent = null;
3630
+ this.idleAt = null;
3631
+ this.chatterAt = null;
3632
+ return null;
3633
+ }
3373
3634
  var speech = cleanSpeech(preferences && preferences.speech);
3374
3635
  var t = this.now();
3375
3636
  if (!speech.enabled) {
@@ -3626,11 +3887,17 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3626
3887
  * clause is DROPPED — DSH's own token-free 「深度求索中」 rather than a number invented here. Passed per call so the
3627
3888
  * two surfaces (the app's pet and the floating window) can only differ by what they are told, never by the rule.
3628
3889
  *
3890
+ * `language` is the SIXTH parameter and it reaches two places: which shipped default an empty box falls back to
3891
+ * ({@link DEFAULT_STATUS_TEXT}), and the units a `{duration}` clause is written in — DSH writes 1分30秒 in one
3892
+ * language and 1m 30s in the other, so a line translated without its clock would read as a foreign number.
3893
+ * Optional, like the status-entry language above: a caller that has no language to pass keeps the Chinese answer
3894
+ * every existing suite pins.
3895
+ *
3629
3896
  * @returns {{state: string, text: string, tag: string, animation: string, seconds: number}|null}
3630
3897
  */
3631
- function statusFor(preferences, state, enteredAt, now, clock) {
3898
+ function statusFor(preferences, state, enteredAt, now, clock, language, completedCount) {
3632
3899
  if (STATUS_STATES.indexOf(state) === -1) return null;
3633
- var entry = statusEntryFor(preferences, state);
3900
+ var entry = statusEntryFor(preferences, state, language);
3634
3901
  if (!entry.text && !entry.tag) return null;
3635
3902
  var at = Number.isFinite(now) ? now : Date.now();
3636
3903
  /**
@@ -3683,7 +3950,10 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3683
3950
  }
3684
3951
  text = elapsed === null
3685
3952
  ? durationClauseRemoved(text)
3686
- : text.split(STATUS_DURATION_TOKEN).join(formatRunDuration(elapsed));
3953
+ : text.split(STATUS_DURATION_TOKEN).join(formatRunDuration(elapsed, language));
3954
+ }
3955
+ if (state === 'done' && Number(completedCount) > 1 && text) {
3956
+ text = language === 'en' ? (text === 'Task complete' ? 'All tasks complete' : 'All ' + text) : '全部' + text;
3687
3957
  }
3688
3958
  var look = statusTone(state);
3689
3959
  return {
@@ -4718,6 +4988,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4718
4988
  * @param {object} options url, intervalMs, fetchImpl, onPreview(preview|null), onUnsupported
4719
4989
  */
4720
4990
  function watchHostPreview(options) {
4991
+ var navigationSeen = null;
4721
4992
  var settings = options || {};
4722
4993
  var url = settings.url || '/vrmpet3d/preview';
4723
4994
  // Four hundred milliseconds: this drives a hover preview, so it has to feel immediate, and the route is a
@@ -4726,11 +4997,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4726
4997
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch : null);
4727
4998
  var timer = null;
4728
4999
  var seen = -1;
5000
+ var issued = 0;
5001
+ var delivered = 0;
4729
5002
  var supported = Boolean(doFetch);
4730
5003
  var stopped = false;
4731
5004
 
4732
5005
  function load() {
4733
5006
  if (!supported || stopped) return Promise.resolve(null);
5007
+ var request = ++issued;
4734
5008
  return doFetch(url, { credentials: 'omit' })
4735
5009
  .then(function (response) {
4736
5010
  if (response.status === 404 || response.status === 405) {
@@ -4742,6 +5016,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4742
5016
  return response.json ? response.json() : null;
4743
5017
  })
4744
5018
  .then(function (body) {
5019
+ if (request < delivered) return null;
5020
+ if (body && typeof body.seq === 'number') delivered = request;
5021
+ if (body && body.navigation && typeof body.navigation.seq === 'number') {
5022
+ var navigation = body.navigation;
5023
+ if (navigationSeen !== null && navigationSeen !== navigation.seq && typeof navigation.sessionId === 'string'
5024
+ && typeof settings.onNavigate === 'function') settings.onNavigate(navigation.sessionId);
5025
+ navigationSeen = navigation.seq;
5026
+ }
4745
5027
  if (!body || typeof body.seq !== 'number' || body.seq === seen) return null;
4746
5028
  seen = body.seq;
4747
5029
  var preview = cleanPreview(body.preview);
@@ -4879,11 +5161,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4879
5161
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch : null);
4880
5162
  var timer = null;
4881
5163
  var seen = -1;
5164
+ var issued = 0;
5165
+ var delivered = 0;
4882
5166
  var supported = Boolean(doFetch);
4883
5167
  var stopped = false;
4884
5168
 
4885
5169
  function load() {
4886
5170
  if (!supported || stopped) return Promise.resolve(null);
5171
+ var request = ++issued;
4887
5172
  return doFetch(url, { credentials: 'omit' })
4888
5173
  .then(function (response) {
4889
5174
  if (response.status === 404 || response.status === 405) {
@@ -4895,6 +5180,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4895
5180
  return response.json ? response.json() : null;
4896
5181
  })
4897
5182
  .then(function (body) {
5183
+ if (request < delivered) return null;
5184
+ if (body && typeof body.seq === 'number') delivered = request;
4898
5185
  if (!body || typeof body.seq !== 'number' || body.seq === seen) return null;
4899
5186
  seen = body.seq;
4900
5187
  var state = STATES.indexOf(body.state) === -1 ? null : body.state;
@@ -5004,6 +5291,12 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5004
5291
  }
5005
5292
  if (typeof why.scope === 'string' && why.scope) out.scope = why.scope.slice(0, 16);
5006
5293
  if (typeof why.session === 'string' && why.session) out.session = why.session.slice(0, 64);
5294
+ if (typeof why.statusSessionId === 'string' && why.statusSessionId) out.statusSessionId = why.statusSessionId.slice(0, 64);
5295
+ if (typeof why.completedSessionId === 'string' && why.completedSessionId) out.completedSessionId = why.completedSessionId.slice(0, 64);
5296
+ ['waitingSessionIds', 'completedSessionIds'].forEach(function (key) {
5297
+ if (Array.isArray(why[key])) out[key] = why[key].filter(function (id) { return typeof id === 'string' && id; })
5298
+ .slice(0, 256).map(function (id) { return id.slice(0, 64); });
5299
+ });
5007
5300
  if (typeof why.interaction === 'string' && why.interaction) out.interaction = why.interaction.slice(0, 16);
5008
5301
  if (Array.isArray(why.runningIds)) {
5009
5302
  out.runningIds = why.runningIds
@@ -5161,15 +5454,19 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5161
5454
  * its 进行中 line has to travel with the state. A whole number, or nothing — an older host that does not send it
5162
5455
  * leaves the window's dot as it was rather than inventing a count.
5163
5456
  */
5164
- var nextUnread = Number.isFinite(Number(unread)) && Number(unread) > 0 ? Math.floor(Number(unread)) : null;
5457
+ var nextUnread = typeof unread === 'number' && Number.isFinite(unread) && unread >= 0 ? Math.floor(unread) : null;
5165
5458
  /**
5166
5459
  * The diagnostic, kept OUT of the change key on purpose: it carries counts and clock readings that move
5167
5460
  * constantly, and letting them force a report would turn "the pet announces a change" into a 1 Hz feed. It
5168
5461
  * rides along with the reports the state and the line already earn, and the heartbeat re-states the last one.
5169
5462
  */
5170
5463
  var nextWhy = cleanWhy(why);
5464
+ var navigationKey = function (value) {
5465
+ return JSON.stringify(value ? [value.waitingSessionIds || [], value.completedSessionIds || [],
5466
+ value.statusSessionId || null, value.completedSessionId || null] : [[], [], null, null]);
5467
+ };
5171
5468
  if (force !== true && next === sent && nextLine === sentLine && nextTouchKey === sentTouchKey
5172
- && nextClockKey === sentClockKey && nextUnread === sentUnread) {
5469
+ && nextClockKey === sentClockKey && nextUnread === sentUnread && navigationKey(nextWhy) === navigationKey(sentWhy)) {
5173
5470
  return Promise.resolve(null);
5174
5471
  }
5175
5472
  sent = next;
@@ -5294,10 +5591,15 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5294
5591
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch : null);
5295
5592
  var timer = null;
5296
5593
  var seen = -1;
5594
+ var issued = 0;
5595
+ var delivered = 0;
5297
5596
  var supported = Boolean(doFetch);
5298
5597
  var stopped = false;
5299
5598
 
5300
- function deliver(snapshot) {
5599
+ function deliver(snapshot, request) {
5600
+ // A slow poll/write must not roll a live gesture back to an older reply.
5601
+ if (request < delivered) return null;
5602
+ if (snapshot && typeof snapshot.version === 'number') delivered = request;
5301
5603
  if (!snapshot || typeof snapshot.version !== 'number' || snapshot.version === seen) return null;
5302
5604
  seen = snapshot.version;
5303
5605
  if (typeof settings.onSnapshot === 'function') settings.onSnapshot(snapshot);
@@ -5307,6 +5609,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5307
5609
  /** One look at the host. Returns a promise for the snapshot it delivered, if any. */
5308
5610
  function load() {
5309
5611
  if (!supported || stopped) return Promise.resolve(null);
5612
+ var request = ++issued;
5310
5613
  return doFetch(url, { credentials: 'omit' })
5311
5614
  .then(function (response) {
5312
5615
  if (response.status === 404 || response.status === 405) {
@@ -5318,7 +5621,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5318
5621
  if (!response.ok) return null;
5319
5622
  return response.json ? response.json() : null;
5320
5623
  })
5321
- .then(deliver)
5624
+ .then(function (snapshot) { return deliver(snapshot, request); })
5322
5625
  .catch(function () { return null; });
5323
5626
  }
5324
5627
 
@@ -5330,6 +5633,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5330
5633
  */
5331
5634
  function push(body) {
5332
5635
  if (!supported || stopped) return Promise.resolve(null);
5636
+ var request = ++issued;
5333
5637
  return doFetch(url, {
5334
5638
  method: 'POST',
5335
5639
  credentials: 'omit',
@@ -5337,7 +5641,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5337
5641
  body: JSON.stringify(body || {}),
5338
5642
  })
5339
5643
  .then(function (response) { return response.ok ? response.json() : null; })
5340
- .then(deliver)
5644
+ .then(function (snapshot) { return deliver(snapshot, request); })
5341
5645
  .catch(function () { return null; });
5342
5646
  }
5343
5647
 
@@ -5550,6 +5854,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5550
5854
  * sits above the watermark and is adopted as usual.
5551
5855
  */
5552
5856
  this.dismissedNoticeAt = 0;
5857
+ this.dismissedSessions = new Map();
5553
5858
  /**
5554
5859
  * The TURN the pet is currently describing, and the one that just ended.
5555
5860
  *
@@ -5832,6 +6137,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5832
6137
  PetMachine.prototype.dismissNotice = function (keepDebt) {
5833
6138
  if (this.notice && Number.isFinite(this.notice.at)) {
5834
6139
  this.dismissedNoticeAt = Math.max(this.dismissedNoticeAt, this.notice.at);
6140
+ if (this.notice.sessionId) this.dismissedSessions.set(this.notice.sessionId, this.notice.at);
5835
6141
  }
5836
6142
  if (keepDebt !== true && this.notice && this.notice.sessionId) {
5837
6143
  delete this.retractions[this.notice.sessionId];
@@ -5954,7 +6260,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5954
6260
  * well as in the acknowledgement rule in `resolve`. (The floating window never takes focus, which is why the same
5955
6261
  * drag could not do this there.)
5956
6262
  */
5957
- if (this.notice && (!this.notice.sessionId || !presenceKnown) && this.interaction === null) {
6263
+ if (this.notice && !this.notice.sessionId && !presenceKnown && this.interaction === null) {
5958
6264
  this.noteDismissal('focus-return');
5959
6265
  this.dismissNotice();
5960
6266
  }
@@ -6066,6 +6372,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6066
6372
  // reports as its own `at`, so the minimum hold starts when the user can first see it.
6067
6373
  endedAt: at,
6068
6374
  settledAt: this.now(),
6375
+ confirmed: payload.confirmed === true,
6069
6376
  sessionId: sessionId,
6070
6377
  };
6071
6378
  }
@@ -6091,8 +6398,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6091
6398
  * than a startup that has not finished.
6092
6399
  */
6093
6400
  if (snap.connected === true) this.everConnected = true;
6094
- var busy = snap.available === true && (snap.running === true || snap.pending === true);
6401
+ var busy = snap.pending === true || (snap.available === true && snap.running === true);
6095
6402
  if (busy) this.lastBusyAt = t;
6403
+ var viewedCompletions = Array.isArray(snap.viewedCompletions) ? snap.viewedCompletions : [];
6404
+ if (this.pendingNotice && viewedCompletions.indexOf(this.pendingNotice.sessionId) !== -1) this.pendingNotice = undefined;
6405
+ if (this.notice && viewedCompletions.indexOf(this.notice.sessionId) !== -1) {
6406
+ this.noteDismissal('acknowledged');
6407
+ this.dismissNotice();
6408
+ }
6096
6409
 
6097
6410
  /**
6098
6411
  * Adopt the SNAPSHOT's own notice when it is newer than this machine's.
@@ -6111,9 +6424,11 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6111
6424
  * what the aggregate watches, and it falls between the rounds of one task exactly like a turn does.
6112
6425
  */
6113
6426
  if (snap.notice && typeof snap.notice.at === 'number'
6114
- && snap.notice.at > this.dismissedNoticeAt
6115
- && (!this.notice || snap.notice.at > this.notice.at)
6116
- && (!this.pendingNotice || snap.notice.at > this.pendingNotice.endedAt)) {
6427
+ && (snap.notice.queued === true ? (!this.notice && !this.pendingNotice
6428
+ && (!this.dismissedSessions.has(snap.notice.sessionId) || snap.notice.at > this.dismissedSessions.get(snap.notice.sessionId)))
6429
+ : (snap.notice.at > this.dismissedNoticeAt
6430
+ && (!this.notice || snap.notice.at > this.notice.at)
6431
+ && (!this.pendingNotice || snap.notice.at > this.pendingNotice.endedAt)))) {
6117
6432
  var snapSession = typeof snap.notice.sessionId === 'string' && snap.notice.sessionId ? snap.notice.sessionId : null;
6118
6433
  if (snap.notice.reason === 'error') {
6119
6434
  // A failure is not a completion and is never settled: it is shown at once, exactly as a `turn/end` failure is.
@@ -6123,7 +6438,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6123
6438
  this.pendingNotice = {
6124
6439
  reason: snap.notice.reason,
6125
6440
  endedAt: snap.notice.at,
6126
- settledAt: t,
6441
+ settledAt: snap.notice.queued === true ? snap.notice.at : t,
6442
+ confirmed: snap.notice.confirmed === true,
6127
6443
  // The session the notice is ABOUT. Without it there is nothing to come back to, and the notice falls
6128
6444
  // back to the older focus/timer rules below.
6129
6445
  sessionId: snapSession,
@@ -6139,6 +6455,9 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6139
6455
  * (a `turn/end` frame and the store's falling edge) are decided together.
6140
6456
  */
6141
6457
  if (this.pendingNotice) {
6458
+ if (snap.notice && snap.notice.sessionId === this.pendingNotice.sessionId && snap.notice.confirmed === true) {
6459
+ this.pendingNotice.confirmed = true;
6460
+ }
6142
6461
  var pendingSession = this.pendingNotice.sessionId;
6143
6462
  /**
6144
6463
  * Cancelled by WORK RESUMING, and only that — the ordering is the whole point.
@@ -6161,7 +6480,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6161
6480
  * never produced one.
6162
6481
  */
6163
6482
  this.pendingNotice = undefined;
6164
- } else if (t - this.pendingNotice.settledAt >= this.settleFor(this.pendingNotice.sessionId)) {
6483
+ } else if (this.pendingNotice.confirmed || t - this.pendingNotice.settledAt >= this.settleFor(this.pendingNotice.sessionId)) {
6165
6484
  this.notice = {
6166
6485
  reason: this.pendingNotice.reason,
6167
6486
  // Visible from NOW: the settle window was spent waiting, and a hold measured from the turn's end would
@@ -6237,8 +6556,10 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6237
6556
  */
6238
6557
  var unreadDropped = isFiniteNumber(this.unreadAtNotice) && this.unreadCount < this.unreadAtNotice;
6239
6558
  var neverUnread = this.unreadAtNotice === 0 && this.unreadCount === 0;
6240
- acknowledged = notice.sessionId && presentId
6241
- ? (presentId === notice.sessionId && this.focused === true)
6559
+ var viewed = Array.isArray(snap.viewedCompletions) && snap.viewedCompletions.indexOf(notice.sessionId) !== -1;
6560
+ acknowledged = notice.sessionId
6561
+ ? (viewed || ((!this.requireViewedAcknowledgement || notice.reason !== 'completed')
6562
+ && presentId === notice.sessionId && this.focused === true))
6242
6563
  : (notice.reason !== 'error' && this.focused === true
6243
6564
  && (unreadDropped || (neverUnread && this.interaction === null)));
6244
6565
  this.pendingDismissalKind = notice.sessionId && presentId ? 'acknowledged' : 'no-session';
@@ -6260,8 +6581,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6260
6581
  * still on screen would flip the pet to 出错 and hide the very prompt the user has to answer, and a
6261
6582
  * turn that completes would show 任务达成 over an unanswered approval.
6262
6583
  *
6263
- * So while the session reports a pending interaction, EVERY other branch is skipped except the two the
6264
- * user owns by hand (a drag, and a click they just made). When the interaction is answered the session
6584
+ * While the session reports a pending interaction, EVERY other state is skipped, including gestures
6585
+ * and greetings. When the interaction is answered the session
6265
6586
  * stops reporting it, this latch releases, and the same frame's cascade decides where the pet goes.
6266
6587
  */
6267
6588
  var pending = snap.pending === true;
@@ -6288,7 +6609,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6288
6609
  /**
6289
6610
  * 跳舞 — the user's own state, and the two rules that make it a good guest (operator, 2026-10-14).
6290
6611
  *
6291
- * It sits here, AFTER the drag states and BEFORE greeting/waiting/offline — so a drag still owns the pet while it happens —
6612
+ * Attention comes first, followed by drag states, dance, greeting and offline.
6292
6613
  * and it is dropped the moment the session is BUSY, which is the operator's 「跳舞也会被进入工作状态打断」 without a rule of its
6293
6614
  * own: work outranks it because the busy branch below is reached first once this field is cleared. A dance also ends when the
6294
6615
  * clip ends (the client watches the clip and calls `stopDancing`) or when the user double-clicks the pet, and both of those
@@ -6299,14 +6620,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6299
6620
  this.stopDancing();
6300
6621
  dance = null;
6301
6622
  }
6302
- if (this.interaction) {
6623
+ if (pending) {
6624
+ state = 'waiting';
6625
+ } else if (this.interaction) {
6303
6626
  state = this.interaction;
6304
6627
  } else if (dance) {
6305
6628
  state = 'dancing';
6306
6629
  } else if (t < this.greetingUntil) {
6307
6630
  state = 'greeting';
6308
- } else if (pending) {
6309
- state = 'waiting';
6310
6631
  } else if (snap.connected === false && this.everConnected) {
6311
6632
  /**
6312
6633
  * 离线 means the host is GONE — not that it has nothing to say yet.
@@ -6406,6 +6727,11 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6406
6727
  this.records = new Map();
6407
6728
  /** Each session's own `running` flag as the store last reported it (see `observeStoreEdge`). */
6408
6729
  this.storeRunning = new Map();
6730
+ this.completionChecks = new Map();
6731
+ this.attentionSessions = new Map();
6732
+ this.completionQueue = new Map();
6733
+ this.completionSeen = new Set();
6734
+ this.viewedCompletions = new Set();
6409
6735
  /**
6410
6736
  * When this aggregate's `running` answer last became true, and what it was last published as.
6411
6737
  *
@@ -6465,6 +6791,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6465
6791
  var status = statuses && statuses.get ? statuses.get(id) : undefined;
6466
6792
  var running = status && typeof status.running === 'boolean' ? status.running : undefined;
6467
6793
  if (running === undefined) return;
6794
+ if (running || status.pendingInteraction != null) self.completionChecks.delete(id);
6795
+ if (running) self.forgetCompletion(id);
6468
6796
  var previous = self.storeRunning.get(id);
6469
6797
  self.storeRunning.set(id, running);
6470
6798
  if (previous === running) return;
@@ -6488,13 +6816,18 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6488
6816
  * stream covers that case when it is wired, and a false 任务完成 on every boot is worse than a missed one.
6489
6817
  */
6490
6818
  if (previous !== true) return;
6819
+ var boundaryRecord = self.records.get(id);
6820
+ if (boundaryRecord && boundaryRecord.lastEndReason && boundaryRecord.lastEndReason !== 'completed') return;
6491
6821
  // Stopped: a completion. A failure already latched outranks it, the same rule the boundary path uses.
6822
+ var completion = { id: id + ':store', reason: 'completed', sessionId: id, at: self.now() };
6823
+ self.rememberCompletion(completion);
6492
6824
  if (self.notice && self.notice.reason === 'error') return;
6493
- self.notice = { id: id + ':store', reason: 'completed', sessionId: id, at: self.now() };
6825
+ self.notice = completion;
6494
6826
  });
6495
6827
  };
6496
6828
 
6497
6829
  SessionAggregate.prototype.reset = function (identities) {
6830
+ this.completionChecks.clear();
6498
6831
  this.records.clear();
6499
6832
  this.notice = undefined;
6500
6833
  if (Array.isArray(identities)) {
@@ -6508,6 +6841,34 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6508
6841
  }
6509
6842
  };
6510
6843
 
6844
+ SessionAggregate.prototype.rememberCompletion = function (notice, announce) {
6845
+ var id = notice && notice.sessionId;
6846
+ if (!id || this.completionSeen.has(id)) return;
6847
+ this.completionSeen.add(id);
6848
+ this.completionQueue.set(id, Object.assign({}, notice, {
6849
+ announce: announce !== false, queued: true, confirmed: announce !== false,
6850
+ }));
6851
+ this.completionChecks.set(id, this.now());
6852
+ };
6853
+
6854
+ SessionAggregate.prototype.forgetCompletion = function (id) {
6855
+ this.completionQueue.delete(id);
6856
+ this.completionChecks.delete(id);
6857
+ this.completionSeen.delete(id);
6858
+ this.viewedCompletions.delete(id);
6859
+ };
6860
+
6861
+ SessionAggregate.prototype.acknowledgeViewed = function (id) {
6862
+ if (!id || this.running(id) !== false) return;
6863
+ var status = this.input.statuses.get ? this.input.statuses.get(id) : null;
6864
+ if (!this.completionQueue.has(id) && !(status && status.completionUnread === true)) return;
6865
+ this.completionSeen.add(id);
6866
+ this.viewedCompletions.add(id);
6867
+ this.completionQueue.delete(id);
6868
+ this.completionChecks.delete(id);
6869
+ if (this.notice && this.notice.sessionId === id && this.notice.reason === 'completed') this.notice = undefined;
6870
+ };
6871
+
6511
6872
  SessionAggregate.prototype.boundary = function (event) {
6512
6873
  if (!event || typeof event !== 'object') return this.snapshot();
6513
6874
  var id = event.sessionId;
@@ -6534,12 +6895,16 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6534
6895
  // The host's live answer for a session it vouches for: every root it classified arrives as `false`.
6535
6896
  if (event.isSubagent === false) record.isSubagent = false;
6536
6897
  if (event.type === 'turn/start') {
6898
+ this.forgetCompletion(id);
6537
6899
  record.running = true;
6900
+ record.lastEndReason = null;
6901
+ this.completionChecks.delete(id);
6538
6902
  record.candidate = undefined;
6539
6903
  // A new turn supersedes whatever the last one reported, error included.
6540
6904
  this.notice = undefined;
6541
6905
  } else if (event.type === 'turn/end') {
6542
6906
  record.running = false;
6907
+ record.lastEndReason = event.reason;
6543
6908
  record.candidate = { id: id + ':' + event.seq, reason: event.reason, at: this.now() };
6544
6909
  }
6545
6910
  this.records.set(id, record);
@@ -6549,7 +6914,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6549
6914
  * a frame for a session nobody can vouch for — not classified, not listed by the app — is silence rather than a
6550
6915
  * 任务达成, because the pet has no way to tell it from a helper's turn ending (2026-10-14).
6551
6916
  */
6552
- if (candidate && (candidate.reason === 'completed' || record.running === false) && this.mainSession(id)) {
6917
+ if (candidate && (candidate.reason === 'completed' || candidate.reason === 'error') && this.mainSession(id)) {
6918
+ if (candidate.reason === 'completed') this.rememberCompletion({ id: candidate.id, reason: candidate.reason, sessionId: id, at: candidate.at });
6553
6919
  record.candidate = undefined;
6554
6920
  // A failure latches: a success arriving moments later must not erase the
6555
6921
  // fact that a turn just died. The machine owns the same rule, so the two
@@ -6570,6 +6936,19 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6570
6936
  };
6571
6937
 
6572
6938
  SessionAggregate.prototype.subagent = function (id) {
6939
+ var list = this.input.catalog || {};
6940
+ var row = list.byId && list.byId[id];
6941
+ // The current DSH store publishes origin and its parents' subagent catalogs, not isSubagent.
6942
+ // These explicit identities outrank an older boundary baseline that called the agent a root.
6943
+ if (row && (row.origin === 'subagent' || row.isSubagent === true)) return true;
6944
+ var parents = Object.keys(list.byId || {});
6945
+ for (var p = 0; p < parents.length; p += 1) {
6946
+ var parent = list.byId[parents[p]];
6947
+ var projection = list.projectionsBySession && list.projectionsBySession[parents[p]];
6948
+ var values = (projection && projection.values) || (parent && parent.projectionValues);
6949
+ var children = values && values.subagentCatalog;
6950
+ if (Array.isArray(children) && children.some(function (child) { return child && child.id === id; })) return true;
6951
+ }
6573
6952
  var record = this.records.get(id);
6574
6953
  if (record && record.isSubagent !== undefined) return record.isSubagent === true;
6575
6954
  var catalog = this.input.catalog && this.input.catalog.byId ? this.input.catalog.byId[id] : undefined;
@@ -6651,7 +7030,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6651
7030
  * only ever make the answer STRICTER on a store that has the list.
6652
7031
  */
6653
7032
  var listed = catalog.ids;
6654
- if (listed && typeof listed.length === 'number' && listed.length > 0) {
7033
+ if (listed && typeof listed.length === 'number') {
6655
7034
  for (var i = 0; i < listed.length; i += 1) {
6656
7035
  if (listed[i] === id) return true;
6657
7036
  }
@@ -6669,6 +7048,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6669
7048
 
6670
7049
  var workingCount = 0;
6671
7050
  var waitingCount = 0;
7051
+ var waitingSessionId = null;
6672
7052
  /**
6673
7053
  * How many sessions have a completion the user has NOT been back to.
6674
7054
  *
@@ -6685,6 +7065,9 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6685
7065
  * contributes nothing.
6686
7066
  */
6687
7067
  var unreadCount = 0;
7068
+ var completedUnreadCount = 0;
7069
+ var completedSessionId = null;
7070
+ var completedSessionIds = [];
6688
7071
  /**
6689
7072
  * WHY that count is what it is, as counts rather than ids or titles — added with the 绿点 fix of 2026-10-14, so
6690
7073
  * "the dot is lit and no session finished" is answerable from `/vrmpet3d/state` alone instead of by guesswork.
@@ -6700,9 +7083,32 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6700
7083
  /** Which pending interaction wins when several sessions are waiting at once. Approval is the rarer. */
6701
7084
  var waitingKind = null;
6702
7085
  var self = this;
7086
+ this.attentionSessions.forEach(function (_kind, id) {
7087
+ var status = statuses.get ? statuses.get(id) : undefined;
7088
+ // Navigating away, a missing report, or losing focus is not an answer to the problem.
7089
+ if (status) {
7090
+ if (status.pendingInteraction == null) self.attentionSessions.delete(id);
7091
+ else self.attentionSessions.set(id, pendingInteractionKind(status.pendingInteraction));
7092
+ }
7093
+ });
6703
7094
  ids.forEach(function (id) {
6704
7095
  if (!self.inScope(id)) return;
6705
7096
  var status = statuses.get ? statuses.get(id) : undefined;
7097
+ var record = self.records.get(id);
7098
+ if (status && status.completionUnread === true && self.mainSession(id) && !self.completionSeen.has(id)
7099
+ && self.running(id) === false && status.pendingInteraction == null) {
7100
+ self.rememberCompletion({ sessionId: id, reason: 'completed', at: self.now() }, false);
7101
+ }
7102
+ var completed = self.completionQueue.has(id) && !self.viewedCompletions.has(id) && self.mainSession(id)
7103
+ && self.running(id) === false && (!status || status.pendingInteraction == null)
7104
+ && !(record && record.lastEndReason && record.lastEndReason !== 'completed');
7105
+ if (!completed) self.completionChecks.delete(id);
7106
+ else {
7107
+ if (!self.completionChecks.has(id)) self.completionChecks.set(id, self.now());
7108
+ if (self.completionQueue.get(id).confirmed || self.now() - self.completionChecks.get(id) >= COMPLETION_SETTLE_MS) {
7109
+ completedSessionIds.push(id);
7110
+ }
7111
+ }
6706
7112
  if (status && status.completionUnread === true) {
6707
7113
  unreadDetail.candidates += 1;
6708
7114
  var entry = (catalog.byId || {})[id];
@@ -6725,14 +7131,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6725
7131
  }
6726
7132
  var pending = status ? status.pendingInteraction != null : false;
6727
7133
  if (pending) {
6728
- waitingCount++;
6729
- var kind = pendingInteractionKind(status.pendingInteraction);
6730
- // Ranked rather than first-wins: a global-scope pet watching ten sessions must not report
6731
- // "a question" just because that session happens to come out of the map first when another one
6732
- // is blocked on a permission prompt.
6733
- if (waitingKind === null || PENDING_KIND_RANK.indexOf(kind) < PENDING_KIND_RANK.indexOf(waitingKind)) {
6734
- waitingKind = kind;
6735
- }
7134
+ self.attentionSessions.set(id, pendingInteractionKind(status.pendingInteraction));
6736
7135
  }
6737
7136
  // One rule for "this is the user's own session", shared with the two completion paths (`mainSession`).
6738
7137
  if (self.mainSession(id) && self.running(id) === true) {
@@ -6743,7 +7142,23 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6743
7142
  }
6744
7143
  });
6745
7144
 
7145
+ this.attentionSessions.forEach(function (kind, id) {
7146
+ waitingCount++;
7147
+ if (waitingKind === null || PENDING_KIND_RANK.indexOf(kind) < PENDING_KIND_RANK.indexOf(waitingKind)) {
7148
+ waitingKind = kind;
7149
+ waitingSessionId = id;
7150
+ }
7151
+ });
7152
+ completedSessionIds.sort(function (a, b) { return self.completionQueue.get(a).at - self.completionQueue.get(b).at; });
7153
+ completedUnreadCount = completedSessionIds.length;
7154
+ completedSessionId = completedSessionIds[0] || null;
7155
+ var waitingSessionIds = Array.from(this.attentionSessions.keys()).sort(function (a, b) {
7156
+ return PENDING_KIND_RANK.indexOf(self.attentionSessions.get(a)) - PENDING_KIND_RANK.indexOf(self.attentionSessions.get(b));
7157
+ });
6746
7158
  var ready = catalog.phase === 'ready';
7159
+ self.completionChecks.forEach(function (_at, id) {
7160
+ if (!ids.has(id) || !self.inScope(id)) self.completionChecks.delete(id);
7161
+ });
6747
7162
 
6748
7163
  // Availability is "there is a session whose activity this pet could show".
6749
7164
  // In `current` scope that must be proved by the selected session actually
@@ -6756,7 +7171,12 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6756
7171
 
6757
7172
  var hasAnything = ready || workingCount > 0 || waitingCount > 0 || Boolean(this.notice);
6758
7173
  var available = this.input.connected === true && hasAnything && hasSelectedSession;
6759
- var notice = available && this.notice ? this.notice : undefined;
7174
+ var queuedNotice;
7175
+ this.completionQueue.forEach(function (entry, id) {
7176
+ if (!queuedNotice && entry.announce && self.mainSession(id) && self.inScope(id) && self.running(id) === false) queuedNotice = entry;
7177
+ });
7178
+ var notice = available ? (this.notice && this.notice.reason === 'error' ? this.notice
7179
+ : (queuedNotice || (this.notice && !this.viewedCompletions.has(this.notice.sessionId) ? this.notice : undefined))) : undefined;
6760
7180
  /**
6761
7181
  * `connected` rides along, because 离线 is decided by the CONNECTION and not by `available`.
6762
7182
  *
@@ -6812,6 +7232,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6812
7232
  pendingKind: waitingCount > 0 ? waitingKind : null,
6813
7233
  workingCount: workingCount,
6814
7234
  waitingCount: waitingCount,
7235
+ waitingSessionId: waitingSessionId,
7236
+ waitingSessionIds: waitingSessionIds,
6815
7237
  /**
6816
7238
  * Sessions whose completion is still UNREAD (see above), and whether there are any.
6817
7239
  *
@@ -6819,6 +7241,10 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6819
7241
  * surface could say more later without another plumbing round.
6820
7242
  */
6821
7243
  unreadCount: unreadCount,
7244
+ completedUnreadCount: completedUnreadCount,
7245
+ completedSessionId: completedSessionId,
7246
+ completedSessionIds: completedSessionIds,
7247
+ viewedCompletions: Array.from(this.viewedCompletions),
6822
7248
  hasUnread: unreadCount > 0,
6823
7249
  /** The breakdown behind that count — see `unreadDetail` where it is built. */
6824
7250
  unreadDetail: unreadDetail,
@@ -7299,6 +7725,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
7299
7725
  STATES: STATES,
7300
7726
  INTERACTION_STATES: INTERACTION_STATES,
7301
7727
  STATUS_STATES: STATUS_STATES,
7728
+ LANGUAGE_SETTINGS: LANGUAGE_SETTINGS,
7729
+ cleanLanguage: cleanLanguage,
7730
+ resolveLanguage: resolveLanguage,
7731
+ browserLanguageHints: browserLanguageHints,
7732
+ textFor: textFor,
7733
+ textOf: textOf,
7734
+ applyI18n: applyI18n,
7735
+ defaultStatusText: defaultStatusText,
7302
7736
  DEFAULT_STATUS_TEXT: DEFAULT_STATUS_TEXT,
7303
7737
  STATUS_TAGS: STATUS_TAGS,
7304
7738
  STATUS_ANIMATIONS: STATUS_ANIMATIONS,