let-them-talk 3.5.1 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +62 -21
- package/cli.js +16 -9
- package/conversation-templates/managed-team.json +12 -0
- package/dashboard.html +7389 -5720
- package/dashboard.js +2017 -1766
- package/mods/built-in-accessories.json +122 -0
- package/mods/registry.json +4 -0
- package/office/accessories.js +265 -0
- package/office/agents.js +376 -0
- package/office/animation.js +337 -0
- package/office/appearance.js +56 -0
- package/office/character.js +208 -0
- package/office/constants.js +62 -0
- package/office/environment.js +805 -0
- package/office/face.js +258 -0
- package/office/hair.js +183 -0
- package/office/index.js +337 -0
- package/office/mod-loader.js +257 -0
- package/office/monitors.js +113 -0
- package/office/outfits.js +212 -0
- package/office/scene.js +75 -0
- package/office/spectator-camera.js +177 -0
- package/office/state.js +25 -0
- package/package.json +58 -56
- package/server.js +2704 -2196
- package/templates/managed.json +26 -0
package/office/index.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
|
|
3
|
+
import { S } from './state.js';
|
|
4
|
+
import { DESK_POSITIONS, DRESSING_ROOM_POS, DRESSING_ROOM_ENTRANCE, REST_AREA_POS, REST_AREA_ENTRANCE } from './constants.js';
|
|
5
|
+
import { initScene } from './scene.js';
|
|
6
|
+
import { buildEnvironment, updateTVScreen } from './environment.js';
|
|
7
|
+
import { updateAgent } from './animation.js';
|
|
8
|
+
import { syncAgents, processMessages, walkTo, showBubble } from './agents.js';
|
|
9
|
+
// Side-effect: registers window.officeGetAppearance
|
|
10
|
+
import './appearance.js';
|
|
11
|
+
|
|
12
|
+
// Expose createCharacter + resolveAppearance for the character designer (Phase 3)
|
|
13
|
+
export { createCharacter } from './character.js';
|
|
14
|
+
export { resolveAppearance } from './appearance.js';
|
|
15
|
+
export { buildHair } from './hair.js';
|
|
16
|
+
export { buildFaceSprite } from './face.js';
|
|
17
|
+
export { buildGlasses, buildHeadwear, buildNeckwear } from './accessories.js';
|
|
18
|
+
export { buildOutfit, removeOutfit } from './outfits.js';
|
|
19
|
+
|
|
20
|
+
// ===================== RAYCASTER + COMMAND MENU =====================
|
|
21
|
+
var raycaster = new THREE.Raycaster();
|
|
22
|
+
var mouse = new THREE.Vector2();
|
|
23
|
+
var activeMenu = null; // { agentName, css2dObj, div, timeout }
|
|
24
|
+
var clickHandlerBound = null;
|
|
25
|
+
|
|
26
|
+
function setupClickHandler() {
|
|
27
|
+
if (clickHandlerBound) return;
|
|
28
|
+
// Track mouse-down position to distinguish clicks from camera drags
|
|
29
|
+
var downPos = { x: 0, y: 0 };
|
|
30
|
+
S.renderer.domElement.addEventListener('mousedown', function(e) {
|
|
31
|
+
downPos.x = e.clientX; downPos.y = e.clientY;
|
|
32
|
+
});
|
|
33
|
+
clickHandlerBound = function(event) {
|
|
34
|
+
if (!S.running || !S.renderer) return;
|
|
35
|
+
// Ignore if mouse moved more than 5px (it was a drag, not a click)
|
|
36
|
+
var dx = event.clientX - downPos.x, dy = event.clientY - downPos.y;
|
|
37
|
+
if (Math.sqrt(dx * dx + dy * dy) > 5) return;
|
|
38
|
+
|
|
39
|
+
var rect = S.renderer.domElement.getBoundingClientRect();
|
|
40
|
+
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
41
|
+
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
42
|
+
|
|
43
|
+
raycaster.setFromCamera(mouse, S.camera);
|
|
44
|
+
|
|
45
|
+
// Test all agent body meshes
|
|
46
|
+
var agentMeshes = [];
|
|
47
|
+
for (var name in S.agents3d) {
|
|
48
|
+
var agent = S.agents3d[name];
|
|
49
|
+
if (agent.dying) continue;
|
|
50
|
+
// Collect body-part meshes for intersection
|
|
51
|
+
agent.parts.group.traverse(function(child) {
|
|
52
|
+
if (child.isMesh && !child.userData.isShadow) {
|
|
53
|
+
child.userData._agentName = name;
|
|
54
|
+
agentMeshes.push(child);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var intersects = raycaster.intersectObjects(agentMeshes, false);
|
|
60
|
+
if (intersects.length > 0) {
|
|
61
|
+
var hitAgent = intersects[0].object.userData._agentName;
|
|
62
|
+
if (hitAgent && S.agents3d[hitAgent]) {
|
|
63
|
+
event.stopPropagation();
|
|
64
|
+
showCommandMenu(hitAgent);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Clicked nothing — dismiss menu
|
|
70
|
+
dismissCommandMenu();
|
|
71
|
+
};
|
|
72
|
+
S.renderer.domElement.addEventListener('click', clickHandlerBound);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function showCommandMenu(agentName) {
|
|
76
|
+
dismissCommandMenu();
|
|
77
|
+
var agent = S.agents3d[agentName];
|
|
78
|
+
if (!agent) return;
|
|
79
|
+
|
|
80
|
+
var loc = agent.location || 'desk';
|
|
81
|
+
var isWalking = agent.target !== null;
|
|
82
|
+
|
|
83
|
+
var div = document.createElement('div');
|
|
84
|
+
div.className = 'office3d-cmd-menu';
|
|
85
|
+
|
|
86
|
+
var commands = [
|
|
87
|
+
{ icon: '\uD83D\uDC57', label: 'Dressing Room', action: 'dressing_room', disabled: loc === 'dressing_room' || isWalking },
|
|
88
|
+
{ icon: '\uD83D\uDCA4', label: 'Go Rest', action: 'rest', disabled: loc === 'rest' || isWalking },
|
|
89
|
+
{ icon: '\uD83D\uDCBB', label: 'Back to Work', action: 'desk', disabled: loc === 'desk' || isWalking },
|
|
90
|
+
{ divider: true },
|
|
91
|
+
{ icon: '\u270F\uFE0F', label: 'Edit Profile', action: 'edit_profile', disabled: false },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
commands.forEach(function(cmd) {
|
|
95
|
+
if (cmd.divider) {
|
|
96
|
+
var d = document.createElement('div');
|
|
97
|
+
d.className = 'office3d-cmd-divider';
|
|
98
|
+
div.appendChild(d);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
var btn = document.createElement('button');
|
|
102
|
+
btn.className = 'office3d-cmd-btn' + (cmd.disabled ? ' disabled' : '');
|
|
103
|
+
btn.innerHTML = '<span class="office3d-cmd-icon">' + cmd.icon + '</span>' + cmd.label;
|
|
104
|
+
btn.addEventListener('click', function(e) {
|
|
105
|
+
e.stopPropagation();
|
|
106
|
+
dismissCommandMenu();
|
|
107
|
+
executeCommand(agentName, cmd.action);
|
|
108
|
+
});
|
|
109
|
+
div.appendChild(btn);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
var menuObj = new CSS2DObject(div);
|
|
113
|
+
menuObj.position.set(0, 2.1, 0);
|
|
114
|
+
agent.parts.group.add(menuObj);
|
|
115
|
+
|
|
116
|
+
activeMenu = {
|
|
117
|
+
agentName: agentName,
|
|
118
|
+
css2dObj: menuObj,
|
|
119
|
+
div: div,
|
|
120
|
+
timeout: setTimeout(dismissCommandMenu, 5000),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function dismissCommandMenu() {
|
|
125
|
+
if (!activeMenu) return;
|
|
126
|
+
var agent = S.agents3d[activeMenu.agentName];
|
|
127
|
+
if (agent) {
|
|
128
|
+
agent.parts.group.remove(activeMenu.css2dObj);
|
|
129
|
+
}
|
|
130
|
+
if (activeMenu.div.parentElement) activeMenu.div.remove();
|
|
131
|
+
clearTimeout(activeMenu.timeout);
|
|
132
|
+
activeMenu = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function executeCommand(agentName, action) {
|
|
136
|
+
var agent = S.agents3d[agentName];
|
|
137
|
+
if (!agent) return;
|
|
138
|
+
|
|
139
|
+
switch (action) {
|
|
140
|
+
case 'dressing_room':
|
|
141
|
+
agent.location = 'walking';
|
|
142
|
+
agent.isSitting = false;
|
|
143
|
+
showBubble(agent, 'Going to change!');
|
|
144
|
+
walkTo(agent, DRESSING_ROOM_ENTRANCE.x, DRESSING_ROOM_ENTRANCE.z, function() {
|
|
145
|
+
walkTo(agent, DRESSING_ROOM_POS.x, DRESSING_ROOM_POS.z, function() {
|
|
146
|
+
agent.location = 'dressing_room';
|
|
147
|
+
agent.isSitting = false;
|
|
148
|
+
showBubble(agent, 'Time for a new look!');
|
|
149
|
+
// Open character editor for this agent
|
|
150
|
+
window.editingAgent = agentName;
|
|
151
|
+
if (typeof window.openProfileEditor === 'function') {
|
|
152
|
+
window.openProfileEditor();
|
|
153
|
+
}
|
|
154
|
+
// Listen for editor close to return to desk
|
|
155
|
+
waitForEditorClose(agent);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
case 'rest':
|
|
161
|
+
agent.location = 'walking';
|
|
162
|
+
agent.isSitting = false;
|
|
163
|
+
showBubble(agent, 'Need a break...');
|
|
164
|
+
walkTo(agent, REST_AREA_ENTRANCE.x, REST_AREA_ENTRANCE.z, function() {
|
|
165
|
+
walkTo(agent, REST_AREA_POS.x, REST_AREA_POS.z, function() {
|
|
166
|
+
agent.location = 'rest';
|
|
167
|
+
agent.state = 'sleeping';
|
|
168
|
+
agent.isSitting = false;
|
|
169
|
+
showBubble(agent, 'Zzz...');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
break;
|
|
173
|
+
|
|
174
|
+
case 'desk':
|
|
175
|
+
agent.location = 'walking';
|
|
176
|
+
agent.state = 'active';
|
|
177
|
+
agent.isSitting = false;
|
|
178
|
+
showBubble(agent, 'Back to work!');
|
|
179
|
+
walkTo(agent, agent.deskPos.x, agent.deskPos.z + 0.7, function() {
|
|
180
|
+
agent.location = 'desk';
|
|
181
|
+
agent.registered = true;
|
|
182
|
+
});
|
|
183
|
+
break;
|
|
184
|
+
|
|
185
|
+
case 'edit_profile':
|
|
186
|
+
window.editingAgent = agentName;
|
|
187
|
+
if (typeof window.openProfileEditor === 'function') {
|
|
188
|
+
window.openProfileEditor();
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function waitForEditorClose(agent) {
|
|
195
|
+
// Poll for the character designer panel closing
|
|
196
|
+
var checkInterval = setInterval(function() {
|
|
197
|
+
var panel = document.getElementById('char-designer');
|
|
198
|
+
if (!panel || !panel.classList.contains('open')) {
|
|
199
|
+
clearInterval(checkInterval);
|
|
200
|
+
// Agent walks back to desk after editor closes
|
|
201
|
+
if (agent.location === 'dressing_room') {
|
|
202
|
+
agent.location = 'walking';
|
|
203
|
+
showBubble(agent, 'Looking good!');
|
|
204
|
+
walkTo(agent, DRESSING_ROOM_ENTRANCE.x, DRESSING_ROOM_ENTRANCE.z, function() {
|
|
205
|
+
walkTo(agent, agent.deskPos.x, agent.deskPos.z + 0.7, function() {
|
|
206
|
+
agent.location = 'desk';
|
|
207
|
+
agent.registered = true;
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}, 500);
|
|
213
|
+
// Safety: stop checking after 2 minutes
|
|
214
|
+
setTimeout(function() { clearInterval(checkInterval); }, 120000);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ===================== ANIMATION LOOP =====================
|
|
218
|
+
function animate() {
|
|
219
|
+
if (!S.running) return;
|
|
220
|
+
S.animationId = requestAnimationFrame(animate);
|
|
221
|
+
|
|
222
|
+
var dt = Math.min(S.clock.getDelta(), 0.1);
|
|
223
|
+
var time = S.clock.getElapsedTime();
|
|
224
|
+
|
|
225
|
+
for (var name in S.agents3d) {
|
|
226
|
+
updateAgent(S.agents3d[name], dt, time);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Update TV screen every ~0.5s for smooth ticker
|
|
230
|
+
if (!S._tvTimer) S._tvTimer = 0;
|
|
231
|
+
S._tvTimer += dt;
|
|
232
|
+
if (S._tvTimer >= 0.15) {
|
|
233
|
+
S._tvTimer = 0;
|
|
234
|
+
updateTVScreen(time);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
S.controls.update(dt);
|
|
238
|
+
S.renderer.render(S.scene, S.camera);
|
|
239
|
+
S.cssRenderer.render(S.scene, S.camera);
|
|
240
|
+
|
|
241
|
+
// FPS
|
|
242
|
+
S.fpsCounter++;
|
|
243
|
+
var now = performance.now();
|
|
244
|
+
if (now - S.fpsTime > 1000) {
|
|
245
|
+
var fpsEl = document.getElementById('office-fps');
|
|
246
|
+
if (fpsEl && window.activeView === 'office') fpsEl.textContent = S.fpsCounter + ' fps';
|
|
247
|
+
S.fpsCounter = 0;
|
|
248
|
+
S.fpsTime = now;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ===================== PUBLIC API =====================
|
|
253
|
+
window.office3dStart = function() {
|
|
254
|
+
if (S.running) return;
|
|
255
|
+
S.container = document.getElementById('office-3d-container');
|
|
256
|
+
if (!S.container) return;
|
|
257
|
+
|
|
258
|
+
if (!S.scene) {
|
|
259
|
+
if (!initScene()) return;
|
|
260
|
+
buildEnvironment();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!S.container.contains(S.renderer.domElement)) {
|
|
264
|
+
S.container.appendChild(S.renderer.domElement);
|
|
265
|
+
S.container.appendChild(S.cssRenderer.domElement);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
var w = S.container.clientWidth;
|
|
269
|
+
var h = S.container.clientHeight;
|
|
270
|
+
if (w > 0 && h > 0) {
|
|
271
|
+
S.camera.aspect = w / h;
|
|
272
|
+
S.camera.updateProjectionMatrix();
|
|
273
|
+
S.renderer.setSize(w, h);
|
|
274
|
+
S.cssRenderer.setSize(w, h);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
S.running = true;
|
|
278
|
+
S.clock.start();
|
|
279
|
+
S.lastProcessedMsg = 0;
|
|
280
|
+
syncAgents();
|
|
281
|
+
processMessages();
|
|
282
|
+
setupClickHandler();
|
|
283
|
+
animate();
|
|
284
|
+
|
|
285
|
+
if (S.syncInterval) clearInterval(S.syncInterval);
|
|
286
|
+
S.syncInterval = setInterval(function() {
|
|
287
|
+
if (S.running && window.activeView === 'office') {
|
|
288
|
+
syncAgents();
|
|
289
|
+
processMessages();
|
|
290
|
+
updateTVScreen(S.clock.getElapsedTime());
|
|
291
|
+
}
|
|
292
|
+
}, 2000);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
window.office3dStop = function() {
|
|
296
|
+
S.running = false;
|
|
297
|
+
if (S.animationId) {
|
|
298
|
+
cancelAnimationFrame(S.animationId);
|
|
299
|
+
S.animationId = null;
|
|
300
|
+
}
|
|
301
|
+
if (S.syncInterval) {
|
|
302
|
+
clearInterval(S.syncInterval);
|
|
303
|
+
S.syncInterval = null;
|
|
304
|
+
}
|
|
305
|
+
dismissCommandMenu();
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
window.office3dSetEnvironment = function(env) {
|
|
309
|
+
if (env === S.currentEnv) return;
|
|
310
|
+
S.currentEnv = env;
|
|
311
|
+
if (S.scene) {
|
|
312
|
+
buildEnvironment();
|
|
313
|
+
var i = 0;
|
|
314
|
+
for (var name in S.agents3d) {
|
|
315
|
+
var agent = S.agents3d[name];
|
|
316
|
+
if (i < DESK_POSITIONS.length) {
|
|
317
|
+
agent.deskIdx = i;
|
|
318
|
+
agent.deskPos = { x: DESK_POSITIONS[i].x, z: DESK_POSITIONS[i].z };
|
|
319
|
+
walkTo(agent, agent.deskPos.x, agent.deskPos.z + 0.7);
|
|
320
|
+
}
|
|
321
|
+
i++;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
window.office3dSetCamSpeed = function(speed) {
|
|
327
|
+
if (S.controls) S.controls.moveSpeed = speed;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Handle visibility change for 3D mode
|
|
331
|
+
document.addEventListener('visibilitychange', function() {
|
|
332
|
+
if (document.hidden && S.running) {
|
|
333
|
+
window.office3dStop();
|
|
334
|
+
} else if (!document.hidden && window.activeView === 'office' && window.officeMode === '3d') {
|
|
335
|
+
window.office3dStart();
|
|
336
|
+
}
|
|
337
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Mod loader — loads and validates GLB/GLTF community mods + built-in procedural items.
|
|
2
|
+
// GLB files contain ONLY geometry/textures/animations — NO executable code.
|
|
3
|
+
import * as THREE from 'three';
|
|
4
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
5
|
+
|
|
6
|
+
// Type limits for validation
|
|
7
|
+
const TYPE_LIMITS = {
|
|
8
|
+
accessory: { maxPolys: 500, maxBytes: 200 * 1024 },
|
|
9
|
+
hairstyle: { maxPolys: 800, maxBytes: 300 * 1024 },
|
|
10
|
+
outfit: { maxPolys: 1500, maxBytes: 500 * 1024 },
|
|
11
|
+
character: { maxPolys: 3000, maxBytes: 1024 * 1024 },
|
|
12
|
+
environment: { maxPolys: 10000, maxBytes: 2 * 1024 * 1024 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Manifest schema validation
|
|
16
|
+
const REQUIRED_FIELDS = ['id', 'name', 'version', 'author', 'type', 'category'];
|
|
17
|
+
const VALID_TYPES = ['accessory', 'hairstyle', 'outfit', 'character', 'environment'];
|
|
18
|
+
const ID_PATTERN = /^[a-z0-9_-]{1,40}$/;
|
|
19
|
+
|
|
20
|
+
// GLB magic bytes: 0x46546C67 ("glTF" in ASCII)
|
|
21
|
+
const GLB_MAGIC = 0x46546C67;
|
|
22
|
+
|
|
23
|
+
const modRegistry = {};
|
|
24
|
+
const loadedModels = {};
|
|
25
|
+
let builtInItems = [];
|
|
26
|
+
|
|
27
|
+
// Load built-in procedural item manifests
|
|
28
|
+
export async function loadBuiltInManifests() {
|
|
29
|
+
try {
|
|
30
|
+
var resp = await fetch('/mods/built-in-accessories.json');
|
|
31
|
+
if (resp.ok) {
|
|
32
|
+
builtInItems = await resp.json();
|
|
33
|
+
builtInItems.forEach(function(item) {
|
|
34
|
+
modRegistry[item.id] = item;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.warn('Could not load built-in mod manifests:', e);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getInstalledMods() {
|
|
43
|
+
return Object.values(modRegistry);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getModsByCategory(category) {
|
|
47
|
+
return Object.values(modRegistry).filter(function(m) { return m.category === category; });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getModsByType(type) {
|
|
51
|
+
return Object.values(modRegistry).filter(function(m) { return m.type === type; });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isModInstalled(id) {
|
|
55
|
+
return !!modRegistry[id];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getMod(id) {
|
|
59
|
+
return modRegistry[id] || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate manifest JSON
|
|
63
|
+
export function validateManifest(manifest) {
|
|
64
|
+
var errors = [];
|
|
65
|
+
|
|
66
|
+
// Required fields
|
|
67
|
+
for (var i = 0; i < REQUIRED_FIELDS.length; i++) {
|
|
68
|
+
if (!manifest[REQUIRED_FIELDS[i]]) {
|
|
69
|
+
errors.push('Missing required field: ' + REQUIRED_FIELDS[i]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ID format
|
|
74
|
+
if (manifest.id && !ID_PATTERN.test(manifest.id)) {
|
|
75
|
+
errors.push('Invalid id format: must be 1-40 chars of a-z, 0-9, _, -');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Type
|
|
79
|
+
if (manifest.type && !VALID_TYPES.includes(manifest.type)) {
|
|
80
|
+
errors.push('Invalid type: must be one of ' + VALID_TYPES.join(', '));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ID collision
|
|
84
|
+
if (manifest.id && modRegistry[manifest.id]) {
|
|
85
|
+
errors.push('ID collision: "' + manifest.id + '" already exists');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Asset definition
|
|
89
|
+
if (!manifest.asset || !manifest.asset.format) {
|
|
90
|
+
errors.push('Missing asset definition');
|
|
91
|
+
} else if (!['glb', 'gltf', 'procedural'].includes(manifest.asset.format)) {
|
|
92
|
+
errors.push('Invalid asset format: must be glb, gltf, or procedural');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { valid: errors.length === 0, errors: errors };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate GLB binary data (client-side, called after loading)
|
|
99
|
+
export function validateGLBBytes(arrayBuffer) {
|
|
100
|
+
if (arrayBuffer.byteLength < 12) {
|
|
101
|
+
return { valid: false, error: 'File too small to be a valid GLB' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
var view = new DataView(arrayBuffer);
|
|
105
|
+
var magic = view.getUint32(0, true);
|
|
106
|
+
if (magic !== GLB_MAGIC) {
|
|
107
|
+
return { valid: false, error: 'Invalid GLB magic bytes (not a GLB file)' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
var version = view.getUint32(4, true);
|
|
111
|
+
if (version !== 2) {
|
|
112
|
+
return { valid: false, error: 'Unsupported GLB version: ' + version + ' (expected 2)' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { valid: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Count polygons in a loaded GLTF scene
|
|
119
|
+
export function countPolygons(gltfScene) {
|
|
120
|
+
var totalPolys = 0;
|
|
121
|
+
gltfScene.traverse(function(child) {
|
|
122
|
+
if (child.isMesh && child.geometry) {
|
|
123
|
+
var geo = child.geometry;
|
|
124
|
+
if (geo.index) {
|
|
125
|
+
totalPolys += geo.index.count / 3;
|
|
126
|
+
} else if (geo.attributes.position) {
|
|
127
|
+
totalPolys += geo.attributes.position.count / 3;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return Math.floor(totalPolys);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check bounding box fits expected dimensions
|
|
135
|
+
export function checkBoundingBox(gltfScene, maxDim) {
|
|
136
|
+
var box = new THREE.Box3().setFromObject(gltfScene);
|
|
137
|
+
var size = box.getSize(new THREE.Vector3());
|
|
138
|
+
var max = Math.max(size.x, size.y, size.z);
|
|
139
|
+
if (max > maxDim) {
|
|
140
|
+
return { valid: false, error: 'Model too large: ' + max.toFixed(2) + ' > ' + maxDim + ' max dimension' };
|
|
141
|
+
}
|
|
142
|
+
return { valid: true, size: size };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Sanitize materials (cap emissive, force roughness minimum)
|
|
146
|
+
export function sanitizeMaterials(gltfScene) {
|
|
147
|
+
gltfScene.traverse(function(child) {
|
|
148
|
+
if (child.isMesh && child.material) {
|
|
149
|
+
var mats = Array.isArray(child.material) ? child.material : [child.material];
|
|
150
|
+
mats.forEach(function(mat) {
|
|
151
|
+
if (mat.emissiveIntensity > 2) mat.emissiveIntensity = 2;
|
|
152
|
+
if (mat.roughness !== undefined && mat.roughness < 0.1) mat.roughness = 0.1;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Load a GLB mod and validate it
|
|
159
|
+
export async function loadGLBMod(manifest) {
|
|
160
|
+
if (manifest.asset.format !== 'glb' || !manifest.asset.file) {
|
|
161
|
+
return { success: false, error: 'Not a GLB mod or missing file path' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
var limits = TYPE_LIMITS[manifest.type] || TYPE_LIMITS.accessory;
|
|
165
|
+
var url = '/mods/' + manifest.id + '/' + manifest.asset.file;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Fetch raw bytes for magic check
|
|
169
|
+
var resp = await fetch(url);
|
|
170
|
+
if (!resp.ok) throw new Error('Failed to fetch: ' + resp.status);
|
|
171
|
+
var buffer = await resp.arrayBuffer();
|
|
172
|
+
|
|
173
|
+
// Size check
|
|
174
|
+
if (buffer.byteLength > limits.maxBytes) {
|
|
175
|
+
return { success: false, error: 'File too large: ' + (buffer.byteLength / 1024).toFixed(0) + 'KB > ' + (limits.maxBytes / 1024) + 'KB limit' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Magic bytes check
|
|
179
|
+
var bytesCheck = validateGLBBytes(buffer);
|
|
180
|
+
if (!bytesCheck.valid) return { success: false, error: bytesCheck.error };
|
|
181
|
+
|
|
182
|
+
// Parse with Three.js GLTFLoader
|
|
183
|
+
var loader = new GLTFLoader();
|
|
184
|
+
var gltf = await new Promise(function(resolve, reject) {
|
|
185
|
+
loader.parse(buffer, '', resolve, reject);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Poly count check
|
|
189
|
+
var polyCount = countPolygons(gltf.scene);
|
|
190
|
+
if (polyCount > limits.maxPolys) {
|
|
191
|
+
return { success: false, error: 'Too many polygons: ' + polyCount + ' > ' + limits.maxPolys + ' limit' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Bounding box check (max dimension based on type)
|
|
195
|
+
var maxDims = { accessory: 1, hairstyle: 1, outfit: 2, character: 3, environment: 30 };
|
|
196
|
+
var bbCheck = checkBoundingBox(gltf.scene, maxDims[manifest.type] || 1);
|
|
197
|
+
if (!bbCheck.valid) return { success: false, error: bbCheck.error };
|
|
198
|
+
|
|
199
|
+
// Sanitize materials
|
|
200
|
+
sanitizeMaterials(gltf.scene);
|
|
201
|
+
|
|
202
|
+
// Store loaded model
|
|
203
|
+
loadedModels[manifest.id] = gltf;
|
|
204
|
+
|
|
205
|
+
return { success: true, gltf: gltf, polyCount: polyCount, size: bbCheck.size };
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return { success: false, error: 'Failed to load GLB: ' + e.message };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Get a loaded GLB model's scene (clone for instancing)
|
|
212
|
+
export function getModModel(id) {
|
|
213
|
+
if (!loadedModels[id]) return null;
|
|
214
|
+
return loadedModels[id].scene.clone();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Register a mod into the registry
|
|
218
|
+
export function registerMod(manifest) {
|
|
219
|
+
modRegistry[manifest.id] = manifest;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Unregister a mod
|
|
223
|
+
export function unregisterMod(id) {
|
|
224
|
+
delete modRegistry[id];
|
|
225
|
+
if (loadedModels[id]) {
|
|
226
|
+
// Dispose loaded model
|
|
227
|
+
loadedModels[id].scene.traverse(function(child) {
|
|
228
|
+
if (child.geometry) child.geometry.dispose();
|
|
229
|
+
if (child.material) {
|
|
230
|
+
if (Array.isArray(child.material)) child.material.forEach(function(m) { m.dispose(); });
|
|
231
|
+
else child.material.dispose();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
delete loadedModels[id];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Initialize: load registry + built-in manifests
|
|
239
|
+
export async function initModSystem() {
|
|
240
|
+
await loadBuiltInManifests();
|
|
241
|
+
|
|
242
|
+
// Load community registry
|
|
243
|
+
try {
|
|
244
|
+
var resp = await fetch('/api/mods');
|
|
245
|
+
if (resp.ok) {
|
|
246
|
+
var data = await resp.json();
|
|
247
|
+
var mods = data.mods || {};
|
|
248
|
+
for (var id in mods) {
|
|
249
|
+
if (!modRegistry[id]) {
|
|
250
|
+
modRegistry[id] = mods[id];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.warn('Could not load mod registry:', e);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { S } from './state.js';
|
|
3
|
+
|
|
4
|
+
export function updateMonitorScreen(deskIdx, agentName, time) {
|
|
5
|
+
var desk = S.deskMeshes[deskIdx];
|
|
6
|
+
if (!desk) return;
|
|
7
|
+
var W = 256, H = 160;
|
|
8
|
+
if (!S.monitorCanvases[deskIdx]) {
|
|
9
|
+
var cvs = document.createElement('canvas');
|
|
10
|
+
cvs.width = W; cvs.height = H;
|
|
11
|
+
S.monitorCanvases[deskIdx] = { canvas: cvs, texture: new THREE.CanvasTexture(cvs) };
|
|
12
|
+
S.monitorCanvases[deskIdx].texture.minFilter = THREE.LinearFilter;
|
|
13
|
+
desk.screen.material = new THREE.MeshStandardMaterial({
|
|
14
|
+
map: S.monitorCanvases[deskIdx].texture,
|
|
15
|
+
emissive: 0x58a6ff, emissiveIntensity: 0.3, roughness: 0.2
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
var mc = S.monitorCanvases[deskIdx];
|
|
19
|
+
var ctx = mc.canvas.getContext('2d');
|
|
20
|
+
|
|
21
|
+
// Terminal background
|
|
22
|
+
ctx.fillStyle = '#0c1021';
|
|
23
|
+
ctx.fillRect(0, 0, W, H);
|
|
24
|
+
|
|
25
|
+
// Title bar
|
|
26
|
+
ctx.fillStyle = '#1a1f36';
|
|
27
|
+
ctx.fillRect(0, 0, W, 14);
|
|
28
|
+
ctx.fillStyle = '#ff5f57'; ctx.beginPath(); ctx.arc(8, 7, 3, 0, Math.PI * 2); ctx.fill();
|
|
29
|
+
ctx.fillStyle = '#ffbd2e'; ctx.beginPath(); ctx.arc(18, 7, 3, 0, Math.PI * 2); ctx.fill();
|
|
30
|
+
ctx.fillStyle = '#28c840'; ctx.beginPath(); ctx.arc(28, 7, 3, 0, Math.PI * 2); ctx.fill();
|
|
31
|
+
ctx.fillStyle = '#8892b0';
|
|
32
|
+
ctx.font = '9px monospace';
|
|
33
|
+
ctx.fillText(agentName + ' \u2014 terminal', 40, 11);
|
|
34
|
+
|
|
35
|
+
// Gather real data
|
|
36
|
+
var history = window.cachedHistory || [];
|
|
37
|
+
var agentInfo = (window.cachedAgents || {})[agentName] || {};
|
|
38
|
+
var lines = [];
|
|
39
|
+
|
|
40
|
+
var statusColor = agentInfo.status === 'active' ? '#28c840' : '#ffbd2e';
|
|
41
|
+
lines.push({ color: '#546178', text: '$ agent status' });
|
|
42
|
+
lines.push({ color: statusColor, text: ' ' + (agentInfo.status || 'unknown').toUpperCase() + (agentInfo.is_listening ? ' (listening)' : ' (working)') });
|
|
43
|
+
lines.push({ color: '#546178', text: '' });
|
|
44
|
+
|
|
45
|
+
var sent = 0, recv = 0;
|
|
46
|
+
for (var i = 0; i < history.length; i++) {
|
|
47
|
+
if (history[i].from === agentName) sent++;
|
|
48
|
+
if (history[i].to === agentName) recv++;
|
|
49
|
+
}
|
|
50
|
+
lines.push({ color: '#546178', text: '$ stats' });
|
|
51
|
+
lines.push({ color: '#79c0ff', text: ' sent: ' + sent + ' recv: ' + recv + ' total: ' + history.length });
|
|
52
|
+
lines.push({ color: '#546178', text: '' });
|
|
53
|
+
|
|
54
|
+
lines.push({ color: '#546178', text: '$ tail -f messages.jsonl' });
|
|
55
|
+
var agentMsgs = [];
|
|
56
|
+
for (var j = history.length - 1; j >= 0 && agentMsgs.length < 4; j--) {
|
|
57
|
+
var m = history[j];
|
|
58
|
+
if (m.from === agentName || m.to === agentName) agentMsgs.unshift(m);
|
|
59
|
+
}
|
|
60
|
+
for (var k = 0; k < agentMsgs.length; k++) {
|
|
61
|
+
var msg = agentMsgs[k];
|
|
62
|
+
var isSent = msg.from === agentName;
|
|
63
|
+
var prefix = isSent ? ' > ' : ' < ';
|
|
64
|
+
var peer = isSent ? (msg.to || 'all') : msg.from;
|
|
65
|
+
var snippet = (msg.content || msg.message || '').substring(0, 28);
|
|
66
|
+
if ((msg.content || msg.message || '').length > 28) snippet += '..';
|
|
67
|
+
lines.push({ color: isSent ? '#7ee787' : '#d2a8ff', text: prefix + peer + ': ' + snippet });
|
|
68
|
+
}
|
|
69
|
+
if (agentMsgs.length === 0) {
|
|
70
|
+
lines.push({ color: '#3d4663', text: ' (no messages yet)' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
var showCursor = Math.floor(time * 2) % 2 === 0;
|
|
74
|
+
|
|
75
|
+
ctx.font = '9px monospace';
|
|
76
|
+
var lineY = 24;
|
|
77
|
+
var maxLines = Math.floor((H - 24) / 11);
|
|
78
|
+
var startLine = Math.max(0, lines.length - maxLines);
|
|
79
|
+
for (var r = startLine; r < lines.length; r++) {
|
|
80
|
+
ctx.fillStyle = lines[r].color;
|
|
81
|
+
ctx.fillText(lines[r].text, 4, lineY);
|
|
82
|
+
lineY += 11;
|
|
83
|
+
}
|
|
84
|
+
if (showCursor) {
|
|
85
|
+
ctx.fillStyle = '#58a6ff';
|
|
86
|
+
ctx.fillText('$ _', 4, lineY);
|
|
87
|
+
} else {
|
|
88
|
+
ctx.fillStyle = '#58a6ff';
|
|
89
|
+
ctx.fillText('$', 4, lineY);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Scanline effect
|
|
93
|
+
ctx.fillStyle = 'rgba(0,0,0,0.03)';
|
|
94
|
+
for (var sl = 0; sl < H; sl += 2) {
|
|
95
|
+
ctx.fillRect(0, sl, W, 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
mc.texture.needsUpdate = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function setMonitorDim(deskIdx) {
|
|
102
|
+
var desk = S.deskMeshes[deskIdx];
|
|
103
|
+
if (!desk) return;
|
|
104
|
+
if (S.monitorCanvases[deskIdx]) {
|
|
105
|
+
S.monitorCanvases[deskIdx].texture.dispose();
|
|
106
|
+
if (desk.screen.material !== desk.screenMat) desk.screen.material.dispose();
|
|
107
|
+
delete S.monitorCanvases[deskIdx];
|
|
108
|
+
}
|
|
109
|
+
desk.screen.material = desk.screenMat;
|
|
110
|
+
desk.screenMat.emissive.setHex(0x1a2744);
|
|
111
|
+
desk.screenMat.emissiveIntensity = 0.15;
|
|
112
|
+
desk.screenMat.color.setHex(0x1a2744);
|
|
113
|
+
}
|