@7ots/cli 0.1.6 → 0.1.8

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.
Files changed (61) hide show
  1. package/README.md +3 -3
  2. package/cli/7ots.mjs +13 -1
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +107 -38
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +5 -1
  7. package/cli/lib/inferences.mjs +30 -6
  8. package/cli/lib/integrations.mjs +69 -5
  9. package/cli/lib/orquesta.mjs +44 -1
  10. package/cli/lib/pet-server.mjs +352 -11
  11. package/cli/lib/prompts.mjs +31 -17
  12. package/cli/lib/prowl.mjs +110 -0
  13. package/cli/pet/electron/director.cjs +37 -5
  14. package/cli/pet/electron/main.cjs +92 -10
  15. package/cli/pet/pet.html +149 -10
  16. package/cli/pet/settings.html +549 -43
  17. package/dist/7ots.esm.js +216 -137
  18. package/dist/7ots.esm.js.map +4 -4
  19. package/dist/7ots.iife.js +216 -137
  20. package/dist/7ots.iife.js.map +4 -4
  21. package/llms.txt +1 -1
  22. package/package.json +17 -14
  23. package/server/channels/agent.mjs +7 -1
  24. package/server/channels/index.mjs +3 -2
  25. package/server/platform/connect.mjs +4 -1
  26. package/server/platform/db.mjs +14 -1
  27. package/server/platform/device.mjs +101 -4
  28. package/server/platform/mods.mjs +222 -0
  29. package/server/platform/orquesta.mjs +78 -14
  30. package/server/platform/profile.mjs +161 -0
  31. package/server/platform/routes.mjs +39 -33
  32. package/server/platform/runtime.mjs +5 -3
  33. package/server/platform/store.mjs +1 -1
  34. package/server/platform/vm.mjs +688 -17
  35. package/server/server.mjs +12 -2
  36. package/server/tts.mjs +17 -4
  37. package/src/avatar/three.js +3 -2
  38. package/src/character/AvatarEditor.js +95 -7
  39. package/src/character/Character.js +116 -17
  40. package/src/character/TradingCard.js +226 -0
  41. package/src/character/mods/index.js +4 -0
  42. package/src/character/mods/orc.js +64 -0
  43. package/src/character/mods.js +251 -0
  44. package/src/character/more/acc-head.js +213 -0
  45. package/src/character/more/acc-rest.js +342 -0
  46. package/src/character/more/brows.js +39 -0
  47. package/src/character/more/cheeks.js +78 -0
  48. package/src/character/more/eyes.js +288 -0
  49. package/src/character/more/mouths.js +259 -0
  50. package/src/character/more/patterns.js +120 -0
  51. package/src/character/parts.js +100 -0
  52. package/src/character/skin.js +166 -1
  53. package/src/character/styles.js +1285 -9
  54. package/src/character3d/Character3D.js +253 -37
  55. package/src/character3d/index.js +7 -1
  56. package/src/character3d/model.js +367 -0
  57. package/src/i18n/messages/character.js +37 -22
  58. package/src/i18n/messages/platform.js +33 -0
  59. package/src/identity/random.js +17 -3
  60. package/src/identity/schema.js +10 -0
  61. package/src/index.js +1 -0
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Mod de cuerpo completo: un modelo 3D (VRM o GLB) en lugar del cuerpo procedural del ot.
3
+ *
4
+ * El modelo vive dentro del mismo `rig` de Character3D, así que gestos sin animación propia,
5
+ * efectos, ánimos y el encuadre siguen funcionando. Lo que el modelo traiga se aprovecha:
6
+ * - VRM (0.x y 1.0): expresiones estándar (aa/ih/ou/ee/oh, blink, happy, angry, sad,
7
+ * relaxed, surprised) y el hueso de la cabeza para mirar.
8
+ * - GLB: morph targets por nombre (blink, jawOpen/mouthOpen, happy/smile, sad, angry,
9
+ * surprised) y un hueso «head» si lo hay.
10
+ * - Animaciones: «idle» en bucle; las que se llamen como un gesto (wave, dance, yes, no,
11
+ * jump, thumbsup…) sustituyen a la pose procedural de ese gesto.
12
+ * Unidades: el modelo se escala a la altura de un ot (su caja), con y hacia arriba y de frente a +z
13
+ * (los VRM 0.x miran a -z y se giran solos).
14
+ */
15
+
16
+ import * as THREE from 'three';
17
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
18
+ import { layout } from '../character/Character.js';
19
+ import { EASE, clamp, approach } from '../character/motion.js';
20
+ import { U } from './body.js';
21
+ import { shadowTexture } from './materials.js';
22
+
23
+ const DEG = Math.PI / 180;
24
+ const TAU = Math.PI * 2;
25
+
26
+ /** Animación del modelo para cada gesto de 7ots (por nombre del clip). */
27
+ const CLIP_FOR = {
28
+ wave: /wave|hello|greet|hi\b/i,
29
+ dance: /dance/i,
30
+ celebrate: /victory|celebrat|cheer|dance/i,
31
+ jump: /jump/i,
32
+ bounce: /jump|hop/i,
33
+ nod: /\byes\b|nod/i,
34
+ shake: /^no$|\bno\b|shake_?head/i,
35
+ thumbsup: /thumbs?_?up|\byes\b/i,
36
+ thumbsdown: /thumbs?_?down|\bno\b/i,
37
+ bow: /bow/i,
38
+ think: /think/i,
39
+ clap: /clap/i,
40
+ point: /point/i,
41
+ laugh: /laugh/i,
42
+ stomp: /stomp|punch/i,
43
+ shrug: /shrug/i,
44
+ dizzy: /dizz|death|die/i,
45
+ yawn: /yawn|sit/i,
46
+ fly: /fly/i,
47
+ };
48
+ const IDLE = /idle|breath|stand/i;
49
+
50
+ /** Nombre de expresión VRM 0.x → VRM 1.0. */
51
+ const VRM0 = { a: 'aa', i: 'ih', u: 'ou', e: 'ee', o: 'oh', joy: 'happy', sorrow: 'sad', fun: 'relaxed', angry: 'angry', blink: 'blink', surprised: 'surprised' };
52
+ /** Morph targets de un GLB cualquiera → expresión. */
53
+ const MORPH_NAMES = [
54
+ ['aa', /jaw_?open|mouth_?open|viseme_?aa|^v_?aa$|^aa$|^a$/i],
55
+ ['oh', /viseme_?(oh|o|ou)$|^oh$|^o$|mouth_?(funnel|pucker)/i],
56
+ ['blink', /^blink$|blink_?both|eyes?_?clos|^eyeblink$|^blink/i],
57
+ ['happy', /happy|joy|smile/i],
58
+ ['sad', /sad|sorrow/i],
59
+ ['angry', /angry|anger|\bmad\b/i],
60
+ ['surprised', /surpris|shock/i],
61
+ ];
62
+ /** Ánimo de 7ots → expresiones del modelo (con su peso). */
63
+ const MOOD_EXPR = {
64
+ happy: { happy: 0.8 },
65
+ love: { happy: 0.7, relaxed: 0.3 },
66
+ angry: { angry: 0.85 },
67
+ disgust: { angry: 0.5 },
68
+ sad: { sad: 0.85 },
69
+ fear: { surprised: 0.5, sad: 0.3 },
70
+ surprised: { surprised: 0.9 },
71
+ sleep: { relaxed: 0.6 },
72
+ };
73
+
74
+ function loadGltf(url) {
75
+ return new Promise((res, rej) => {
76
+ const loader = new GLTFLoader();
77
+ if (url.startsWith('data:')) {
78
+ const bin = atob(url.slice(url.indexOf(',') + 1));
79
+ const buf = new Uint8Array(bin.length);
80
+ for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
81
+ loader.parse(buf.buffer, '', res, rej);
82
+ } else loader.load(url, res, undefined, rej);
83
+ });
84
+ }
85
+
86
+ const meshesUnder = (obj) => {
87
+ const out = [];
88
+ obj.traverse((o) => { if (o.isMesh && o.morphTargetInfluences) out.push(o); });
89
+ return out;
90
+ };
91
+
92
+ /** Expresiones del modelo: { nombre: [{ mesh, index, w }] }. */
93
+ async function expressionsOf(gltf) {
94
+ const ex = {};
95
+ const add = (name, mesh, index, w) => { if (Number.isInteger(index)) (ex[name] ||= []).push({ mesh, index, w }); };
96
+ const { parser } = gltf;
97
+ const ext = parser.json.extensions || {};
98
+ if (ext.VRMC_vrm) {
99
+ const all = { ...ext.VRMC_vrm.expressions?.preset, ...ext.VRMC_vrm.expressions?.custom };
100
+ for (const [name, e] of Object.entries(all)) {
101
+ for (const b of e?.morphTargetBinds || []) {
102
+ const node = await parser.getDependency('node', b.node).catch(() => null);
103
+ if (node) for (const m of meshesUnder(node)) add(name, m, b.index, b.weight ?? 1);
104
+ }
105
+ }
106
+ } else if (ext.VRM) {
107
+ const byMesh = new Map();
108
+ gltf.scene.traverse((o) => {
109
+ const a = parser.associations.get(o);
110
+ if (o.isMesh && a?.meshes != null) (byMesh.get(a.meshes) || byMesh.set(a.meshes, []).get(a.meshes)).push(o);
111
+ });
112
+ for (const g of ext.VRM.blendShapeMaster?.blendShapeGroups || []) {
113
+ const name = VRM0[String(g.presetName || '').toLowerCase()];
114
+ if (!name) continue;
115
+ for (const b of g.binds || []) for (const m of byMesh.get(b.mesh) || []) add(name, m, b.index, (b.weight ?? 100) / 100);
116
+ }
117
+ } else {
118
+ gltf.scene.traverse((o) => {
119
+ if (!o.isMesh || !o.morphTargetDictionary) return;
120
+ for (const [key, index] of Object.entries(o.morphTargetDictionary)) {
121
+ const hit = MORPH_NAMES.find(([, re]) => re.test(key));
122
+ if (hit) add(hit[0], o, index, 1);
123
+ }
124
+ });
125
+ }
126
+ return ex;
127
+ }
128
+
129
+ /** Hueso de la cabeza (VRM humanoid, o el primer hueso «head»). */
130
+ async function headOf(gltf) {
131
+ const { parser } = gltf;
132
+ const ext = parser.json.extensions || {};
133
+ const idx = ext.VRMC_vrm?.humanoid?.humanBones?.head?.node ?? ext.VRM?.humanoid?.humanBones?.find((b) => b.bone === 'head')?.node;
134
+ if (idx != null) return parser.getDependency('node', idx).catch(() => null);
135
+ let head = null;
136
+ const re = /^(mixamorig:?)?head$|^head[_\d]*$/i;
137
+ gltf.scene.traverse((o) => { if (!head && o.isBone && re.test(o.name)) head = o; });
138
+ // sin esqueleto: un nodo «head» rígido (modelos hechos por partes) también sirve
139
+ if (!head) gltf.scene.traverse((o) => { if (!head && !o.isMesh && re.test(o.name)) head = o; });
140
+ return head;
141
+ }
142
+
143
+ /**
144
+ * Construye el ot como modelo (llamado desde Character3D.build con la ficha ya resuelta).
145
+ * `this` es la instancia de Character3D.
146
+ */
147
+ export function buildModel(s) {
148
+ const m = s._model;
149
+ const L = (this.L = layout(s));
150
+ const W = (this.W = L.box.w * U);
151
+ const H = (this.H = Math.max(L.box.h * U, W) * 1.2);
152
+ this.modelMode = true;
153
+ this.kk = Math.sqrt(W * H) / 1.25;
154
+ this.depth = W * 0.6;
155
+ this.furLen = 0;
156
+ this.plush = false;
157
+ this.maxShells = 0;
158
+ this.pal = { body: s.body.color };
159
+ this.furs = [];
160
+ this.parts = [];
161
+ this.anims = [];
162
+ this.bodyMeshes = [];
163
+ this.eyes = [];
164
+ this.brows = [];
165
+ this.hands = [];
166
+ this.eyeY = H * 0.86;
167
+ this.r3 = W * 0.07;
168
+ this.model = { ex: {}, w: {}, head: null, q0: null, mixer: null, clips: [], idle: null, act: null, ready: false };
169
+ this.fxc = { W, H, D: this.depth, eyeY: this.eyeY, r: this.r3, sp: W * 0.12, mouth: new THREE.Vector3(0, H * 0.78, this.depth * 0.5), low: this.quality === 'low', tears: [] };
170
+
171
+ const sh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: shadowTexture(), transparent: true, depthWrite: false, toneMapped: false }));
172
+ sh.rotation.x = -Math.PI / 2;
173
+ sh.position.y = 0.002;
174
+ sh.scale.set(W * 1.2, this.depth * 2, 1);
175
+ sh.renderOrder = -1;
176
+ this.shadow = sh;
177
+ this.root.add(sh);
178
+
179
+ // caja provisional mientras carga (el encuadre se rehace al llegar el modelo)
180
+ this.bounds = new THREE.Box3(new THREE.Vector3(-W / 2, 0, -this.depth / 2), new THREE.Vector3(W / 2, H, this.depth / 2));
181
+ this.boundsRev = (this.boundsRev || 0) + 1;
182
+
183
+ const tok = (this.modelTok = {});
184
+ loadGltf(m.url)
185
+ .then(async (gltf) => {
186
+ if (this.modelTok !== tok) return;
187
+ const scene = gltf.scene;
188
+ const ext = gltf.parser.json.extensions || {};
189
+ if (ext.VRM) scene.rotation.y = Math.PI; // VRM 0.x mira a -z
190
+ scene.traverse((o) => {
191
+ if (!o.isMesh) return;
192
+ o.frustumCulled = false;
193
+ if (!Array.isArray(o.material)) o.userData.ownMaterial = true;
194
+ });
195
+ scene.updateMatrixWorld(true);
196
+ const box = new THREE.Box3().setFromObject(scene);
197
+ const size = box.getSize(new THREE.Vector3());
198
+ const c = box.getCenter(new THREE.Vector3());
199
+ if (!(size.y > 0)) throw new Error('empty model');
200
+ scene.position.set(-c.x, -box.min.y, -c.z);
201
+ const holder = new THREE.Group();
202
+ holder.add(scene);
203
+ const k = (H / size.y) * m.scale;
204
+ holder.scale.setScalar(k);
205
+ holder.rotation.y = m.rot * DEG;
206
+ holder.position.y = m.y * H;
207
+ this.rig.add(holder);
208
+
209
+ const md = this.model;
210
+ md.ex = await expressionsOf(gltf);
211
+ md.head = await headOf(gltf);
212
+ if (this.modelTok !== tok) return;
213
+ if (md.head) md.q0 = md.head.quaternion.clone();
214
+ if (gltf.animations.length) {
215
+ md.mixer = new THREE.AnimationMixer(scene);
216
+ md.clips = gltf.animations;
217
+ const idle = md.clips.find((a) => IDLE.test(a.name));
218
+ if (idle) {
219
+ md.idle = md.mixer.clipAction(idle);
220
+ md.idle.play();
221
+ }
222
+ md.mixer.addEventListener('finished', (e) => {
223
+ if (e.action !== md.act) return;
224
+ md.act = null;
225
+ e.action.fadeOut(0.25);
226
+ md.idle?.reset().fadeIn(0.25).play();
227
+ });
228
+ }
229
+ md.ready = true;
230
+
231
+ // encuadre y puntos de los efectos con el modelo real
232
+ this.rig.updateMatrixWorld(true);
233
+ const rb = new THREE.Box3().setFromObject(holder);
234
+ this.bounds = rb;
235
+ this.depth = Math.max(0.05, rb.max.z - rb.min.z);
236
+ this.W = Math.max(0.05, rb.max.x - rb.min.x);
237
+ this.shadow.scale.set(this.W * 1.1, this.depth * 1.6, 1);
238
+ if (md.head) {
239
+ const hp = md.head.getWorldPosition(new THREE.Vector3());
240
+ this.rig.worldToLocal(hp);
241
+ this.eyeY = this.fxc.eyeY = hp.y + H * 0.04;
242
+ this.fxc.mouth.set(hp.x, hp.y - H * 0.01, rb.max.z * 0.6);
243
+ }
244
+ this.fxc.W = this.W;
245
+ this.fxc.D = this.depth;
246
+ this.boundsRev++;
247
+ })
248
+ .catch((e) => {
249
+ if (this.modelTok !== tok) return;
250
+ console.warn('[7ots] modelo 3D del mod no disponible:', e?.message || e);
251
+ // sin modelo: un ot normal (la ficha sin el modelo)
252
+ const { _model, ...rest } = s;
253
+ this.modelFailed = m.url;
254
+ this.build({ ...rest, mods: (rest.mods || []).filter((x) => !x.model) });
255
+ });
256
+ }
257
+
258
+ /** Gesto con la animación propia del modelo, si la trae. Devuelve true si la usó. */
259
+ export function modelGesture(name) {
260
+ const md = this.model;
261
+ if (!md?.mixer) return false;
262
+ const re = CLIP_FOR[name];
263
+ const clip = re && md.clips.find((a) => re.test(a.name) && !IDLE.test(a.name));
264
+ if (!clip) return false;
265
+ const a = md.mixer.clipAction(clip);
266
+ if (md.act && md.act !== a) md.act.fadeOut(0.2);
267
+ a.reset();
268
+ a.setLoop(THREE.LoopOnce, 1);
269
+ a.clampWhenFinished = false;
270
+ a.fadeIn(0.2).play();
271
+ md.idle?.fadeOut(0.2);
272
+ md.act = a;
273
+ return true;
274
+ }
275
+
276
+ /** Un frame del ot-modelo (equivale a Character3D.tick). */
277
+ export function tickModel(t, dt) {
278
+ const rm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
279
+ const idle = this.opts.idle !== false;
280
+ const md = this.model;
281
+ if (idle && t >= this.nextBlink) {
282
+ if (this.moodName !== 'sleep' && !rm) {
283
+ this.blinkAt = t;
284
+ if (t - this.look.at > 3000 && Math.random() < 0.45) {
285
+ this.look.tx = Math.random() < 0.5 ? 0 : (Math.random() - 0.5) * 0.9;
286
+ this.look.ty = Math.random() < 0.5 ? 0 : (Math.random() - 0.5) * 0.5;
287
+ }
288
+ }
289
+ this.nextBlink = t + 1800 + Math.random() * 3600;
290
+ }
291
+ if (this.speech && !this.speech.tick(t)) this.endSpeech();
292
+ const mo = this.mo;
293
+ mo.open = approach(mo.open, mo.target, dt, mo.target > mo.open ? 35 : 70);
294
+ mo.msx = approach(mo.msx, mo.tmsx, dt, 60);
295
+ const look = this.look;
296
+ look.x = rm ? look.tx : approach(look.x, look.tx, dt, 70);
297
+ look.y = rm ? look.ty : approach(look.y, look.ty, dt, 70);
298
+ this.sleepK = approach(this.sleepK, this.moodName === 'sleep' ? 1 : 0, dt, rm ? 1 : 260);
299
+
300
+ // gesto: la animación del modelo, o la pose procedural sobre todo el cuerpo
301
+ const pose = { x: 0, y: 0, r: 0, sx: 1, sy: 1, blink: 0 };
302
+ let spin = 0;
303
+ const gest = this.gest;
304
+ if (gest) {
305
+ const age = t - gest.start;
306
+ const tt = clamp(age / gest.ms);
307
+ if (gest.clip === undefined) gest.clip = md.ready && this.modelGesture(gest.name);
308
+ if (!gest.clip) {
309
+ Object.assign(pose, gest.def.pose(tt, rm ? 0 : age, gest));
310
+ if (gest.dir < 0) { pose.x = -pose.x; pose.r = -pose.r; }
311
+ pose.sx = 1 + (pose.sx - 1) * 0.4; // un muñeco se aplasta menos que un ot
312
+ pose.sy = 1 + (pose.sy - 1) * 0.4;
313
+ if (gest.name === 'spin') spin = EASE.inOut(clamp((tt - 0.12) / 0.76)) * TAU;
314
+ }
315
+ for (const f of gest.def.fx || []) {
316
+ if (tt >= f[0] && !gest.fired.has(f)) {
317
+ gest.fired.add(f);
318
+ this.effect(f[1], { dir: gest.dir, ...(f[2] || {}) });
319
+ }
320
+ }
321
+ if (tt >= 1) this.endGesture();
322
+ }
323
+ if (rm) Object.assign(pose, { x: 0, y: 0, r: 0, sx: 1, sy: 1 });
324
+ const { ksx, ksy } = this.tickMorphs(t, rm);
325
+
326
+ const W = this.W;
327
+ const H = this.H;
328
+ const sec = t / 1000;
329
+ const breath = rm || !idle || md.idle ? 0 : Math.sin(sec * TAU / 3.4 + this.phase);
330
+ const rig = this.rig;
331
+ rig.position.set(pose.x * W, -pose.y * H, 0);
332
+ rig.rotation.set(md.head ? 0 : look.y * 0.1, this.yaw + look.x * (md.head ? 0.12 : 0.3) + spin + (rm || !idle ? 0 : Math.sin(sec * 0.7 + this.phase) * 0.03), -(pose.r + look.x * 1.5) * DEG);
333
+ rig.scale.set(pose.sx * ksx * (1 - 0.004 * breath), pose.sy * ksy * (1 + 0.01 * breath), pose.sx * ksx);
334
+ const air = Math.max(0, -pose.y);
335
+ this.shadow.material.opacity = 1 - air * 1.5;
336
+
337
+ // animaciones y cabeza
338
+ if (md.head && md.q0) md.head.quaternion.copy(md.q0);
339
+ md.mixer?.update(rm ? 0 : dt / 1000);
340
+ if (md.head && md.head.parent) {
341
+ const pw = md.head.parent.getWorldQuaternion(new THREE.Quaternion());
342
+ const rw = rig.getWorldQuaternion(new THREE.Quaternion());
343
+ const d = new THREE.Quaternion().setFromEuler(new THREE.Euler(look.y * 0.35, look.x * 0.55, 0, 'YXZ'));
344
+ d.premultiply(rw).multiply(rw.clone().invert()); // giro en el espacio del ot
345
+ md.head.quaternion.premultiply(pw.clone().invert().multiply(d).multiply(pw));
346
+ }
347
+
348
+ // expresiones
349
+ const blink = Math.max(this.blinkValue(t), pose.blink, this.sleepK > 0.02 ? Math.min(1, this.sleepK * 1.1) : 0);
350
+ const round = clamp((1 - mo.msx) / 0.35);
351
+ const want = { ...(MOOD_EXPR[this.moodName] || {}) };
352
+ for (const k in md.w) if (!(k in want)) want[k] = 0;
353
+ for (const k in want) md.w[k] = rm ? want[k] : approach(md.w[k] || 0, want[k], dt, 120);
354
+ const val = { ...md.w, blink: Math.max(blink, md.w.blink || 0), aa: mo.open * (md.ex.oh ? 1 - round : 1), oh: mo.open * round };
355
+ if (val.happy > 0.3) val.blink = Math.max(val.blink * (1 - val.happy), 0); // VRM: happy ya entrecierra los ojos
356
+ const touched = new Set();
357
+ for (const name in md.ex) for (const b of md.ex[name]) if (!touched.has(b)) { b.mesh.morphTargetInfluences[b.index] = 0; touched.add(b); }
358
+ for (const name in md.ex) {
359
+ const v = val[name] || 0;
360
+ if (!v) continue;
361
+ for (const b of md.ex[name]) b.mesh.morphTargetInfluences[b.index] = Math.min(1, b.mesh.morphTargetInfluences[b.index] + v * b.w);
362
+ }
363
+
364
+ this.tickEffects(t, rm);
365
+ this.fxg.position.copy(rig.position);
366
+ this.fxg.rotation.y = this.yaw;
367
+ }
@@ -45,6 +45,7 @@ const es = {
45
45
  party: 'Fiesta', chef: 'Chef', viking: 'Vikingo', grad: 'Graduación', beret: 'Boina', bow: 'Lazo', flower: 'Flor', halo: 'Aureola', horns: 'Cuernos',
46
46
  antenna: 'Antenas', bunny: 'Orejas de conejo', catears: 'Orejas de gato', santa: 'Papá Noel', hardhat: 'Casco de obra', headband: 'Cinta ninja',
47
47
  bandana: 'Pañuelo pirata', helmet: 'Casco espacial', headphones: 'Auriculares', hair: 'Tupé', sprout: 'Brote',
48
+ fedora: 'Fedora', bowler: 'Bombín', propeller: 'Gorra de hélice', witch: 'Sombrero de bruja', fez: 'Fez', sombrero: 'Sombrero mexicano', bucket: 'Gorro pescador', jester: 'Bufón',
48
49
  glasses: 'Gafas redondas', square: 'Gafas cuadradas', nerd: 'Gafas empollón', sunglasses: 'Gafas de sol', aviator: 'Aviador', heartglasses: 'Gafas corazón',
49
50
  starglasses: 'Gafas estrella', '3d': 'Gafas 3D', monocle: 'Monóculo', eyepatch: 'Parche', goggles: 'Gafas de piloto', visor: 'Visor',
50
51
  mustache: 'Bigote', handlebar: 'Bigote manillar', beard: 'Barba', wizardbeard: 'Barba de mago', goatee: 'Perilla', pipe: 'Pipa', lollipop: 'Piruleta',
@@ -73,7 +74,7 @@ const es = {
73
74
  },
74
75
  },
75
76
  editor: {
76
- kinds: { label: 'Tipo de avatar', character: 'Personaje 2D', plush: 'Personaje 3D', image: 'Tarjeta' },
77
+ kinds: { label: 'Tipo de avatar', character: 'Personaje 2D', plush: 'Personaje 3D', image: 'Carta' },
77
78
  tabs: { styles: 'Estilos', shape: 'Forma', color: 'Color y acabado', eyes: 'Ojos', mouth: 'Boca', face: 'Cara', acc: 'Accesorios' },
78
79
  controls: 'Controles de edición',
79
80
  preview: 'Vista previa del avatar',
@@ -101,15 +102,19 @@ const es = {
101
102
  unavailable: 'Este navegador no puede mostrar el 3D; ves la vista 2D (el widget hará lo mismo).',
102
103
  },
103
104
  image: {
104
- title: 'Retrato',
105
- intro: 'Una imagen fija (PNG, JPG, WebP o SVG). La boca no se anima; al hablar se ilumina el contorno.',
106
- url: 'URL de la imagen',
107
- placeholder: 'https://…/retrato.png',
105
+ title: 'Carta coleccionable',
106
+ intro: 'La carta holográfica de tu ot, con su personaje, nombre, rasgos y rareza. Muévela con el puntero.',
107
+ foil: 'Lámina',
108
+ foils: { auto: 'Según rareza', holo: 'Holo', prism: 'Prisma', cosmos: 'Cosmos', gold: 'Oro', none: 'Sin lámina' },
109
+ url: 'Arte propio (opcional)',
110
+ hint: 'Si pones una imagen (PNG, JPG, WebP o SVG), reemplaza al personaje en la carta y como avatar.',
111
+ placeholder: 'https://…/arte.png',
108
112
  alt: 'Vista previa del retrato',
109
113
  },
110
114
  styles: {
111
- title: 'Estilos completos',
112
- hint: 'Un estilo reemplaza todo el personaje. Después puedes ajustar cada pieza.',
115
+ title: 'Ots al azar sin dueño',
116
+ hint: 'Caras que nadie tiene todavía. Elegir una reemplaza el personaje; después puedes ajustar cada pieza.',
117
+ more: 'Otros',
113
118
  keepBody: 'Conservar mi forma y color al cambiar de estilo',
114
119
  },
115
120
  shape: {
@@ -128,7 +133,7 @@ const es = {
128
133
  custom: 'Personalizado',
129
134
  finish: 'Acabado',
130
135
  outline: 'Contorno',
131
- outlines: { auto: 'Automático', none: 'Sin contorno', thin: 'Fino', bold: 'Grueso' },
136
+ outlines: { auto: 'Automático', none: 'Sin contorno', thin: 'Fino', bold: 'Grueso', tone: 'Tono', sticker: 'Pegatina' },
132
137
  pattern: 'Patrón',
133
138
  patternColor: 'Color del patrón',
134
139
  },
@@ -214,6 +219,7 @@ const en = {
214
219
  party: 'Party', chef: 'Chef', viking: 'Viking', grad: 'Graduation', beret: 'Beret', bow: 'Bow', flower: 'Flower', halo: 'Halo', horns: 'Horns',
215
220
  antenna: 'Antennae', bunny: 'Bunny ears', catears: 'Cat ears', santa: 'Santa', hardhat: 'Hard hat', headband: 'Ninja headband',
216
221
  bandana: 'Pirate bandana', helmet: 'Space helmet', headphones: 'Headphones', hair: 'Quiff', sprout: 'Sprout',
222
+ fedora: 'Fedora', bowler: 'Bowler hat', propeller: 'Propeller cap', witch: 'Witch hat', fez: 'Fez', sombrero: 'Sombrero', bucket: 'Bucket hat', jester: 'Jester hat',
217
223
  glasses: 'Round glasses', square: 'Square glasses', nerd: 'Nerd glasses', sunglasses: 'Sunglasses', aviator: 'Aviator', heartglasses: 'Heart glasses',
218
224
  starglasses: 'Star glasses', '3d': '3D glasses', monocle: 'Monocle', eyepatch: 'Eye patch', goggles: 'Pilot goggles', visor: 'Visor',
219
225
  mustache: 'Mustache', handlebar: 'Handlebar mustache', beard: 'Beard', wizardbeard: 'Wizard beard', goatee: 'Goatee', pipe: 'Pipe', lollipop: 'Lollipop',
@@ -270,15 +276,19 @@ const en = {
270
276
  unavailable: 'This browser can’t show 3D; you see the 2D preview (the widget will do the same).',
271
277
  },
272
278
  image: {
273
- title: 'Portrait',
274
- intro: 'A still image (PNG, JPG, WebP or SVG). The mouth isn’t animated; the outline lights up while speaking.',
275
- url: 'Image URL',
276
- placeholder: 'https://…/portrait.png',
279
+ title: 'Trading card',
280
+ intro: 'Your ot’s holographic card, with its character, name, traits and rarity. Move your pointer over it.',
281
+ foil: 'Foil',
282
+ foils: { auto: 'By rarity', holo: 'Holo', prism: 'Prism', cosmos: 'Cosmos', gold: 'Gold', none: 'No foil' },
283
+ url: 'Custom art (optional)',
284
+ hint: 'An image (PNG, JPG, WebP or SVG) replaces the character on the card and as the avatar.',
285
+ placeholder: 'https://…/art.png',
277
286
  alt: 'Portrait preview',
278
287
  },
279
288
  styles: {
280
- title: 'Complete styles',
281
- hint: 'A style replaces the whole character. You can then adjust each part.',
289
+ title: 'Random unclaimed ots',
290
+ hint: 'Faces nobody has yet. Picking one replaces the character; you can then adjust each part.',
291
+ more: 'Shuffle',
282
292
  keepBody: 'Keep my shape and color when changing style',
283
293
  },
284
294
  shape: {
@@ -297,7 +307,7 @@ const en = {
297
307
  custom: 'Custom',
298
308
  finish: 'Finish',
299
309
  outline: 'Outline',
300
- outlines: { auto: 'Automatic', none: 'No outline', thin: 'Thin', bold: 'Bold' },
310
+ outlines: { auto: 'Automatic', none: 'No outline', thin: 'Thin', bold: 'Bold', tone: 'Tone', sticker: 'Sticker' },
301
311
  pattern: 'Pattern',
302
312
  patternColor: 'Pattern color',
303
313
  },
@@ -383,6 +393,7 @@ const pt = {
383
393
  party: 'Festa', chef: 'Chef', viking: 'Viking', grad: 'Formatura', beret: 'Boina', bow: 'Laço', flower: 'Flor', halo: 'Auréola', horns: 'Chifres',
384
394
  antenna: 'Antenas', bunny: 'Orelhas de coelho', catears: 'Orelhas de gato', santa: 'Papai Noel', hardhat: 'Capacete de obra', headband: 'Faixa ninja',
385
395
  bandana: 'Bandana pirata', helmet: 'Capacete espacial', headphones: 'Fones de ouvido', hair: 'Topete', sprout: 'Broto',
396
+ fedora: 'Fedora', bowler: 'Chapéu-coco', propeller: 'Boné de hélice', witch: 'Chapéu de bruxa', fez: 'Fez', sombrero: 'Sombreiro mexicano', bucket: 'Chapéu de pescador', jester: 'Chapéu de bobo da corte',
386
397
  glasses: 'Óculos redondos', square: 'Óculos quadrados', nerd: 'Óculos de nerd', sunglasses: 'Óculos de sol', aviator: 'Aviador', heartglasses: 'Óculos de coração',
387
398
  starglasses: 'Óculos de estrela', '3d': 'Óculos 3D', monocle: 'Monóculo', eyepatch: 'Tapa-olho', goggles: 'Óculos de piloto', visor: 'Viseira',
388
399
  mustache: 'Bigode', handlebar: 'Bigode guidão', beard: 'Barba', wizardbeard: 'Barba de mago', goatee: 'Cavanhaque', pipe: 'Cachimbo', lollipop: 'Pirulito',
@@ -439,15 +450,19 @@ const pt = {
439
450
  unavailable: 'Este navegador não consegue mostrar o 3D; você vê a prévia 2D (o widget fará o mesmo).',
440
451
  },
441
452
  image: {
442
- title: 'Retrato',
443
- intro: 'Uma imagem fixa (PNG, JPG, WebP ou SVG). A boca não é animada; ao falar, o contorno se ilumina.',
444
- url: 'URL da imagem',
445
- placeholder: 'https://…/retrato.png',
453
+ title: 'Carta colecionável',
454
+ intro: 'A carta holográfica do seu ot, com personagem, nome, atributos e raridade. Passe o ponteiro sobre ela.',
455
+ foil: 'Lâmina',
456
+ foils: { auto: 'Pela raridade', holo: 'Holo', prism: 'Prisma', cosmos: 'Cosmos', gold: 'Ouro', none: 'Sem lâmina' },
457
+ url: 'Arte própria (opcional)',
458
+ hint: 'Uma imagem (PNG, JPG, WebP ou SVG) substitui o personagem na carta e como avatar.',
459
+ placeholder: 'https://…/arte.png',
446
460
  alt: 'Prévia do retrato',
447
461
  },
448
462
  styles: {
449
- title: 'Estilos completos',
450
- hint: 'Um estilo substitui o personagem inteiro. Depois você pode ajustar cada peça.',
463
+ title: 'Ots aleatórios sem dono',
464
+ hint: 'Rostos que ninguém tem ainda. Escolher um substitui o personagem; depois você pode ajustar cada peça.',
465
+ more: 'Outros',
451
466
  keepBody: 'Manter minha forma e cor ao trocar de estilo',
452
467
  },
453
468
  shape: {
@@ -466,7 +481,7 @@ const pt = {
466
481
  custom: 'Personalizada',
467
482
  finish: 'Acabamento',
468
483
  outline: 'Contorno',
469
- outlines: { auto: 'Automático', none: 'Sem contorno', thin: 'Fino', bold: 'Grosso' },
484
+ outlines: { auto: 'Automático', none: 'Sem contorno', thin: 'Fino', bold: 'Grosso', tone: 'Tom', sticker: 'Adesivo' },
470
485
  pattern: 'Padrão',
471
486
  patternColor: 'Cor do padrão',
472
487
  },
@@ -74,6 +74,7 @@ const es = {
74
74
  busy: 'Ya hay una tarea en marcha ({id}); espera a que acabe',
75
75
  noAgent: 'Ese proyecto de Orquesta no tiene agente: configura uno en Orquesta ({url})',
76
76
  noProject: 'Ese proyecto de Orquesta no existe o no está compartido con 7ots',
77
+ notLinked: 'Este ot no está conectado a ese proyecto en Orquesta, o no tiene permiso para ejecutar tareas ahí. Conéctalo en el proyecto → Identidad → Ots conectados: {url}',
77
78
  rateLimited: 'Orquesta está recibiendo muchas peticiones; espera un momento',
78
79
  submitFailed: 'Orquesta no aceptó la tarea (error {status})',
79
80
  agentOffline: 'El agente de este proyecto está desconectado: la tarea queda en cola y empezará cuando vuelva',
@@ -95,6 +96,16 @@ const es = {
95
96
  jobGone: 'Ese trabajo ya no existe o ya terminó',
96
97
  llmLimit: 'Este trabajo ya usó sus {max} llamadas al modelo',
97
98
  paidSoon: 'Los servidores de 7ots llegan pronto; mientras tanto usa tu propio servidor',
99
+ paidOff: 'Los servidores de 7ots aún no están activados para tu cuenta; mientras tanto usa tu propio servidor',
100
+ paidUnavailable: 'Los servidores de 7ots no están disponibles ahora mismo; inténtalo más tarde',
101
+ exists: 'Este ot ya tiene un servidor; elimínalo o revócalo antes de crear otro',
102
+ accountLimit: 'Tu cuenta ya tiene {max} servidor(es) de 7ots (su límite)',
103
+ full: 'No quedan servidores de 7ots disponibles por ahora; inténtalo más tarde',
104
+ badSize: 'Tamaño de servidor no disponible',
105
+ needsPayment: 'Falta el pago del servidor de 7ots: vuelve a pulsar «Pagar y crear servidor»',
106
+ userNeedsKey: 'Para crear el servidor en tu cuenta de Orquesta, conéctala con una clave oak_ que tenga los permisos projects:write y vms:write (Orquesta → Ajustes → API keys). La conexión por OAuth no puede crear servidores.',
107
+ userScopes: 'Tu cuenta de Orquesta no dejó crear el servidor: vuelve a conectarla con una clave oak_ que tenga los permisos projects:write y vms:write (Orquesta → Ajustes → API keys)',
108
+ subscriptionInUse: 'Elimina antes tus servidores de 7ots; luego puedes cancelar la suscripción',
98
109
  },
99
110
  },
100
111
  };
@@ -167,6 +178,7 @@ const en = {
167
178
  busy: 'A task is already running ({id}); wait for it to finish',
168
179
  noAgent: 'That Orquesta project has no agent: set up an agent in Orquesta ({url})',
169
180
  noProject: 'That Orquesta project does not exist or is not shared with 7ots',
181
+ notLinked: 'This ot is not connected to that Orquesta project, or may not run tasks there. Connect it in the project → Identity → Connected ots: {url}',
170
182
  rateLimited: 'Orquesta is getting too many requests; wait a moment',
171
183
  submitFailed: 'Orquesta did not accept the task (error {status})',
172
184
  agentOffline: "This project's agent is offline: the task is queued and will start when it is back",
@@ -188,6 +200,16 @@ const en = {
188
200
  jobGone: 'That job no longer exists or already finished',
189
201
  llmLimit: 'This job already used its {max} model calls',
190
202
  paidSoon: '7ots servers are coming soon; meanwhile use your own server',
203
+ paidOff: '7ots servers are not enabled for your account yet; meanwhile use your own server',
204
+ paidUnavailable: '7ots servers are not available right now; try again later',
205
+ exists: 'This ot already has a server; delete or revoke it before creating another',
206
+ accountLimit: 'Your account already has {max} 7ots server(s) (its limit)',
207
+ full: 'No 7ots servers are available right now; try again later',
208
+ badSize: 'That server size is not available',
209
+ needsPayment: 'The 7ots server has not been paid for yet: click "Pay and create server" again',
210
+ userNeedsKey: 'To create the server in your Orquesta account, connect it with an oak_ key that has the projects:write and vms:write scopes (Orquesta → Settings → API keys). An OAuth connection cannot create servers.',
211
+ userScopes: 'Your Orquesta account did not allow creating the server: reconnect it with an oak_ key that has the projects:write and vms:write scopes (Orquesta → Settings → API keys)',
212
+ subscriptionInUse: 'Delete your 7ots servers first; then you can cancel the subscription',
191
213
  },
192
214
  },
193
215
  };
@@ -260,6 +282,7 @@ const pt = {
260
282
  busy: 'Já há uma tarefa em andamento ({id}); espere ela terminar',
261
283
  noAgent: 'Esse projeto do Orquesta não tem agente: configure um agente no Orquesta ({url})',
262
284
  noProject: 'Esse projeto do Orquesta não existe ou não está compartilhado com o 7ots',
285
+ notLinked: 'Este ot não está conectado a esse projeto no Orquesta, ou não pode executar tarefas lá. Conecte-o no projeto → Identidade → Ots conectados: {url}',
263
286
  rateLimited: 'O Orquesta está recebendo muitas solicitações; espere um momento',
264
287
  submitFailed: 'O Orquesta não aceitou a tarefa (erro {status})',
265
288
  agentOffline: 'O agente deste projeto está desconectado: a tarefa fica na fila e começa quando ele voltar',
@@ -281,6 +304,16 @@ const pt = {
281
304
  jobGone: 'Esse trabalho não existe mais ou já terminou',
282
305
  llmLimit: 'Este trabalho já usou suas {max} chamadas ao modelo',
283
306
  paidSoon: 'Os servidores do 7ots chegam em breve; enquanto isso use seu próprio servidor',
307
+ paidOff: 'Os servidores do 7ots ainda não estão ativados para sua conta; enquanto isso use seu próprio servidor',
308
+ paidUnavailable: 'Os servidores do 7ots não estão disponíveis agora; tente mais tarde',
309
+ exists: 'Este ot já tem um servidor; exclua ou revogue antes de criar outro',
310
+ accountLimit: 'Sua conta já tem {max} servidor(es) do 7ots (seu limite)',
311
+ full: 'Não há servidores do 7ots disponíveis agora; tente mais tarde',
312
+ badSize: 'Esse tamanho de servidor não está disponível',
313
+ needsPayment: 'O servidor da 7ots ainda não foi pago: clique de novo em «Pagar e criar servidor»',
314
+ userNeedsKey: 'Para criar o servidor na sua conta do Orquesta, conecte-a com uma chave oak_ que tenha as permissões projects:write e vms:write (Orquesta → Configurações → API keys). A conexão por OAuth não pode criar servidores.',
315
+ userScopes: 'Sua conta do Orquesta não permitiu criar o servidor: reconecte-a com uma chave oak_ que tenha as permissões projects:write e vms:write (Orquesta → Configurações → API keys)',
316
+ subscriptionInUse: 'Exclua antes seus servidores da 7ots; depois você pode cancelar a assinatura',
284
317
  },
285
318
  },
286
319
  };
@@ -33,13 +33,27 @@ const TEXT = {
33
33
  },
34
34
  };
35
35
 
36
- /** Semilla corta y legible (6 caracteres base36). */
36
+ /**
37
+ * Semilla corta y legible (7 caracteres base36). Las de 7 caracteres sortean entre el catálogo
38
+ * completo de piezas; cualquier otra longitud (las de 6 de antes de 2026-10, las escritas a mano)
39
+ * usa solo el catálogo 1, así que sigue dando exactamente el mismo ot que siempre.
40
+ */
37
41
  export function newSeed() {
38
42
  let s = '';
39
- for (let i = 0; i < 6; i++) s += Math.floor(Math.random() * 36).toString(36);
43
+ for (let i = 0; i < 7; i++) s += Math.floor(Math.random() * 36).toString(36);
40
44
  return s;
41
45
  }
42
46
 
47
+ /** ¿La semilla usa el catálogo 1 (todas salvo las de 7 caracteres, ver newSeed)? */
48
+ export function isLegacySeed(seed) {
49
+ return cleanSeed(seed).length !== 7;
50
+ }
51
+
52
+ /** Personaje de una semilla (el mismo en la web, el CLI, la mascota y el visor 3D). */
53
+ export function seedCharacter(seed) {
54
+ return randomCharacter(seedNumber(seed), { legacy: isLegacySeed(seed) });
55
+ }
56
+
43
57
  /** Normaliza una semilla escrita por una persona: minúsculas, [a-z0-9-], máx. 32. */
44
58
  export function cleanSeed(seed) {
45
59
  return String(seed ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32);
@@ -78,7 +92,7 @@ export function randomIdentity(seed, { lang = 'en', name } = {}) {
78
92
  const L = TEXT[lang] ? lang : 'en';
79
93
  const tx = TEXT[L];
80
94
  const { rnd, pick } = rng(seedNumber(sd));
81
- const character = randomCharacter(seedNumber(sd));
95
+ const character = seedCharacter(sd);
82
96
  const nm = String(name || '').trim().slice(0, 40) || randomName(sd);
83
97
  const traits = [];
84
98
  while (traits.length < 3) {
@@ -66,6 +66,9 @@ export const IDENTITY_DEFAULTS = Object.freeze({
66
66
  * @param {object} input
67
67
  * @param {object} [base] identidad sobre la que se aplica (por defecto, los valores de serie)
68
68
  */
69
+ /** Campos de la ficha de personaje y su largo máximo. */
70
+ const SHEET = { backstory: 3000, style: 1500, quirks: 1500, scenario: 1500, greeting: 600, examples: 4000 };
71
+
69
72
  export function defineIdentity(input = {}, base = IDENTITY_DEFAULTS) {
70
73
  const i = input && typeof input === 'object' ? input : {};
71
74
  const b = base;
@@ -87,6 +90,8 @@ export function defineIdentity(input = {}, base = IDENTITY_DEFAULTS) {
87
90
  tone: str(personality.tone, 120),
88
91
  traits: list(personality.traits, 12).map((t) => str(t, 40)).filter(Boolean),
89
92
  instructions: str(personality.instructions, 4000),
93
+ // ficha de personaje (la misma del pet de escritorio; privada como las instrucciones)
94
+ sheet: Object.fromEntries(Object.entries(SHEET).map(([k, n]) => [k, str(personality.sheet?.[k], n)]).filter(([, v]) => v)),
90
95
  },
91
96
  look: {
92
97
  kind: LOOK_KINDS.includes(look.kind) ? look.kind : look.image ? 'image' : 'character',
@@ -96,6 +101,7 @@ export function defineIdentity(input = {}, base = IDENTITY_DEFAULTS) {
96
101
  accent: color(look.accent) || IDENTITY_DEFAULTS.look.accent,
97
102
  emoji: str(look.emoji, 8),
98
103
  image: url(look.image),
104
+ card: { foil: ['holo', 'prism', 'cosmos', 'gold', 'none'].includes(look.card?.foil) ? look.card.foil : '' },
99
105
  avatar: {
100
106
  url: url(look.avatar?.url),
101
107
  body: look.avatar?.body === 'M' ? 'M' : 'F',
@@ -195,6 +201,10 @@ export function identityPrompt(id, locale = id.language) {
195
201
  id.bio ? t('identity.prompt.about', { bio: id.bio }) : '',
196
202
  p.tone ? t('identity.prompt.tone', { tone: p.tone }) : '',
197
203
  p.traits.length ? t('identity.prompt.traits', { traits: p.traits.join(', ') }) : '',
204
+ p.sheet?.backstory,
205
+ p.sheet?.style,
206
+ p.sheet?.quirks,
207
+ p.sheet?.examples,
198
208
  p.instructions,
199
209
  ]
200
210
  .filter(Boolean)