@7ots/cli 0.1.7 → 0.1.9

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 (57) hide show
  1. package/README.md +1 -1
  2. package/cli/7ots.mjs +28 -4
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +106 -41
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +3 -0
  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 +344 -11
  11. package/cli/pet/electron/director.cjs +37 -5
  12. package/cli/pet/electron/main.cjs +92 -10
  13. package/cli/pet/pet.html +149 -10
  14. package/cli/pet/settings.html +545 -42
  15. package/dist/7ots.esm.js +216 -137
  16. package/dist/7ots.esm.js.map +4 -4
  17. package/dist/7ots.iife.js +216 -137
  18. package/dist/7ots.iife.js.map +4 -4
  19. package/package.json +1 -1
  20. package/server/channels/agent.mjs +7 -1
  21. package/server/channels/index.mjs +3 -2
  22. package/server/platform/connect.mjs +4 -1
  23. package/server/platform/db.mjs +9 -1
  24. package/server/platform/device.mjs +101 -4
  25. package/server/platform/mods.mjs +222 -0
  26. package/server/platform/orquesta.mjs +78 -14
  27. package/server/platform/profile.mjs +161 -0
  28. package/server/platform/routes.mjs +38 -33
  29. package/server/platform/runtime.mjs +3 -1
  30. package/server/platform/store.mjs +1 -1
  31. package/server/platform/vm.mjs +370 -91
  32. package/server/server.mjs +12 -2
  33. package/src/avatar/three.js +3 -2
  34. package/src/character/AvatarEditor.js +95 -7
  35. package/src/character/Character.js +116 -17
  36. package/src/character/TradingCard.js +226 -0
  37. package/src/character/mods/index.js +4 -0
  38. package/src/character/mods/orc.js +64 -0
  39. package/src/character/mods.js +251 -0
  40. package/src/character/more/acc-head.js +213 -0
  41. package/src/character/more/acc-rest.js +342 -0
  42. package/src/character/more/brows.js +39 -0
  43. package/src/character/more/cheeks.js +78 -0
  44. package/src/character/more/eyes.js +288 -0
  45. package/src/character/more/mouths.js +259 -0
  46. package/src/character/more/patterns.js +120 -0
  47. package/src/character/parts.js +90 -0
  48. package/src/character/skin.js +166 -1
  49. package/src/character/styles.js +1285 -9
  50. package/src/character3d/Character3D.js +148 -35
  51. package/src/character3d/index.js +7 -1
  52. package/src/character3d/model.js +367 -0
  53. package/src/i18n/messages/character.js +34 -22
  54. package/src/i18n/messages/platform.js +15 -0
  55. package/src/identity/random.js +17 -3
  56. package/src/identity/schema.js +10 -0
  57. package/src/index.js +1 -0
package/server/server.mjs CHANGED
@@ -106,10 +106,20 @@ if (PLATFORM) {
106
106
 
107
107
  // ───────────────────────────── servidor ─────────────────────────────
108
108
 
109
+ // Commit desplegado: scripts/deploy.sh escribe REVISION (JSON) en la raíz. Sin él (dev) → null.
110
+ const REVISION = (() => {
111
+ try { return JSON.parse(readFileSync(join(ROOT, 'REVISION'), 'utf8')); } catch { return null; }
112
+ })();
113
+
109
114
  const server = createServer(async (req, res) => {
110
115
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
111
116
  const t = reqT(req);
117
+ if (REVISION?.commit) res.setHeader('X-7ots-Revision', REVISION.commit);
112
118
  try {
119
+ if (url.pathname === '/version.json' && (req.method === 'GET' || req.method === 'HEAD')) {
120
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*' });
121
+ return res.end(req.method === 'HEAD' ? undefined : JSON.stringify(REVISION || { commit: null }));
122
+ }
113
123
  if (platform && ((await platform(req, res, url)) || (await auth(req, res, url)))) return;
114
124
  if (PLATFORM && (url.pathname === '/app' || url.pathname.startsWith('/app/'))) return await serveApp(req, res, url.pathname);
115
125
  if (url.pathname === '/llms.txt' && (req.method === 'GET' || req.method === 'HEAD')) {
@@ -322,8 +332,8 @@ async function serveBackoffice(req, res, pathname, dir = 'backoffice') {
322
332
  if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
323
333
  const data = await readFile(file);
324
334
  const headers = { 'Content-Type': MIME[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
325
- // El panel no se puede enmarcar (clickjacking); la vista previa sí, desde el propio panel.
326
- headers['Content-Security-Policy'] = file.endsWith('preview.html') ? "frame-ancestors 'self'" : "frame-ancestors 'none'";
335
+ // Nada se puede enmarcar (clickjacking).
336
+ headers['Content-Security-Policy'] = "frame-ancestors 'none'";
327
337
  res.writeHead(200, headers);
328
338
  res.end(data);
329
339
  } catch {
@@ -42,12 +42,13 @@ export function loadThree() {
42
42
  threeP ??= (async () => {
43
43
  if (globalThis.__OTS_THREE__?.three) return globalThis.__OTS_THREE__;
44
44
  const u = (p) => `${base()}/${p}/+esm`;
45
- const [three, BufferGeometryUtils, RoomEnvironment] = await Promise.all([
45
+ const [three, BufferGeometryUtils, RoomEnvironment, GLTFLoader] = await Promise.all([
46
46
  import(/* @vite-ignore */ /* webpackIgnore: true */ `${base()}/+esm`),
47
47
  import(/* @vite-ignore */ /* webpackIgnore: true */ u('examples/jsm/utils/BufferGeometryUtils.js')),
48
48
  import(/* @vite-ignore */ /* webpackIgnore: true */ u('examples/jsm/environments/RoomEnvironment.js')),
49
+ import(/* @vite-ignore */ /* webpackIgnore: true */ u('examples/jsm/loaders/GLTFLoader.js')),
49
50
  ]);
50
- return (globalThis.__OTS_THREE__ = { three, BufferGeometryUtils, RoomEnvironment });
51
+ return (globalThis.__OTS_THREE__ = { three, BufferGeometryUtils, RoomEnvironment, GLTFLoader });
51
52
  })();
52
53
  threeP.catch(() => { threeP = null; });
53
54
  return threeP;
@@ -13,7 +13,9 @@
13
13
  * - Personaje 3D: la MISMA spec y las mismas pestañas, con vista previa 3D (src/character3d, se
14
14
  * arrastra para girarlo) y material peluche/vinilo. Los puntos arrastrables son del 2D: en 3D se
15
15
  * ocultan (forma, ojos y boca se cambian desde las pestañas).
16
- * - Imagen: retrato fijo.
16
+ * - Carta: la carta coleccionable holográfica del ot (la del pet de escritorio) con su personaje, o
17
+ * con una imagen propia como arte; se elige la lámina (holo, prisma, cosmos, oro, sin lámina).
18
+ * Nombre, rol y bio de la carta: propiedad `el.identity` u `opts.identity` (+ `opts.otsId`).
17
19
  *
18
20
  * Idioma: atributo `lang` (o la propiedad nativa `el.lang`, u `opts.locale` en createAvatarEditor).
19
21
  * Cambiarlo vuelve a pintar los textos en caliente conservando el valor, la pestaña y el historial.
@@ -23,25 +25,27 @@
23
25
 
24
26
  import {
25
27
  normalizeCharacter, renderCharacter, createCharacter, applyPreset, randomCharacter, layout, bodyPoints,
26
- SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, ACCESSORY_GROUPS, PRESETS, BODY_POINTS,
28
+ SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, ACCESSORY_GROUPS, BODY_POINTS,
27
29
  CHARACTER_MOODS, CHARACTER_GESTURES, CHARACTER_MORPHS, partLabel,
28
30
  } from './Character.js';
29
31
  import { translator, detectLocale, getLocale, onLocaleChange, formatNumber } from '../i18n/index.js';
30
32
  import { STYLES as ART_STYLES } from './styles.js';
31
33
  import { loadCharacter3D } from '../avatar/three.js';
34
+ import { createTradingCard, CARD_FOILS } from './TradingCard.js';
32
35
 
33
36
  const VIEW = [-20, -50, 240, 270];
34
37
  const AXIS = 100;
35
38
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
36
39
 
37
40
  const KINDS = ['character', 'plush', 'image'];
41
+ const RANDOM_N = 12;
38
42
  const MATERIALS = ['plush', 'vinyl'];
39
43
 
40
44
  const TABS = ['styles', 'shape', 'color', 'eyes', 'mouth', 'face', 'acc'];
41
45
  const GESTURES = CHARACTER_GESTURES;
42
46
  // Morphs temporales para la vista previa (las formas "shape:<id>" se ven ya en la pestaña de forma).
43
47
  const MORPHS = CHARACTER_MORPHS.filter((m) => !m.startsWith('shape'));
44
- const OUTLINES = ['auto', 'none', 'thin', 'bold'];
48
+ const OUTLINES = ['auto', 'none', 'thin', 'bold', 'tone', 'sticker'];
45
49
  const MAX_ACC = 16;
46
50
 
47
51
  const CSS = `
@@ -111,7 +115,15 @@ input[type=text],input[type=url]{font:inherit;width:100%;padding:7px 9px;border-
111
115
  :host([simple]) [data-kind=image],:host([simple]) [data-bar=export]{display:none!important}
112
116
  :host([simple]) .wrap{border:0;border-radius:0;background:transparent}
113
117
  .alt{padding:18px;display:grid;gap:14px;max-width:640px}
118
+ .rh{display:flex;align-items:center;gap:10px}
119
+ .rh .chip{margin-left:auto}
120
+ .opt i.ph{display:block;aspect-ratio:1;border-radius:10px;background:var(--ae-line);opacity:.5;animation:ph 1.2s ease-in-out infinite alternate}
121
+ @keyframes ph{to{opacity:.15}}
114
122
  .alt img{max-width:160px;border-radius:14px;border:1px solid var(--ae-line)}
123
+ .alt.cardv{max-width:none;grid-template-columns:minmax(220px,340px) minmax(220px,1fr);align-items:start;gap:22px}
124
+ .alt .tcard{padding:8px 0}
125
+ .alt .side{display:grid;gap:14px;align-content:start}
126
+ @media (max-width:640px){.alt.cardv{grid-template-columns:1fr}}
115
127
  `;
116
128
 
117
129
  const SWATCHES = ['#a0714f', '#f1c27d', '#fcd9b6', '#e0a97e', '#8d5524', '#ef4444', '#f97316', '#facc15', '#84cc16', '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#f8fafc', '#94a3b8', '#1f2937'];
@@ -125,6 +137,7 @@ export function normalizeEditorValue(v = {}) {
125
137
  material: MATERIALS.includes(v.material) ? v.material : 'plush',
126
138
  avatar: { url: typeof v.avatar?.url === 'string' ? v.avatar.url : '', body: v.avatar?.body === 'M' ? 'M' : 'F' },
127
139
  image: typeof v.image === 'string' ? v.image : '',
140
+ card: { foil: CARD_FOILS.includes(v.card?.foil) ? v.card.foil : '' },
128
141
  };
129
142
  }
130
143
 
@@ -133,6 +146,8 @@ export function normalizeEditorValue(v = {}) {
133
146
  * @param {{ value?: object, onChange?: (v:object)=>void, plush?: boolean, simple?: boolean, theme?: 'light'|'dark', locale?: string }} [opts]
134
147
  * plush: false oculta el tipo «Personaje 3D» (se acepta también el antiguo `realistic: false`).
135
148
  * locale: idioma de la interfaz ('es', 'en', 'pt'…); se puede cambiar luego con `el.lang = 'en'`.
149
+ * lookFree: async (characters[]) => boolean[]: qué caras están libres; las sugerencias al azar de la
150
+ * pestaña Estilos solo muestran las libres (en 7ots.com cada ot es único). Sin ella se muestran todas.
136
151
  */
137
152
  export function createAvatarEditor(container, opts = {}) {
138
153
  const el = document.createElement('ots-avatar-editor');
@@ -140,6 +155,8 @@ export function createAvatarEditor(container, opts = {}) {
140
155
  if (opts.simple) el.setAttribute('simple', ''); // sin selector de tipo ni exportar (p. ej. la portada)
141
156
  if (opts.theme) el.setAttribute('theme', opts.theme);
142
157
  if (opts.locale) el.setAttribute('lang', opts.locale);
158
+ if (opts.identity || opts.otsId) el.identity = { ...(opts.identity || {}), id: opts.otsId, createdAt: opts.createdAt };
159
+ if (opts.lookFree) el.lookFree = opts.lookFree;
143
160
  if (opts.value) el.value = opts.value;
144
161
  if (opts.onChange) el.addEventListener('change', (e) => opts.onChange(e.detail));
145
162
  container.appendChild(el);
@@ -195,6 +212,13 @@ export class AvatarEditor extends Base {
195
212
  this._renderAll();
196
213
  }
197
214
 
215
+ /** Datos de la carta (name, role, bio, tagline, id, createdAt); no forman parte del valor. */
216
+ get identity() { return this._ident || {}; }
217
+ set identity(i) {
218
+ this._ident = { ...(i || {}) };
219
+ if (this._ready && this._v.kind === 'image') this._renderImage();
220
+ }
221
+
198
222
  get value() { return structuredClone(this._v); }
199
223
  set value(v) {
200
224
  this._v = normalizeEditorValue(v);
@@ -220,7 +244,7 @@ export class AvatarEditor extends Base {
220
244
  this._applyLocale();
221
245
  });
222
246
  });
223
- if (this._ready) return;
247
+ if (this._ready) return this._renderKind();
224
248
  this._ready = true;
225
249
  this._t = translator(this._resolveLocale());
226
250
  const root = this.attachShadow({ mode: 'open' });
@@ -277,6 +301,7 @@ export class AvatarEditor extends Base {
277
301
  cancelAnimationFrame(this._raf3);
278
302
  this._raf3 = 0;
279
303
  this._ch3?.destroy();
304
+ this._dropCard();
280
305
  this._ch3 = null;
281
306
  this._ch3P = null;
282
307
  }
@@ -437,15 +462,36 @@ export class AvatarEditor extends Base {
437
462
  this.$('[data-bar=material]').classList.toggle('hidden', !plush);
438
463
  this.$$('[data-material]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.material === this._v.material)));
439
464
  if (k === 'image') this._renderImage();
465
+ else this._dropCard();
466
+ }
467
+
468
+ _dropCard() {
469
+ if (!this._card) return;
470
+ this._card.destroy();
471
+ this._card = null;
472
+ this.$('[data-view=image]').innerHTML = '';
440
473
  }
441
474
 
442
475
  _renderImage() {
443
476
  const img = this._v.image;
444
477
  const t = this._t;
445
- this.$('[data-view=image]').innerHTML = `
478
+ const view = this.$('[data-view=image]');
479
+ const id = this.identity;
480
+ const look = { ...(id.look || {}), kind: 'image', image: /^https?:\/\//.test(img) ? img : '', character: this._v.character };
481
+ const data = { identity: { ...id, look }, id: id.id, createdAt: id.createdAt, foil: this._v.card.foil, locale: this.locale };
482
+ const foil = this._v.card.foil;
483
+ if (!view.querySelector('.tcard')) {
484
+ view.classList.add('cardv');
485
+ view.innerHTML = `<div class="tcard"></div><div class="side"></div>`;
486
+ this._card?.destroy();
487
+ this._card = createTradingCard(view.querySelector('.tcard'), data);
488
+ } else this._card.update(data);
489
+ view.querySelector('.side').innerHTML = `
446
490
  <div><h4>${esc(t('editor.image.title'))}</h4><p class="hint" style="margin:0">${esc(t('editor.image.intro'))}</p></div>
491
+ <div><span class="hint" style="margin:0 0 6px;display:block">${esc(t('editor.image.foil'))}</span>
492
+ <span class="chips"><button type="button" class="chip" data-foil="" aria-pressed="${!foil}">${esc(t('editor.image.foils.auto'))}</button>${CARD_FOILS.map((f) => `<button type="button" class="chip" data-foil="${f}" aria-pressed="${foil === f}">${esc(t(`editor.image.foils.${f}`))}</button>`).join('')}</span></div>
447
493
  <label class="f">${esc(t('editor.image.url'))}<input type="url" data-m="image" placeholder="${esc(t('editor.image.placeholder'))}" value="${esc(img)}"></label>
448
- ${/^https?:\/\//.test(img) ? `<img src="${esc(img)}" alt="${esc(t('editor.image.alt'))}">` : ''}`;
494
+ <p class="hint" style="margin:0">${esc(t('editor.image.hint'))}</p>`;
449
495
  }
450
496
 
451
497
  _thumbView(kind) {
@@ -467,6 +513,38 @@ export class AvatarEditor extends Base {
467
513
  .join('')}</div>`;
468
514
  }
469
515
 
516
+ /**
517
+ * Sugerencias al azar que nadie tiene (si hay `lookFree`): reemplazan a los estilos fijos, que en
518
+ * 7ots.com ya estarían tomados. Se piden de a lotes y se guardan hasta que se pida «Otros».
519
+ */
520
+ _randomGrid(view) {
521
+ if (!this._rand) {
522
+ this._loadRandoms();
523
+ return `<div class="grid">${'<button type="button" class="opt" disabled><i class="ph"></i></button>'.repeat(RANDOM_N)}</div>`;
524
+ }
525
+ return `<div class="grid">${this._rand
526
+ .map((ch, i) => `<button type="button" class="opt" data-rand="${i}" title="🎲">${renderCharacter({ ...ch, style: 'none' }, { open: 0 }, { viewBox: view })}</button>`)
527
+ .join('')}</div>`;
528
+ }
529
+
530
+ async _loadRandoms() {
531
+ if (this._randBusy) return;
532
+ this._randBusy = true;
533
+ const out = [];
534
+ try {
535
+ for (let round = 0; round < 4 && out.length < RANDOM_N; round++) {
536
+ const batch = Array.from({ length: RANDOM_N + 4 }, () => normalizeCharacter(randomCharacter()));
537
+ let free = batch.map(() => true);
538
+ if (this.lookFree) free = await Promise.resolve(this.lookFree(batch)).catch(() => free);
539
+ out.push(...batch.filter((_, i) => free?.[i] !== false));
540
+ }
541
+ } finally {
542
+ this._randBusy = false;
543
+ }
544
+ this._rand = out.slice(0, RANDOM_N);
545
+ if (this._ready && this._tab === 'styles') this._renderPanel();
546
+ }
547
+
470
548
  /**
471
549
  * Tira de estilos artísticos (styles.js): el personaje actual en cada estilo, con su fondo.
472
550
  * Son muchos SVG con filtros: cada miniatura se rasteriza una vez como <img> estática, solo
@@ -540,7 +618,7 @@ export class AvatarEditor extends Base {
540
618
  let h = '';
541
619
  switch (this._tab) {
542
620
  case 'styles':
543
- h = `${this._artStyles()}<h4>${T('editor.styles.title')}</h4>${this._grid('presets', PRESETS, c.preset, 'data-preset', (p) => ({ preset: p.id }), full)}
621
+ h = `${this._artStyles()}<h4 class="rh">${T('editor.styles.title')}<button type="button" class="chip" data-a="reroll">🎲 ${T('editor.styles.more')}</button></h4>${this._randomGrid(full)}
544
622
  <p class="hint">${T('editor.styles.hint')}</p>
545
623
  <label class="c" style="margin-top:8px"><input type="checkbox" data-keep-body ${this._keepBody ? 'checked' : ''}> ${T('editor.styles.keepBody')}</label>`;
546
624
  break;
@@ -794,6 +872,14 @@ export class AvatarEditor extends Base {
794
872
  this._drawOverlay();
795
873
  return;
796
874
  }
875
+ if (d.rand) {
876
+ const pick = structuredClone(this._rand?.[+d.rand]);
877
+ if (!pick) return;
878
+ return this._set((n) => {
879
+ const keep = this._keepBody ? { body: n.body, finish: n.finish } : {};
880
+ Object.assign(n, pick, keep, { style: n.style });
881
+ });
882
+ }
797
883
  if (d.preset) return this._set((n) => Object.assign(n, applyPreset(n, d.preset, this._keepBody ? ['body', 'finish'] : [])));
798
884
  if (d.artstyle) return this._set((n) => { n.style = d.artstyle; });
799
885
  if (d.shape) return this._set((n) => { n.body.shape = d.shape; n.body.points = null; });
@@ -825,6 +911,7 @@ export class AvatarEditor extends Base {
825
911
  this._renderPanel();
826
912
  return this._drawOverlay();
827
913
  }
914
+ if (d.foil !== undefined) return this._setMeta((v) => (v.card = { foil: d.foil }));
828
915
  if (d.material) {
829
916
  this._setMeta((v) => (v.material = d.material));
830
917
  return this._sync3d();
@@ -834,6 +921,7 @@ export class AvatarEditor extends Base {
834
921
  case 'undo': return this._undo();
835
922
  case 'redo': return this._redo();
836
923
  case 'random': return this._set((n) => Object.assign(n, randomCharacter()));
924
+ case 'reroll': this._rand = null; return this._renderPanel();
837
925
  case 'sym': this._sym = !this._sym; b.setAttribute('aria-pressed', String(this._sym)); return this._drawOverlay();
838
926
  case 'reshape': return this._set((n) => { n.body.points = null; });
839
927
  case 'squash':
@@ -23,13 +23,15 @@
23
23
  * en styles.js: remapean la paleta, añaden un filtro y una capa al cuerpo y un fondo a juego.
24
24
  *
25
25
  * Todo el SVG sale de catálogos propios; lo que llega del usuario se reduce a ids de catálogo,
26
- * colores #rrggbb y números acotados (normalizeCharacter), así que es seguro inyectarlo.
26
+ * colores #rrggbb y números acotados (normalizeCharacter), así que es seguro inyectarlo. Los mods
27
+ * (`mods`, mods.js) traen SVG propio: se reescribe con una lista cerrada de etiquetas y atributos.
27
28
  */
28
29
 
29
30
  import {
30
- BODY_POINTS, SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, PRESETS, shade, luminance,
31
+ BODY_POINTS, SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, PRESETS, CATALOG_V1, shade, luminance,
31
32
  } from './parts.js';
32
33
  import { renderSkin, joinSVG } from './skin.js';
34
+ import { normalizeMods, applyMods, modPart } from './mods.js';
33
35
  import { STYLE_IDS, stylizeSpec, styleLayers, styleSeed, backdropIn, backdropCSS } from './styles.js';
34
36
  import { translator } from '../i18n/index.js';
35
37
  import {
@@ -149,7 +151,7 @@ export function normalizeCharacter(input = {}) {
149
151
  finish: pickId(FINISH, i.finish ?? base.finish, 'glossy'),
150
152
  // textura de superficie; 'auto' (o ausente, specs antiguas) la elige la propia spec (skin.js)
151
153
  texture: pickId(TEXTURE, i.texture ?? base.texture, 'auto'),
152
- outline: ['auto', 'none', 'thin', 'bold'].includes(i.outline) ? i.outline : 'auto',
154
+ outline: OUTLINES.includes(i.outline) ? i.outline : 'auto',
153
155
  // estilo artístico (styles.js); 'none' no se guarda: las specs de siempre no cambian
154
156
  ...(STYLE_IDS.includes(i.style ?? base.style) && (i.style ?? base.style) !== 'none' ? { style: i.style ?? base.style } : {}),
155
157
  eyes: {
@@ -183,9 +185,15 @@ export function normalizeCharacter(input = {}) {
183
185
  color: hex(a.color, ACC[a.id].color || '#1f2937'),
184
186
  color2: hex(a.color2, ACC[a.id].color2 || '#ffffff'),
185
187
  })),
188
+ ...(Array.isArray(i.mods) && i.mods.length ? { mods: normalizeMods(i.mods) } : {}),
186
189
  };
187
190
  }
188
191
 
192
+ /** La ficha que se pinta: la base con sus mods aplicados (mods.js). */
193
+ export function resolveCharacter(spec) {
194
+ return applyMods(normalizeCharacter(spec), normalizeCharacter);
195
+ }
196
+
189
197
  /** Aplica un estilo (preset) conservando lo que se indique en `keep` (p. ej. ['body']). */
190
198
  export function applyPreset(spec, id, keep = []) {
191
199
  const next = normalizeCharacter({ preset: id });
@@ -193,8 +201,13 @@ export function applyPreset(spec, id, keep = []) {
193
201
  return next;
194
202
  }
195
203
 
196
- /** Personaje aleatorio (determinista con `seed`). */
197
- export function randomCharacter(seed = Math.random() * 1e9) {
204
+ /**
205
+ * Personaje aleatorio (determinista con `seed`).
206
+ * opts.legacy: sortea solo entre las piezas del catálogo 1 (CATALOG_V1), con la misma secuencia
207
+ * que antes de 2026-10: las semillas antiguas siguen dando exactamente el mismo ot.
208
+ */
209
+ export function randomCharacter(seed = Math.random() * 1e9, { legacy = false } = {}) {
210
+ const v1 = (list, k) => (legacy ? list.slice(0, CATALOG_V1[k]) : list);
198
211
  let s = Math.floor(seed) >>> 0 || 1;
199
212
  const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32);
200
213
  const pick = (list) => list[Math.floor(rnd() * list.length)];
@@ -208,16 +221,18 @@ export function randomCharacter(seed = Math.random() * 1e9) {
208
221
  return `#${[f(0), f(8), f(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
209
222
  };
210
223
  const groups = new Map();
211
- for (const a of ACCESSORIES) if (a.group !== 'extra' && a.random !== false) groups.set(a.group, [...(groups.get(a.group) || []), a]);
224
+ // Los sombreros con `random: false` del catálogo 1 solo entran en el sorteo nuevo (no legacy).
225
+ const accPool = legacy ? v1(ACCESSORIES, 'accessories').filter((a) => a.random !== false) : ACCESSORIES.filter((a, i) => a.random !== false || i < CATALOG_V1.accessories);
226
+ for (const a of accPool) if (a.group !== 'extra') groups.set(a.group, [...(groups.get(a.group) || []), a]);
212
227
  const acc = [];
213
228
  for (const [, list] of groups) if (rnd() < 0.35) acc.push({ id: pick(list).id });
214
229
  return normalizeCharacter({
215
- body: { shape: pick(SHAPES).id, color: hsl(hue, 0.55, 0.62), pattern: rnd() < 0.5 ? 'none' : pick(PATTERNS).id, patternColor: hsl((hue + 30) % 360, 0.5, 0.75) },
216
- finish: pick(FINISHES.filter((f) => f.id !== 'sketch')).id,
217
- eyes: { type: pick(EYES).id, iris: hsl(Math.floor(rnd() * 360), 0.7, 0.5), size: 0.85 + rnd() * 0.4, spacing: 0.85 + rnd() * 0.3 },
218
- brows: { type: rnd() < 0.5 ? 'none' : pick(BROWS).id },
219
- mouth: { type: pick(MOUTHS).id },
220
- cheeks: { type: rnd() < 0.5 ? 'blush' : pick(CHEEKS).id },
230
+ body: { shape: pick(v1(SHAPES, 'shapes')).id, color: hsl(hue, 0.55, 0.62), pattern: rnd() < 0.5 ? 'none' : pick(v1(PATTERNS, 'patterns')).id, patternColor: hsl((hue + 30) % 360, 0.5, 0.75) },
231
+ finish: pick(v1(FINISHES, 'finishes').filter((f) => f.id !== 'sketch' && f.random !== false)).id,
232
+ eyes: { type: pick(v1(EYES, 'eyes')).id, iris: hsl(Math.floor(rnd() * 360), 0.7, 0.5), size: 0.85 + rnd() * 0.4, spacing: 0.85 + rnd() * 0.3 },
233
+ brows: { type: rnd() < 0.5 ? 'none' : pick(v1(BROWS, 'brows')).id },
234
+ mouth: { type: pick(v1(MOUTHS, 'mouths')).id },
235
+ cheeks: { type: rnd() < 0.5 ? 'blush' : pick(v1(CHEEKS, 'cheeks')).id },
221
236
  accessories: acc,
222
237
  });
223
238
  }
@@ -280,6 +295,9 @@ export function layout(spec, points = null) {
280
295
  let uidSeq = 0;
281
296
  const R = (v) => Math.round(v * 100) / 100;
282
297
 
298
+ /** Contornos del cuerpo. 'tone': grueso, del color del cuerpo oscurecido; 'sticker': borde blanco de pegatina. */
299
+ export const OUTLINES = ['auto', 'none', 'thin', 'bold', 'tone', 'sticker'];
300
+
283
301
  function palette(spec) {
284
302
  const body = spec.body.color;
285
303
  const dark = luminance(body) < 0.28;
@@ -293,14 +311,77 @@ function palette(spec) {
293
311
  };
294
312
  }
295
313
 
314
+ /**
315
+ * Acabados del catálogo 2 (2026-10): degradado del cuerpo (stops [offset, color, opacidad?]) y
316
+ * capa interior recortada al cuerpo (brillos, reflejos). Los 7 originales siguen en renderLayers.
317
+ */
318
+ const FINISH2 = {
319
+ pastel: {
320
+ grad: (c) => ({ stops: [[0, shade(c, 0.4)], [1, shade(c, 0.12)]] }),
321
+ inner: ({ d, box, col }) => `<path d="${d}" fill="none" stroke="#fff" stroke-width="${R(box.w * 0.05)}" opacity=".35"/><path d="${d}" fill="none" stroke="${shade(col, -0.2)}" stroke-width="${R(box.w * 0.035)}" opacity=".18" transform="translate(${R(box.w * 0.015)} ${R(box.h * 0.02)})"/>`,
322
+ },
323
+ metal: {
324
+ grad: (c) => ({ x2: 0.25, stops: [[0, shade(c, 0.6)], [0.3, shade(c, 0.12)], [0.48, shade(c, -0.38)], [0.56, shade(c, 0.32)], [0.8, shade(c, 0.05)], [1, shade(c, -0.42)]] }),
325
+ inner: ({ box }) => {
326
+ const x = box.x + box.w * 0.24;
327
+ const y = box.y + box.h * 0.3;
328
+ return `<ellipse cx="${R(x)}" cy="${R(y)}" rx="${R(box.w * 0.05)}" ry="${R(box.h * 0.2)}" transform="rotate(14 ${R(x)} ${R(y)})" fill="#fff" opacity=".7"/><circle cx="${R(box.x + box.w * 0.36)}" cy="${R(box.y + box.h * 0.1)}" r="${R(box.w * 0.03)}" fill="#fff" opacity=".85"/><ellipse cx="${R(box.x + box.w * 0.78)}" cy="${R(box.y + box.h * 0.62)}" rx="${R(box.w * 0.025)}" ry="${R(box.h * 0.12)}" fill="#fff" opacity=".35"/>`;
329
+ },
330
+ },
331
+ pearl: {
332
+ grad: (c) => ({ radial: true, stops: [[0, shade(c, 0.62)], [0.6, shade(c, 0.18)], [1, shade(c, -0.18)]] }),
333
+ defs: (id, c, box) => `<linearGradient id="${id}-iri" gradientUnits="userSpaceOnUse" x1="${R(box.x)}" y1="${R(box.y)}" x2="${R(box.x + box.w)}" y2="${R(box.y + box.h)}"><stop offset="0" stop-color="#f9a8d4"/><stop offset=".35" stop-color="#a5f3fc"/><stop offset=".65" stop-color="#fef08a"/><stop offset="1" stop-color="#c4b5fd"/></linearGradient>`,
334
+ inner: ({ id, d, box }) => `<path d="${d}" fill="url(#${id}-iri)" opacity=".26"/><ellipse cx="${R(box.x + box.w * 0.32)}" cy="${R(box.y + box.h * 0.22)}" rx="${R(box.w * 0.17)}" ry="${R(box.h * 0.09)}" transform="rotate(-30 ${R(box.x + box.w * 0.32)} ${R(box.y + box.h * 0.22)})" fill="#fff" opacity=".6"/><path d="${d}" fill="none" stroke="#fff" stroke-width="${R(box.w * 0.04)}" opacity=".3"/>`,
335
+ },
336
+ glass: {
337
+ grad: (c) => ({ radial: true, cx: 0.5, cy: 0.4, r: 0.7, stops: [[0, shade(c, 0.35), 0.5], [0.7, c, 0.66], [1, shade(c, -0.25), 0.88]] }),
338
+ inner: ({ d, box, col }) => {
339
+ const x = box.x + box.w * 0.2;
340
+ const y = box.y + box.h * 0.38;
341
+ return `<path d="${d}" fill="none" stroke="#fff" stroke-width="${R(box.w * 0.05)}" opacity=".45"/><ellipse cx="${R(box.x + box.w * 0.55)}" cy="${R(box.y + box.h * 0.95)}" rx="${R(box.w * 0.3)}" ry="${R(box.h * 0.1)}" fill="${shade(col, 0.6)}" opacity=".45"/><path d="M${R(x)} ${R(y + box.h * 0.12)}Q${R(x)} ${R(y - box.h * 0.12)} ${R(x + box.w * 0.16)} ${R(y - box.h * 0.22)}" stroke="#fff" stroke-width="${R(box.w * 0.045)}" fill="none" stroke-linecap="round" opacity=".85"/><circle cx="${R(x + box.w * 0.25)}" cy="${R(y - box.h * 0.25)}" r="${R(box.w * 0.025)}" fill="#fff" opacity=".9"/>`;
342
+ },
343
+ },
344
+ velvet: {
345
+ grad: (c) => ({ radial: true, cx: 0.48, cy: 0.42, r: 0.62, stops: [[0, shade(c, 0.1)], [0.7, shade(c, -0.08)], [1, shade(c, -0.42)]] }),
346
+ defs: (id) => `<filter id="${id}-vel" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="1.4" numOctaves="1" seed="11"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .7 0"/></filter>`,
347
+ inner: ({ id, d, box }) => `<rect x="${R(box.x - 5)}" y="${R(box.y - 5)}" width="${R(box.w + 10)}" height="${R(box.h + 10)}" filter="url(#${id}-vel)" opacity=".12"/><path d="${d}" fill="none" stroke="#fff" stroke-width="${R(box.w * 0.025)}" opacity=".25" transform="translate(${R(-box.w * 0.012)} ${R(-box.h * 0.012)})"/>`,
348
+ },
349
+ holo: {
350
+ grad: (c) => ({ radial: true, stops: [[0, shade(c, 0.45)], [0.55, shade(c, 0.1)], [1, shade(c, -0.22)]] }),
351
+ defs: (id, c, box) => `<linearGradient id="${id}-holo" gradientUnits="userSpaceOnUse" x1="${R(box.x)}" y1="${R(box.y + box.h)}" x2="${R(box.x + box.w)}" y2="${R(box.y)}"><stop offset="0" stop-color="#ff7ad9"/><stop offset=".25" stop-color="#ffd36e"/><stop offset=".5" stop-color="#7affc8"/><stop offset=".75" stop-color="#7ab8ff"/><stop offset="1" stop-color="#c47aff"/></linearGradient>`,
352
+ inner: ({ id, d, box }) => {
353
+ let bands = '';
354
+ for (let i = 0; i < 5; i++) {
355
+ const x = box.x - box.w * 0.3 + i * box.w * 0.32;
356
+ bands += `M${R(x)} ${R(box.y + box.h + 6)}L${R(x + box.w * 0.6)} ${R(box.y - 6)}`;
357
+ }
358
+ return `<path d="${d}" fill="url(#${id}-holo)" opacity=".42"/><path d="${bands}" stroke="#fff" stroke-width="${R(box.w * 0.05)}" opacity=".22"/><ellipse cx="${R(box.x + box.w * 0.3)}" cy="${R(box.y + box.h * 0.2)}" rx="${R(box.w * 0.14)}" ry="${R(box.h * 0.07)}" transform="rotate(-32 ${R(box.x + box.w * 0.3)} ${R(box.y + box.h * 0.2)})" fill="#fff" opacity=".6"/>`;
359
+ },
360
+ },
361
+ vinyl: {
362
+ grad: (c) => ({ stops: [[0, c], [1, c]] }),
363
+ inner: ({ d, box, col }) => {
364
+ const x = box.x + box.w * 0.3;
365
+ const y = box.y + box.h * 0.2;
366
+ return `<path d="${d}" fill="none" stroke="${shade(col, -0.18)}" stroke-width="${R(box.w * 0.09)}" transform="translate(${R(box.w * 0.035)} ${R(box.h * 0.035)})"/><ellipse cx="${R(x)}" cy="${R(y)}" rx="${R(box.w * 0.11)}" ry="${R(box.h * 0.055)}" transform="rotate(-30 ${R(x)} ${R(y)})" fill="#fff" opacity=".9"/><circle cx="${R(box.x + box.w * 0.44)}" cy="${R(box.y + box.h * 0.12)}" r="${R(box.w * 0.022)}" fill="#fff" opacity=".9"/>`;
367
+ },
368
+ },
369
+ };
370
+
296
371
  function outlineOf(spec, pal) {
297
372
  const o = spec.outline;
298
373
  if (o === 'none') return null;
299
374
  // el estilo artístico manda en el trazo (lw 0: sin contorno, p. ej. plastilina)
300
375
  if (spec._style) return spec._style.lw ? { color: spec._style.ink, w: spec._style.lw } : null;
376
+ if (o === 'tone') return { color: shade(spec.body.color, -0.55), w: 4 };
377
+ if (o === 'sticker') return { color: '#ffffff', w: 6, under: { color: '#1f2937', w: 8.6 } };
301
378
  if (o === 'bold' || spec.finish === 'toon') return { color: '#1f2937', w: 4.5 };
302
379
  if (spec.finish === 'sketch') return { color: '#1f2937', w: 2.2 };
303
380
  if (spec.finish === 'neon') return { color: spec.body.color, w: 3.5 };
381
+ // acabados del catálogo 2
382
+ if (spec.finish === 'vinyl' && o === 'auto') return { color: shade(spec.body.color, -0.5), w: 3.2 };
383
+ if (spec.finish === 'glass' && o === 'auto') return { color: shade(spec.body.color, -0.3), w: 2, op: 0.7 };
384
+ if (spec.finish === 'metal' && o === 'auto') return { color: shade(spec.body.color, -0.6), w: 2.2, op: 0.8 };
304
385
  if (o === 'thin' || (o === 'auto' && spec.finish !== 'flat')) return { color: pal.line, w: 2.2, op: 0.55 };
305
386
  return null;
306
387
  }
@@ -355,8 +436,11 @@ function growing(a, part) {
355
436
  return !!part.grow && !!part.shape && Math.abs(a.x) <= 24 && a.y <= 12 && a.y >= -40 && Math.abs(a.rot) <= 35 && a.scale >= 0.6;
356
437
  }
357
438
 
439
+ /** Accesorio de catálogo o pieza de un mod. */
440
+ const accOf = (spec, id) => ACC[id] || modPart(spec, id, shade);
441
+
358
442
  function accessorySVG(a, spec, L, pal, clipUrl, skin, key) {
359
- const part = ACC[a.id];
443
+ const part = accOf(spec, a.id);
360
444
  const at = L.anchors[part.anchor] || L.anchors.center;
361
445
  const grow = growing(a, part);
362
446
  const c = {
@@ -445,6 +529,17 @@ export function renderLayers(spec, state = {}, opts = {}) {
445
529
  defs += `<filter id="${id}-rough"><feTurbulence type="turbulence" baseFrequency=".035" numOctaves="2" seed="3"/><feDisplacementMap in="SourceGraphic" scale="3.5"/></filter>`;
446
530
  defs += `<pattern id="${id}-hatch" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(40)"><path d="M0 0V7" stroke="${shade(col, -0.35)}" stroke-width="1.4" opacity=".45"/></pattern>`;
447
531
  }
532
+ const f2 = FINISH2[f];
533
+ if (f2) {
534
+ const g = f2.grad(col);
535
+ stops = g.stops;
536
+ const st = stops.map(([o, c, a]) => `<stop offset="${o}" stop-color="${c}"${a !== undefined ? ` stop-opacity="${a}"` : ''}/>`).join('');
537
+ const T = `gradientUnits="userSpaceOnUse" gradientTransform="translate(${R(box.x)} ${R(box.y)}) scale(${R(box.w)} ${R(box.h)})"`;
538
+ defs += g.radial ? `<radialGradient id="${id}-g" ${T} cx="${g.cx ?? 0.36}" cy="${g.cy ?? 0.28}" r="${g.r ?? 0.85}">${st}</radialGradient>` : `<linearGradient id="${id}-g" ${T} x1="0" y1="0" x2="${g.x2 ?? 0}" y2="1">${st}</linearGradient>`;
539
+ defs += `<radialGradient id="${id}-lit" cx="32%" cy="24%" r="90%"><stop offset="0" stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".22"/></radialGradient>`;
540
+ defs += f2.defs ? f2.defs(id, col, box) : '';
541
+ fill = `url(#${id}-g)`;
542
+ }
448
543
 
449
544
  // Piel: textura de superficie y lo que comparten las piezas unidas (metamorfosis y accesorios)
450
545
  const bodyFill = f === 'neon' ? shade(col, -0.72) : fill;
@@ -461,7 +556,8 @@ export function renderLayers(spec, state = {}, opts = {}) {
461
556
  let front = '';
462
557
  let mask = '';
463
558
  spec.accessories.forEach((a, i) => {
464
- const part = ACC[a.id];
559
+ const part = accOf(spec, a.id);
560
+ if (!part) return;
465
561
  const x = accessorySVG(a, spec, L, pal, clipUrl, skin, `a${i}`);
466
562
  if (part.layer === 'back' || growing(a, part)) back += x.svg;
467
563
  else front += x.svg;
@@ -489,10 +585,12 @@ export function renderLayers(spec, state = {}, opts = {}) {
489
585
  }
490
586
  if (f === 'clay') inner += `<rect x="${R(box.x - 5)}" y="${R(box.y - 5)}" width="${R(box.w + 10)}" height="${R(box.h + 10)}" filter="url(#${id}-noise)" opacity=".13"/>`;
491
587
  if (f === 'sketch') inner += `<path d="${d}" fill="url(#${id}-hatch)" transform="translate(${R(box.w * 0.05)} ${R(box.h * 0.06)})"/>`;
588
+ if (f2) inner += f2.inner({ id, d, box, col, L });
492
589
  const sty = styleLayers(spec, L, { uid: id, d, quality: opts.detail || 'full', animate: opts.animate });
493
590
  defs += sty.defs;
494
591
  inner += sty.inner;
495
592
  if (inner) body += `<g clip-path="${clipUrl}">${inner}</g>`;
593
+ if (ol?.under) body += `<path d="${d}" fill="none" stroke="${ol.under.color}" stroke-width="${ol.under.w}" stroke-linejoin="round"${masked ? ` mask="url(#${id}-om)"` : ''}/>`;
496
594
  if (ol) body += `<path d="${d}" fill="none" stroke="${ol.color}" stroke-width="${ol.w}" stroke-linejoin="round"${ol.op ? ` stroke-opacity="${ol.op}"` : ''}${f === 'sketch' ? ` filter="url(#${id}-rough)"` : ''}${f === 'neon' ? ` filter="url(#${id}-glow)"` : ''}${masked ? ` mask="url(#${id}-om)"` : ''}/>`;
497
595
  body += sk.rim;
498
596
 
@@ -525,7 +623,7 @@ export function renderLayers(spec, state = {}, opts = {}) {
525
623
  */
526
624
  export function renderCharacter(spec, state = {}, opts = {}) {
527
625
  const src = normalizeCharacter(spec);
528
- const s = stylizeSpec(src);
626
+ const s = stylizeSpec(applyMods(src, normalizeCharacter));
529
627
  const x = renderLayers(s, state, opts);
530
628
  const title = opts.title ? `<title>${String(opts.title).replace(/[<>&"]/g, '')}</title>` : '';
531
629
  const parts = `${x.back}${x.body}${x.cheeks}${x.eyes}${x.brows}${x.mouth}${x.front}`;
@@ -596,7 +694,7 @@ export function createCharacter(container, spec, opts = {}) {
596
694
 
597
695
  // src: la spec tal cual (la que se devuelve); s: la que se pinta (con el estilo artístico aplicado)
598
696
  let src = normalizeCharacter(spec);
599
- let s = stylizeSpec(src);
697
+ let s = stylizeSpec(applyMods(src, normalizeCharacter));
600
698
  let uid = `otsc${(++uidSeq).toString(36)}`;
601
699
  let g = {};
602
700
  let L = layout(s);
@@ -1307,7 +1405,7 @@ export function createCharacter(container, spec, opts = {}) {
1307
1405
  clearEffects,
1308
1406
  update(next) {
1309
1407
  src = normalizeCharacter(next);
1310
- s = stylizeSpec(src);
1408
+ s = stylizeSpec(applyMods(src, normalizeCharacter));
1311
1409
  uid = `otsc${(++uidSeq).toString(36)}`;
1312
1410
  morphMask = '';
1313
1411
  build();
@@ -1327,3 +1425,4 @@ export function createCharacter(container, spec, opts = {}) {
1327
1425
  export { SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, TEXTURES, ACCESSORIES, PRESETS, BODY_POINTS };
1328
1426
  export { ACCESSORY_GROUPS, partLabel } from './parts.js';
1329
1427
  export { STYLES as CHARACTER_STYLES } from './styles.js';
1428
+ export { normalizeMods, normalizeMod, sanitizeSvg, modName, MOD_ANCHORS, MAX_MODS } from './mods.js';